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