Files
ln-bi/src/modules/mileage/DailyReportView.tsx
T
kkfluous d610e4b841
ci/woodpecker/push/woodpecker Pipeline was successful
feat(mileage): add report loading overlay
2026-08-12 23:17:06 +08:00

1193 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
AlertTriangle,
ArrowDown,
ArrowDownUp,
ArrowUp,
Archive,
BarChart3,
CalendarDays,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleGauge,
Info,
MapPin,
RefreshCw,
Route,
Search,
TrendingDown,
TrendingUp,
Truck,
Warehouse,
X,
} from 'lucide-react';
import {
Bar,
BarChart,
Cell,
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import Blur from '../../components/Blur';
import {
ErrorState,
MetricTile,
SurfaceCard,
} from '../../components/ui/surface';
import {
fetchDailyMileageReport,
type DailyMileageReport,
type MileageReportGroup,
type MileageReportVehicle,
} from './api';
type VehicleFilter = 'ALL' | 'LOW' | 'HIGH' | 'INVENTORY';
type VehicleSortKey = 'dailyMileage' | 'sevenDayMileage' | 'completionRate';
type SortDirection = 'asc' | 'desc';
const VEHICLE_PAGE_SIZE = 20;
const MIN_REPORT_LOADING_MS = 280;
const VEHICLE_FILTERS: { id: VehicleFilter; label: string }[] = [
{ id: 'ALL', label: '全部' },
{ id: 'LOW', label: '≤100km' },
{ id: 'HIGH', label: '>100km' },
{ id: 'INVENTORY', label: '库存' },
];
function shanghaiYmd(offsetDays = 0): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const date = new Date(`${parts}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
function dateShift(date: string, days: number): string {
const value = new Date(`${date}T00:00:00Z`);
value.setUTCDate(value.getUTCDate() + days);
return value.toISOString().slice(0, 10);
}
function monthShift(month: string, months: number): string {
const value = new Date(`${month}-01T00:00:00Z`);
value.setUTCMonth(value.getUTCMonth() + months);
return value.toISOString().slice(0, 7);
}
function reportDateLabel(value: string): string {
const date = new Date(`${value}T00:00:00Z`);
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short',
}).format(date).replace(/日(?=周)/, '日 ');
}
function initialReportDate(): string {
const params = new URLSearchParams(window.location.search);
const value = params.get('mileageReportDate');
return /^\d{4}-\d{2}-\d{2}$/.test(value || '') ? value! : shanghaiYmd(-1);
}
function ReportDatePicker({
value,
maxDate,
onChange,
}: {
value: string;
maxDate: string;
onChange: (date: string) => void;
}) {
const [open, setOpen] = useState(false);
const [month, setMonth] = useState(value.slice(0, 7));
const monthStart = new Date(`${month}-01T00:00:00Z`);
const firstGridDate = new Date(monthStart);
firstGridDate.setUTCDate(1 - monthStart.getUTCDay());
const days = Array.from({ length: 42 }, (_, index) => {
const date = new Date(firstGridDate);
date.setUTCDate(firstGridDate.getUTCDate() + index);
return date;
});
const weekdayLabels = ['日', '一', '二', '三', '四', '五', '六'];
const monthLabel = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
}).format(monthStart);
return (
<div className="relative min-w-0 flex-1 sm:flex-none">
<button
type="button"
onClick={() => {
setMonth(value.slice(0, 7));
setOpen(current => !current);
}}
className="flex h-9 w-full min-w-[190px] items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-left text-xs font-black text-slate-700 transition-colors hover:border-blue-200 hover:bg-blue-50/50 sm:w-[208px]"
aria-expanded={open}
aria-haspopup="dialog"
aria-label="选择日报日期"
>
<CalendarDays size={15} className="shrink-0 text-blue-500" />
<span className="min-w-0 flex-1 truncate">{reportDateLabel(value)}</span>
<ChevronDown size={14} className={`shrink-0 text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open ? (
<div role="dialog" aria-label="日报日期日历" className="absolute right-0 top-full z-40 mt-2 w-[308px] rounded-xl border border-slate-200 bg-white p-3 shadow-xl">
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={() => setMonth(current => monthShift(current, -1))}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100"
title="上个月"
aria-label="上个月"
>
<ChevronLeft size={16} />
</button>
<div className="text-sm font-black text-slate-900">{monthLabel}</div>
<button
type="button"
onClick={() => setMonth(current => monthShift(current, 1))}
disabled={month >= maxDate.slice(0, 7)}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-30"
title="下个月"
aria-label="下个月"
>
<ChevronRight size={16} />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center">
{weekdayLabels.map(day => <span key={day} className="py-1 text-[10px] font-black text-slate-400">{day}</span>)}
{days.map(date => {
const dateValue = date.toISOString().slice(0, 10);
const inMonth = dateValue.slice(0, 7) === month;
const selected = dateValue === value;
const disabled = dateValue > maxDate;
return (
<button
key={dateValue}
type="button"
disabled={disabled}
onClick={() => {
onChange(dateValue);
setOpen(false);
}}
className={`flex h-9 items-center justify-center rounded-md text-xs font-black transition-colors ${
selected
? 'bg-blue-600 text-white shadow-sm'
: inMonth
? 'text-slate-700 hover:bg-blue-50 hover:text-blue-600'
: 'text-slate-300 hover:bg-slate-50'
} disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent`}
>
{date.getUTCDate()}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}
function DailyReportLoadingOverlay({ reportDate }: { reportDate: string }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16, ease: 'easeOut' }}
role="status"
aria-live="polite"
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/20 p-5 backdrop-blur-[2px]"
>
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
className="w-full max-w-[260px] overflow-hidden rounded-xl border border-slate-100 bg-white p-5 text-center shadow-2xl"
>
<div className="relative mx-auto flex h-12 w-12 items-center justify-center">
<motion.span
className="absolute inset-0 rounded-full border-2 border-blue-100"
animate={{ scale: [0.86, 1.15], opacity: [0.9, 0] }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeOut' }}
/>
<span className="relative flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 text-blue-600">
<RefreshCw size={19} className="animate-spin" />
</span>
</div>
<div className="mt-3 text-sm font-black text-slate-900">正在加载日报</div>
<div className="mt-1 text-[11px] font-bold text-slate-400">正在汇总 {fmtDate(reportDate)} 的里程数据</div>
<div className="mt-4 h-1 overflow-hidden rounded-full bg-slate-100">
<motion.div
className="h-full w-1/2 rounded-full bg-blue-500"
animate={{ x: ['-100%', '220%'] }}
transition={{ duration: 1.15, repeat: Infinity, ease: 'easeInOut' }}
/>
</div>
</motion.div>
</motion.div>
);
}
function updateReportDateInUrl(date: string): void {
const url = new URL(window.location.href);
url.searchParams.set('mileageReportDate', date);
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
}
function fmtKm(value: number): string {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(2)}万`;
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
}
function fmtDate(value: string): string {
const [, month, day] = value.split('-');
return `${Number(month)}${Number(day)}日`;
}
function fmtDateTime(value: string | null): string {
if (!value) return '数据时间未返回';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function deltaLabel(value: number, rate: number | null): string {
const sign = value >= 0 ? '+' : '';
return `${sign}${fmtKm(value)} km${rate == null ? '' : ` · ${sign}${rate.toFixed(1)}%`}`;
}
function currentAssessmentYearLabel(periods: string[], reportDate: string): string | null {
const report = new Date(`${reportDate}T00:00:00Z`);
if (Number.isNaN(report.getTime())) return null;
const years = new Set<number>();
for (const period of periods) {
const dates = Array.from(period.matchAll(/(\d{4})[/-](\d{1,2})[/-](\d{1,2})/g));
const start = dates[0];
const end = dates[1];
if (!start || !end) continue;
const startDate = new Date(Date.UTC(Number(start[1]), Number(start[2]) - 1, Number(start[3])));
const endDate = new Date(Date.UTC(Number(end[1]), Number(end[2]) - 1, Number(end[3])));
if (report < startDate || report > endDate) continue;
let yearNumber = report.getUTCFullYear() - startDate.getUTCFullYear() + 1;
if (
report.getUTCMonth() < startDate.getUTCMonth()
|| (report.getUTCMonth() === startDate.getUTCMonth() && report.getUTCDate() < startDate.getUTCDate())
) yearNumber -= 1;
if (yearNumber > 0) years.add(yearNumber);
}
if (years.size === 0) return null;
const labels = Array.from(years).sort((a, b) => a - b).map(year => String(year));
return `考核第${labels.join('/')}年`;
}
function dailyMileageStatus(group: MileageReportGroup): {
label: string;
detail: string;
tone: 'positive' | 'warning' | 'neutral';
} {
if (group.dailyRequiredMileage <= 0) {
if (group.remainingMileage <= 0) {
return { label: '本年度已完成', detail: '当前年度剩余任务为 0,无需设置每日应完成里程。', tone: 'positive' };
}
return { label: '暂无法计算', detail: '缺少有效的当前考核年度截止日,暂不设每日应完成里程。', tone: 'warning' };
}
const gap = group.dailyMileage - group.dailyRequiredMileage;
const rate = (group.dailyMileage / group.dailyRequiredMileage) * 100;
if (gap >= 0) {
return { label: '今日已达标', detail: `超出应完成 ${fmtKm(gap)} km,完成 ${rate.toFixed(1)}%。`, tone: 'positive' };
}
return { label: '今日未达标', detail: `距应完成差 ${fmtKm(Math.abs(gap))} km,完成 ${rate.toFixed(1)}%。`, tone: 'warning' };
}
function InsightIcon({ tone }: { tone: string }) {
if (tone === 'positive') return <TrendingUp size={16} />;
if (tone === 'critical') return <AlertTriangle size={16} />;
if (tone === 'warning') return <TrendingDown size={16} />;
return <CircleGauge size={16} />;
}
function sevenDayMileageOf(vehicle: MileageReportVehicle): number {
return (vehicle.trend || []).reduce((total, point) => total + point.totalMileage, 0);
}
function SortableVehicleHeader({
label,
sortKey,
activeSortKey,
sortDirection,
onSort,
}: {
label: string;
sortKey: VehicleSortKey;
activeSortKey: VehicleSortKey;
sortDirection: SortDirection;
onSort: (sortKey: VehicleSortKey) => void;
}) {
const active = activeSortKey === sortKey;
const Icon = !active ? ArrowDownUp : sortDirection === 'asc' ? ArrowUp : ArrowDown;
const order = active ? (sortDirection === 'asc' ? '升序' : '降序') : '未排序';
return (
<button
type="button"
onClick={() => onSort(sortKey)}
className={`ml-auto flex items-center gap-1 rounded-md px-1 py-0.5 transition-colors ${active ? 'text-blue-600' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'}`}
title={`${label}${order},点击切换`}
aria-label={`${label}${order},点击切换`}
>
<span>{label}</span>
<Icon size={12} strokeWidth={active ? 2.5 : 2} />
</button>
);
}
function MobileDailyOverview({
report,
highMileageRate,
}: {
report: DailyMileageReport;
highMileageRate: number;
}) {
const operatingRate = (report.totals.operatingCount / Math.max(1, report.totals.vehicleCount)) * 100;
const stats = [
{
label: '运营车辆',
value: report.totals.operatingCount,
unit: '台',
detail: `${report.totals.vehicleCount} 台考核车辆 · ${operatingRate.toFixed(1)}%`,
icon: Truck,
tone: 'text-emerald-600 bg-emerald-50',
},
{
label: '库存车辆',
value: report.totals.inventoryCount,
unit: '台',
detail: `产生 ${fmtKm(report.totals.inventoryMileage)} km`,
icon: Warehouse,
tone: 'text-rose-600 bg-rose-50',
},
{
label: '运营单车均值',
value: fmtKm(report.totals.averageOperatingMileage),
unit: 'km/台',
detail: `运营里程 ${fmtKm(report.totals.operatingMileage)} km`,
icon: CircleGauge,
tone: 'text-slate-600 bg-slate-100',
},
{
label: '高里程车辆',
value: report.totals.highMileageCount,
unit: '台',
detail: `${highMileageRate.toFixed(1)}% · 低里程 ${report.totals.lowMileageCount} 台`,
icon: TrendingUp,
tone: 'text-blue-600 bg-blue-50',
},
];
return (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm md:hidden">
<div className="flex items-start justify-between gap-3 px-4 py-3.5">
<div>
<div className="text-[11px] font-black text-slate-400">当日总里程</div>
<div className="mt-1 flex items-end gap-1">
<span className="whitespace-nowrap text-[clamp(2rem,10vw,2.45rem)] font-black leading-none tabular-nums text-slate-950">{fmtKm(report.totals.dailyMileage)}</span>
<span className="mb-0.5 text-xs font-black text-slate-400">km</span>
</div>
</div>
<div className={`mt-1 rounded-lg px-2.5 py-1.5 text-right ${report.totals.dayOverDayDelta >= 0 ? 'bg-emerald-50' : 'bg-rose-50'}`}>
<div className="text-[9px] font-black text-slate-400">较前一日</div>
<div className={`mt-0.5 text-[11px] font-black tabular-nums ${report.totals.dayOverDayDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{deltaLabel(report.totals.dayOverDayDelta, report.totals.dayOverDayRate)}
</div>
</div>
</div>
<div className="grid grid-cols-2 border-t border-slate-100">
{stats.map((stat, index) => {
const Icon = stat.icon;
return (
<div key={stat.label} className={`min-w-0 px-3 py-2.5 ${index < 2 ? 'border-b border-slate-100' : ''} ${index % 2 === 0 ? 'border-r border-slate-100' : ''}`}>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-black text-slate-400">{stat.label}</span>
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md ${stat.tone}`}><Icon size={13} /></span>
</div>
<div className="mt-1.5 flex items-end gap-1">
<span className="min-w-0 truncate text-xl font-black leading-none tabular-nums text-slate-900">{stat.value}</span>
<span className="mb-px shrink-0 text-[10px] font-black text-slate-400">{stat.unit}</span>
</div>
<div className="mt-1 truncate text-[9px] font-bold text-slate-500">{stat.detail}</div>
</div>
);
})}
</div>
</section>
);
}
function BreakdownList({
title,
icon,
items,
showAverage = true,
}: {
title: string;
icon: React.ReactNode;
items: MileageReportGroup['departments'];
showAverage?: boolean;
}) {
const max = Math.max(1, ...items.map(item => item.vehicleCount));
return (
<div className="min-w-0">
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
<span className="text-slate-400">{icon}</span>
{title}
</div>
{items.length === 0 ? (
<div className="rounded-lg bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无数据</div>
) : (
<div className="space-y-2.5">
{items.map(item => (
<div key={item.name}>
<div className="mb-1 flex items-center justify-between gap-3 text-[11px] font-bold">
<span className="truncate text-slate-600">{item.name}</span>
<span className="shrink-0 tabular-nums text-slate-500">
{item.vehicleCount} {showAverage ? ` · 均 ${fmtKm(item.averageMileage)} km` : ''}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-blue-500"
style={{ width: `${Math.max(4, (item.vehicleCount / max) * 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
);
}
function VehicleTable({ group }: { group: MileageReportGroup }) {
const [filter, setFilter] = useState<VehicleFilter>('ALL');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<VehicleSortKey>('dailyMileage');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [trendPlate, setTrendPlate] = useState<string | null>(null);
const trendPanelRef = useRef<HTMLDivElement>(null);
const filteredVehicles = useMemo(() => {
const query = search.trim().toLowerCase();
return group.vehicles.filter(vehicle => {
if (filter !== 'ALL' && vehicle.mileageBand !== filter) return false;
if (!query) return true;
return vehicle.plate.toLowerCase().includes(query)
|| (vehicle.customer || '').toLowerCase().includes(query)
|| (vehicle.department || '').toLowerCase().includes(query);
});
}, [filter, group.vehicles, search]);
const vehicles = useMemo(() => [...filteredVehicles].sort((left, right) => {
const leftValue = sortKey === 'sevenDayMileage' ? sevenDayMileageOf(left) : left[sortKey];
const rightValue = sortKey === 'sevenDayMileage' ? sevenDayMileageOf(right) : right[sortKey];
const difference = leftValue - rightValue;
if (difference !== 0) return sortDirection === 'asc' ? difference : -difference;
return left.plate.localeCompare(right.plate, 'zh-CN');
}), [filteredVehicles, sortDirection, sortKey]);
const totalPages = Math.max(1, Math.ceil(vehicles.length / VEHICLE_PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const displayedVehicles = vehicles.slice(
(currentPage - 1) * VEHICLE_PAGE_SIZE,
currentPage * VEHICLE_PAGE_SIZE,
);
const firstVehicleIndex = vehicles.length === 0 ? 0 : (currentPage - 1) * VEHICLE_PAGE_SIZE + 1;
const lastVehicleIndex = Math.min(currentPage * VEHICLE_PAGE_SIZE, vehicles.length);
const toggleSort = (nextSortKey: VehicleSortKey) => {
setPage(1);
if (nextSortKey === sortKey) {
setSortDirection(current => current === 'asc' ? 'desc' : 'asc');
return;
}
setSortKey(nextSortKey);
setSortDirection('desc');
};
const trendVehicle = trendPlate ? group.vehicles.find(vehicle => vehicle.plate === trendPlate) || null : null;
const trendData = trendVehicle?.trend?.length
? trendVehicle.trend
: group.trend.map((point, index) => ({
date: point.date,
totalMileage: index === group.trend.length - 1 ? trendVehicle?.dailyMileage || 0 : 0,
}));
const sevenDayTotal = roundMileageForDisplay(trendData.reduce((total, point) => total + point.totalMileage, 0));
useEffect(() => {
if (!trendPlate) return;
const frame = window.requestAnimationFrame(() => {
trendPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
return () => window.cancelAnimationFrame(frame);
}, [trendPlate]);
return (
<div className="mt-4 border-t border-slate-100 pt-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 gap-1 overflow-x-auto rounded-lg bg-slate-100 p-1">
{VEHICLE_FILTERS.map(item => (
<button
key={item.id}
type="button"
onClick={() => {
setFilter(item.id);
setPage(1);
}}
className={`h-8 shrink-0 rounded-md px-3 text-[11px] font-black transition-colors ${
filter === item.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{item.label}
</button>
))}
</div>
<label className="flex h-9 min-w-0 items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 sm:w-64">
<Search size={14} className="shrink-0 text-slate-400" />
<input
value={search}
onChange={event => {
setSearch(event.target.value);
setPage(1);
}}
placeholder="车牌、客户或部门"
className="min-w-0 flex-1 border-0 bg-transparent text-xs font-bold text-slate-700 outline-none placeholder:text-slate-300"
/>
</label>
</div>
<AnimatePresence initial={false}>
{trendVehicle ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div ref={trendPanelRef} className="mt-3 scroll-mt-20 rounded-lg border border-blue-100 bg-white p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-black text-slate-900">
<BarChart3 size={15} className="text-blue-600" />
<Blur>{trendVehicle.plate}</Blur>
<span className="font-bold text-slate-400">7日里程</span>
</div>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[10px] font-bold text-slate-500">
<span>区间累计 <strong className="text-slate-900">{fmtKm(sevenDayTotal)} km</strong></span>
<span>日均 <strong className="text-slate-900">{fmtKm(roundMileageForDisplay(sevenDayTotal / Math.max(1, trendData.length)))} km</strong></span>
<span>当日 <strong className="text-blue-600">{fmtKm(trendVehicle.dailyMileage)} km</strong></span>
</div>
</div>
<button
type="button"
onClick={() => setTrendPlate(null)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-600"
title="关闭趋势图"
aria-label="关闭趋势图"
>
<X size={15} />
</button>
</div>
<div className="mt-2 h-[190px] min-w-0">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={{ width: 760, height: 190 }}
>
<BarChart data={trendData} margin={{ top: 10, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8' }} tickFormatter={fmtDate} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8' }} />
<Tooltip
labelFormatter={label => fmtDate(String(label))}
formatter={value => [`${fmtKm(Number(value))} km`, '当日里程']}
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
/>
<ReferenceLine y={100} stroke="#f59e0b" strokeDasharray="4 4" label={{ value: '100km', fill: '#d97706', fontSize: 9, position: 'insideTopRight' }} />
<Bar dataKey="totalMileage" radius={[4, 4, 0, 0]} maxBarSize={42}>
{trendData.map(point => (
<Cell key={point.date} fill={point.totalMileage > 100 ? '#2563eb' : '#94a3b8'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
<div className="mt-3 max-h-[420px] overflow-auto rounded-lg border border-slate-100">
<div className="sticky top-0 z-10 grid min-w-[720px] grid-cols-[112px_88px_minmax(140px,1fr)_82px_90px_80px_28px] gap-3 bg-slate-50 px-3 py-2 text-[10px] font-black text-slate-400 md:min-w-[930px] md:grid-cols-[110px_72px_100px_minmax(160px,1fr)_90px_100px_88px_32px]">
<span className="sticky left-3 z-20 -my-2 flex items-center !bg-slate-50 py-2 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)]"><span className="md:hidden">车牌/状态</span><span className="hidden md:inline">车牌</span></span>
<span className="sticky left-[110px] z-20 -my-2 hidden items-center bg-slate-50 py-2 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex">状态</span>
<span>部门/地区</span><span>客户</span>
<SortableVehicleHeader label="当日里程" sortKey="dailyMileage" activeSortKey={sortKey} sortDirection={sortDirection} onSort={toggleSort} />
<SortableVehicleHeader label="近7日累计" sortKey="sevenDayMileage" activeSortKey={sortKey} sortDirection={sortDirection} onSort={toggleSort} />
<SortableVehicleHeader label="考核进度" sortKey="completionRate" activeSortKey={sortKey} sortDirection={sortDirection} onSort={toggleSort} />
<span />
</div>
<div className="min-w-[720px] divide-y divide-slate-100 bg-white md:min-w-[930px]">
{displayedVehicles.map(vehicle => {
const sevenDayMileage = sevenDayMileageOf(vehicle);
const selected = trendPlate === vehicle.plate;
const frozenCellClass = selected ? '!bg-blue-50' : '!bg-white';
return (
<div
key={vehicle.plate}
role="button"
tabIndex={0}
aria-pressed={selected}
aria-label={`选择${vehicle.plate}查看近7日每日里程`}
onClick={() => setTrendPlate(vehicle.plate)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setTrendPlate(vehicle.plate);
}
}}
className={`grid cursor-pointer grid-cols-[112px_88px_minmax(140px,1fr)_82px_90px_80px_28px] items-center gap-3 px-3 py-2.5 text-[11px] font-bold outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 md:grid-cols-[110px_72px_100px_minmax(160px,1fr)_90px_100px_88px_32px] ${selected ? 'bg-blue-50/60' : ''}`}
>
<span className={`sticky left-3 z-10 -my-2.5 flex self-stretch flex-col justify-center font-mono font-black text-slate-800 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex-row md:items-center md:shadow-none ${frozenCellClass}`}>
<Blur>{vehicle.plate}</Blur>
<span className={`mt-0.5 font-sans text-[9px] md:hidden ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
</span>
<span className={`sticky left-[110px] z-10 -my-2.5 hidden self-stretch items-center shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex ${frozenCellClass} ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
<span className="truncate text-slate-500">{vehicle.department || vehicle.inventoryRegion || '未标注'}</span>
<span className="truncate text-slate-500"><Blur>{vehicle.customer || '未绑定客户'}</Blur></span>
<span className={`text-right tabular-nums ${vehicle.mileageBand === 'HIGH' ? 'text-blue-600' : vehicle.mileageBand === 'INVENTORY' && vehicle.dailyMileage > 0 ? 'text-rose-600' : 'text-slate-700'}`}>
{fmtKm(vehicle.dailyMileage)} km
</span>
<span className="text-right tabular-nums font-black text-slate-700">{fmtKm(sevenDayMileage)} km</span>
<span className="text-right tabular-nums text-slate-600">{vehicle.completionRate.toFixed(1)}%</span>
<span className={`flex h-8 w-8 items-center justify-center rounded-md ${selected ? 'bg-blue-600 text-white' : 'text-slate-400'}`} aria-hidden="true">
<BarChart3 size={15} />
</span>
</div>
);})}
{vehicles.length === 0 ? <div className="px-4 py-8 text-center text-xs font-bold text-slate-400">没有符合条件的车辆</div> : null}
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-400">
<span>显示 {firstVehicleIndex}-{lastVehicleIndex} / {vehicles.length} </span>
{totalPages > 1 ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setPage(current => Math.max(1, current - 1))}
disabled={currentPage === 1}
title="上一页"
aria-label="上一页"
className="flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft size={14} />
</button>
<span className="min-w-8 text-center tabular-nums text-slate-500">{currentPage}/{totalPages}</span>
<button
type="button"
onClick={() => setPage(current => Math.min(totalPages, current + 1))}
disabled={currentPage === totalPages}
title="下一页"
aria-label="下一页"
className="flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronRight size={14} />
</button>
</div>
) : null}
</div>
</div>
);
}
function GroupRow({
group,
reportDate,
expanded,
onToggle,
}: {
group: MileageReportGroup;
reportDate: string;
expanded: boolean;
onToggle: () => void;
}) {
const highRate = group.operatingCount > 0 ? (group.highMileageCount / group.operatingCount) * 100 : 0;
const qualifiedRate = group.vehicleCount > 0 ? (group.qualifiedCount / group.vehicleCount) * 100 : 0;
const assessmentYearLabel = currentAssessmentYearLabel(group.assessmentPeriods, reportDate);
const dailyStatus = dailyMileageStatus(group);
const dailyStatusClass = dailyStatus.tone === 'positive'
? 'border-emerald-100 bg-emerald-50/70 text-emerald-700'
: dailyStatus.tone === 'warning'
? 'border-amber-100 bg-amber-50/70 text-amber-700'
: 'border-slate-100 bg-slate-50 text-slate-600';
return (
<div className="border-b border-slate-100 last:border-0">
<button
type="button"
onClick={onToggle}
className="grid w-full grid-cols-[minmax(170px,1.3fr)_82px_100px_88px_100px_96px_24px] items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-50 max-lg:grid-cols-[minmax(150px,1fr)_82px_96px_24px]"
aria-expanded={expanded}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-xs font-black text-slate-900">{group.displayName}</span>
{assessmentYearLabel ? (
<span
className="shrink-0 rounded-md bg-blue-50 px-1.5 py-0.5 text-[9px] font-black text-blue-700 ring-1 ring-blue-100"
title={`当前考核年度:${assessmentYearLabel}`}
>
{assessmentYearLabel}
</span>
) : null}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-1 text-[10px] font-bold text-slate-400">
<span>{group.vehicleCount} · 运营 {group.operatingCount} · 库存 {group.inventoryCount}</span>
<span className="text-emerald-600">· 达标 {group.qualifiedCount} </span>
</span>
</span>
<span className="text-right">
<span className="block text-xs font-black tabular-nums text-blue-600">{group.dailyMileage}</span>
<span className="text-[9px] font-bold text-slate-400">km</span>
</span>
<span className="text-right max-lg:hidden">
<span className={`block text-xs font-black tabular-nums ${group.dayOverDayDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{group.dayOverDayDelta >= 0 ? '+' : ''}{fmtKm(group.dayOverDayDelta)}
</span>
<span className="text-[9px] font-bold text-slate-400">环比变化</span>
</span>
<span className="text-right max-lg:hidden">
<span className="block text-xs font-black tabular-nums text-slate-700">{group.lowMileageCount}/{group.highMileageCount}</span>
<span className="text-[9px] font-bold text-slate-400">/高里程</span>
</span>
<span className="text-right">
<span className="block text-xs font-black tabular-nums text-slate-700">{highRate.toFixed(1)}%</span>
<span className="text-[9px] font-bold text-slate-400">高里程占比</span>
</span>
<span className="text-right max-lg:hidden">
<span className="block text-xs font-black tabular-nums text-slate-700">{group.qualifiedCount}</span>
<span className="text-[9px] font-bold text-slate-400">年度达标</span>
</span>
<ChevronDown size={16} className={`text-slate-400 transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence initial={false}>
{expanded ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="bg-slate-50/70 px-4 py-4">
<div className="grid gap-4 xl:grid-cols-[1fr_1fr_1.1fr]">
<BreakdownList title="业务部门" icon={<Truck size={15} />} items={group.departments} />
<BreakdownList title="库存地区" icon={<MapPin size={15} />} items={group.inventoryRegions} showAverage={false} />
<div>
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
<CircleGauge size={15} className="text-slate-400" />考核进度
</div>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg border border-slate-100 bg-white p-3">
<div className="text-[10px] font-bold text-slate-400">累计完成率</div>
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.completionRate.toFixed(1)}%</div>
</div>
<div className="rounded-lg border border-slate-100 bg-white p-3">
<div className="text-[10px] font-bold text-slate-400">过半车辆</div>
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.halfQualifiedCount} </div>
</div>
<div className="rounded-lg border border-slate-100 bg-white p-3">
<div className="text-[10px] font-bold text-slate-400">剩余任务</div>
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{fmtKm(group.remainingMileage)} km</div>
</div>
<div className="rounded-lg border border-slate-100 bg-white p-3">
<div className="text-[10px] font-bold text-slate-400">每日应完成</div>
<div className="mt-1 text-base font-black tabular-nums text-blue-600">{fmtKm(group.dailyRequiredMileage)} km</div>
</div>
<div className={`col-span-2 flex flex-wrap items-end justify-between gap-x-4 gap-y-2 rounded-lg border p-3 ${dailyStatusClass}`}>
<div>
<div className="text-[10px] font-black">当日目标执行 · {dailyStatus.label}</div>
<div className="mt-1 text-[10px] font-bold opacity-80">当日实际 {fmtKm(group.dailyMileage)} km / 应完成 {fmtKm(group.dailyRequiredMileage)} km</div>
</div>
<div className="max-w-[260px] text-[10px] font-bold leading-relaxed opacity-80">{dailyStatus.detail}</div>
</div>
<div className="col-span-2 flex items-end justify-between gap-3 rounded-lg border border-blue-100 bg-blue-50/50 p-3">
<div>
<div className="text-[10px] font-bold text-slate-500">累计达标率</div>
<div className="mt-1 text-[10px] font-bold text-slate-500">已达标 {group.qualifiedCount} / {group.vehicleCount} </div>
</div>
<div className="text-lg font-black tabular-nums text-blue-700">{qualifiedRate.toFixed(1)}%</div>
</div>
</div>
<div className="mt-2 text-[10px] font-bold leading-relaxed text-slate-400">{group.assessmentPeriods.join('')}</div>
</div>
</div>
<VehicleTable group={group} />
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
function roundMileageForDisplay(value: number): number {
return Math.round(value * 10) / 10;
}
export default function DailyReportView() {
const maxDate = shanghaiYmd(-1);
const [selectedDate, setSelectedDate] = useState(initialReportDate);
const [report, setReport] = useState<DailyMileageReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
const [trendTargetId, setTrendTargetId] = useState<number | null>(null);
useEffect(() => {
let active = true;
let finishTimer: number | undefined;
const loadingStartedAt = performance.now();
setLoading(true);
setError('');
updateReportDateInUrl(selectedDate);
fetchDailyMileageReport(selectedDate)
.then(data => {
if (!active) return;
setReport(data);
setExpandedTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null);
setTrendTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null);
})
.catch(cause => {
if (!active) return;
setError(cause instanceof Error ? cause.message : '日报加载失败');
})
.finally(() => {
if (!active) return;
const delay = Math.max(0, MIN_REPORT_LOADING_MS - (performance.now() - loadingStartedAt));
finishTimer = window.setTimeout(() => {
if (active) setLoading(false);
}, delay);
});
return () => {
active = false;
if (finishTimer != null) window.clearTimeout(finishTimer);
};
}, [selectedDate]);
const highMileageRate = report && report.totals.operatingCount > 0
? (report.totals.highMileageCount / report.totals.operatingCount) * 100
: 0;
const comparisonData = report?.groups.map(group => ({
name: group.displayName,
运营里程: group.operatingMileage,
库存里程: group.inventoryMileage,
})) || [];
const visibleQualityNotes = report?.qualityNotes.filter(
note => note.message !== '日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。',
) || [];
if (loading && !report) return <DailyReportLoadingOverlay reportDate={selectedDate} />;
if (!report) return <ErrorState message={error || '日报接口未返回有效数据'} />;
const trendGroup = trendTargetId == null ? null : report.groups.find(group => group.targetId === trendTargetId) || null;
const trendData = trendGroup?.trend || report.trend;
const trendName = trendGroup?.displayName || '总计';
const trendSubtitle = '选择总计或车型查看近 7 日每日里程,包含库存车辆里程';
const trendColor = trendGroup ? '#0f766e' : '#2563eb';
return (
<div className="relative space-y-3 pb-5" aria-busy={loading}>
<section className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-base font-black text-slate-950">{report.title}</h2>
<span className={`inline-flex h-6 items-center gap-1 rounded-md px-2 text-[10px] font-black ${
report.status === 'ARCHIVED' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
}`}>
{report.status === 'ARCHIVED' ? <Archive size={12} /> : <RefreshCw size={12} />}
{report.status === 'ARCHIVED' ? '已归档' : '预览'}
</span>
</div>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{fmtDate(report.reportDate)} · {report.source === 'XLSX_IMPORT'
? '人工台账归档'
: report.status === 'ARCHIVED'
? `归档于 ${fmtDateTime(report.generatedAt)}`
: `仪表数据更新至 ${fmtDateTime(report.sourceUpdatedAt)}`}
</p>
<p className="mt-1 text-[10px] font-bold text-slate-500">
统计周期:自然日 · 汇总频率:每日 00:30 汇总前一日里程
</p>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setSelectedDate(dateShift(selectedDate, -1))}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"
title="前一天"
aria-label="前一天"
>
<ChevronLeft size={16} />
</button>
<ReportDatePicker value={selectedDate} maxDate={maxDate} onChange={setSelectedDate} />
<button
type="button"
onClick={() => setSelectedDate(dateShift(selectedDate, 1))}
disabled={selectedDate >= maxDate}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-30"
title="后一天"
aria-label="后一天"
>
<ChevronRight size={16} />
</button>
</div>
</div>
</section>
{visibleQualityNotes.length > 0 ? (
<div className="space-y-1.5">
{visibleQualityNotes.map(note => (
<div key={note.message} className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] font-bold ${
note.level === 'warning' ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700'
}`}>
{note.level === 'warning' ? <AlertTriangle size={13} className="mt-0.5 shrink-0" /> : <CheckCircle2 size={13} className="mt-0.5 shrink-0" />}
<span>{note.message}</span>
</div>
))}
</div>
) : null}
<MobileDailyOverview report={report} highMileageRate={highMileageRate} />
<div className="hidden grid-cols-2 gap-2 md:grid xl:grid-cols-5">
<MetricTile
label="当日总里程"
value={fmtKm(report.totals.dailyMileage)}
unit="km"
helper={deltaLabel(report.totals.dayOverDayDelta, report.totals.dayOverDayRate)}
icon={Route}
tone="blue"
className="col-span-2 xl:col-span-1"
/>
<MetricTile
label="运营车辆"
value={report.totals.operatingCount}
unit="台"
helper={`${report.totals.vehicleCount} 台考核车辆 · 运营率 ${((report.totals.operatingCount / Math.max(1, report.totals.vehicleCount)) * 100).toFixed(1)}%`}
icon={Truck}
tone="emerald"
compact
/>
<MetricTile
label="库存车辆"
value={report.totals.inventoryCount}
unit="台"
helper={`库存产生 ${fmtKm(report.totals.inventoryMileage)} km`}
icon={Warehouse}
tone={report.totals.inventoryMileage > 0 ? 'rose' : 'amber'}
compact
/>
<MetricTile
label="运营单车均值"
value={fmtKm(report.totals.averageOperatingMileage)}
unit="km/台"
helper={`运营里程 ${fmtKm(report.totals.operatingMileage)} km`}
icon={CircleGauge}
tone="slate"
compact
/>
<MetricTile
label="高里程车辆"
value={report.totals.highMileageCount}
unit="台"
helper={`占运营车辆 ${highMileageRate.toFixed(1)}% · ≤100km ${report.totals.lowMileageCount} 台`}
icon={TrendingUp}
tone="blue"
compact
/>
</div>
<section className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 xl:grid-cols-4">
{report.insights.map(insight => {
const toneClass = insight.tone === 'positive'
? 'text-emerald-600 bg-emerald-50'
: insight.tone === 'critical'
? 'text-rose-600 bg-rose-50'
: insight.tone === 'warning'
? 'text-amber-600 bg-amber-50'
: 'text-blue-600 bg-blue-50';
return (
<div key={`${insight.title}-${insight.detail}`} className="flex min-h-[96px] gap-2.5 bg-white p-3 xl:min-h-[88px]">
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${toneClass}`}><InsightIcon tone={insight.tone} /></span>
<div className="min-w-0">
<div className="text-xs font-black text-slate-800">{insight.title}</div>
<div className="mt-1 text-[10px] font-bold leading-relaxed text-slate-500">{insight.detail}</div>
</div>
</div>
);
})}
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(360px,0.65fr)]">
<SurfaceCard
title="近 7 日里程趋势"
subtitle={trendSubtitle}
actions={(
<div className="flex max-w-full gap-1 overflow-x-auto rounded-lg bg-slate-100 p-1">
<button
type="button"
onClick={() => setTrendTargetId(null)}
className={`h-7 shrink-0 rounded-md px-2.5 text-[10px] font-black transition-colors ${trendGroup == null ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
总计
</button>
{report.groups.map(group => (
<button
key={group.targetId}
type="button"
onClick={() => setTrendTargetId(group.targetId)}
className={`h-7 shrink-0 rounded-md px-2.5 text-[10px] font-black transition-colors ${trendTargetId === group.targetId ? 'bg-white text-emerald-700 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
{group.displayName}
</button>
))}
</div>
)}
>
<div className="h-[270px] px-2 py-3">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={{ width: 640, height: 246 }}
>
<LineChart data={trendData} margin={{ top: 14, right: 18, left: -4, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtDate} />
<YAxis axisLine={false} tickLine={false} width={58} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
<Tooltip
labelFormatter={label => fmtDate(String(label))}
formatter={value => [`${fmtKm(Number(value))} km`, trendName]}
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
/>
<Line type="monotone" dataKey="totalMileage" stroke={trendColor} strokeWidth={2.5} dot={{ r: 3, fill: trendColor }} activeDot={{ r: 5 }} />
</LineChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
<SurfaceCard
title={(
<span className="flex flex-wrap items-center gap-2">
<span>车型里程构成</span>
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-black text-blue-600 ring-1 ring-blue-100">统计日期 {fmtDate(report.reportDate)}</span>
</span>
)}
subtitle="运营与库存里程分开展示"
>
<div className="h-[270px] px-2 py-3">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={{ width: 480, height: 246 }}
>
<BarChart data={comparisonData} layout="vertical" margin={{ top: 6, right: 14, left: 8, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke="#e2e8f0" strokeDasharray="3 3" />
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
<YAxis type="category" dataKey="name" width={92} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b', fontWeight: 700 }} />
<Tooltip formatter={(value, name) => [`${fmtKm(Number(value))} km`, String(name)]} contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 10, fontWeight: 700 }} />
<Bar dataKey="运营里程" stackId="mileage" fill="#10b981" radius={[3, 0, 0, 3]} />
<Bar dataKey="库存里程" stackId="mileage" fill="#f59e0b" radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
</div>
<section className="flex flex-col gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2.5 sm:flex-row sm:items-center sm:gap-5">
<div className="flex shrink-0 items-center gap-2 text-[11px] font-black text-slate-700">
<Info size={14} className="text-blue-600" />里程分档口径
</div>
<div className="flex flex-wrap gap-x-5 gap-y-1 text-[10px] font-bold text-slate-500">
<span><strong className="text-slate-800">低里程</strong>:当日里程 100km</span>
<span><strong className="text-blue-600">高里程</strong>:当日里程 &gt;100km</span>
<span>仅统计运营车辆,库存车辆单独统计</span>
</div>
</section>
<SurfaceCard
title={(
<span className="flex flex-wrap items-center gap-2">
<span>车型运营明细</span>
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-black text-blue-600 ring-1 ring-blue-100">统计日期 {fmtDate(report.reportDate)}</span>
</span>
)}
subtitle="点击车型查看部门、库存地区、考核进度及车辆近7日趋势"
>
<div className="hidden grid-cols-[minmax(170px,1.3fr)_82px_100px_88px_100px_96px_24px] gap-3 border-b border-slate-100 bg-slate-50 px-4 py-2 text-[10px] font-black text-slate-400 lg:grid">
<span>考核车型</span><span className="text-right">当日里程</span><span className="text-right">环比变化</span><span className="text-right">/高里程</span><span className="text-right">高里程占比</span><span className="text-right">年度达标</span><span />
</div>
{report.groups.map(group => (
<GroupRow
key={group.targetId}
group={group}
reportDate={report.reportDate}
expanded={expandedTargetId === group.targetId}
onToggle={() => setExpandedTargetId(current => current === group.targetId ? null : group.targetId)}
/>
))}
</SurfaceCard>
<AnimatePresence initial={false}>
{loading ? <DailyReportLoadingOverlay reportDate={selectedDate} /> : null}
</AnimatePresence>
{error ? <ErrorState message={error} /> : null}
</div>
);
}