feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,278 +1,304 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchHydrogenDaily } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { fetchHydrogenDaily, fetchHydrogenDailyDetail, type HydrogenVerifyScope } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyDetailResponse, HydrogenDailyRow } from './types';
|
||||
import {
|
||||
buildHydrogenDailyTrend,
|
||||
filterHydrogenRowsByStation,
|
||||
getHydrogenDailyStations,
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
mergeHydrogenDailyRows,
|
||||
normalizeRange,
|
||||
summarizeHydrogenRows,
|
||||
type HydrogenDailyBoardScope,
|
||||
type HydrogenDailyVehicleScope,
|
||||
type RangeMode,
|
||||
} from './hydrogen-daily/model';
|
||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
import { DailyDetailTable } from './hydrogen-daily/components/DailyDetailTable';
|
||||
import { DailyKpiGrid } from './hydrogen-daily/components/DailyKpiGrid';
|
||||
import { DailyTrendChart } from './hydrogen-daily/components/DailyTrendChart';
|
||||
import { StationDailyOverview } from './hydrogen-daily/components/StationDailyOverview';
|
||||
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
|
||||
import './styles/energy-bi-board.css';
|
||||
|
||||
export default function HydrogenDaily() {
|
||||
export interface HydrogenDailyProps {
|
||||
scope?: HydrogenDailyBoardScope;
|
||||
onRangeTextChange?: (rangeText: string) => void;
|
||||
}
|
||||
|
||||
interface DailyDatasets {
|
||||
lingniu: HydrogenDailyRow[] | null;
|
||||
external: HydrogenDailyRow[] | null;
|
||||
}
|
||||
|
||||
export interface DailyDetailState {
|
||||
loading: boolean;
|
||||
data: HydrogenDailyDetailResponse | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export default function HydrogenDaily({ scope = 'global', onRangeTextChange }: HydrogenDailyProps) {
|
||||
const [vehicleScope, setVehicleScope] = useState<HydrogenDailyVehicleScope>('all');
|
||||
// Kept explicit even though this view has no verification toggle yet: drill
|
||||
// requests always declare their data scope instead of silently defaulting.
|
||||
const verifyScope: HydrogenVerifyScope = 'all';
|
||||
const [pick, setPick] = useState<RangeMode>('last15');
|
||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
|
||||
const [datasets, setDatasets] = useState<DailyDatasets>({ lingniu: null, external: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
||||
const [details, setDetails] = useState<Record<string, DailyDetailState>>({});
|
||||
|
||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
const effectiveRange = useMemo(
|
||||
() => normalizeRange(dateRange.start, dateRange.end),
|
||||
[dateRange.start, dateRange.end],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onRangeTextChange?.(`${effectiveRange.start} 至 ${effectiveRange.end}`);
|
||||
}, [effectiveRange.start, effectiveRange.end, onRangeTextChange]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
fetchHydrogenDaily(query, customer)
|
||||
.then(r => { if (!cancelled) setRows(r); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
|
||||
Promise.all([
|
||||
fetchHydrogenDaily(query, 'lingniu'),
|
||||
fetchHydrogenDaily(query, 'external'),
|
||||
])
|
||||
.then(([lingniu, external]) => {
|
||||
if (cancelled) return;
|
||||
setDatasets({ lingniu, external });
|
||||
setUpdatedAt(formatUpdatedAt(new Date()));
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||
}, [pick, effectiveRange.start, effectiveRange.end, refreshKey]);
|
||||
|
||||
const {
|
||||
trendData,
|
||||
totalKg,
|
||||
activeDays,
|
||||
stationCount,
|
||||
avgKg,
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays,
|
||||
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
|
||||
const scopeLabel = getRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
const allRows = useMemo(
|
||||
() => mergeHydrogenDailyRows(datasets.lingniu, datasets.external),
|
||||
[datasets.external, datasets.lingniu],
|
||||
);
|
||||
const vehicleRows = useMemo(() => {
|
||||
if (vehicleScope === 'lingniu') return datasets.lingniu;
|
||||
if (vehicleScope === 'external') return datasets.external;
|
||||
return allRows;
|
||||
}, [allRows, datasets.external, datasets.lingniu, vehicleScope]);
|
||||
const stationOptions = useMemo(() => getHydrogenDailyStations(vehicleRows), [vehicleRows]);
|
||||
|
||||
const toggle = (date: string) => setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(date) ? next.delete(date) : next.add(date);
|
||||
return next;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (scope !== 'station') return;
|
||||
if (selectedStationId !== null && stationOptions.some((station) => station.id === selectedStationId)) return;
|
||||
setSelectedStationId(stationOptions[0]?.id ?? null);
|
||||
}, [scope, selectedStationId, stationOptions]);
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
setPick(nextPick);
|
||||
setDateRange(getQuickRange(nextPick));
|
||||
const scopedRows = useMemo(() => {
|
||||
if (scope !== 'station' || selectedStationId === null) return vehicleRows;
|
||||
return filterHydrogenRowsByStation(vehicleRows, selectedStationId);
|
||||
}, [scope, selectedStationId, vehicleRows]);
|
||||
|
||||
const summary = useMemo(
|
||||
() => summarizeHydrogenRows(scopedRows),
|
||||
[scopedRows],
|
||||
);
|
||||
const trend = useMemo(
|
||||
() => buildHydrogenDailyTrend(datasets.lingniu, datasets.external, vehicleScope, scope === 'station' ? selectedStationId : null),
|
||||
[datasets.external, datasets.lingniu, scope, selectedStationId, vehicleScope],
|
||||
);
|
||||
|
||||
const selectedStation = stationOptions.find((station) => station.id === selectedStationId);
|
||||
|
||||
const dayCount = useMemo(() => {
|
||||
const start = new Date(effectiveRange.start).getTime();
|
||||
const end = new Date(effectiveRange.end).getTime();
|
||||
return Math.max(1, Math.round(Math.abs(end - start) / (24 * 3600 * 1000)) + 1);
|
||||
}, [effectiveRange.end, effectiveRange.start]);
|
||||
|
||||
const lingniuSum = useMemo(() => {
|
||||
return (datasets.lingniu ?? []).reduce((s, r) => s + r.totalKg, 0);
|
||||
}, [datasets.lingniu]);
|
||||
|
||||
const externalSum = useMemo(() => {
|
||||
return (datasets.external ?? []).reduce((s, r) => s + r.totalKg, 0);
|
||||
}, [datasets.external]);
|
||||
|
||||
const loadDetail = async (date: string, force = false) => {
|
||||
if (!force && details[date]?.data) return;
|
||||
setDetails((previous) => ({
|
||||
...previous,
|
||||
[date]: { loading: true, data: previous[date]?.data ?? null, error: null },
|
||||
}));
|
||||
try {
|
||||
const data = await fetchHydrogenDailyDetail(
|
||||
date,
|
||||
vehicleScope,
|
||||
scope === 'station' ? selectedStationId : null,
|
||||
verifyScope,
|
||||
);
|
||||
setDetails((previous) => ({
|
||||
...previous,
|
||||
[date]: { loading: false, data, error: null },
|
||||
}));
|
||||
} catch (reason) {
|
||||
setDetails((previous) => ({
|
||||
...previous,
|
||||
[date]: {
|
||||
loading: false,
|
||||
data: previous[date]?.data ?? null,
|
||||
error: reason instanceof Error ? reason.message : String(reason),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||
if (!value) return;
|
||||
setPick('custom');
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
const toggleRow = (date: string) => {
|
||||
setExpanded((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(date)) {
|
||||
next.delete(date);
|
||||
} else {
|
||||
next.add(date);
|
||||
void loadDetail(date);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectDate = (date: string) => {
|
||||
setExpanded((previous) => new Set(previous).add(date));
|
||||
void loadDetail(date);
|
||||
setHighlightedDate(date);
|
||||
const element = document.getElementById(`hydrogen-daily-row-${date}`);
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
|
||||
if (loading && !scopedRows) {
|
||||
return <LoadingState label="正在加载氢气按日看板数据..." />;
|
||||
}
|
||||
|
||||
if (error && !scopedRows) {
|
||||
return <ErrorState message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
customer={vehicleScope === 'all' ? 'all' : vehicleScope === 'lingniu' ? 'lingniu' : 'external'}
|
||||
stations={scope === 'station' ? stationOptions : undefined}
|
||||
selectedStationId={scope === 'station' ? selectedStationId : undefined}
|
||||
vehicleScope={vehicleScope}
|
||||
updatedAt={updatedAt}
|
||||
loading={loading}
|
||||
onQuickPick={(p: DateQuickPick) => {
|
||||
setPick(p);
|
||||
setDateRange(getQuickRange(p));
|
||||
}}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
onDateRangeChange={(field, value) => {
|
||||
setPick('custom');
|
||||
setDateRange((prev) => ({ ...prev, [field]: value }));
|
||||
}}
|
||||
onCustomerChange={(cust: CustomerType) => {
|
||||
setVehicleScope(cust === 'all' ? 'all' : cust === 'lingniu' ? 'lingniu' : 'external');
|
||||
}}
|
||||
onStationChange={scope === 'station' ? setSelectedStationId : undefined}
|
||||
onVehicleScopeChange={setVehicleScope}
|
||||
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
||||
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
||||
</div>
|
||||
|
||||
{/* 外部车辆:新系统数据还没准备好 */}
|
||||
{customer === 'external' && rows !== null && totalKg === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
||||
<Plug size={22} className="text-blue-500" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
||||
{error ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-amber-600" />
|
||||
<span>最新数据刷新失败,正展示上一版缓存内容:{error}</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
||||
新系统的外部车辆加氢数据还在准备中
|
||||
<br />
|
||||
上线后此处将展示完整明细
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||
<SurfaceCard>
|
||||
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 Kg</span>
|
||||
</div>
|
||||
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400">峰值日</div>
|
||||
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
|
||||
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400">低谷日</div>
|
||||
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
|
||||
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400">零数据日</div>
|
||||
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
|
||||
{zeroDays} 天
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[180px] min-w-0 px-2 pb-2">
|
||||
<ResponsiveContainer width="100%" height={180} minWidth={0}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={8}
|
||||
/>
|
||||
<YAxis
|
||||
width={42}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
{avgKg > 0 && (
|
||||
<ReferenceLine
|
||||
y={avgKg}
|
||||
stroke="#f59e0b"
|
||||
strokeDasharray="4 4"
|
||||
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
|
||||
/>
|
||||
)}
|
||||
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
||||
{trendData.map((_, i) => (
|
||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
||||
))}
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||
{!(customer === 'external' && rows !== null && totalKg === 0) && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* 表头 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
<span>日期 / 加氢站</span>
|
||||
<span className="hidden md:block text-right">单价 (元/Kg)</span>
|
||||
<span className="text-right">加氢量 (Kg)</span>
|
||||
<span className="text-right">环比</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1 font-bold text-amber-900 hover:underline"
|
||||
>
|
||||
<RefreshCw size={12} /> 重试
|
||||
</button>
|
||||
</div>
|
||||
{/* 合计行 */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
|
||||
<span>合计</span>
|
||||
<span className="hidden md:block" />
|
||||
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
||||
<span />
|
||||
</div>
|
||||
{/* 主行 + 子行 */}
|
||||
{error ? (
|
||||
<div className="p-3"><ErrorState message={error} /></div>
|
||||
) : rows === null ? (
|
||||
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
|
||||
) : rows.map(r => {
|
||||
const open = expanded.has(r.date);
|
||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||
const abnormalBg = isAbnormal
|
||||
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||
: '';
|
||||
return (
|
||||
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
|
||||
<button
|
||||
onClick={() => toggle(r.date)}
|
||||
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||
{r.date}
|
||||
</span>
|
||||
<span className="hidden md:block text-right text-[12px] text-slate-300">—</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden bg-slate-50/50"
|
||||
>
|
||||
{r.stations.map(s => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
|
||||
{s.name}
|
||||
</div>
|
||||
{s.pricePerKg > 0 && (
|
||||
<div className="md:hidden mt-1">
|
||||
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
|
||||
单价 {s.pricePerKg} 元/Kg
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scope === 'station' && selectedStation ? (
|
||||
<StationDailyOverview
|
||||
station={selectedStation}
|
||||
totalKg={summary.totalKg}
|
||||
totalFee={summary.totalFee}
|
||||
averagePrice={summary.avgPrice}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DailyKpiGrid
|
||||
rangeLabel={getRangeModeLabel(pick)}
|
||||
rangeText={`${effectiveRange.start} 至 ${effectiveRange.end}`}
|
||||
totalKg={summary.totalKg}
|
||||
activeDays={summary.activeDays}
|
||||
dayCount={dayCount}
|
||||
averageKg={summary.avgKg}
|
||||
stationCount={summary.stationCount}
|
||||
vehicleScope={vehicleScope}
|
||||
lingniuKg={lingniuSum}
|
||||
externalKg={externalSum}
|
||||
selectedStationName={scope === 'station' ? selectedStation?.name : undefined}
|
||||
/>
|
||||
|
||||
<DailyTrendChart
|
||||
rows={trend}
|
||||
averageKg={summary.avgKg}
|
||||
peakDay={summary.peakDay}
|
||||
lowDay={summary.lowDay}
|
||||
zeroDays={summary.zeroDays}
|
||||
selectedDate={highlightedDate}
|
||||
onSelectDate={handleSelectDate}
|
||||
/>
|
||||
|
||||
{scopedRows && scopedRows.length > 0 ? (
|
||||
<DailyDetailTable
|
||||
rows={scopedRows}
|
||||
totalKg={summary.totalKg}
|
||||
totalFee={summary.totalFee}
|
||||
expanded={expanded}
|
||||
highlightedDate={highlightedDate}
|
||||
details={details}
|
||||
onToggle={toggleRow}
|
||||
onRetryDetail={(date) => void loadDetail(date, true)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="当前筛选条件下没有加氢记录"
|
||||
description="可尝试切换统计区间、车辆范围或站点。"
|
||||
/>
|
||||
)}
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user