535 lines
24 KiB
TypeScript
535 lines
24 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Building2, ChevronRight, Fuel, Plug, ReceiptText, TrendingUp, Truck, UserRound, X } 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 RotatingFooterHint from '../../components/RotatingFooterHint';
|
|
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
|
import {
|
|
buildHydrogenDrillUrl,
|
|
parseHydrogenDrillContext,
|
|
type HydrogenDrillContext,
|
|
} from './hydrogen-drill-context';
|
|
import HydrogenOrders from './HydrogenOrders';
|
|
import AnalysisDateFilters from './AnalysisDateFilters';
|
|
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
|
|
import { activateDrillOnKeyDown } from './drill-interaction';
|
|
import {
|
|
dateRangeModeLabel,
|
|
getQuickDateRange,
|
|
inferDateRangeMode,
|
|
normalizeDateRange,
|
|
type DateRangeMode,
|
|
} from './date-range';
|
|
|
|
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<CustomerType>[] = [
|
|
{ id: 'lingniu', label: '羚牛车辆' },
|
|
{ id: 'external', label: '外部车辆' },
|
|
];
|
|
|
|
const HYDROGEN_ORDERS_ID = 'hydrogen-orders';
|
|
|
|
export default function HydrogenDaily() {
|
|
const [drillContext, setDrillContext] = useState<HydrogenDrillContext>(() => (
|
|
parseHydrogenDrillContext(window.location.search)
|
|
));
|
|
const [pick, setPick] = useState<DateRangeMode>(() => (
|
|
drillContext.startDate && drillContext.endDate
|
|
? inferDateRangeMode(drillContext.startDate, drillContext.endDate)
|
|
: 'last15'
|
|
));
|
|
const [dateRange, setDateRange] = useState(() => (
|
|
drillContext.startDate && drillContext.endDate
|
|
? normalizeDateRange(drillContext.startDate, drillContext.endDate)
|
|
: getQuickDateRange('last15')
|
|
));
|
|
const [vehicleScope, setVehicleScope] = useState<CustomerType>(drillContext.vehicleScope);
|
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [retryKey, setRetryKey] = useState(0);
|
|
const orderTriggerRef = useRef<HTMLElement | SVGElement | null>(null);
|
|
|
|
const effectiveRange = useMemo(() => normalizeDateRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
|
const selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined;
|
|
const selectedStationName = drillContext.level === 'station'
|
|
? drillContext.stationName || `站点 #${drillContext.stationId}`
|
|
: undefined;
|
|
const selectedCustomerName = drillContext.level === 'customer'
|
|
? drillContext.customerName
|
|
: undefined;
|
|
const selectedDate = drillContext.selectedDate;
|
|
const selectedDailyRow = rows?.find(row => row.date === selectedDate);
|
|
|
|
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
|
|
const url = buildHydrogenDrillUrl(window.location, next);
|
|
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
|
|
setDrillContext(next);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setError(null);
|
|
const query = pick === 'custom'
|
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId, customerName: selectedCustomerName }
|
|
: { range: pick, stationId: selectedStationId, customerName: selectedCustomerName };
|
|
fetchHydrogenDaily(query, vehicleScope)
|
|
.then(r => { if (!cancelled) setRows(r); })
|
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
|
return () => { cancelled = true; };
|
|
}, [pick, vehicleScope, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName, retryKey]);
|
|
|
|
// 柱图:按日期升序,用于"从左到右时间流"
|
|
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
|
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
|
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
|
|
const stationCount = useMemo(() => {
|
|
const ids = new Set<number>();
|
|
(rows ?? []).forEach(r => r.stations.forEach(s => ids.add(s.stationId)));
|
|
return ids.size;
|
|
}, [rows]);
|
|
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
|
const scopeLabel = dateRangeModeLabel(pick);
|
|
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
|
const orderScopeLabel = selectedCustomerName
|
|
? `客户 ${selectedCustomerName}`
|
|
: selectedStationName
|
|
? `站点 ${selectedStationName}`
|
|
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
|
|
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
|
|
const lowDay = trendData
|
|
.filter(item => item.totalKg > 0)
|
|
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
|
|
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
|
|
|
|
const toggle = (date: string) => setExpanded(prev => {
|
|
const next = new Set(prev);
|
|
next.has(date) ? next.delete(date) : next.add(date);
|
|
return next;
|
|
});
|
|
|
|
const applyQuickPick = (nextPick: DateQuickPick) => {
|
|
const nextRange = getQuickDateRange(nextPick);
|
|
setPick(nextPick);
|
|
setDateRange(nextRange);
|
|
commitDrillContext({
|
|
...drillContext,
|
|
vehicleScope,
|
|
startDate: nextRange.start,
|
|
endDate: nextRange.end,
|
|
selectedDate: undefined,
|
|
orderPage: undefined,
|
|
}, 'replace');
|
|
};
|
|
|
|
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
|
if (!value) return;
|
|
const nextRange = { ...dateRange, [field]: value };
|
|
setPick('custom');
|
|
const normalized = normalizeDateRange(nextRange.start, nextRange.end);
|
|
setDateRange(normalized);
|
|
commitDrillContext({
|
|
...drillContext,
|
|
vehicleScope,
|
|
startDate: normalized.start,
|
|
endDate: normalized.end,
|
|
selectedDate: undefined,
|
|
orderPage: undefined,
|
|
}, 'replace');
|
|
};
|
|
|
|
const updateVehicleScope = (next: CustomerType) => {
|
|
setVehicleScope(next);
|
|
commitDrillContext({
|
|
...drillContext,
|
|
vehicleScope: next,
|
|
startDate: effectiveRange.start,
|
|
endDate: effectiveRange.end,
|
|
orderPage: undefined,
|
|
}, 'replace');
|
|
};
|
|
|
|
const clearEntity = () => {
|
|
commitDrillContext({
|
|
level: 'overview',
|
|
year: drillContext.year,
|
|
vehicleScope,
|
|
startDate: effectiveRange.start,
|
|
endDate: effectiveRange.end,
|
|
}, 'replace');
|
|
};
|
|
|
|
const openOrders = (date: string) => {
|
|
const activeElement = document.activeElement;
|
|
if (activeElement instanceof HTMLElement || activeElement instanceof SVGElement) {
|
|
orderTriggerRef.current = activeElement;
|
|
}
|
|
commitDrillContext({
|
|
...drillContext,
|
|
vehicleScope,
|
|
startDate: effectiveRange.start,
|
|
endDate: effectiveRange.end,
|
|
selectedDate: date,
|
|
orderPage: undefined,
|
|
}, selectedDate === date ? 'replace' : 'push');
|
|
window.requestAnimationFrame(() => {
|
|
const orders = document.getElementById(HYDROGEN_ORDERS_ID);
|
|
orders?.focus({ preventScroll: true });
|
|
orders?.scrollIntoView({
|
|
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
|
block: 'start',
|
|
});
|
|
});
|
|
};
|
|
|
|
const closeOrders = () => {
|
|
commitDrillContext({
|
|
...drillContext,
|
|
vehicleScope,
|
|
startDate: effectiveRange.start,
|
|
endDate: effectiveRange.end,
|
|
selectedDate: undefined,
|
|
orderPage: undefined,
|
|
}, 'replace');
|
|
window.requestAnimationFrame(() => orderTriggerRef.current?.focus());
|
|
};
|
|
|
|
const changeOrderPage = useCallback((page: number, mode: 'push' | 'replace') => {
|
|
commitDrillContext({
|
|
...drillContext,
|
|
orderPage: page > 1 ? page : undefined,
|
|
}, mode);
|
|
}, [commitDrillContext, drillContext]);
|
|
|
|
const retryLoad = () => {
|
|
setRows(null);
|
|
setError(null);
|
|
setRetryKey(key => key + 1);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handlePopState = () => {
|
|
const next = parseHydrogenDrillContext(window.location.search);
|
|
setDrillContext(next);
|
|
setVehicleScope(next.vehicleScope);
|
|
if (next.startDate && next.endDate) {
|
|
const normalized = normalizeDateRange(next.startDate, next.endDate);
|
|
setPick(inferDateRangeMode(normalized.start, normalized.end));
|
|
setDateRange(normalized);
|
|
}
|
|
};
|
|
window.addEventListener('popstate', handlePopState);
|
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<SurfaceCard className="p-2 md:p-3">
|
|
<AnalysisDateFilters
|
|
idPrefix="hydrogen"
|
|
mode={pick}
|
|
startDate={dateRange.start}
|
|
endDate={dateRange.end}
|
|
onQuickPick={applyQuickPick}
|
|
onCustom={() => setPick('custom')}
|
|
onDateChange={updateDateRange}
|
|
/>
|
|
|
|
<AnalysisScopeSwitch
|
|
ariaLabel="氢能车辆范围"
|
|
value={vehicleScope}
|
|
options={VEHICLE_SCOPE_OPTIONS}
|
|
onChange={updateVehicleScope}
|
|
icon={Truck}
|
|
className="mt-2"
|
|
/>
|
|
</SurfaceCard>
|
|
|
|
{selectedStationName && (
|
|
<section className="flex items-center gap-3 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 text-blue-700 shadow-sm">
|
|
<Building2 size={16} className="shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-xs font-black">{selectedStationName}</div>
|
|
<div className="mt-0.5 text-[10px] font-bold text-blue-500">站点每日明细 · {rangeText}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={clearEntity}
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 shadow-sm ring-1 ring-blue-100 hover:text-blue-700"
|
|
aria-label="清除站点筛选"
|
|
title="清除站点筛选"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</section>
|
|
)}
|
|
|
|
{selectedCustomerName && (
|
|
<section className="flex items-center gap-3 rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-emerald-700 shadow-sm">
|
|
<UserRound size={16} className="shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-xs font-black">{selectedCustomerName}</div>
|
|
<div className="mt-0.5 text-[10px] font-bold text-emerald-600">客户每日明细 · {rangeText}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={clearEntity}
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-emerald-600 shadow-sm ring-1 ring-emerald-100 hover:text-emerald-800"
|
|
aria-label="清除客户筛选"
|
|
title="清除客户筛选"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</section>
|
|
)}
|
|
|
|
{error && (
|
|
<ErrorState
|
|
title="氢能数据源暂不可用"
|
|
message={error}
|
|
onRetry={retryLoad}
|
|
/>
|
|
)}
|
|
|
|
{!error && rows !== null && <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={vehicleScope === '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>}
|
|
|
|
{/* 外部车辆:新系统数据还没准备好 */}
|
|
{!error && vehicleScope === '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" />
|
|
</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>
|
|
)}
|
|
|
|
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
|
{!error && !(vehicleScope === '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(item => (
|
|
<Cell
|
|
key={item.date}
|
|
fill="url(#hydrogenBarGrad)"
|
|
stroke={selectedDate === item.date ? '#1d4ed8' : undefined}
|
|
strokeWidth={selectedDate === item.date ? 2 : undefined}
|
|
className={item.totalKg > 0 ? 'cursor-pointer outline-none focus:stroke-blue-700 focus:stroke-2' : undefined}
|
|
role={item.totalKg > 0 ? 'button' : undefined}
|
|
tabIndex={item.totalKg > 0 ? 0 : -1}
|
|
aria-expanded={item.totalKg > 0 ? selectedDate === item.date : undefined}
|
|
aria-controls={item.totalKg > 0 ? HYDROGEN_ORDERS_ID : undefined}
|
|
aria-label={`${item.date},加氢量 ${item.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg${item.totalKg > 0 ? `,查看${orderScopeLabel}加氢订单` : ',无可下钻订单'}`}
|
|
onClick={item.totalKg > 0 ? () => openOrders(item.date) : undefined}
|
|
onKeyDown={item.totalKg > 0
|
|
? event => activateDrillOnKeyDown(event, () => openOrders(item.date))
|
|
: undefined}
|
|
/>
|
|
))}
|
|
</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>
|
|
)}
|
|
|
|
{selectedDate && (
|
|
<HydrogenOrders
|
|
date={selectedDate}
|
|
dailyRow={selectedDailyRow}
|
|
page={drillContext.orderPage ?? 1}
|
|
vehicleScope={vehicleScope}
|
|
stationId={selectedStationId}
|
|
customerName={selectedCustomerName}
|
|
onPageChange={changeOrderPage}
|
|
onClose={closeOrders}
|
|
/>
|
|
)}
|
|
|
|
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
|
{!error && !(vehicleScope === '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)_72px_64px] 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>
|
|
</div>
|
|
{/* 合计行 */}
|
|
<div className="grid grid-cols-[minmax(0,1fr)_72px_64px] 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>
|
|
{/* 主行 + 子行 */}
|
|
{rows === null ? (
|
|
<LoadingState label="正在加载加氢明细" variant="inline" />
|
|
) : rows.length === 0 ? (
|
|
<EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" variant="inline" />
|
|
) : 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}`}>
|
|
<div className={`grid w-full grid-cols-[minmax(0,1fr)_72px_64px] gap-2 px-3 py-2.5 text-left transition-colors hover:bg-slate-50/60 md:grid-cols-[minmax(0,1fr)_140px_120px_104px] md:gap-3 ${selectedDate === r.date ? 'bg-blue-50/70' : ''}`}>
|
|
<span className="flex min-w-0 items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggle(r.date)}
|
|
className="flex min-w-0 items-center gap-1 text-[12px] font-bold text-slate-700"
|
|
aria-expanded={open}
|
|
aria-label={`${open ? '收起' : '展开'} ${r.date} 加氢站明细`}
|
|
>
|
|
<ChevronRight size={14} className={`shrink-0 transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
|
<span className="truncate">{r.date}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => openOrders(r.date)}
|
|
disabled={r.totalKg <= 0}
|
|
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-blue-100 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-25"
|
|
aria-label={`查看 ${r.date} 加氢订单`}
|
|
aria-expanded={selectedDate === r.date}
|
|
aria-controls={HYDROGEN_ORDERS_ID}
|
|
title="查看当日订单"
|
|
>
|
|
<ReceiptText size={13} />
|
|
</button>
|
|
</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>
|
|
</div>
|
|
<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)_72px_64px] 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>
|
|
)}
|
|
{!error && <RotatingFooterHint />}
|
|
</div>
|
|
);
|
|
}
|