import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft, Calendar, Check, ChevronDown, ChevronLeft, ChevronRight, Download, Fuel, RefreshCw, Wallet, X, } from 'lucide-react'; import * as XLSX from 'xlsx'; import { fetchHydrogenStationBoard } from './api'; import type { HydrogenStationBoardCustomerMonth, HydrogenStationBoardResponse, HydrogenStationBoardStation, HydrogenStationBoardSummaryDailyRow, } from './types'; import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from './components/SortableColumnHeader'; import './styles/energy-bi-board.css'; import './hydrogen-bi-v2/prototype-station-daily.css'; function formatYmd(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function defaultRange() { const end = new Date(); end.setHours(0, 0, 0, 0); const start = new Date(end); start.setDate(start.getDate() - 9); return { start: formatYmd(start), end: formatYmd(end) }; } function formatNumber(value: number, digits = 2): string { return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); } function regionLabel(station: HydrogenStationBoardStation): string { const parts = [station.province, station.city].filter(part => part && part !== '未归属'); return [...new Set(parts)].join(' · ') || '区域待补充'; } function resetPageScroll() { document.documentElement.scrollTop = 0; document.body.scrollTop = 0; window.scrollTo(0, 0); } export default function HydrogenStationBoard({ embedded = false }: { embedded?: boolean }) { const [dateRange, setDateRange] = useState(defaultRange); const [selectedStationId, setSelectedStationId] = useState(null); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const load = useCallback(async (force = false) => { setLoading(true); try { const result = await fetchHydrogenStationBoard({ startDate: dateRange.start, endDate: dateRange.end, stationId: selectedStationId, force, }); setData(result); setError(null); } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } finally { setLoading(false); } }, [dateRange.end, dateRange.start, selectedStationId]); useEffect(() => { void load(false); }, [load]); useEffect(() => { // 站点列表较长,切换列表/详情时统一从页面顶部开始展示。 resetPageScroll(); const frame = window.requestAnimationFrame(resetPageScroll); return () => window.cancelAnimationFrame(frame); }, [selectedStationId]); const selectedStation = data?.stations.find(station => station.id === selectedStationId) ?? null; if (error && !data) { return (
单站经营数据暂时不可用。
); } return (
{!(selectedStation && data?.selected) ? { setSelectedStationId(null); window.requestAnimationFrame(resetPageScroll); }} onRangeChange={setDateRange} onRefresh={() => void load(true)} onExport={() => data && exportStationEvidence(data, selectedStation)} /> : null} {data ? selectedStation && data.selected ? ( setSelectedStationId(null)} onRangeChange={setDateRange} onRefresh={() => void load(true)} onExport={() => exportStationEvidence(data, selectedStation)} /> ) : ( { setSelectedStationId(stationId); window.requestAnimationFrame(resetPageScroll); }} /> ) : ( )} {loading && data ? (
正在刷新单站数据
) : null}
); } function StationBoardHeader({ embedded, selectedStation, range, latestLedgerTime, loading, onBack, onRangeChange, onRefresh, onExport, }: { embedded: boolean; selectedStation: HydrogenStationBoardStation | null; range: { start: string; end: string }; latestLedgerTime: string | null; loading: boolean; onBack: () => void; onRangeChange: (range: { start: string; end: string }) => void; onRefresh: () => void; onExport: () => void; }) { if (embedded && !selectedStation) { return

最后更新时间 {latestLedgerTime ?? '暂无账本时间'}

; } return (
{selectedStation ? ( ) : null}
{selectedStation ?

{selectedStation.name}

: null} {selectedStation ?

{regionLabel(selectedStation)} · {range.start} 至 {range.end}

: null}

最后更新时间 {latestLedgerTime ?? '暂无账本时间'}

{selectedStation ? ( ) : null}
); } function StationRangePicker({ range, onChange }: { range: { start: string; end: string }; onChange: (range: { start: string; end: string }) => void }) { const [open, setOpen] = useState(false); const [activeBoundary, setActiveBoundary] = useState<'start' | 'end'>('start'); const [draft, setDraft] = useState(range); const initial = new Date(`${range.start}T00:00:00`); const [displayMonth, setDisplayMonth] = useState({ year: initial.getFullYear(), month: initial.getMonth() + 1 }); const rootRef = useRef(null); useEffect(() => { const closeWhenOutside = (event: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false); }; document.addEventListener('mousedown', closeWhenOutside); return () => document.removeEventListener('mousedown', closeWhenOutside); }, []); const anchor = new Date(`${range.end}T00:00:00`); const apply = (next: { start: string; end: string }, close = false) => { const normalized = next.start <= next.end ? next : { start: next.end, end: next.start }; setDraft(normalized); if (close) { onChange(normalized); setOpen(false); } }; const today = formatYmd(anchor); const startOfWeek = new Date(anchor); startOfWeek.setDate(anchor.getDate() - ((anchor.getDay() + 6) % 7)); const shortcuts = { today: { start: today, end: today }, week: { start: formatYmd(startOfWeek), end: formatYmd(new Date(startOfWeek.getFullYear(), startOfWeek.getMonth(), startOfWeek.getDate() + 6)) }, month: { start: `${anchor.getFullYear()}-${String(anchor.getMonth() + 1).padStart(2, '0')}-01`, end: formatYmd(new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0)) }, }; const daysInMonth = new Date(displayMonth.year, displayMonth.month, 0).getDate(); const leadingDays = new Date(displayMonth.year, displayMonth.month - 1, 1).getDay(); const selectDay = (day: number) => { const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; if (activeBoundary === 'start') { const next = { start: value, end: value > draft.end ? value : draft.end }; setDraft(next); setActiveBoundary('end'); return; } const next = value < draft.start ? { start: value, end: draft.start } : { start: draft.start, end: value }; setDraft(next); setActiveBoundary('start'); }; return
{open ?
{displayMonth.year}年{String(displayMonth.month).padStart(2, '0')}月
{['日','一','二','三','四','五','六'].map(day => {day})}
{Array.from({ length: leadingDays }, (_, index) => )}{Array.from({ length: daysInMonth }, (_, index) => { const day = index + 1; const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const classes = ['sd-date__day', value === draft.start || value === draft.end ? 'is-selected' : '', value >= draft.start && value <= draft.end ? 'is-in-range' : ''].filter(Boolean).join(' '); return ; })}
: null}
; } function StationOverview({ data, onSelectStation }: { data: HydrogenStationBoardResponse; onSelectStation: (id: number) => void }) { const [displayMode, setDisplayMode] = useState<'all' | 'single'>('all'); const [displayStationId, setDisplayStationId] = useState(data.stations[0]?.id ?? 0); const [dailyDrill, setDailyDrill] = useState<'kg' | 'fee' | 'payment' | null>(null); const [partnersExpanded, setPartnersExpanded] = useState(false); const { selfManagedStations, partnerStations } = useMemo(() => { const selfManaged = data.stations .filter(isSelfManagedStation) .sort((left, right) => selfManagedStationRank(left) - selfManagedStationRank(right)); const selfManagedIds = new Set(selfManaged.map(station => station.id)); return { selfManagedStations: selfManaged, partnerStations: data.stations .filter(station => station.kg > 0 && !selfManagedIds.has(station.id)) .sort((left, right) => right.kg - left.kg || left.name.localeCompare(right.name, 'zh-CN')), }; }, [data.stations]); const visiblePartnerStations = partnersExpanded ? partnerStations : partnerStations.slice(0, 10); const selectedStation = data.stations.filter(station => station.id === displayStationId); // Top5 的条形、图例与钻取入口必须共用同一份按加氢量降序的结果。 const shareStations = data.stations .filter(station => station.kg > 0) .sort((left, right) => right.kg - left.kg) .slice(0, 5); const shareTrackTotal = shareStations.reduce((sum, station) => sum + station.kg, 0); return ( <>
加氢站{data.summary.stationCount}统计站点数 · 活跃 {data.summary.activeStationCount} 站
setDailyDrill('kg')} /> setDailyDrill('fee')} /> item.paymentAmount > 0).length} 天有进账`} onClick={() => setDailyDrill('payment')} />

加氢量占比Top5 {data.range.start} 至 {data.range.end}

合计 {formatNumber(data.summary.totalKg)} Kg
{shareStations.map((station, index) => )}
    {shareStations.map((station, index) =>
  • )}

各站概况

{displayMode === 'single' ? : null}
{displayMode === 'all' ?
{partnerStations.length > 10 ? : null}
:
}
setDailyDrill(null)} /> ); } function isSelfManagedStation(station: HydrogenStationBoardStation): boolean { return station.name.includes('佛山南海羚牛') || station.name.includes('东鹏大道'); } function selfManagedStationRank(station: HydrogenStationBoardStation): number { return station.name.includes('佛山南海羚牛') ? 0 : 1; } function StationGroup({ title, count, stations, onSelectStation, emptyText }: { title: string; count: number; stations: HydrogenStationBoardStation[]; onSelectStation: (id: number) => void; emptyText: string; }) { return

{title}

{count} 站
{stations.length ?
:

{emptyText}

}
; } function StationRows({ stations, onSelectStation }: { stations: HydrogenStationBoardStation[]; onSelectStation: (id: number) => void }) { return <>{stations.map(station => )}; } function StationSummaryMetric({ label, value, unit, helper, onClick }: { label: string; value: string; unit?: string; helper: string; onClick: () => void }) { return ; } function StationSummaryDailyDialog({ mode, range, rows, onClose, }: { mode: 'kg' | 'fee' | 'payment' | null; range: { start: string; end: string }; rows: HydrogenStationBoardSummaryDailyRow[]; onClose: () => void; }) { const [sortKey, setSortKey] = useState<'date' | 'recordCount' | 'kg' | 'fee' | 'paymentCount' | 'paymentAmount'>('date'); const [sortDirection, setSortDirection] = useState('desc'); const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]); const changeSort = (nextKey: typeof sortKey) => { const next = toggleSort(sortKey, sortDirection, nextKey); setSortKey(next.key); setSortDirection(next.direction); }; if (!mode) return null; const title = mode === 'kg' ? '统计加氢总量 · 日明细' : mode === 'fee' ? '统计加氢金额 · 单日数据' : '统计现结金额 · 单日数据'; return (
{ if (event.target === event.currentTarget) onClose(); }}>

{title}

{range.start} 至 {range.end}

{mode === 'payment' ? ( {sortedRows.map(row => )}
{row.date}{row.paymentCount}{formatNumber(row.paymentAmount)}
) : ( {sortedRows.map(row => )}
{row.date}{row.recordCount}{formatNumber(row.kg)}{formatNumber(row.fee)}
)}
); } function StationRowMetric({ label, value, unit }: { label: string; value: string; unit: string }) { return
{label}{value}{unit}
; } function MiniSpark({ values }: { values: number[] }) { const max = Math.max(...values, 0); return
{values.map((value, index) => 0 ? Math.max(6, Math.round((value / max) * 100)) : 0}%` }} />)}
; } function StationDetail({ station, data, latestLedgerTime, loading, onBack, onRangeChange, onRefresh, onExport, }: { station: HydrogenStationBoardStation; data: HydrogenStationBoardResponse; latestLedgerTime: string | null; loading: boolean; onBack: () => void; onRangeChange: (range: { start: string; end: string }) => void; onRefresh: () => void; onExport: () => void; }) { const detail = data.selected!; const [hoveredDate, setHoveredDate] = useState(null); const [dailySummaryExpanded, setDailySummaryExpanded] = useState(false); const lastDaily = detail.daily.at(-1); const visibleDailySummary = dailySummaryExpanded ? detail.daily : detail.daily.slice(-30); const monthKey = data.range.end.slice(0, 7); const monthRows = detail.customerMonths.filter(item => item.month === monthKey); const monthKg = monthRows.reduce((sum, row) => sum + row.kg, 0); const intervalPayments = detail.daily.reduce((sum, row) => sum + row.paymentAmount, 0); const trendMax = Math.max(...detail.daily.map(row => row.kg), 0); const hovered = detail.daily.find(row => row.date === hoveredDate) ?? null; return ( <>

{station.name}

{regionLabel(station)} · {data.range.start} 至 {data.range.end}

最后更新时间 {latestLedgerTime ?? '暂无账本时间'}

row.paymentAmount > 0).length}天有进账`} />

加氢站每日加氢量汇总({data.range.start} 至 {data.range.end})

{[...visibleDailySummary].reverse().map(row => )} {detail.daily.length > 30 ? : null}
日期 ↓加氢量(Kg) ↕较昨日 ↕单价 ↕成本金额(元) ↕车次 ↕
查询区间合计{formatNumber(detail.daily.reduce((sum, row) => sum + row.kg, 0))}{formatNumber(detail.daily.reduce((sum, row) => sum + row.fee, 0))}{detail.daily.reduce((sum, row) => sum + row.recordCount, 0)}
{row.date}{formatNumber(row.kg)} 0 ? 'is-stock-up' : row.changeKg < 0 ? 'is-stock-down' : 'is-stock-flat'}`}>{row.changeKg > 0 ? '+' : ''}{formatNumber(row.changeKg)}{row.changeKg !== 0 ? {row.changeKg > 0 ? '▲' : '▼'} : null}{formatNumber(row.avgPrice)}{formatNumber(row.fee)}{row.recordCount}

区间加氢量趋势

{station.name}
{hovered ?
{hovered.date}
加氢量{formatNumber(hovered.kg)} Kg
{hovered.recordCount}车次 · ¥{formatNumber(hovered.fee)}
: null}
{detail.daily.map(row =>
setHoveredDate(row.date)} onMouseLeave={() => setHoveredDate(null)}>{formatNumber(row.kg, 0)}
0 ? Math.max(3, Math.round((row.kg / trendMax) * 100)) : 0}%` }} />
{row.date.slice(5)}
)}
); } function DetailMetric({ label, value, unit, helper }: { label: string; value: string; unit: string; helper: string }) { return
{label}{value}{unit}{helper}
; } function CustomerMonthMatrix({ title, valueKey, rows }: { title: string; valueKey: 'kg' | 'fee'; rows: HydrogenStationBoardCustomerMonth[] }) { const months = useMemo(() => [...new Set(rows.map(row => row.month))].sort(), [rows]); const customers = useMemo(() => [...new Set(rows.map(row => row.customerName))].sort((left, right) => left.localeCompare(right, 'zh-CN')), [rows]); const [selectedCustomers, setSelectedCustomers] = useState([]); const [expanded, setExpanded] = useState(false); const values = useMemo(() => new Map(rows.map(row => [`${row.customerName}\u0000${row.month}`, row[valueKey]])), [rows, valueKey]); const totals = useMemo(() => new Map(months.map(month => [month, rows.filter(row => row.month === month).reduce((sum,row)=>sum+row[valueKey],0)])), [months, rows, valueKey]); const visibleCustomers = selectedCustomers.length === 0 ? customers : customers.filter(customer => selectedCustomers.includes(customer)); const shownCustomers = expanded ? visibleCustomers : visibleCustomers.slice(0, 10); const trendClass = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? 'is-stock-up' : 'is-stock-down'; const trendMark = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? '▲' : '▼'; return (

{title}

{months.map(month => )}{months.map((month, index) => { const current = totals.get(month) ?? 0; const previous = index > 0 ? totals.get(months[index - 1]) : undefined; return ; })} {shownCustomers.map(customer => {months.map((month, index) => { const current = values.get(`${customer}\u0000${month}`) ?? 0; const previous = index > 0 ? values.get(`${customer}\u0000${months[index - 1]}`) : undefined; return ; })})}
客户{month.replace('-', '年')}月
合计{formatNumber(current)}{trendMark(current, previous)}
{customer}{formatNumber(current)}{trendMark(current, previous)}
{visibleCustomers.length > 10 ? : null}
); } function StationCashPanels() { return

客户氢费收支汇总

客户充值/现金结算扣预付现结余额备注
数据写入中,敬请期待。

氢费充值/现结进账明细

数据写入中
充值日期客户付款方式金额(元)
数据写入中,敬请期待。
; } function CustomerMultiSelect({ options, value, onChange }: { options: string[]; value: string[]; onChange: (value: string[]) => void }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const rootRef = useRef(null); const allSelected = value.length === 0 || value.length === options.length; const selectedLabel = allSelected ? '全部客户' : value.length <= 2 ? value.join('、') : `已选 ${value.length} 家`; const visibleOptions = search.trim() ? options.filter(option => option.includes(search.trim())) : options; useEffect(() => { if (!open) return; const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener('mousedown', close); return () => document.removeEventListener('mousedown', close); }, [open]); const toggle = (customer: string) => { if (allSelected) { onChange([customer]); return; } if (value.includes(customer)) { const next = value.filter(item => item !== customer); onChange(next.length === 0 ? [] : next); return; } const next = [...value, customer]; onChange(next.length === options.length ? [] : next); }; return
{open ?
setSearch(event.target.value)} placeholder="搜索客户" aria-label="搜索客户" />
    {visibleOptions.length === 0 ?
  • 无匹配客户
  • : visibleOptions.map(option => { const on = allSelected || value.includes(option); return
  • ; })}
: null}
; } function exportStationEvidence(data: HydrogenStationBoardResponse, station: HydrogenStationBoardStation | null) { if (!station || !data.selected) return; const workbook = XLSX.utils.book_new(); const daily = [['日期','加氢量(Kg)','较昨日(Kg)','平均单价','加氢金额(元)','车次','现结金额(元)','现结笔数'], ...data.selected.daily.map(row => [row.date,row.kg,row.changeKg,row.avgPrice,row.fee,row.recordCount,row.paymentAmount,row.paymentCount])]; const customers = [['月份','客户','加氢量(Kg)','加氢费(元)','车次'], ...data.selected.customerMonths.map(row => [row.month,row.customerName,row.kg,row.fee,row.recordCount])]; XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(daily), '每日汇总'); XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(customers), '客户月汇总'); XLSX.writeFile(workbook, `${station.name}_${data.range.start}_${data.range.end}_取证.xlsx`); } function StationBoardSkeleton() { return
; }