Files
mileage-bonus/energy-h2-bi-board-html/source/EnergyBiBoardApp.tsx
T

5279 lines
233 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.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Activity,
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
Download,
Fuel,
RefreshCw,
Search,
Shield,
TrendingDown,
TrendingUp,
Truck,
Wallet,
X,
Zap,
} from 'lucide-react';
import { downloadExcelAoa } from '../../common/download-xls';
import {
SOURCE_LABEL,
companyRowsForStats,
computeHostKpi,
costDimCards,
costDimLabel,
customerAttrAgg,
filterOrders,
formatKg,
formatYuan,
pendingAmount,
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 type { FleetScope, HostView, H2OrderRow } from './types';
import { StationDailyApp } from '../energy-h2-station-daily/StationDailyApp';
import '../energy-h2-station-daily/styles.css';
import './styles/energy-bi-board.css';
type BoardScope = 'global' | 'station';
type StatsTab = 'siteMonth' | 'customer';
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="年份选择"
>
<span className="ehb-year-text">{value} </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>
);
}
// 站加氢汇总假数据 (带省份归属,与用户截图高保真100%对齐)
const MOCK_STATION_SUMMARY_LIST = [
{ rank: 1, name: '嘉兴中石化滨海加氢站', province: '浙江省', kgT: '243.66', kgPct: 35.0, incomeWan: '52.66', incomePct: 7.1 },
{ rank: 2, name: '嘉兴嘉锦加氢站', province: '浙江省', kgT: '182.89', kgPct: 26.2, incomeWan: '119.53', incomePct: 16.1 },
{ rank: 3, name: '嘉兴嘉燃加氢站', province: '浙江省', kgT: '28.23', kgPct: 4.0, incomeWan: '61.47', incomePct: 8.3 },
{ rank: 4, name: '桐乡中石化绿能加氢站', province: '浙江省', kgT: '26.08', kgPct: 3.7, incomeWan: '35.76', incomePct: 4.8 },
{ rank: 5, name: '成都中石化天府机场高速北站加氢站', province: '四川省', kgT: '22.93', kgPct: 3.3, incomeWan: '68.62', incomePct: 9.2 },
{ rank: 6, name: '花桥中石油加氢站', province: '江苏省', kgT: '19.71', kgPct: 2.8, incomeWan: '35.15', incomePct: 4.7 },
{ rank: 7, name: '常熟嘉化加氢站', province: '江苏省', kgT: '15.56', kgPct: 2.2, incomeWan: '25.81', incomePct: 3.5 },
{ rank: 8, name: '嘉善中石化站前路加氢站', province: '浙江省', kgT: '14.61', kgPct: 2.1, incomeWan: '25.34', incomePct: 3.4 },
{ rank: 9, name: '嘉兴东方港湾加氢站', province: '浙江省', kgT: '13.45', kgPct: 1.9, incomeWan: '7.01', incomePct: 0.9 },
{ rank: 10, name: '成都中石化天府机场高速南站加氢站', province: '四川省', kgT: '12.86', kgPct: 1.8, incomeWan: '38.46', incomePct: 5.2 },
{ rank: 11, name: '成都国氢华通加氢站', province: '四川省', kgT: '12.09', kgPct: 1.7, incomeWan: '36.26', incomePct: 4.9 },
{ rank: 12, name: '广州新锋交通联新加氢站', province: '广东省', kgT: '9.33', kgPct: 1.3, incomeWan: '13.49', incomePct: 1.8 },
{ rank: 13, name: '乌鲁木齐隆盛达沙坪加氢站', province: '新疆维吾尔自治区', kgT: '9.33', kgPct: 1.3, incomeWan: '21.12', incomePct: 2.8 },
{ rank: 14, name: '佛山豪石油加氢站', province: '广东省', kgT: '8.96', kgPct: 1.3, incomeWan: '26.6', incomePct: 3.6 },
{ rank: 15, name: '佛南海羚牛加氢站', province: '广东省', kgT: '7.10', kgPct: 1.0, incomeWan: '3.29', incomePct: 0.4 },
{ rank: 16, name: '佛山中石化佛西加氢站', province: '广东省', kgT: '5.83', kgPct: 0.8, incomeWan: '20.32', incomePct: 2.7 },
{ rank: 17, name: '广州中石化东明三路加氢站', province: '广东省', kgT: '5.62', kgPct: 0.8, incomeWan: '5.73', incomePct: 0.8 },
{ rank: 18, name: '常熟AP银河路加氢站', province: '江苏省', kgT: '4.98', kgPct: 0.7, incomeWan: '20.06', incomePct: 2.7 },
{ rank: 19, name: '武汉中石化革新加氢站', province: '湖北省', kgT: '3.95', kgPct: 0.6, incomeWan: '9.45', incomePct: 1.3 },
{ rank: 20, name: '韶关韶钢加氢站', province: '广东省', kgT: '3.72', kgPct: 0.5, incomeWan: '10.81', incomePct: 1.5 },
{ rank: 21, name: '成都博能加氢站', province: '四川省', kgT: '3.49', kgPct: 0.5, incomeWan: '10.21', incomePct: 1.4 },
{ rank: 22, name: '无锡润硕氢能加氢站', province: '江苏省', kgT: '3.22', kgPct: 0.5, incomeWan: '11.98', incomePct: 1.6 },
{ rank: 23, name: '上海安亭加氢站', province: '上海市', kgT: '3.10', kgPct: 0.4, incomeWan: '9.82', incomePct: 1.3 },
{ rank: 24, name: '昆山千灯加氢站', province: '江苏省', kgT: '2.88', kgPct: 0.4, incomeWan: '8.90', incomePct: 1.2 },
{ rank: 25, name: '宁波港区示范加氢站', province: '浙江省', kgT: '2.45', kgPct: 0.3, incomeWan: '7.65', incomePct: 1.0 },
];
// 客户账单汇总假数据 (Top 30 与用户截图高保真100%对齐)
const MOCK_CUSTOMER_SUMMARY_LIST = [
{ rank: 1, name: '嘉兴市乍浦港口经营有限公司', bearer: 'cust' as const, kgT: '288.37', costWan: '807.94', receivable: '¥1,987 元' },
{ rank: 2, name: '嘉兴益顺冷链物流有限公司', bearer: 'cust' as const, kgT: '38.93', costWan: '136.83', receivable: '¥66.25 万元' },
{ rank: 3, name: '嘉兴智奇供应链管理有限公司', bearer: 'cust' as const, kgT: '35.45', costWan: '101.73', receivable: '¥14.44 万元' },
{ rank: 4, name: '车辆异动', bearer: 'cust' as const, kgT: '28.10', costWan: '92.96', receivable: '¥2,503 元' },
{ rank: 5, name: '四川群彬物流有限公司', bearer: 'cust' as const, kgT: '23.35', costWan: '69.98', receivable: '¥69.98 万元' },
{ rank: 6, name: '浙江洋井供应链管理有限公司', bearer: 'cust' as const, kgT: '18.62', costWan: '61.08', receivable: '¥321 元' },
{ rank: 7, name: '无锡铭康物流有限公司-1', bearer: 'lingniu' as const, kgT: '16.89', costWan: '58.38', receivable: '¥0 元' },
{ rank: 8, name: '上海明纳物流有限公司', bearer: 'lingniu' as const, kgT: '12.44', costWan: '41.4', receivable: '¥0 元' },
{ rank: 9, name: '嘉兴中外运物流有限公司', bearer: 'lingniu' as const, kgT: '11.39', costWan: '31.94', receivable: '¥0 元' },
{ rank: 10, name: '重庆金时源供应链有限公司', bearer: 'cust' as const, kgT: '11.18', costWan: '27.96', receivable: '¥27.96 万元' },
{ rank: 11, name: '四川拱照物流有限公司', bearer: 'cust' as const, kgT: '10.34', costWan: '31', receivable: '¥31 万元' },
{ rank: 12, name: '无锡铭康物流有限公司', bearer: 'lingniu' as const, kgT: '9.21', costWan: '31.72', receivable: '¥0 元' },
{ rank: 13, name: '嘉兴羚利供应链科技有限公司', bearer: 'cust' as const, kgT: '8.62', costWan: '24.13', receivable: '¥25.85 万元' },
{ rank: 14, name: '宁波港集装箱运输有限公司嘉兴分公司', bearer: 'cust' as const, kgT: '8.09', costWan: '22.66', receivable: '¥23.01 万元' },
{ rank: 15, name: '成都诺和物流有限公司', bearer: 'cust' as const, kgT: '6.67', costWan: '19.99', receivable: '¥19.99 万元' },
{ rank: 16, name: '嘉兴市飞宇物流有限公司', bearer: 'cust' as const, kgT: '6.49', costWan: '18.22', receivable: '¥6,483 元' },
{ rank: 17, name: '嘉兴港区众通快递有限公司', bearer: 'cust' as const, kgT: '6.27', costWan: '17.55', receivable: '¥18.81 万元' },
{ rank: 18, name: '浙江集佑供应链有限公司', bearer: 'cust' as const, kgT: '6.20', costWan: '17.35', receivable: '¥18.59 万元' },
{ rank: 19, name: '日邮物流(中国)有限公司', bearer: 'cust' as const, kgT: '5.90', costWan: '22.11', receivable: '¥22.2 万元' },
{ rank: 20, name: '四川邦达蜀运供应链管理有限公司', bearer: 'cust' as const, kgT: '5.21', costWan: '15.63', receivable: '¥15.65 万元' },
{ rank: 21, name: '嘉兴古道物流有限公司', bearer: 'cust' as const, kgT: '5.18', costWan: '14.49', receivable: '¥15.53 万元' },
{ rank: 22, name: '宁波乐驰物流有限公司', bearer: 'cust' as const, kgT: '5.01', costWan: '14.01', receivable: '¥14.1 万元' },
{ rank: 23, name: '广东清运物流专线', bearer: 'cust' as const, kgT: '4.82', costWan: '13.50', receivable: '¥13.50 万元' },
{ rank: 24, name: '顺丰冷运嘉兴分线', bearer: 'cust' as const, kgT: '4.21', costWan: '11.78', receivable: '¥11.80 万元' },
{ rank: 25, name: '极兔速递冷链事业部', bearer: 'cust' as const, kgT: '3.95', costWan: '11.06', receivable: '¥11.06 万元' },
{ rank: 26, name: '武汉捷运货运有限公司', bearer: 'cust' as const, kgT: '3.62', costWan: '10.13', receivable: '¥10.15 万元' },
{ rank: 27, name: '成都天府物流二部', bearer: 'cust' as const, kgT: '3.11', costWan: '8.70', receivable: '¥8.70 万元' },
{ rank: 28, name: '广州黄埔冷链车队', bearer: 'cust' as const, kgT: '2.85', costWan: '7.98', receivable: '¥8.00 万元' },
{ rank: 29, name: '常熟物流储运中心', bearer: 'cust' as const, kgT: '2.40', costWan: '6.72', receivable: '¥6.72 万元' },
{ rank: 30, name: '无锡灵通运输公司', bearer: 'cust' as const, kgT: '2.10', costWan: '5.88', receivable: '¥5.90 万元' },
];
interface OverviewTrendsProps {
year: number;
fleetScope: FleetScope;
verifyScope: 'all' | 'verified';
onOpenDrill: (label: string) => void;
onOpenCustomerBill: (custName: string) => void;
onOpenStationBill: (stName: string, province: string) => void;
}
function OverviewTrendsDashboard({ year, fleetScope, verifyScope, onOpenDrill, onOpenCustomerBill, onOpenStationBill }: OverviewTrendsProps) {
// 月度加氢量数据 (根据年份、车辆范围、核对范围加权)
const monthlyData = useMemo(() => {
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]);
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(() => {
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]);
const maxRevenueVal = useMemo(() => {
return Math.max(...monthlyRevenueData.flatMap((d) => [d.income, d.cost]), 1);
}, [monthlyRevenueData]);
// Top5 站列表 (带内部 vs 外部堆积;跟随车辆/核对筛选)
const topStations = useMemo(() => {
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]);
// 区域维度控制: 按市 ('city') | 按省 ('province')
const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city');
// 省份筛选控制: 'all' | '浙江省' | '四川省' | '广东省' | '江苏省' | '湖北省' 等
const [selectedProvince, setSelectedProvince] = useState<string>('all');
// 已有加氢站的省份去重列表
const availableProvinces = useMemo(() => {
const list: string[] = ['all'];
MOCK_STATION_SUMMARY_LIST.forEach((st) => {
if (st.province && !list.includes(st.province)) {
list.push(st.province);
}
});
return list;
}, []);
// 根据选定省份精准过滤加氢站列表
const filteredStationList = useMemo(() => {
if (selectedProvince === 'all') return MOCK_STATION_SUMMARY_LIST;
return MOCK_STATION_SUMMARY_LIST.filter((st) => st.province === selectedProvince);
}, [selectedProvince]);
// 根据过滤结果计算总站数 (全国 65 站基准,按比例联动)
const stationCountDisplay = useMemo(() => {
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]);
// 按市区域占比数据 (规范地级市名称)
const cityRegions = [
{ label: '嘉兴市', pct: '65.2%', color: '#0284c7', dashArray: '155 238', dashOffset: '0' },
{ label: '成都市', pct: '7.4%', color: '#38bdf8', dashArray: '18 238', dashOffset: '-156' },
{ label: '佛山市', pct: '3.7%', color: '#10b981', dashArray: '9 238', dashOffset: '-175' },
{ label: '昆山市', pct: '2.8%', color: '#f59e0b', dashArray: '7 238', dashOffset: '-185' },
{ label: '常熟市', pct: '2.2%', color: '#8b5cf6', dashArray: '5 238', dashOffset: '-193' },
{ label: '广州市', pct: '2.1%', color: '#ec4899', dashArray: '5 238', dashOffset: '-199' },
{ label: '深圳市', pct: '1.9%', color: '#06b6d4', dashArray: '4 238', dashOffset: '-205' },
{ label: '无锡市', pct: '1.9%', color: '#84cc16', dashArray: '4 238', dashOffset: '-210' },
{ label: '其他城市', pct: '12.7%', color: '#94a3b8', dashArray: '30 238', dashOffset: '-215' },
];
// 按省区域占比数据
const provinceRegions = [
{ label: '浙江省', pct: '73.2%', color: '#0284c7', dashArray: '175 238', dashOffset: '0' },
{ label: '四川省', pct: '11.8%', color: '#38bdf8', dashArray: '28 238', dashOffset: '-176' },
{ label: '广东省', pct: '7.5%', color: '#10b981', dashArray: '18 238', dashOffset: '-205' },
{ label: '江苏省', pct: '5.4%', color: '#f59e0b', dashArray: '13 238', dashOffset: '-224' },
{ label: '其他省份', pct: '2.1%', color: '#94a3b8', dashArray: '5 238', dashOffset: '-238' },
];
const activeRegions = regionGranularity === 'province' ? provinceRegions : cityRegions;
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: '#0284c7' }} />
内部客户
</span>
<span className="ehb-chart-legend-tag">
<span className="ehb-legend-sq" style={{ background: '#f59e0b' }} />
外部客户
</span>
<span className="ehb-chart-box-meta">
统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · 单位 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: '#38bdf8' }}>
{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: 'linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%)',
}}
/>
<div
style={{
height: `${ownRatio}%`,
background: 'linear-gradient(180deg, #38bdf8 0%, #0284c7 100%)',
}}
/>
</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">
统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · 单位
</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: '#fbbf24', 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: '#34d399', 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: '#0284c7' }} />
内部客户
</span>
<span className="ehb-chart-legend-tag">
<span className="ehb-legend-sq" style={{ background: '#f59e0b' }} />
外部客户
</span>
<span className="ehb-chart-box-meta">
统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · 单位 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: '#38bdf8', 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"
className={`ehb-mini-tab ${regionGranularity === 'province' ? 'is-active' : ''}`}
onClick={() => setRegionGranularity('province')}
>
按省
</button>
<button
type="button"
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">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>
{/* 5. 趋势图下方:加氢站加氢汇总表 (支持区域按省筛选切换) */}
<div className="ehb-sum-table-card">
<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)}
>
{prov === 'all' ? '全国' : prov}
</button>
))}
</div>
</div>
<div className="ehb-sum-table-card__meta">
统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · {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 style={{ fontWeight: 600, color: '#0284c7' }}>
{st.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}>钻取 </span>
</td>
<td>
<span style={{ fontSize: 11, color: '#0284c7', background: '#eff6ff', padding: '1px 6px', borderRadius: 4, fontWeight: 500 }}>
{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">
<div className="ehb-sum-table-card__head">
<div className="ehb-sum-table-card__title">
客户账单汇总
<span className="ehb-title-sub ehb-hide-h5">
(已收 / 未收:等待客户能源账户和对账单打通后获取)
</span>
<span className="ehb-title-sub ehb-show-h5">
(已收未收打通中)
</span>
</div>
<div className="ehb-sum-table-card__meta">
统计范围:{year === 2026 ? '2026-01-01 至 2026-08-08' : `${year}-01-01 至 ${year}-12-31`} · Top 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>
<th style={{ textAlign: 'right' }} title="等待客户能源账户和对账单打通后,取客户能源账户和对账单即可">已收</th>
<th style={{ textAlign: 'right' }} title="等待客户能源账户和对账单打通后,取客户能源账户和对账单即可">未收</th>
</tr>
</thead>
<tbody>
{MOCK_CUSTOMER_SUMMARY_LIST.map((cust) => (
<tr
key={cust.rank}
onClick={() => onOpenCustomerBill(cust.name)}
style={{ cursor: 'pointer' }}
title="点击钻取:承担方 / 加氢量 / 成本支出 / 应收 / 已收 / 未收"
>
<td className="col-idx">{cust.rank}</td>
<td style={{ fontWeight: 600, color: '#0284c7' }}>
{cust.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}>钻取 </span>
</td>
<td style={{ textAlign: 'center' }}>
<span
className={`ehb-bearer-tag ${cust.bearer === 'cust' ? 'is-cust' : 'is-lingniu'}`}
>
{cust.bearer === 'cust' ? '客户' : '羚牛'}
</span>
</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>
<td style={{ textAlign: 'right' }}>
<span
className="ehb-stay-tuned-tag"
title="等待客户能源账户和对账单打通后,取客户能源账户和对账单即可"
>
敬请期待
</span>
</td>
<td style={{ textAlign: 'right' }}>
<span
className="ehb-stay-tuned-tag"
title="等待客户能源账户和对账单打通后,取客户能源账户和对账单即可"
>
敬请期待
</span>
</td>
</tr>
))}
</tbody>
</table>
</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 [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);
// 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 [dailyStartDate, setDailyStartDate] = useState('2026-07-25');
const [dailyEndDate, setDailyEndDate] = useState('2026-08-08');
// 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管)
const timeRangeLabel = '统计时间范围';
const timeRangeText = useMemo(() => {
if (hostView === 'daily') {
return `${dailyStartDate}${dailyEndDate}`;
}
return year === 2026 ? '2026-01-01 至 2026-08-08 11:25' : `${year}-01-01 至 ${year}-12-31 23:59`;
}, [hostView, dailyStartDate, dailyEndDate, year]);
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(
() => computeHostKpi(rows, year, MOCK_ORDERS, HOST_KPI),
[rows, year],
);
// 加氢站加氢量排名(高→低),跟随年份/车辆/核对筛选
const stationRankList = useMemo(() => {
const yearFactor = year === 2026 ? 1 : year === 2025 ? 0.85 : 0.7;
const fleetFactor = fleetScope === 'all' ? 1 : fleetScope === 'own' ? 0.67 : 0.33;
const verifyFactor = verifyScope === 'verified' ? (fleetScope === 'external' ? 0 : 0.75) : 1;
const scale = yearFactor * fleetFactor * verifyFactor;
const list = MOCK_STATION_SUMMARY_LIST.map((st) => ({
name: st.name,
province: st.province,
kg: Math.round(parseFloat(st.kgT) * 1000 * scale),
}))
.filter((st) => st.kg > 0)
.sort((a, b) => b.kg - a.kg);
const maxKg = list[0]?.kg || 1;
const totalKg = list.reduce((s, x) => s + x.kg, 0) || 1;
return list.map((st, i) => ({
...st,
rank: i + 1,
barPct: Math.round((st.kg / maxKg) * 100),
sharePct: Math.round((st.kg / totalKg) * 1000) / 10,
}));
}, [year, fleetScope, verifyScope]);
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]);
// 月度波动洞察副文案:当月 / 峰值月 / 月均(与图表月度口径一致,跟随筛选)
const monthFluctuationDesc = useMemo(() => {
const yearFactor = year === 2026 ? 1 : year === 2025 ? 0.85 : 0.7;
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 },
];
const months = baseMonths.map((item) => {
let ownKg = Math.round(item.own * yearFactor * ownVerifyFactor);
let extKg = Math.round(item.ext * yearFactor * extVerifyFactor);
if (fleetScope === 'own') extKg = 0;
if (fleetScope === 'external') ownKg = 0;
return { month: item.m, totalKg: ownKg + extKg };
});
const toT = (kg: number) => Math.round((kg / 1000) * 100) / 100;
const current = months[months.length - 1];
const peak = months.reduce((best, cur) => (cur.totalKg > best.totalKg ? cur : best), months[0]);
const avgKg = months.reduce((s, m) => s + m.totalKg, 0) / (months.length || 1);
return `当月 ${toT(current.totalKg)} T · 峰值${peak.month} ${toT(peak.totalKg)} T · 月均 ${toT(avgKg)} T`;
}, [year, fleetScope, verifyScope]);
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 pending = pendingAmount(rows);
const risk = unverified(rows);
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 externalEmpty = fleetScope === 'external' && rows.length === 0;
const [updatedAt, setUpdatedAt] = useState('2026-08-08 10:12');
const handleRefreshData = () => {
const now = new Date();
const timeStr = now.toTimeString().split(' ')[0].slice(0, 5);
setUpdatedAt(`2026-08-08 ${timeStr}`);
};
return (
<div className="ehb-shell" data-annotation-id="energy-h2-bi-board">
<aside className="ehb-rail" aria-label="能源BI模块">
<button type="button" className="ehb-rail__item is-active" title="氢能">
<Fuel size={20} aria-hidden />
氢能
</button>
<button type="button" className="ehb-rail__item" disabled title="电能(宿主)">
<Zap size={20} aria-hidden />
电能
</button>
<button type="button" className="ehb-rail__item" disabled title="ETC(宿主)">
<Wallet size={20} aria-hidden />
ETC
</button>
</aside>
<div className="ehb-body">
<header className="ehb-chrome">
<div
className="ehb-opening-watermark"
role="status"
data-annotation-id="ehb-opening-calib-banner"
style={{
width: '100%',
marginBottom: 10,
padding: '8px 12px',
borderRadius: 10,
border: '1px solid #f59e0b',
background: 'linear-gradient(90deg, #fffbeb, #fef3c7)',
color: '#92400e',
fontSize: 13,
fontWeight: 600,
lineHeight: 1.4,
}}
>
期初校准中:期初余额与成本单价未锁定前,本看板仅供内部核对,不作对外真源。
</div>
<div className="ehb-chrome__lead" style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
{boardScope === 'global' ? (
<span
className="ehb-time-range-pill"
style={{
fontSize: 12,
color: '#0284c7',
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-chrome__tools">
<div className="ehb-seg ehb-seg--wrap" role="tablist" aria-label="范围">
<button
type="button"
role="tab"
className={boardScope === 'global' ? 'is-active' : ''}
aria-selected={boardScope === 'global'}
onClick={() => setBoardScope('global')}
>
全局
</button>
<button
type="button"
role="tab"
className={boardScope === 'station' ? 'is-active' : ''}
aria-selected={boardScope === 'station'}
onClick={() => setBoardScope('station')}
>
单站
</button>
</div>
{boardScope === 'global' ? (
<div className="ehb-seg ehb-seg--wrap" role="tablist" aria-label="视图">
<button
type="button"
role="tab"
className={hostView === 'daily' ? 'is-active' : ''}
aria-selected={hostView === 'daily'}
onClick={() => setHostView('daily')}
>
按日
</button>
<button
type="button"
role="tab"
className={hostView === 'overview' ? 'is-active' : ''}
aria-selected={hostView === 'overview'}
onClick={() => setHostView('overview')}
>
总览
</button>
</div>
) : null}
</div>
</header>
{boardScope === 'station' ? (
<StationDailyApp embedded />
) : (
<>
{hostView === 'daily' ? (
<HostDailyView
updatedAt={updatedAt}
onRefresh={handleRefreshData}
startDate={dailyStartDate}
endDate={dailyEndDate}
onStartDateChange={setDailyStartDate}
onEndDateChange={setDailyEndDate}
/>
) : (
<>
{/* 总览视角筛选条 (包含年份选择、核对筛选、车辆归属及刷新,样式与按日视角全面对齐) */}
<section className="ehb-daily-filter-card" 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-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>
</div>
<div className="ehb-daily-filter-group">
<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();
}}
>
<Truck size={14} />
仅羚牛车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === 'external' ? 'is-active' : ''}`}
onClick={() => {
setFleetScope('external');
clearEntity();
}}
>
<Truck size={14} />
仅外部车辆
</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="经营总览">
<div className="ehb-host-kpi">
<HostKpi
icon={<Fuel size={14} />}
tone="blue"
label="累计加氢量"
value={hostKpi.totalKgT}
unit="T"
left={`我司 ${hostKpi.companyKgT} T`}
right={`客户 ${hostKpi.customerKgT} T`}
onClick={() => setKpiDrillType('累计加氢量')}
/>
<HostKpi
icon={<Wallet size={14} />}
tone="blue"
label="累计加氢费"
prefix="¥"
value={hostKpi.totalFeeWan}
unit="万"
left={`我司 ¥${hostKpi.companyFeeWan} 万`}
right={`客户 ¥${hostKpi.customerFeeWan} 万`}
onClick={() => setKpiDrillType('累计加氢费')}
/>
<HostKpi
icon={<Activity size={14} />}
tone="green"
label="加氢利润"
prefix="¥"
value={hostKpi.profitWan}
unit="万"
left={`收入 ¥${hostKpi.incomeWan} 万`}
right={`成本 ¥${hostKpi.costWan} 万`}
onClick={() => setKpiDrillType('加氢利润')}
/>
<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 className="ehb-insight" aria-label="经营洞察">
<div className="ehb-insight__card">
<div className="ehb-insight__icon is-down">
<TrendingDown size={18} aria-hidden />
</div>
<div>
<div className="ehb-insight__title">月度加氢异常波动</div>
<div className="ehb-insight__value is-neg">
{hostKpi.monthYearPct > 0 ? `占年 ${hostKpi.monthYearPct}%` : '—'}
</div>
<div className="ehb-insight__desc">
{monthFluctuationDesc}
</div>
</div>
</div>
<div
className={`ehb-insight__card ehb-insight__card--rank ${stationRankOpen ? 'is-open' : ''}`}
ref={stationRankRef}
onClick={() => setStationRankOpen((v) => !v)}
style={{ cursor: 'pointer' }}
title="点击查看加氢站加氢量排名"
>
<div className="ehb-insight__icon">
<Shield size={18} aria-hidden />
</div>
<div className="ehb-insight__rank-body">
<div className="ehb-insight__title">头部加氢站占比</div>
<div className="ehb-insight__value">Top5 {top5SharePct}%</div>
<div className="ehb-insight__desc">
累计 {hostKpi.totalKgT} T · 点击展开加氢量排名
</div>
</div>
<ChevronDown
size={14}
className={`ehb-insight__rank-chevron ${stationRankOpen ? 'is-open' : ''}`}
aria-hidden
/>
{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>
)}
</div>
<div className="ehb-insight__card">
<div className="ehb-insight__icon is-ok">
<Activity size={18} aria-hidden />
</div>
<div>
<div className="ehb-insight__title">加氢利润率</div>
<div className={`ehb-insight__value ${hostKpi.profitRatePct >= 0 ? 'is-pos' : 'is-neg'}`}>
{hostKpi.profitRatePct}%
</div>
<div className="ehb-insight__desc">
加氢利润 {hostKpi.profitWan} · 收入 {hostKpi.incomeWan}
</div>
</div>
</div>
</div>
</section>
{/* 经营趋势图表大盘:月度加氢量、月度收支对比、Top5站加氢量、各区域加氢占比 */}
<OverviewTrendsDashboard
year={year}
fleetScope={fleetScope}
verifyScope={verifyScope}
onOpenDrill={(lbl) => setKpiDrillType(lbl)}
onOpenCustomerBill={(custName) => setSelectedBillCustomer(custName)}
onOpenStationBill={(stName, prov) => setSelectedStationForDrill({ name: stName, province: prov })}
/>
</>
)}
</>
)}
{/* KPI 点击下钻数据来源穿透 Modal */}
{boardScope === 'global' && kpiDrillType && (
<KpiDrillModal
label={kpiDrillType}
year={year}
fleetScope={fleetScope}
verifyScope={verifyScope}
onClose={() => setKpiDrillType(null)}
/>
)}
{/* 客户账单专属下钻 Modal (客户 → 日期 → 车牌加氢记录) */}
{boardScope === 'global' && selectedBillCustomer && (
<CustomerBillDrillModal
customerName={selectedBillCustomer}
year={year}
onClose={() => setSelectedBillCustomer(null)}
/>
)}
{/* 加氢站账单专属下钻 Modal (加氢站 → 所有日期的加氢量、占比、氢费收入、收入占比) */}
{boardScope === 'global' && selectedStationForDrill && (
<StationBillDrillModal
stationName={selectedStationForDrill.name}
province={selectedStationForDrill.province}
year={year}
onClose={() => setSelectedStationForDrill(null)}
/>
)}
</div>
</div>
);
};
function PlugZapHint() {
return <Zap size={36} className="ehb-empty__icon" aria-hidden />;
}
function HostKpi({
icon,
tone,
label,
value,
prefix,
unit,
left,
right,
onClick,
}: {
icon: React.ReactNode;
tone: 'blue' | 'green' | 'amber' | 'purple' | 'cyan';
label: string;
value: React.ReactNode;
prefix?: string;
unit?: string;
left: string;
right: string;
onClick?: () => void;
}) {
return (
<div className={`ehb-kpi-dual is-${tone}`} onClick={onClick} 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">
<span>{left}</span>
<span>{right}</span>
</div>
</div>
);
}
/** KPI 数据来源穿透 & 站 -> 客户 -> 车牌三级下钻 Modal 弹窗 */
interface KpiDrillModalProps {
label: string;
year: number;
fleetScope: FleetScope;
verifyScope: 'all' | 'verified';
onClose: () => void;
}
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', factor: 1.2, source: 'api' as const },
{ time: '2026-08-08 14:30', factor: 0.9, source: 'api' as const },
{ time: '2026-08-07 11:20', factor: 1.1, source: 'station_report' as const },
{ time: '2026-08-06 16:45', factor: 0.8, source: 'lingniu_report' as const },
{ time: '2026-08-05 10:10', factor: 1.05, source: 'api' as const },
{ time: '2026-08-04 15:25', factor: 0.95, source: 'station_report' as const },
{ time: '2026-08-03 08:50', factor: 1.15, source: 'api' as const },
{ time: '2026-08-02 17:05', factor: 0.85, source: 'lingniu_report' as const },
{ time: '2026-08-01 12:40', factor: 1.0, source: 'station_report' as const },
{ time: '2026-07-31 09:30', 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>
);
}
function KpiDrillModal({ label, year, fleetScope, verifyScope, onClose }: KpiDrillModalProps) {
const [stationFilter, setStationFilter] = useState<string>('all');
const [customerFilter, setCustomerFilter] = useState<string>('all');
const [plateFilter, setPlateFilter] = useState<string>('all');
const [fleetCategoryFilter, setFleetCategoryFilter] = useState<'all' | 'own' | 'external'>(fleetScope);
// 跟随顶栏车辆筛选:打开/切换时同步
useEffect(() => {
setFleetCategoryFilter(fleetScope);
}, [fleetScope, label]);
// 展开折叠状态
const [expandedStations, setExpandedStations] = useState<Record<string, boolean>>({
'st-jx': true, // 默认展开嘉兴站
});
const [expandedCustomers, setExpandedCustomers] = useState<Record<string, boolean>>({
'st-jx_c-zp': true, // 默认展开乍浦港口客户
});
const [expandedVehicles, setExpandedVehicles] = useState<Record<string, boolean>>({
'st-jx_c-zp_浙F88888': true, // 默认展开首辆车,展现单笔订单与部分核对明细
});
const [expandedAllVehicleOrders, setExpandedAllVehicleOrders] = useState<Record<string, boolean>>({});
const toggleStation = (stId: string) => {
setExpandedStations((prev) => ({ ...prev, [stId]: !prev[stId] }));
};
const toggleCustomer = (stId: string, custId: string) => {
const key = `${stId}_${custId}`;
setExpandedCustomers((prev) => ({ ...prev, [key]: !prev[key] }));
};
const toggleVehicle = (stId: string, custId: string, plateNo: string) => {
const key = `${stId}_${custId}_${plateNo}`;
setExpandedVehicles((prev) => ({ ...prev, [key]: !prev[key] }));
};
const toggleShowAllOrders = (vhKey: string) => {
setExpandedAllVehicleOrders((prev) => ({ ...prev, [vhKey]: !prev[vhKey] }));
};
// 站 -> 客户 -> 车牌 & 接口凭证 高保真全链路数据
const drillStations = useMemo(() => {
const is2026 = year === 2026;
const factor = is2026 ? 1 : 0.85;
return [
{
stationId: 'st-jx',
stationName: '嘉兴中石化滨海加氢站',
stationType: 'self_use' as const,
province: '浙江省',
totalKg: Math.round(243661 * factor),
totalFeeWan: (1096.47 * factor).toFixed(2),
customers: [
{
customerId: 'c-zp',
customerName: '嘉兴市乍浦港口经营有限公司',
category: 'internal' as const,
totalKg: Math.round(163250 * factor),
totalFeeWan: (734.63 * factor).toFixed(2),
vehicles: [
{ plateNo: '浙F88888', fleetCategory: 'own' as const, source: 'api' as const, certNo: 'API-20260808-9821', count: 142, kg: 42600, amount: 191700, orders: makeVehicleOrders('9821', 'own', 'partial', 42600) },
{ plateNo: '浙F66666', fleetCategory: 'own' as const, source: 'api' as const, certNo: 'API-20260808-9822', count: 128, kg: 38400, amount: 172800, orders: makeVehicleOrders('9822', 'own', 'verified', 38400) },
{ plateNo: '浙F77777', fleetCategory: 'own' as const, source: 'lingniu_report' as const, certNo: 'LN-REP-202608-012', count: 110, kg: 33000, amount: 148500, orders: makeVehicleOrders('012', 'own', 'verified', 33000) },
{ plateNo: '浙F55555', fleetCategory: 'own' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-045', count: 98, kg: 29400, amount: 132300, orders: makeVehicleOrders('045', 'own', 'unverified', 29400) },
{ plateNo: '浙F33333', fleetCategory: 'own' as const, source: 'api' as const, certNo: 'API-20260808-9825', count: 66, kg: 19850, amount: 89325, orders: makeVehicleOrders('9825', 'own', 'partial', 19850) },
],
},
{
customerId: 'c-ys',
customerName: '嘉兴益顺冷链物流有限公司',
category: 'external' as const,
totalKg: Math.round(54869 * factor),
totalFeeWan: (246.91 * factor).toFixed(2),
vehicles: [
{ plateNo: '浙F12345', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-7712', count: 85, kg: 25500, amount: 114750, orders: makeVehicleOrders('7712', 'external', 'verified', 25500) },
{ plateNo: '浙F67890', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-088', count: 62, kg: 18600, amount: 83700, orders: makeVehicleOrders('088', 'external', 'verified', 18600) },
{ plateNo: '无车牌', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-099', count: 36, kg: 10769, amount: 48460, orders: makeVehicleOrders('099', 'external', 'verified', 10769) },
],
},
{
customerId: 'c-zq',
customerName: '嘉兴智奇供应链管理有限公司',
category: 'external' as const,
totalKg: Math.round(25542 * factor),
totalFeeWan: (114.93 * factor).toFixed(2),
vehicles: [
{ plateNo: '沪A99881', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-6623', count: 52, kg: 15600, amount: 70200, orders: makeVehicleOrders('6623', 'external', 'verified', 15600) },
{ plateNo: '沪A99882', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-6624', count: 33, kg: 9942, amount: 44739, orders: makeVehicleOrders('6624', 'external', 'verified', 9942) },
],
},
],
},
{
stationId: 'st-jj',
stationName: '嘉兴嘉锦加氢站',
stationType: 'external_sale' as const,
province: '浙江省',
totalKg: Math.round(182889 * factor),
totalFeeWan: (823.00 * factor).toFixed(2),
customers: [
{
customerId: 'c-ln',
customerName: '羚牛自营车队',
category: 'internal' as const,
totalKg: Math.round(128020 * factor),
totalFeeWan: (576.09 * factor).toFixed(2),
vehicles: [
{ plateNo: '浙A88881F', fleetCategory: 'own' as const, source: 'api' as const, certNo: 'API-20260808-1001', count: 180, kg: 54000, amount: 243000, orders: makeVehicleOrders('1001', 'own', 'verified', 54000) },
{ plateNo: '浙A88882F', fleetCategory: 'own' as const, source: 'lingniu_report' as const, certNo: 'LN-REP-202608-102', count: 150, kg: 45000, amount: 202500, orders: makeVehicleOrders('102', 'own', 'verified', 45000) },
{ plateNo: '浙A88883F', fleetCategory: 'own' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-103', count: 96, kg: 29020, amount: 130590, orders: makeVehicleOrders('103', 'own', 'unverified', 29020) },
],
},
{
customerId: 'c-qb',
customerName: '四川群彬物流有限公司',
category: 'external' as const,
totalKg: Math.round(54869 * factor),
totalFeeWan: (246.91 * factor).toFixed(2),
vehicles: [
{ plateNo: '川A77123', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-3301', count: 110, kg: 33000, amount: 148500, orders: makeVehicleOrders('3301', 'external', 'verified', 33000) },
{ plateNo: '川A77124', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-332', count: 72, kg: 21869, amount: 98410, orders: makeVehicleOrders('332', 'external', 'verified', 21869) },
],
},
],
},
{
stationId: 'st-jr',
stationName: '嘉兴嘉燃加氢站',
stationType: 'self_use' as const,
province: '浙江省',
totalKg: Math.round(28234 * factor),
totalFeeWan: (127.05 * factor).toFixed(2),
customers: [
{
customerId: 'c-yj',
customerName: '浙江洋井供应链管理有限公司',
category: 'external' as const,
totalKg: Math.round(28234 * factor),
totalFeeWan: (127.05 * factor).toFixed(2),
vehicles: [
{ plateNo: '浙B99112', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-4411', count: 60, kg: 18350, amount: 82575, orders: makeVehicleOrders('4411', 'external', 'verified', 18350) },
{ plateNo: '浙B99113', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-4412', count: 32, kg: 9884, amount: 44478, orders: makeVehicleOrders('4412', 'external', 'verified', 9884) },
],
},
],
},
{
stationId: 'st-ln',
stationName: '桐乡中石化绿能加氢站',
stationType: 'external_sale' as const,
province: '浙江省',
totalKg: Math.round(26080 * factor),
totalFeeWan: (117.36 * factor).toFixed(2),
customers: [
{
customerId: 'c-js',
customerName: '重庆金时源供应链有限公司',
category: 'external' as const,
totalKg: Math.round(26080 * factor),
totalFeeWan: (117.36 * factor).toFixed(2),
vehicles: [
{ plateNo: '渝A66881', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-5501', count: 52, kg: 15648, amount: 70416, orders: makeVehicleOrders('5501', 'external', 'verified', 15648) },
{ plateNo: '渝A66882', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-552', count: 35, kg: 10432, amount: 46944, orders: makeVehicleOrders('552', 'external', 'verified', 10432) },
],
},
],
},
{
stationId: 'st-tf',
stationName: '成都中石化天府机场北站',
stationType: 'external_sale' as const,
province: '四川省',
totalKg: Math.round(22929 * factor),
totalFeeWan: (103.18 * factor).toFixed(2),
customers: [
{
customerId: 'c-gz',
customerName: '四川拱照物流有限公司',
category: 'external' as const,
totalKg: Math.round(22929 * factor),
totalFeeWan: (103.18 * factor).toFixed(2),
vehicles: [
{ plateNo: '川A88901', fleetCategory: 'external' as const, source: 'api' as const, certNo: 'API-20260808-8801', count: 53, kg: 16050, amount: 72225, orders: makeVehicleOrders('8801', 'external', 'verified', 16050) },
{ plateNo: '川A88902', fleetCategory: 'external' as const, source: 'station_report' as const, certNo: 'ST-REP-202608-8802', count: 23, kg: 6879, amount: 30955, orders: makeVehicleOrders('8802', 'external', 'verified', 6879) },
],
},
],
},
];
}, [year]);
// 按条件过滤(站/客户/车牌可搜索选择 + 车辆归属 + 顶栏核对范围)
const filteredStations = useMemo(() => {
return drillStations
.map((st) => {
if (stationFilter !== 'all' && st.stationId !== stationFilter) return null;
const filteredCustomers = st.customers
.map((cust) => {
if (customerFilter !== 'all' && cust.customerId !== customerFilter) return null;
const filteredVehicles = cust.vehicles
.map((vh) => {
if (fleetCategoryFilter !== 'all' && vh.fleetCategory !== fleetCategoryFilter) return null;
if (plateFilter !== 'all' && vh.plateNo !== plateFilter) return null;
if (verifyScope === 'verified') {
// 仅已核对:外部车不参与核对;羚牛车只保留已核订单
if (vh.fleetCategory !== 'own') return null;
const orders = vh.orders.filter((o) => o.verifyStatus === 'verified');
if (orders.length === 0) return null;
const kg = orders.reduce((s, o) => s + o.kg, 0);
const amount = orders.reduce((s, o) => s + o.amount, 0);
return { ...vh, orders, count: orders.length, kg, amount };
}
return vh;
})
.filter(Boolean) as typeof cust.vehicles;
if (filteredVehicles.length === 0) return null;
return {
...cust,
vehicles: filteredVehicles,
totalKg: filteredVehicles.reduce((sum, v) => sum + v.kg, 0),
totalFeeWan: (filteredVehicles.reduce((sum, v) => sum + v.amount, 0) / 10000).toFixed(2),
};
})
.filter(Boolean) as typeof st.customers;
if (filteredCustomers.length === 0) return null;
return {
...st,
customers: filteredCustomers,
totalKg: filteredCustomers.reduce((sum, c) => sum + c.totalKg, 0),
totalFeeWan: (filteredCustomers.reduce((sum, c) => sum + parseFloat(c.totalFeeWan), 0)).toFixed(2),
};
})
.filter(Boolean) as typeof drillStations;
}, [drillStations, stationFilter, customerFilter, plateFilter, fleetCategoryFilter, verifyScope]);
// 级联选项:站 → 客户 → 车牌(选项池随上级选择收窄)
const stationOptions = useMemo(
() => drillStations.map((st) => ({ value: st.stationId, label: st.stationName })),
[drillStations],
);
const customerOptions = useMemo(() => {
const map = new Map<string, string>();
drillStations.forEach((st) => {
if (stationFilter !== 'all' && st.stationId !== stationFilter) return;
st.customers.forEach((c) => {
if (!map.has(c.customerId)) map.set(c.customerId, c.customerName);
});
});
return Array.from(map.entries()).map(([value, label]) => ({ value, label }));
}, [drillStations, stationFilter]);
const plateOptions = useMemo(() => {
const set = new Set<string>();
drillStations.forEach((st) => {
if (stationFilter !== 'all' && st.stationId !== stationFilter) return;
st.customers.forEach((c) => {
if (customerFilter !== 'all' && c.customerId !== customerFilter) return;
c.vehicles.forEach((vh) => {
if (fleetCategoryFilter !== 'all' && vh.fleetCategory !== fleetCategoryFilter) return;
set.add(vh.plateNo);
});
});
});
return Array.from(set).map((plate) => ({
value: plate,
label: /无车牌/.test(plate) ? '无车牌' : plate,
}));
}, [drillStations, stationFilter, customerFilter, fleetCategoryFilter]);
// 上级变更时清掉下级无效选中
useEffect(() => {
if (customerFilter !== 'all' && !customerOptions.some((o) => o.value === customerFilter)) {
setCustomerFilter('all');
setPlateFilter('all');
}
}, [customerOptions, customerFilter]);
useEffect(() => {
if (plateFilter !== 'all' && !plateOptions.some((o) => o.value === plateFilter)) {
setPlateFilter('all');
}
}, [plateOptions, plateFilter]);
// KPI 穿透列口径:默认量/金额;加氢利润→收入/成本/利润;本月→月量/月费/占年比;本日→日量/日费/占月比;月度柱→站×内外部客户量;收支柱→站×收入/成本
const isProfitDrill = label === '加氢利润';
const isMonthDrill = label === '本月加氢';
const isDayDrill = label === '本日加氢';
const monthMetricMatch = label.match(/^(\d{4})年(\d{1,2})月(加氢量|客户收入|成本支出)$/);
const isMonthBarDrill = monthMetricMatch?.[3] === '加氢量';
const isMonthIncomeDrill = monthMetricMatch?.[3] === '客户收入';
const isMonthCostDrill = monthMetricMatch?.[3] === '成本支出';
const isStationMonthFlat = isMonthBarDrill || isMonthIncomeDrill || isMonthCostDrill;
const stationCustMatch = label.match(/^加氢站(?:客户量)?(.+)$/);
const isStationCustomerDrill = Boolean(stationCustMatch);
const stationCustTarget = stationCustMatch?.[1]?.trim() ?? '';
const regionMatch = label.match(/^区域(市|省)(.+)$/);
const isRegionDrill = Boolean(regionMatch);
const regionKind = regionMatch?.[1] as '市' | '省' | undefined;
const regionLabel = regionMatch?.[2]?.trim() ?? '';
const isFlatOverviewDrill = isStationMonthFlat || isStationCustomerDrill || isRegionDrill;
const monthBarIndex = monthMetricMatch ? Number(monthMetricMatch[2]) - 1 : -1;
const MONTH_BAR_KG = [85200, 52000, 112800, 135000, 128000, 118000, 122000, 28000];
const monthBarYearKg = MONTH_BAR_KG.reduce((s, n) => s + n, 0) || 1;
const monthBarShare =
monthBarIndex >= 0 && monthBarIndex < MONTH_BAR_KG.length
? MONTH_BAR_KG[monthBarIndex] / monthBarYearKg
: 1;
const incomeRatio = HOST_KPI.incomeWan / (HOST_KPI.costWan || 1);
const profitRatio = HOST_KPI.profitWan / (HOST_KPI.costWan || 1);
const monthShare = HOST_KPI.monthKgT / (HOST_KPI.totalKgT || 1);
const dayShare = HOST_KPI.dayKg / ((HOST_KPI.totalKgT || 1) * 1000);
const toIncomeYuan = (costYuan: number) => Math.round(costYuan * incomeRatio * 100) / 100;
const toProfitYuan = (costYuan: number) => Math.round(costYuan * profitRatio * 100) / 100;
const toMonthKg = (kg: number) => Math.round(kg * monthShare * 100) / 100;
const toMonthFee = (yuan: number) => Math.round(yuan * monthShare * 100) / 100;
const toDayKg = (kg: number) => Math.round(kg * dayShare * 100) / 100;
const toDayFee = (yuan: number) => Math.round(yuan * dayShare * 100) / 100;
const feeWanToYuan = (wan: string | number) => parseFloat(String(wan)) * 10000;
const filteredYearFeeYuan = filteredStations.reduce((s, st) => s + feeWanToYuan(st.totalFeeWan), 0);
const filteredMonthFeeYuan = toMonthFee(filteredYearFeeYuan);
const toFeeYearPct = (monthFeeYuan: number) =>
filteredYearFeeYuan > 0 ? Math.round((monthFeeYuan / filteredYearFeeYuan) * 10000) / 100 : 0;
const toFeeMonthPct = (dayFeeYuan: number) =>
filteredMonthFeeYuan > 0 ? Math.round((dayFeeYuan / filteredMonthFeeYuan) * 10000) / 100 : 0;
const colCount = isProfitDrill || isMonthDrill || isDayDrill ? 8 : 7;
/** 月度柱钻取:各站内部/外部客户加氢量 + 合计(按当月占年份额缩放) */
const stationMonthRows = useMemo(() => {
return filteredStations
.map((st) => {
const internalKg = st.customers
.filter((c) => c.category === 'internal')
.reduce((s, c) => s + c.totalKg, 0);
const externalKg = st.customers
.filter((c) => c.category === 'external')
.reduce((s, c) => s + c.totalKg, 0);
return {
stationId: st.stationId,
stationName: st.stationName,
province: st.province,
internalKg: Math.round(internalKg * monthBarShare),
externalKg: Math.round(externalKg * monthBarShare),
totalKg: Math.round((internalKg + externalKg) * monthBarShare),
};
})
.sort((a, b) => b.totalKg - a.totalKg);
}, [filteredStations, monthBarShare]);
/** 月度收支柱钻取:各站客户收入 / 成本支出 */
const stationRevRows = useMemo(() => {
return filteredStations
.map((st) => {
const monthFee = feeWanToYuan(st.totalFeeWan) * monthBarShare;
return {
stationId: st.stationId,
stationName: st.stationName,
province: st.province,
incomeYuan: Math.round(monthFee * incomeRatio),
costYuan: Math.round(monthFee),
};
})
.sort((a, b) =>
isMonthCostDrill ? b.costYuan - a.costYuan : b.incomeYuan - a.incomeYuan,
);
}, [filteredStations, monthBarShare, incomeRatio, isMonthCostDrill]);
const monthBarInternalSum = stationMonthRows.reduce((s, r) => s + r.internalKg, 0);
const monthBarExternalSum = stationMonthRows.reduce((s, r) => s + r.externalKg, 0);
const monthBarTotalSum = stationMonthRows.reduce((s, r) => s + r.totalKg, 0);
const monthIncomeSum = stationRevRows.reduce((s, r) => s + r.incomeYuan, 0);
const monthCostSum = stationRevRows.reduce((s, r) => s + r.costYuan, 0);
/** Top5 / 站名钻取:该站内部客户 vs 外部客户加氢总量 */
const stationCustomerRows = useMemo(() => {
if (!isStationCustomerDrill || !stationCustTarget) return [];
const st =
drillStations.find((s) => s.stationName === stationCustTarget) ||
drillStations.find(
(s) =>
s.stationName.includes(stationCustTarget.replace(/加氢站$/, '')) ||
stationCustTarget.includes(s.stationName.replace(/加氢站$/, '')),
);
if (!st) {
const top = [
{ 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 },
].find((t) => t.name === stationCustTarget || stationCustTarget.includes(t.name.slice(0, 6)));
if (!top) return [];
return [
{ customerId: 'own', customerName: '内部客户合计', category: 'internal' as const, totalKg: top.ownKg },
{ customerId: 'ext', customerName: '外部客户合计', category: 'external' as const, totalKg: top.extKg },
];
}
return st.customers
.map((c) => ({
customerId: c.customerId,
customerName: c.customerName,
category: c.category,
totalKg: c.totalKg,
}))
.sort((a, b) => b.totalKg - a.totalKg);
}, [drillStations, isStationCustomerDrill, stationCustTarget]);
const stationCustInternalKg = stationCustomerRows
.filter((r) => r.category === 'internal')
.reduce((s, r) => s + r.totalKg, 0);
const stationCustExternalKg = stationCustomerRows
.filter((r) => r.category === 'external')
.reduce((s, r) => s + r.totalKg, 0);
const stationCustTotalKg = stationCustInternalKg + stationCustExternalKg;
/** 区域(市/省)钻取:区域内各站加氢总量与占比 */
const regionStationRows = useMemo(() => {
if (!isRegionDrill || !regionLabel) return [];
const CITY_KEYS: Record<string, string[]> = {
嘉兴市: ['嘉兴', '桐乡', '嘉善'],
成都市: ['成都'],
佛山市: ['佛山'],
昆山市: ['昆山'],
常熟市: ['常熟'],
广州市: ['广州'],
深圳市: ['深圳'],
无锡市: ['无锡'],
};
const matchCity = (name: string, city: string) => {
if (city === '其他城市') {
const keys = Object.values(CITY_KEYS).flat();
return !keys.some((k) => name.includes(k));
}
const keys = CITY_KEYS[city] || [city.replace(/市$/, '')];
return keys.some((k) => name.includes(k));
};
const list = MOCK_STATION_SUMMARY_LIST.filter((st) => {
if (regionKind === '省') {
if (regionLabel === '其他省份') {
return !['浙江省', '四川省', '广东省', '江苏省'].includes(st.province);
}
return st.province === regionLabel;
}
return matchCity(st.name, regionLabel);
});
const totalKg = list.reduce((s, st) => s + parseFloat(st.kgT) * 1000, 0) || 1;
return list
.map((st) => {
const kg = Math.round(parseFloat(st.kgT) * 1000);
return {
name: st.name,
province: st.province,
kg,
kgT: st.kgT,
pct: Math.round((kg / totalKg) * 10000) / 100,
incomeWan: st.incomeWan,
};
})
.sort((a, b) => b.kg - a.kg);
}, [isRegionDrill, regionKind, regionLabel]);
const regionKgSum = regionStationRows.reduce((s, r) => s + r.kg, 0);
const renderMetricCells = (kg: number, feeYuan: number) => {
if (isProfitDrill) {
return (
<>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#10b981', fontWeight: 600 }}>
¥{toIncomeYuan(feeYuan).toLocaleString('zh-CN')}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#f59e0b', fontWeight: 600 }}>
¥{feeYuan.toLocaleString('zh-CN')}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#0284c7', fontWeight: 700 }}>
¥{toProfitYuan(feeYuan).toLocaleString('zh-CN')}
</td>
</>
);
}
if (isMonthDrill) {
const mKg = toMonthKg(kg);
const mFee = toMonthFee(feeYuan);
return (
<>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#0f172a', fontWeight: 600 }}>
{mKg.toLocaleString('zh-CN')} Kg
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#f59e0b', fontWeight: 600 }}>
¥{mFee.toLocaleString('zh-CN')}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#0284c7', fontWeight: 700 }}>
{toFeeYearPct(mFee).toFixed(2)}%
</td>
</>
);
}
if (isDayDrill) {
const dKg = toDayKg(kg);
const dFee = toDayFee(feeYuan);
return (
<>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#0f172a', fontWeight: 600 }}>
{dKg.toLocaleString('zh-CN')} Kg
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#f59e0b', fontWeight: 600 }}>
¥{dFee.toLocaleString('zh-CN')}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#0284c7', fontWeight: 700 }}>
{toFeeMonthPct(dFee).toFixed(2)}%
</td>
</>
);
}
return (
<>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#0f172a', fontWeight: 600 }}>
{kg.toLocaleString('zh-CN')} Kg
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#10b981' }}>
¥{feeYuan.toLocaleString('zh-CN')}
</td>
</>
);
};
const handleExportDrillExcel = () => {
if (isMonthBarDrill) {
const aoa: (string | number)[][] = [
['加氢站名称', '所属省份', '内部客户加氢总量(Kg)', '外部客户加氢总量(Kg)', '合计加氢总量(Kg)'],
];
stationMonthRows.forEach((r) => {
aoa.push([r.stationName, r.province, r.internalKg, r.externalKg, r.totalKg]);
});
downloadExcelAoa(aoa, `${label}_各站内外部客户加氢量.xlsx`, '月度各站加氢量');
return;
}
if (isMonthIncomeDrill) {
const aoa: (string | number)[][] = [['加氢站名称', '所属省份', '客户收入(元)']];
stationRevRows.forEach((r) => {
aoa.push([r.stationName, r.province, r.incomeYuan]);
});
downloadExcelAoa(aoa, `${label}_各站客户收入.xlsx`, '月度各站客户收入');
return;
}
if (isMonthCostDrill) {
const aoa: (string | number)[][] = [['加氢站名称', '所属省份', '成本支出(元)']];
stationRevRows.forEach((r) => {
aoa.push([r.stationName, r.province, r.costYuan]);
});
downloadExcelAoa(aoa, `${label}_各站成本支出.xlsx`, '月度各站成本支出');
return;
}
if (isStationCustomerDrill) {
const aoa: (string | number)[][] = [
['加氢站', '客户名称', '客户类型', '加氢总量(Kg)'],
];
stationCustomerRows.forEach((r) => {
aoa.push([
stationCustTarget,
r.customerName,
r.category === 'internal' ? '内部客户' : '外部客户',
r.totalKg,
]);
});
downloadExcelAoa(aoa, `${stationCustTarget}_内外部客户加氢量.xlsx`, '站客户加氢量');
return;
}
if (isRegionDrill) {
const aoa: (string | number)[][] = [
['区域', '加氢站名称', '所属省份', '加氢总量(Kg)', '区域内占比(%)'],
];
regionStationRows.forEach((r) => {
aoa.push([regionLabel, r.name, r.province, r.kg, r.pct]);
});
downloadExcelAoa(aoa, `${regionLabel}_各站加氢总量占比.xlsx`, '区域各站加氢量');
return;
}
const aoa: (string | number)[][] = [
isProfitDrill
? [
'加氢站名称',
'客户名称',
'车牌号',
'车辆归属',
'订单编号',
'加氢时间',
'数据来源',
'订单核对状态',
'加氢量(Kg)',
'收入(元)',
'成本(元)',
'利润(元)',
]
: isMonthDrill
? [
'加氢站名称',
'客户名称',
'车牌号',
'车辆归属',
'订单编号',
'加氢时间',
'数据来源',
'订单核对状态',
'本月加氢量(Kg)',
'本月加氢费(元)',
'加氢费占年比(%)',
]
: isDayDrill
? [
'加氢站名称',
'客户名称',
'车牌号',
'车辆归属',
'订单编号',
'加氢时间',
'数据来源',
'订单核对状态',
'本日加氢量(Kg)',
'本日加氢费(元)',
'加氢费占月比(%)',
]
: [
'加氢站名称',
'加氢站类型',
'客户名称',
'客户类型',
'车牌号',
'车辆归属',
'订单编号',
'加氢时间',
'数据来源',
'来源凭证/接口流水号',
'订单核对状态',
'单价(元/Kg)',
'加氢量(Kg)',
'加氢金额(元)',
],
];
drillStations.forEach((st) => {
st.customers.forEach((cust) => {
cust.vehicles.forEach((vh) => {
vh.orders.forEach((ord) => {
if (isProfitDrill) {
aoa.push([
st.stationName,
cust.customerName,
vh.plateNo,
vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆',
ord.orderId,
ord.time,
SOURCE_TYPE_LABEL[ord.source] || ord.source,
vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[ord.verifyStatus || 'unverified'] || '未核对') : '-',
ord.kg,
toIncomeYuan(ord.amount),
ord.amount,
toProfitYuan(ord.amount),
]);
} else if (isMonthDrill) {
const mFee = toMonthFee(ord.amount);
aoa.push([
st.stationName,
cust.customerName,
vh.plateNo,
vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆',
ord.orderId,
ord.time,
SOURCE_TYPE_LABEL[ord.source] || ord.source,
vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[ord.verifyStatus || 'unverified'] || '未核对') : '-',
toMonthKg(ord.kg),
mFee,
toFeeYearPct(mFee),
]);
} else if (isDayDrill) {
const dFee = toDayFee(ord.amount);
aoa.push([
st.stationName,
cust.customerName,
vh.plateNo,
vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆',
ord.orderId,
ord.time,
SOURCE_TYPE_LABEL[ord.source] || ord.source,
vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[ord.verifyStatus || 'unverified'] || '未核对') : '-',
toDayKg(ord.kg),
dFee,
toFeeMonthPct(dFee),
]);
} else {
aoa.push([
st.stationName,
st.stationType === 'self_use' ? '自用消费' : '对外销售',
cust.customerName,
cust.category === 'internal' ? '内部客户' : '外部客户',
vh.plateNo,
vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆',
ord.orderId,
ord.time,
SOURCE_TYPE_LABEL[ord.source] || ord.source,
ord.certNo,
vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[ord.verifyStatus || 'unverified'] || '未核对') : '-',
ord.unitPrice,
ord.kg,
ord.amount,
]);
}
});
});
});
});
const fileName = isProfitDrill
? `${year}年_加氢利润_收入成本穿透明细.xlsx`
: isMonthDrill
? `${year}年_本月加氢_量费占年比穿透明细.xlsx`
: isDayDrill
? `${year}年_本日加氢_量费占月比穿透明细.xlsx`
: `${year}年_${label}_加氢站_客户_车牌_单笔订单穿透流水账单.xlsx`;
const sheetName = isProfitDrill
? '加氢利润收入成本明细'
: isMonthDrill
? '本月加氢穿透明细'
: isDayDrill
? '本日加氢穿透明细'
: 'KPI穿透订单明细';
downloadExcelAoa(aoa, fileName, sheetName);
};
return (
<div className="ehb-modal-overlay" onClick={onClose}>
<div className="ehb-modal-card" onClick={(e) => e.stopPropagation()}>
{/* Modal 头部 */}
<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">
<span>{year}{label}明细</span>
</div>
</div>
</div>
<div className="ehb-modal-head__actions">
<button type="button" className="ehb-modal-close-btn" onClick={onClose} title="关闭">
<X size={18} />
</button>
</div>
</div>
{/* Modal 内容区 */}
<div className="ehb-modal-body">
{isFlatOverviewDrill ? (
isStationCustomerDrill ? (
<>
<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" style={{ color: '#0284c7', fontSize: 13 }}>
{stationCustTarget}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">内部客户加氢总量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{stationCustInternalKg.toLocaleString('zh-CN')} Kg
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">外部客户加氢总量</span>
<span className="ehb-modal-meta-val" style={{ color: '#f59e0b' }}>
{stationCustExternalKg.toLocaleString('zh-CN')} Kg
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">合计加氢总量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0f172a' }}>
{stationCustTotalKg.toLocaleString('zh-CN')} Kg
</span>
</div>
</div>
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={handleExportDrillExcel}
>
<Download size={14} aria-hidden />
导出 Excel
</button>
<div className="ehb-modal-hint-text">该站内部客户与外部客户加氢总量(可纵向滚动)</div>
</div>
</div>
<div className="ehb-modal-table-wrap is-v-scroll">
<table className="ehb-modal-table">
<thead>
<tr>
<th>客户</th>
<th>类型</th>
<th style={{ textAlign: 'right' }}>加氢总量 (Kg)</th>
<th style={{ textAlign: 'right' }}>站内占比</th>
</tr>
</thead>
<tbody>
{stationCustomerRows.length === 0 ? (
<tr>
<td colSpan={4} style={{ textAlign: 'center', color: '#94a3b8', padding: 28 }}>
暂无该站客户数据
</td>
</tr>
) : (
stationCustomerRows.map((row) => (
<tr key={row.customerId}>
<td style={{ fontWeight: 600 }}>{row.customerName}</td>
<td>
<span
style={{
fontSize: 11,
color: row.category === 'internal' ? '#0284c7' : '#b45309',
background: row.category === 'internal' ? '#eff6ff' : '#fffbeb',
padding: '1px 6px',
borderRadius: 4,
}}
>
{row.category === 'internal' ? '内部客户' : '外部客户'}
</span>
</td>
<td
style={{
textAlign: 'right',
fontFamily: 'var(--bi-font-mono)',
fontWeight: 700,
color: row.category === 'internal' ? '#0284c7' : '#f59e0b',
}}
>
{row.totalKg.toLocaleString('zh-CN')}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)' }}>
{stationCustTotalKg > 0
? ((row.totalKg / stationCustTotalKg) * 100).toFixed(1)
: '0.0'}
%
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
) : isRegionDrill ? (
<>
<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" style={{ color: '#0284c7', fontSize: 13 }}>
{regionLabel}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">加氢总量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{(regionKgSum / 1000).toFixed(2)} T
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">覆盖加氢站数</span>
<span className="ehb-modal-meta-val">{regionStationRows.length} </span>
</div>
</div>
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={handleExportDrillExcel}
>
<Download size={14} aria-hidden />
导出 Excel
</button>
<div className="ehb-modal-hint-text">
{regionKind}各加氢站加氢总量与区域内占比(可纵向滚动)
</div>
</div>
</div>
<div className="ehb-modal-table-wrap is-v-scroll">
<table className="ehb-modal-table">
<thead>
<tr>
<th style={{ width: 48 }}>#</th>
<th>加氢站</th>
<th>所属省份</th>
<th style={{ textAlign: 'right' }}>加氢总量 (Kg)</th>
<th style={{ textAlign: 'right' }}>区域内占比</th>
</tr>
</thead>
<tbody>
{regionStationRows.length === 0 ? (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: '#94a3b8', padding: 28 }}>
该区域暂无加氢站数据
</td>
</tr>
) : (
regionStationRows.map((row, idx) => (
<tr key={row.name}>
<td style={{ color: '#94a3b8' }}>{idx + 1}</td>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{row.name}</td>
<td style={{ color: '#64748b' }}>{row.province}</td>
<td
style={{
textAlign: 'right',
fontFamily: 'var(--bi-font-mono)',
fontWeight: 700,
color: '#0284c7',
}}
>
{row.kg.toLocaleString('zh-CN')}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', fontWeight: 600 }}>
{row.pct.toFixed(1)}%
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
) : (
<>
<div className="ehb-modal-meta-bar">
{isMonthBarDrill ? (
<>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">内部客户加氢总量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{monthBarInternalSum.toLocaleString('zh-CN')} Kg
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">外部客户加氢总量</span>
<span className="ehb-modal-meta-val" style={{ color: '#f59e0b' }}>
{monthBarExternalSum.toLocaleString('zh-CN')} Kg
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">合计加氢总量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0f172a' }}>
{monthBarTotalSum.toLocaleString('zh-CN')} Kg
</span>
</div>
</>
) : isMonthIncomeDrill ? (
<>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">客户收入合计</span>
<span className="ehb-modal-meta-val" style={{ color: '#10b981' }}>
¥{monthIncomeSum.toLocaleString('zh-CN')}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">站均收入</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
¥
{(stationRevRows.length
? Math.round(monthIncomeSum / stationRevRows.length)
: 0
).toLocaleString('zh-CN')}
</span>
</div>
</>
) : (
<>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">成本支出合计</span>
<span className="ehb-modal-meta-val" style={{ color: '#f59e0b' }}>
¥{monthCostSum.toLocaleString('zh-CN')}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">站均成本</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
¥
{(stationRevRows.length
? Math.round(monthCostSum / stationRevRows.length)
: 0
).toLocaleString('zh-CN')}
</span>
</div>
</>
)}
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">覆盖加氢站数</span>
<span className="ehb-modal-meta-val">
{(isMonthBarDrill ? stationMonthRows : stationRevRows).length}
</span>
</div>
</div>
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<BiSearchSelect
value={stationFilter}
onChange={(v) => {
setStationFilter(v);
setCustomerFilter('all');
setPlateFilter('all');
}}
options={stationOptions}
allLabel="全部加氢站"
placeholder="搜索加氢站…"
width={220}
/>
<div className="ehb-fleet-segmented" role="radiogroup" aria-label="车辆归属">
<button
type="button"
role="radio"
aria-checked={fleetCategoryFilter === 'all'}
className={`ehb-fleet-btn ${fleetCategoryFilter === 'all' ? 'is-active' : ''}`}
onClick={() => {
setFleetCategoryFilter('all');
setPlateFilter('all');
}}
>
全部车辆
</button>
<button
type="button"
role="radio"
aria-checked={fleetCategoryFilter === 'own'}
className={`ehb-fleet-btn ${fleetCategoryFilter === 'own' ? 'is-active' : ''}`}
onClick={() => {
setFleetCategoryFilter('own');
setPlateFilter('all');
}}
>
<Truck size={14} aria-hidden />
仅羚牛车辆
</button>
<button
type="button"
role="radio"
aria-checked={fleetCategoryFilter === 'external'}
className={`ehb-fleet-btn ${fleetCategoryFilter === 'external' ? 'is-active' : ''}`}
onClick={() => {
setFleetCategoryFilter('external');
setPlateFilter('all');
}}
>
<Truck size={14} aria-hidden />
仅外部车辆
</button>
</div>
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={handleExportDrillExcel}
title="导出 Excel"
>
<Download size={14} aria-hidden />
导出 Excel
</button>
<div className="ehb-modal-hint-text">
{isMonthBarDrill
? '该月各加氢站:内部客户加氢总量 · 外部客户加氢总量 · 合计'
: isMonthIncomeDrill
? '该月各加氢站客户收入'
: '该月各加氢站成本支出'}
</div>
</div>
</div>
<div className="ehb-modal-table-wrap">
<table className="ehb-modal-table">
<thead>
<tr>
<th>加氢站</th>
<th>所属省份</th>
{isMonthBarDrill ? (
<>
<th style={{ textAlign: 'right' }}>内部客户加氢总量 (Kg)</th>
<th style={{ textAlign: 'right' }}>外部客户加氢总量 (Kg)</th>
<th style={{ textAlign: 'right' }}>合计加氢总量 (Kg)</th>
</>
) : (
<th style={{ textAlign: 'right' }}>
{isMonthIncomeDrill ? '客户收入 (元)' : '成本支出 (元)'}
</th>
)}
</tr>
</thead>
<tbody>
{isMonthBarDrill ? (
stationMonthRows.length === 0 ? (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: '#94a3b8', padding: 28 }}>
暂无符合筛选的加氢站数据
</td>
</tr>
) : (
stationMonthRows.map((row) => (
<tr key={row.stationId}>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{row.stationName}</td>
<td style={{ color: '#64748b' }}>{row.province}</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#0284c7', fontWeight: 600 }}>
{row.internalKg.toLocaleString('zh-CN')}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#f59e0b', fontWeight: 600 }}>
{row.externalKg.toLocaleString('zh-CN')}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#0f172a', fontWeight: 700 }}>
{row.totalKg.toLocaleString('zh-CN')}
</td>
</tr>
))
)
) : stationRevRows.length === 0 ? (
<tr>
<td colSpan={3} style={{ textAlign: 'center', color: '#94a3b8', padding: 28 }}>
暂无符合筛选的加氢站数据
</td>
</tr>
) : (
stationRevRows.map((row) => (
<tr key={row.stationId}>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{row.stationName}</td>
<td style={{ color: '#64748b' }}>{row.province}</td>
<td
style={{
textAlign: 'right',
fontFamily: 'var(--bi-font-mono)',
color: isMonthIncomeDrill ? '#10b981' : '#f59e0b',
fontWeight: 700,
}}
>
¥{(isMonthIncomeDrill ? row.incomeYuan : row.costYuan).toLocaleString('zh-CN')}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
)
) : (
<>
{/* 汇总与追溯证明 Card */}
<div className="ehb-modal-meta-bar">
{isProfitDrill ? (
<>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">收入合计</span>
<span className="ehb-modal-meta-val" style={{ color: '#10b981' }}>
¥{toIncomeYuan(
filteredStations.reduce((s, st) => s + feeWanToYuan(st.totalFeeWan), 0),
).toLocaleString('zh-CN')}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">成本合计</span>
<span className="ehb-modal-meta-val" style={{ color: '#f59e0b' }}>
¥{filteredStations
.reduce((s, st) => s + feeWanToYuan(st.totalFeeWan), 0)
.toLocaleString('zh-CN')}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">加氢利润</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
¥{(HOST_KPI.profitWan * 10000).toLocaleString('zh-CN')}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">覆盖加氢站数</span>
<span className="ehb-modal-meta-val">{filteredStations.length} </span>
</div>
</>
) : isMonthDrill ? (
<>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">本月加氢量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{(toMonthKg(filteredStations.reduce((s, st) => s + st.totalKg, 0)) / 1000).toFixed(2)} T
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">本月加氢费</span>
<span className="ehb-modal-meta-val" style={{ color: '#f59e0b' }}>
¥{(toMonthFee(filteredYearFeeYuan) / 10000).toFixed(2)} 万元
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">加氢费占年比</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{(monthShare * 100).toFixed(2)}%
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">覆盖加氢站数</span>
<span className="ehb-modal-meta-val">{filteredStations.length} </span>
</div>
</>
) : isDayDrill ? (
<>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">本日加氢量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{toDayKg(filteredStations.reduce((s, st) => s + st.totalKg, 0)).toLocaleString('zh-CN')} Kg
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">本日加氢费</span>
<span className="ehb-modal-meta-val" style={{ color: '#f59e0b' }}>
¥{toDayFee(filteredYearFeeYuan).toLocaleString('zh-CN')}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">加氢费占月比</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{filteredMonthFeeYuan > 0
? ((toDayFee(filteredYearFeeYuan) / filteredMonthFeeYuan) * 100).toFixed(2)
: '0.00'}
%
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">覆盖加氢站数</span>
<span className="ehb-modal-meta-val">{filteredStations.length} </span>
</div>
</>
) : (
<>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">数据归集总量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{(filteredStations.reduce((s, st) => s + st.totalKg, 0) / 1000).toFixed(2)} T
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">数据总金额</span>
<span className="ehb-modal-meta-val" style={{ color: '#10b981' }}>
¥{filteredStations.reduce((s, st) => s + parseFloat(st.totalFeeWan), 0).toFixed(2)} 万元
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">覆盖加氢站数</span>
<span className="ehb-modal-meta-val">{filteredStations.length} </span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">数据源可追溯率</span>
<span className="ehb-modal-meta-val" style={{ color: '#f59e0b' }}>100% (API/站点上报凭证)</span>
</div>
</>
)}
</div>
{/* 过滤筛选条:加氢站 / 客户 / 车辆(可搜索)+ 车辆归属 + 导出 */}
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<BiSearchSelect
value={stationFilter}
onChange={(v) => {
setStationFilter(v);
setCustomerFilter('all');
setPlateFilter('all');
}}
options={stationOptions}
allLabel="全部加氢站"
placeholder="搜索加氢站…"
width={200}
/>
<BiSearchSelect
value={customerFilter}
onChange={(v) => {
setCustomerFilter(v);
setPlateFilter('all');
}}
options={customerOptions}
allLabel="全部客户"
placeholder="搜索客户…"
width={200}
/>
<BiSearchSelect
value={plateFilter}
onChange={setPlateFilter}
options={plateOptions}
allLabel="全部车辆"
placeholder="搜索车牌…"
width={160}
/>
<div className="ehb-fleet-segmented" role="radiogroup" aria-label="车辆归属">
<button
type="button"
role="radio"
aria-checked={fleetCategoryFilter === 'all'}
className={`ehb-fleet-btn ${fleetCategoryFilter === 'all' ? 'is-active' : ''}`}
onClick={() => {
setFleetCategoryFilter('all');
setPlateFilter('all');
}}
>
全部车辆
</button>
<button
type="button"
role="radio"
aria-checked={fleetCategoryFilter === 'own'}
className={`ehb-fleet-btn ${fleetCategoryFilter === 'own' ? 'is-active' : ''}`}
onClick={() => {
setFleetCategoryFilter('own');
setPlateFilter('all');
}}
>
<Truck size={14} aria-hidden />
仅羚牛车辆
</button>
<button
type="button"
role="radio"
aria-checked={fleetCategoryFilter === 'external'}
className={`ehb-fleet-btn ${fleetCategoryFilter === 'external' ? 'is-active' : ''}`}
onClick={() => {
setFleetCategoryFilter('external');
setPlateFilter('all');
}}
>
<Truck size={14} aria-hidden />
仅外部车辆
</button>
</div>
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={handleExportDrillExcel}
title="导出 Excel 穿透账单"
>
<Download size={14} aria-hidden />
导出 Excel 穿透账单
</button>
<div className="ehb-modal-hint-text">
提示:点击表格行可四级层层展开 【加氢站 客户 车牌 单笔订单与核对明细】
</div>
</div>
</div>
<div className="ehb-h5-scroll-hint"> 左右滑动查看完整数据与凭证列 </div>
{/* 穿透树形表格 */}
<div className="ehb-modal-table-wrap">
<table className="ehb-modal-table">
<thead>
<tr>
<th>加氢站 / 客户 / 车辆与凭证链路</th>
<th>类型 / 归属</th>
<th>数据来源及凭证号</th>
<th>核对状态</th>
<th style={{ textAlign: 'right' }}>加氢笔数</th>
{isProfitDrill ? (
<>
<th style={{ textAlign: 'right' }}>收入 ()</th>
<th style={{ textAlign: 'right' }}>成本 ()</th>
<th style={{ textAlign: 'right' }}>利润 ()</th>
</>
) : isMonthDrill ? (
<>
<th style={{ textAlign: 'right' }}>本月加氢量 (Kg)</th>
<th style={{ textAlign: 'right' }}>本月加氢费 ()</th>
<th style={{ textAlign: 'right' }}>加氢费占年比</th>
</>
) : isDayDrill ? (
<>
<th style={{ textAlign: 'right' }}>本日加氢量 (Kg)</th>
<th style={{ textAlign: 'right' }}>本日加氢费 ()</th>
<th style={{ textAlign: 'right' }}>加氢费占月比</th>
</>
) : (
<>
<th style={{ textAlign: 'right' }}>加氢总量 (Kg)</th>
<th style={{ textAlign: 'right' }}>加氢金额 ()</th>
</>
)}
</tr>
</thead>
<tbody>
{filteredStations.map((st) => {
const isStExpanded = !!expandedStations[st.stationId];
const stationVehicles = st.customers.flatMap((c) => c.vehicles);
const stationVerify = aggregateVehiclesVerifyStatus(stationVehicles);
return (
<React.Fragment key={st.stationId}>
{/* Level 1: 加氢站 */}
<tr
style={{ cursor: 'pointer', background: isStExpanded ? '#f0f9ff' : '#f8fafc', fontWeight: 700 }}
onClick={() => toggleStation(st.stationId)}
>
<td style={{ color: '#0f172a' }}>
<span style={{ marginRight: 6, color: '#0284c7', display: 'inline-block', width: 14 }}>
{isStExpanded ? '▼' : '►'}
</span>
<span>{st.stationName}</span>
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 400, marginLeft: 8 }}>
({st.customers.length} 家客户)
</span>
</td>
<td style={{ color: '#94a3b8' }}>-</td>
<td style={{ color: '#64748b', fontSize: 11 }} title="本站数据由系统自动归集,无需单笔手工录入">
全量自动归集
</td>
<td>{renderAggVerifyTag(stationVerify, '本站羚牛车辆')}</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)' }}>
{st.customers.reduce((sum, c) => sum + c.vehicles.reduce((vs, v) => vs + v.count, 0), 0)}
</td>
{renderMetricCells(st.totalKg, feeWanToYuan(st.totalFeeWan))}
</tr>
{/* Level 2: 客户层 */}
{isStExpanded &&
st.customers.map((cust) => {
const custKey = `${st.stationId}_${cust.customerId}`;
const isCustExpanded = !!expandedCustomers[custKey];
return (
<React.Fragment key={custKey}>
<tr
style={{ cursor: 'pointer', background: isCustExpanded ? '#f1f5f9' : '#ffffff', fontSize: 12 }}
onClick={() => toggleCustomer(st.stationId, cust.customerId)}
>
<td style={{ paddingLeft: 28, fontWeight: 600, color: '#1e293b' }}>
<span style={{ marginRight: 6, color: '#0284c7', display: 'inline-block', width: 14 }}>
{isCustExpanded ? '▼' : '►'}
</span>
<span>└─ 客户:{cust.customerName}</span>
</td>
<td style={{ color: '#94a3b8' }}>-</td>
<td style={{ color: '#64748b', fontSize: 11 }}>{cust.vehicles.length} 辆车挂载</td>
<td>-</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)' }}>
{cust.vehicles.reduce((sum, v) => sum + v.count, 0)}
</td>
{renderMetricCells(cust.totalKg, feeWanToYuan(cust.totalFeeWan))}
</tr>
{/* Level 3: 车辆及数据来源与凭证层 (支持折叠展开单笔订单) */}
{isCustExpanded &&
cust.vehicles.map((vh, idx) => {
const vhKey = `${st.stationId}_${cust.customerId}_${vh.plateNo}`;
const isVhExpanded = !!expandedVehicles[vhKey];
const showAllOrders = !!expandedAllVehicleOrders[vhKey];
const displayOrders = showAllOrders ? vh.orders : vh.orders.slice(0, 5);
const aggVerify = computeVehicleVerifyStatus(vh.orders, vh.fleetCategory);
// 无法识别车牌 →「无车牌」类目,与有牌车辆同级;标签固定「外部车辆」。能识别 →「羚牛车辆」/「外部车辆」按归属。
const isNoPlate = !vh.plateNo || /无车牌/.test(vh.plateNo);
const plateDisplay = isNoPlate ? '无车牌' : vh.plateNo;
const isOwnFleet = !isNoPlate && vh.fleetCategory === 'own';
return (
<React.Fragment key={`${custKey}_${idx}`}>
<tr
style={{ cursor: 'pointer', background: isVhExpanded ? '#f1f5f9' : '#ffffff', fontSize: 11 }}
onClick={() => toggleVehicle(st.stationId, cust.customerId, vh.plateNo)}
>
<td style={{ paddingLeft: 52, color: '#334155', fontWeight: 600 }}>
<span style={{ marginRight: 6, color: '#0284c7', display: 'inline-block', width: 14 }}>
{isVhExpanded ? '▼' : '►'}
</span>
<strong style={{ fontFamily: 'var(--bi-font-mono)', color: '#0f172a' }}>{plateDisplay}</strong>
<span style={{ fontSize: 10, color: '#64748b', fontWeight: 400, marginLeft: 6 }}>
({vh.count} 笔订单)
</span>
</td>
<td>
{renderFleetTag(isOwnFleet)}
</td>
<td>
{renderSourceTag(vh.source)}
</td>
<td>
{renderVehicleVerifyTag(vh.fleetCategory, aggVerify)}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#475569' }}>
{vh.count}
</td>
{renderMetricCells(vh.kg, vh.amount)}
</tr>
{/* Level 4: 单笔加氢订单流水与核对明细层 */}
{isVhExpanded && (
<>
{displayOrders.map((ord) => (
<tr key={ord.orderId} style={{ background: '#f8fafc', fontSize: 11 }}>
<td style={{ paddingLeft: 76, color: '#334155' }}>
<span style={{ color: '#cbd5e1', marginRight: 6 }}>└──</span>
<span style={{ color: '#64748b', fontSize: 10, marginRight: 6 }}>订单编号</span>
<span
style={{ fontFamily: 'var(--bi-font-mono)', color: '#0284c7', fontWeight: 600, marginRight: 6 }}
title="加氢订单编号"
>
{ord.orderId}
</span>
<span style={{ color: '#64748b', fontSize: 10 }}>({ord.time})</span>
</td>
<td>
<span style={{ color: '#64748b', fontSize: 10, fontFamily: 'var(--bi-font-mono)' }}>
单价 ¥{ord.unitPrice.toFixed(2)}/Kg
</span>
</td>
<td>
{renderSourceTag(ord.source)}
</td>
<td>
{renderOrderVerifyTag(vh.fleetCategory, ord.verifyStatus)}
</td>
<td style={{ textAlign: 'right', fontFamily: 'var(--bi-font-mono)', color: '#94a3b8' }}>
1
</td>
{renderMetricCells(ord.kg, ord.amount)}
</tr>
))}
<tr style={{ background: '#f8fafc', fontSize: 11 }}>
<td colSpan={colCount} style={{ paddingLeft: 76, paddingTop: 6, paddingBottom: 8 }}>
<span style={{ color: '#cbd5e1', marginRight: 6 }}>└──</span>
<span className="ehb-order-more-row">
<button
type="button"
className="ehb-order-more-btn"
onClick={(e) => {
e.stopPropagation();
toggleShowAllOrders(vhKey);
}}
>
{showAllOrders
? '收起至近 5 笔 ▲'
: `查看全量 ${vh.count} 笔订单流水 ▼`}
</button>
<span className="ehb-order-more-hint">
{showAllOrders
? `包含该车辆共 ${vh.count} 笔历史加氢订单,已展示全量 ${vh.orders.length} 笔穿透流水`
: `包含该车辆共 ${vh.count} 笔历史加氢订单,默认展示近 ${displayOrders.length} 笔穿透核对流水明细`}
</span>
</span>
</td>
</tr>
</>
)}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
</div>
);
}
interface CustomerBillDrillModalProps {
customerName: string;
year: number;
onClose: () => void;
}
function CustomerBillDrillModal({ customerName, year, onClose }: CustomerBillDrillModalProps) {
const [searchTerm, setSearchTerm] = useState('');
const [expandedDates, setExpandedDates] = useState<Record<string, boolean>>({
'2026-08-08': true, // 默认展开最新一日
});
const custSummary = MOCK_CUSTOMER_SUMMARY_LIST.find((c) => c.name === customerName);
const bearerLabel = custSummary?.bearer === 'lingniu' ? '羚牛' : '客户';
const summaryKgT = custSummary ? parseFloat(custSummary.kgT) : 0;
const summaryCostWan = custSummary ? parseFloat(custSummary.costWan) : 0;
const summaryReceivableText = custSummary?.receivable ?? '—';
const toggleDate = (dateStr: string) => {
setExpandedDates((prev) => ({ ...prev, [dateStr]: !prev[dateStr] }));
};
// 根据客户名称与年份建立【客户 -> 日期 -> 车牌加氢记录(最小粒度)】层层下钻明细
const billData = useMemo(() => {
const dates = [
{
date: '2026-08-08',
stationCount: 2,
records: [
{ plateNo: '浙A88888F', fleetCategory: 'own' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-08 08:15', kg: 45.2, receivable: 1356.0 },
{ plateNo: '浙A66666F', fleetCategory: 'own' as const, stationName: '嘉兴嘉锦加氢站', time: '2026-08-08 09:30', kg: 54.8, receivable: 1644.0 },
{ plateNo: '粤B12345D', fleetCategory: 'external' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-08 10:10', kg: 350.0, receivable: 10500.0 },
{ plateNo: '粤B99881D', fleetCategory: 'external' as const, stationName: '嘉兴嘉锦加氢站', time: '2026-08-08 14:20', kg: 280.0, receivable: 8400.0 },
],
},
{
date: '2026-08-07',
stationCount: 1,
records: [
{ plateNo: '浙A88888F', fleetCategory: 'own' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-07 11:20', kg: 48.0, receivable: 1440.0 },
{ plateNo: '浙A33333F', fleetCategory: 'own' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-07 16:45', kg: 52.0, receivable: 1560.0 },
{ plateNo: '沪A66128D', fleetCategory: 'external' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-07 17:30', kg: 120.0, receivable: 3600.0 },
],
},
{
date: '2026-08-06',
stationCount: 2,
records: [
{ plateNo: '浙A88888F', fleetCategory: 'own' as const, stationName: '嘉兴中石化滨海加氢站', time: '2026-08-06 09:10', kg: 42.5, receivable: 1275.0 },
{ plateNo: '川A88901', fleetCategory: 'external' as const, stationName: '成都中石化天府机场高速北站加氢站', time: '2026-08-06 13:15', kg: 210.0, receivable: 6300.0 },
{ plateNo: '川A88902', fleetCategory: 'external' as const, stationName: '成都中石化天府机场高速北站加氢站', time: '2026-08-06 15:50', kg: 180.0, receivable: 5400.0 },
],
},
{
date: '2026-08-05',
stationCount: 1,
records: [
{ plateNo: '浙A66666F', fleetCategory: 'own' as const, stationName: '桐乡中石化绿能加氢站', time: '2026-08-05 10:40', kg: 50.0, receivable: 1500.0 },
{ plateNo: '渝A66881', fleetCategory: 'external' as const, stationName: '桐乡中石化绿能加氢站', time: '2026-08-05 14:05', kg: 310.0, receivable: 9300.0 },
],
},
];
return dates.map((d) => {
const filteredRecords = d.records.filter((r) => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
(r.plateNo && r.plateNo.toLowerCase().includes(term)) ||
r.stationName.toLowerCase().includes(term)
);
});
const dayKg = filteredRecords.reduce((sum, r) => sum + r.kg, 0);
const dayReceivable = filteredRecords.reduce((sum, r) => sum + r.receivable, 0);
const dayCost =
summaryKgT > 0
? Math.round((dayKg / (summaryKgT * 1000)) * summaryCostWan * 10000 * 100) / 100
: Math.round(dayReceivable * 0.9 * 100) / 100;
return {
...d,
records: filteredRecords,
totalKg: Math.round(dayKg * 10) / 10,
totalReceivable: Math.round(dayReceivable * 100) / 100,
totalCost: dayCost,
};
}).filter((d) => d.records.length > 0);
}, [searchTerm, summaryKgT, summaryCostWan]);
const totalKgSum = useMemo(() => {
return billData.reduce((sum, d) => sum + d.totalKg, 0);
}, [billData]);
const totalReceivableSum = useMemo(() => {
return billData.reduce((sum, d) => sum + d.totalReceivable, 0);
}, [billData]);
// 导出客户账单 Excel (.xlsx)
const handleExportExcel = () => {
const aoa: (string | number)[][] = [
['客户名称', '日期', '车牌号', '车辆归属', '加氢站', '加氢时间', '加氢量(Kg)', '应收(元)', '已收', '未收'],
];
billData.forEach((d) => {
d.records.forEach((r) => {
aoa.push([
customerName,
d.date,
r.plateNo || '无车牌(散车)',
r.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆',
r.stationName,
r.time,
r.kg,
r.receivable,
'敬请期待 (对接账户)',
'敬请期待 (对接账单)',
]);
});
});
downloadExcelAoa(aoa, `客户账单穿透流水_${customerName}_${year}年.xlsx`, '客户账单穿透流水');
};
return (
<div className="ehb-modal-overlay" onClick={onClose}>
<div className="ehb-modal-card" onClick={(e) => e.stopPropagation()}>
{/* Modal 头部 */}
<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">
<span>{customerName}」客户账单明细</span>
</div>
</div>
</div>
<div className="ehb-modal-head__actions">
<button type="button" className="ehb-modal-close-btn" onClick={onClose} title="关闭">
<X size={18} />
</button>
</div>
</div>
{/* Modal 内容区 */}
<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" style={{ color: '#0284c7', fontSize: 13 }}>
{bearerLabel}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">加氢量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{summaryKgT > 0 ? summaryKgT.toFixed(2) : (totalKgSum / 1000).toFixed(2)}{' '}
<span style={{ fontSize: 11, fontWeight: 400 }}>T</span>
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">成本支出</span>
<span className="ehb-modal-meta-val" style={{ color: '#f59e0b' }}>
¥{summaryCostWan > 0 ? summaryCostWan.toFixed(2) : '—'}{' '}
<span style={{ fontSize: 11, fontWeight: 400 }}>万元</span>
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">应收</span>
<span className="ehb-modal-meta-val" style={{ color: '#10b981', fontSize: 13 }}>
{summaryReceivableText}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">已收</span>
<span className="ehb-stay-tuned-tag" title="等待客户能源账户和对账单打通后获取">
敬请期待
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">未收</span>
<span className="ehb-stay-tuned-tag" title="等待客户能源账户和对账单打通后获取">
敬请期待
</span>
</div>
</div>
{/* 搜寻卡 */}
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<div className="ehb-modal-search-input">
<Search size={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input
type="text"
placeholder="搜索车牌号 / 加氢站..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button type="button" onClick={() => setSearchTerm('')} title="清空">
<X size={12} />
</button>
)}
</div>
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={handleExportExcel}
title="导出 Excel 客户账单"
>
<Download size={14} aria-hidden />
导出 Excel
</button>
<div className="ehb-modal-hint-text">
按日展开:加氢量 · 成本支出 · 应收 · 已收 · 未收
</div>
</div>
</div>
<div className="ehb-h5-scroll-hint"> 左右滑动查看完整关键字段 </div>
{/* 表格:对齐客户账单汇总关键字段 */}
<div className="ehb-modal-table-wrap is-v-scroll">
<table className="ehb-modal-table">
<thead>
<tr>
<th style={{ width: 140 }}>日期 / 车牌明细</th>
<th>加氢站</th>
<th style={{ textAlign: 'center' }}>承担方</th>
<th style={{ textAlign: 'right' }}>加氢量(Kg)</th>
<th style={{ textAlign: 'right' }}>成本支出()</th>
<th style={{ textAlign: 'right' }}>应收()</th>
<th style={{ textAlign: 'center' }}>已收</th>
<th style={{ textAlign: 'center' }}>未收</th>
</tr>
</thead>
<tbody>
{billData.map((day) => {
const isExpanded = !!expandedDates[day.date];
return (
<React.Fragment key={day.date}>
{/* Level 2: 日期层 */}
<tr
style={{ background: '#f8fafc', fontWeight: 600, cursor: 'pointer' }}
onClick={() => toggleDate(day.date)}
>
<td className="ehb-tree-cell-l1" style={{ color: '#0284c7' }}>
<div className="ehb-tree-node-title">
<span style={{ marginRight: 6 }}>{isExpanded ? '▼' : '►'}</span>
<span>📅 {day.date}</span>
</div>
</td>
<td style={{ color: '#64748b', fontSize: 12 }}>
涉及 {day.stationCount} 个加氢站 · {day.records.length}
</td>
<td style={{ textAlign: 'center' }}>
<span
className={`ehb-bearer-tag ${custSummary?.bearer === 'cust' ? 'is-cust' : 'is-lingniu'}`}
>
{bearerLabel}
</span>
</td>
<td style={{ textAlign: 'right', color: '#0284c7' }}>
{day.totalKg.toLocaleString('zh-CN')} Kg
</td>
<td style={{ textAlign: 'right', color: '#f59e0b', fontFamily: 'var(--bi-font-mono)' }}>
¥{day.totalCost.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ textAlign: 'right', color: '#10b981' }}>
¥{day.totalReceivable.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ textAlign: 'center' }}>
<span className="ehb-stay-tuned-tag" title="等待客户能源账户和对账单打通后获取">
敬请期待
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span className="ehb-stay-tuned-tag" title="等待客户能源账户和对账单打通后获取">
敬请期待
</span>
</td>
</tr>
{/* Level 3: 车牌加氢记录 (最小粒度) */}
{isExpanded &&
day.records.map((rec, rIdx) => {
const rowCost =
day.totalKg > 0
? Math.round((rec.kg / day.totalKg) * day.totalCost * 100) / 100
: 0;
return (
<tr key={`${day.date}_${rec.plateNo}_${rIdx}`} style={{ background: '#ffffff' }}>
<td className="ehb-tree-cell-l2" style={{ fontSize: 12 }}>
<div className="ehb-tree-node-title">
<span style={{ color: '#cbd5e1', flexShrink: 0 }}>└──</span>
<span style={{ fontWeight: 600, color: '#1e293b' }}>
{rec.plateNo || '无车牌(散车)'}
</span>
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>
{rec.time}
</span>
</div>
</td>
<td style={{ fontSize: 12, color: '#475569' }}>{rec.stationName}</td>
<td style={{ textAlign: 'center' }}>
<span
className={`ehb-bearer-tag ${custSummary?.bearer === 'cust' ? 'is-cust' : 'is-lingniu'}`}
>
{bearerLabel}
</span>
</td>
<td style={{ textAlign: 'right', fontSize: 12, fontWeight: 600, color: '#0284c7', fontFamily: 'var(--bi-font-mono)' }}>
{rec.kg.toFixed(1)} Kg
</td>
<td style={{ textAlign: 'right', fontSize: 12, fontWeight: 600, color: '#f59e0b', fontFamily: 'var(--bi-font-mono)' }}>
¥{rowCost.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ textAlign: 'right', fontSize: 12, fontWeight: 600, color: '#059669', fontFamily: 'var(--bi-font-mono)' }}>
¥{rec.receivable.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td style={{ textAlign: 'center' }}>
<span className="ehb-stay-tuned-tag">敬请期待</span>
</td>
<td style={{ textAlign: 'center' }}>
<span className="ehb-stay-tuned-tag">敬请期待</span>
</td>
</tr>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
interface StationBillDrillModalProps {
stationName: string;
province: string;
year: number;
onClose: () => void;
}
function StationBillDrillModal({ stationName, province, year, onClose }: StationBillDrillModalProps) {
const [searchTerm, setSearchTerm] = useState('');
const [expandedDates, setExpandedDates] = useState<Record<string, boolean>>({
'2026-08-08': true, // 默认展开最新一日
});
const stSummary =
MOCK_STATION_SUMMARY_LIST.find((s) => s.name === stationName) ||
MOCK_STATION_SUMMARY_LIST.find((s) => stationName.includes(s.name.slice(0, 8)) || s.name.includes(stationName.slice(0, 8)));
const summaryKgT = stSummary ? parseFloat(stSummary.kgT) : 0;
const summaryKgPct = stSummary?.kgPct ?? 0;
const summaryIncomeWan = stSummary ? parseFloat(stSummary.incomeWan) : 0;
const summaryIncomePct = stSummary?.incomePct ?? 0;
const toggleDate = (dateStr: string) => {
setExpandedDates((prev) => ({ ...prev, [dateStr]: !prev[dateStr] }));
};
// 全站累计基准总量 (Kg) 与 总氢费收入 (元) — 优先取汇总表关键字段
const totalStationKg = summaryKgT > 0 ? Math.round(summaryKgT * 1000) : 243660;
const totalStationIncome = summaryIncomeWan > 0 ? Math.round(summaryIncomeWan * 10000) : 526600;
// 根据加氢站构建【加氢站 -> 所有日期的加氢量、占比、氢费收入、收入占比】下钻明细
const stationData = useMemo(() => {
const dates = [
{
date: '2026-08-08',
records: [
{ plateNo: '浙A88888F', fleetCategory: 'own' as const, customerName: '羚牛氢能科技(广东)有限公司', time: '2026-08-08 08:15', kg: 450.2, income: 13506.0 },
{ plateNo: '浙A66666F', fleetCategory: 'own' as const, customerName: '嘉兴市乍浦港口经营有限公司', time: '2026-08-08 09:30', kg: 540.8, income: 16224.0 },
{ plateNo: '粤B12345D', fleetCategory: 'external' as const, customerName: '广东氢动力科技服务有限公司', time: '2026-08-08 10:10', kg: 350.0, income: 10500.0 },
{ plateNo: '沪A66128D', fleetCategory: 'external' as const, customerName: '上海明纳物流有限公司', time: '2026-08-08 14:20', kg: 280.0, income: 8400.0 },
],
},
{
date: '2026-08-07',
records: [
{ plateNo: '浙A88888F', fleetCategory: 'own' as const, customerName: '羚牛氢能科技(广东)有限公司', time: '2026-08-07 11:20', kg: 480.0, income: 14400.0 },
{ plateNo: '浙A33333F', fleetCategory: 'own' as const, customerName: '嘉兴益顺冷链物流有限公司', time: '2026-08-07 16:45', kg: 520.0, income: 15600.0 },
{ plateNo: '粤B99881D', fleetCategory: 'external' as const, customerName: '嘉兴智奇供应链管理有限公司', time: '2026-08-07 17:30', kg: 410.0, income: 12300.0 },
],
},
{
date: '2026-08-06',
records: [
{ plateNo: '浙A88888F', fleetCategory: 'own' as const, customerName: '羚牛氢能科技(广东)有限公司', time: '2026-08-06 09:10', kg: 425.0, income: 12750.0 },
{ plateNo: '川A88901', fleetCategory: 'external' as const, customerName: '四川群彬物流有限公司', time: '2026-08-06 13:15', kg: 610.0, income: 18300.0 },
{ plateNo: '川A88902', fleetCategory: 'external' as const, customerName: '四川拱照物流有限公司', time: '2026-08-06 15:50', kg: 580.0, income: 17400.0 },
],
},
{
date: '2026-08-05',
records: [
{ plateNo: '浙A66666F', fleetCategory: 'own' as const, customerName: '嘉兴市乍浦港口经营有限公司', time: '2026-08-05 10:40', kg: 500.0, income: 15000.0 },
{ plateNo: '渝A66881', fleetCategory: 'external' as const, customerName: '重庆金时源供应链有限公司', time: '2026-08-05 14:05', kg: 710.0, income: 21300.0 },
],
},
];
return dates.map((d) => {
const filteredRecords = d.records.filter((r) => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
d.date.includes(term) ||
(r.plateNo && r.plateNo.toLowerCase().includes(term)) ||
r.customerName.toLowerCase().includes(term)
);
});
const dayKg = filteredRecords.reduce((sum, r) => sum + r.kg, 0);
const dayIncome = filteredRecords.reduce((sum, r) => sum + r.income, 0);
const dayKgPct = Math.round((dayKg / totalStationKg) * 10000) / 100;
const dayIncomePct = Math.round((dayIncome / totalStationIncome) * 10000) / 100;
return {
...d,
records: filteredRecords,
totalKg: Math.round(dayKg * 10) / 10,
totalKgPct: dayKgPct,
totalIncome: Math.round(dayIncome * 100) / 100,
totalIncomePct: dayIncomePct,
};
}).filter((d) => d.records.length > 0);
}, [searchTerm]);
const totalKgSum = useMemo(() => {
return stationData.reduce((sum, d) => sum + d.totalKg, 0);
}, [stationData]);
const totalIncomeSum = useMemo(() => {
return stationData.reduce((sum, d) => sum + d.totalIncome, 0);
}, [stationData]);
// 导出加氢站按日账单 Excel (.xlsx)
const handleExportExcel = () => {
const aoa: (string | number)[][] = [
['加氢站名称', '所属省份', '日期', '车牌号', '车辆归属', '关联客户', '加氢时间', '加氢量(Kg)', '加氢量占比', '氢费收入(元)', '收入占比'],
];
stationData.forEach((d) => {
d.records.forEach((r) => {
const rKgPct = ((r.kg / totalStationKg) * 100).toFixed(2) + '%';
const rIncomePct = ((r.income / totalStationIncome) * 100).toFixed(2) + '%';
aoa.push([
stationName,
province,
d.date,
r.plateNo || '无车牌(散车)',
r.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆',
r.customerName,
r.time,
r.kg,
rKgPct,
r.income,
rIncomePct,
]);
});
});
downloadExcelAoa(aoa, `加氢站按日账单穿透_${stationName}_${year}年.xlsx`, '站按日账单穿透');
};
return (
<div className="ehb-modal-overlay" onClick={onClose}>
<div className="ehb-modal-card" onClick={(e) => e.stopPropagation()}>
{/* Modal 头部 */}
<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">
<span>{stationName}」加氢汇总明细</span>
</div>
</div>
</div>
<div className="ehb-modal-head__actions">
<button type="button" className="ehb-modal-close-btn" onClick={onClose} title="关闭">
<X size={18} />
</button>
</div>
</div>
{/* Modal 内容区 */}
<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" style={{ color: '#0284c7', fontSize: 13 }}>
{province}
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">加氢量</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{(totalStationKg / 1000).toFixed(2)} <span style={{ fontSize: 11, fontWeight: 400 }}>T</span>
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">占比</span>
<span className="ehb-modal-meta-val" style={{ color: '#0284c7' }}>
{summaryKgPct.toFixed(1)}%
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">氢费收入</span>
<span className="ehb-modal-meta-val" style={{ color: '#10b981' }}>
¥{(totalStationIncome / 10000).toFixed(2)} <span style={{ fontSize: 11, fontWeight: 400 }}>万元</span>
</span>
</div>
<div className="ehb-modal-meta-item">
<span className="ehb-modal-meta-label">收入占比</span>
<span className="ehb-modal-meta-val" style={{ color: '#10b981' }}>
{summaryIncomePct.toFixed(1)}%
</span>
</div>
</div>
{/* 搜寻卡 */}
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<div className="ehb-modal-search-input">
<Search size={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input
type="text"
placeholder="搜索日期 / 车牌号 / 客户..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button type="button" onClick={() => setSearchTerm('')} title="清空">
<X size={12} />
</button>
)}
</div>
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={handleExportExcel}
title="导出 Excel"
>
<Download size={14} aria-hidden />
导出 Excel
</button>
<div className="ehb-modal-hint-text">
按日展开关键字段:加氢量 · 占比 · 氢费收入 · 收入占比
</div>
</div>
</div>
<div className="ehb-h5-scroll-hint"> 左右滑动查看完整数据与状态 </div>
{/* 表格:对齐加氢站汇总关键字段(按日) */}
<div className="ehb-modal-table-wrap is-v-scroll">
<table className="ehb-modal-table">
<thead>
<tr>
<th style={{ width: 140 }}>日期 / 车牌明细</th>
<th>关联客户 / 车辆归属</th>
<th>加氢时间</th>
<th style={{ textAlign: 'right' }}>加氢量(Kg)</th>
<th style={{ textAlign: 'right', width: 120 }}>加氢量占比</th>
<th style={{ textAlign: 'right' }}>氢费收入()</th>
<th style={{ textAlign: 'right', width: 120 }}>收入占比</th>
</tr>
</thead>
<tbody>
{stationData.map((day) => {
const isExpanded = !!expandedDates[day.date];
return (
<React.Fragment key={day.date}>
{/* Level 1: 所有日期的加氢量、占比、氢费收入、收入占比 */}
<tr
style={{ background: '#f8fafc', fontWeight: 600, cursor: 'pointer' }}
onClick={() => toggleDate(day.date)}
>
<td className="ehb-tree-cell-l1" style={{ color: '#0284c7' }}>
<div className="ehb-tree-node-title">
<span style={{ marginRight: 6 }}>{isExpanded ? '▼' : '►'}</span>
<span>📅 {day.date}</span>
</div>
</td>
<td style={{ color: '#64748b', fontSize: 12 }}>
{day.records.length} 笔车辆加氢发生
</td>
<td style={{ color: '#94a3b8', fontSize: 12 }}>当天汇总</td>
<td style={{ textAlign: 'right', color: '#0284c7' }}>
{day.totalKg.toLocaleString('zh-CN')} Kg
</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, day.totalKgPct * 10)}%` }}
/>
</div>
<span className="ehb-ratio-text">{day.totalKgPct.toFixed(2)}%</span>
</div>
</td>
<td style={{ textAlign: 'right', color: '#10b981' }}>
¥{day.totalIncome.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</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, day.totalIncomePct * 10)}%` }}
/>
</div>
<span className="ehb-ratio-text">{day.totalIncomePct.toFixed(2)}%</span>
</div>
</td>
</tr>
{/* Level 2: 车牌/客户加氢记录 (最小粒度) */}
{isExpanded &&
day.records.map((rec, rIdx) => {
const rKgPct = Math.round((rec.kg / totalStationKg) * 10000) / 100;
const rIncomePct = Math.round((rec.income / totalStationIncome) * 10000) / 100;
return (
<tr key={`${day.date}_${rec.plateNo}_${rIdx}`} style={{ background: '#ffffff' }}>
<td className="ehb-tree-cell-l2" style={{ fontSize: 12 }}>
<div className="ehb-tree-node-title">
<span style={{ color: '#cbd5e1', flexShrink: 0 }}>└──</span>
<span style={{ fontWeight: 600, color: '#1e293b' }}>
{rec.plateNo || '无车牌(散车)'}
</span>
<span
className={`ehb-tag ${rec.fleetCategory === 'own' ? 'is-own' : 'is-ext'}`}
style={{ marginLeft: 6, fontSize: 10 }}
>
{rec.fleetCategory === 'own' ? '羚牛' : '外部'}
</span>
</div>
</td>
<td style={{ fontSize: 12, color: '#475569' }}>{rec.customerName}</td>
<td style={{ fontSize: 12, color: '#64748b', fontFamily: 'var(--bi-font-mono)' }}>
{rec.time}
</td>
<td style={{ textAlign: 'right', fontSize: 12, fontWeight: 600, color: '#0284c7', fontFamily: 'var(--bi-font-mono)' }}>
{rec.kg.toFixed(1)} Kg
</td>
<td>
<span style={{ fontSize: 11, color: '#64748b' }}>{rKgPct.toFixed(2)}%</span>
</td>
<td style={{ textAlign: 'right', fontSize: 12, fontWeight: 600, color: '#059669', fontFamily: 'var(--bi-font-mono)' }}>
¥{rec.income.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</td>
<td>
<span style={{ fontSize: 11, color: '#64748b' }}>{rIncomePct.toFixed(2)}%</span>
</td>
</tr>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
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>{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>{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>{r.stationName}</td>
<td className="ehb-mono">{r.plateNo}</td>
<td>{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;
}
function HostDailyView({
updatedAt,
onRefresh,
startDate,
endDate,
onStartDateChange,
onEndDateChange,
}: HostDailyViewProps) {
const [rangePreset, setRangePreset] = useState<'week' | 'month' | '15days' | 'custom'>('15days');
const [fleetType, setFleetType] = useState<FleetCategoryFilter>('all');
// 上方时间预设连动 KPI 卡片标题
const kpiRangeTitle = useMemo(() => {
if (rangePreset === 'week') return '本周加氢量';
if (rangePreset === 'month') return '本月加氢量';
if (rangePreset === '15days') return '近 15 天加氢量';
return '自定义区间加氢量';
}, [rangePreset]);
const handlePresetChange = (preset: 'week' | 'month' | '15days' | 'custom') => {
setRangePreset(preset);
if (preset === 'week') {
onStartDateChange('2026-08-03');
onEndDateChange('2026-08-08');
} else if (preset === 'month') {
onStartDateChange('2026-08-01');
onEndDateChange('2026-08-08');
} else if (preset === '15days') {
onStartDateChange('2026-07-25');
onEndDateChange('2026-08-08');
}
};
// 日期归一化转换(兼容手选 年 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]);
// 1. 根据 startDate & endDate 动态生成或提取指定日期范围内的全量每日加氢数据列表
const dateFilteredList = useMemo(() => {
return getDailyDataForRange(normStart, normEnd);
}, [normStart, normEnd]);
// 2. 根据 fleetType 过滤出对应车辆归属下的加氢列表 ('all' 时包含内部与外部合并显示)
const filteredDailyList = useMemo(() => {
return filterDailyDataByFleet(dateFilteredList, fleetType);
}, [dateFilteredList, fleetType]);
// 2. 动态计算关联的 KPI 及柱图统计数据
const dailyKpis = useMemo(() => {
return calculateDailyKpis(filteredDailyList, fleetType);
}, [filteredDailyList, fleetType]);
// 深层折叠/展开状态
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]);
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' ? '羚牛车辆' : '外部车辆';
downloadExcelAoa(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细');
};
return (
<div className="ehb-daily-container">
{/* 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={() => handlePresetChange('week')}
>
本周
</button>
<button
type="button"
className={`ehb-pill-btn ${rangePreset === 'month' ? 'is-active' : ''}`}
onClick={() => handlePresetChange('month')}
>
本月
</button>
<button
type="button"
className={`ehb-pill-btn ${rangePreset === '15days' ? 'is-active' : ''}`}
onClick={() => handlePresetChange('15days')}
>
15
</button>
<button
type="button"
className={`ehb-pill-btn ${rangePreset === 'custom' ? 'is-active' : ''}`}
onClick={() => handlePresetChange('custom')}
>
自定义
</button>
</div>
<BiCustomDatePicker
label="开始日期"
value={startDate}
onChange={(val) => {
onStartDateChange(val);
setRangePreset('custom');
}}
/>
<BiCustomDatePicker
label="结束日期"
value={endDate}
onChange={(val) => {
onEndDateChange(val);
setRangePreset('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={() => setFleetType('all')}
>
<Truck size={14} />
全部车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetType === 'own' ? 'is-active' : ''}`}
onClick={() => setFleetType('own')}
>
<Truck size={14} />
仅羚牛车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetType === 'external' ? 'is-active' : ''}`}
onClick={() => setFleetType('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>
</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">
<Truck size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className="ehb-kpi-dual__num" style={{ fontSize: fleetType === 'all' ? 18 : 24 }}>
{dailyKpis.fleetTypeLabel}
</span>
</div>
<div className="ehb-daily-kpi-sub">{dailyKpis.fleetSubLabel}</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-amber">
<TrendingUp size={14} />
</span>
</div>
<div className="ehb-daily-kpi-val">
<span className="ehb-kpi-dual__num">{dailyKpis.activeDays}</span>
</div>
<div className="ehb-daily-kpi-sub">日均 {dailyKpis.dailyAvgKg}</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}</span>
<span className="ehb-kpi-dual__unit"></span>
</div>
<div className="ehb-daily-kpi-sub">按明细站点去重</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>
<span className="ehb-legend-item">
<span className="ehb-legend-dot is-ext" />
外部客户
</span>
</div>
<span className="ehb-daily-chart-meta">时间单位:日 · 单位 Kg</span>
</div>
</div>
<div className="ehb-daily-summary-pills">
<div className="ehb-daily-pill-item">
<span>峰值日</span>
<strong>{dailyKpis.peakDayLabel}</strong>
</div>
<div className="ehb-daily-pill-item">
<span>低谷日</span>
<strong>{dailyKpis.troughDayLabel}</strong>
</div>
<div className="ehb-daily-pill-item">
<span>零数日</span>
<strong>{dailyKpis.zeroDaysCount} </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;
}
});
});
});
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 ? '#0284c7' : 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 ? '#0284c7' : undefined, fontWeight: isBarActive ? 700 : undefined }}
>
{item.shortDate}
</div>
</div>
);
})}
</div>
</section>
{/* 4. 每日数据明细多层钻取表格 */}
<section className="ehb-daily-table-card">
<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>
<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: '#0284c7' }}>
{row.stations.length > 0 ? (isDateExpanded ? '▼' : '►') : '•'}
</span>
<span>{row.date}</span>
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 400, marginLeft: 8 }}>
({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 style={{ color: row.momPct < 0 ? '#ef4444' : '#10b981', fontWeight: 600 }}>
{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: '#0284c7', 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: '#0284c7' }}
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: '#0284c7', 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 style={{ fontFamily: 'var(--bi-font-mono)', color: '#64748b', marginRight: 10 }}>
{vh.time}
</span>
{vh.plateNo ? (
<span style={{ fontFamily: 'var(--bi-font-mono)', fontWeight: 700, marginRight: 8 }}>
{vh.plateNo}
</span>
) : (
<span style={{ fontStyle: 'italic', color: '#94a3b8', marginRight: 8 }}>
无车牌(散车)
</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>
);
}