1193 lines
54 KiB
TypeScript
1193 lines
54 KiB
TypeScript
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>:当日里程 >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>
|
||
);
|
||
}
|