5279 lines
233 KiB
TypeScript
5279 lines
233 KiB
TypeScript
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>
|
||
);
|
||
}
|