Files
ln-bi/src/modules/mileage/DailyReportView.tsx
T

349 lines
14 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { motion } from 'motion/react';
import {
Activity,
AlertTriangle,
CalendarDays,
ChevronRight,
CircleGauge,
Database,
RefreshCw,
Route,
Truck,
} from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import Blur from '../../components/Blur';
import { MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
fetchDailyReport,
fetchDailyReportTrend,
type DailyReportData,
type DailyReportVehicle,
} from './api';
import { buildMileageDrillUrl } from './drill-context';
function fmtKm(value: number): string {
if (value >= 10000) return `${(value / 10000).toFixed(2)}万`;
return value.toLocaleString(undefined, { maximumFractionDigits: 1 });
}
function fmtTime(value: string): string {
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 VehicleRow({
vehicle,
mode,
onClick,
}: {
vehicle: DailyReportVehicle;
mode: 'top' | 'risk';
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="grid w-full grid-cols-[minmax(0,1fr)_auto_18px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50"
>
<span className="min-w-0">
<span className="flex items-center gap-2">
<span className="truncate font-mono text-xs font-black text-slate-800"><Blur>{vehicle.plate}</Blur></span>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${vehicle.isOnline ? 'bg-emerald-500' : 'bg-slate-300'}`} />
</span>
<span className="mt-0.5 block truncate text-[9px] font-bold text-slate-400">
{vehicle.model} · {vehicle.status} · <Blur>{vehicle.customer}</Blur>
</span>
</span>
<span className="text-right">
{mode === 'top' ? (
<>
<span className="block text-xs font-black text-blue-600">{fmtKm(vehicle.today)} km</span>
<span className="block text-[9px] font-bold text-slate-400">当日里程</span>
</>
) : (
<>
<span className="block text-xs font-black text-rose-600">{fmtKm(vehicle.shortfall)} km</span>
<span className="block text-[9px] font-bold text-slate-400">任务缺口</span>
</>
)}
</span>
<ChevronRight size={14} className="text-slate-300" />
</button>
);
}
export default function DailyReportView() {
const [report, setReport] = useState<DailyReportData | null>(null);
const [loading, setLoading] = useState(true);
const [trendLoading, setTrendLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async (force = false) => {
setLoading(true);
setTrendLoading(true);
setError('');
const trendRequest = fetchDailyReportTrend(force);
try {
const nextReport = await fetchDailyReport(force);
setReport(nextReport);
setLoading(false);
try {
const trend = await trendRequest;
setReport(current => current ? {
...current,
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
} : current);
} catch {
setError('7 日趋势加载失败');
} finally {
setTrendLoading(false);
}
} catch (cause) {
void trendRequest.catch(() => {});
setError(cause instanceof Error ? cause.message : '日报加载失败');
setLoading(false);
setTrendLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const openTarget = useCallback((targetId: number) => {
const url = buildMileageDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#mileage/statistics' },
{ level: 'overview', targetId, sourcePriority: ['instrument'] },
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, []);
const openVehicle = useCallback((vehicle: DailyReportVehicle) => {
if (!report) return;
const url = buildMileageDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#mileage' },
{
level: 'vehicle',
targetId: vehicle.targetId,
plate: vehicle.plate,
startDate: report.reportDate,
endDate: report.reportDate,
sourcePriority: ['instrument'],
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, [report]);
if (loading && !report) {
return (
<SurfaceCard className="min-h-[360px]">
<div className="flex min-h-[320px] items-center justify-center gap-2 text-xs font-bold text-slate-400">
<RefreshCw size={16} className="animate-spin" />
正在生成里程日报
</div>
</SurfaceCard>
);
}
if (!report) {
return (
<SurfaceCard className="min-h-[360px]">
<div className="flex min-h-[320px] flex-col items-center justify-center gap-3 px-6 text-center">
<AlertTriangle size={24} className="text-rose-500" />
<div className="text-sm font-black text-slate-800">日报暂不可用</div>
<div className="max-w-md text-xs font-bold text-slate-400">{error || '数据接口未返回有效结果'}</div>
<button
type="button"
onClick={() => void load(true)}
className="rounded-lg bg-slate-900 px-4 py-2 text-xs font-black text-white"
>
重新加载
</button>
</div>
</SurfaceCard>
);
}
return (
<div className="space-y-3 pb-4">
<section className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<CalendarDays size={17} />
</span>
<div>
<h2 className="text-sm font-black text-slate-900">{report.reportDate} 里程日报</h2>
<p className="mt-0.5 text-[10px] font-bold text-slate-400">统计时间 {fmtTime(report.updatedAt)} · 仪表数据</p>
</div>
</div>
<button
type="button"
onClick={() => void load(true)}
disabled={loading || trendLoading}
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-blue-100 bg-blue-50 px-3 text-xs font-black text-blue-600 transition-colors hover:bg-blue-100 disabled:opacity-50"
>
<RefreshCw size={14} className={loading || trendLoading ? 'animate-spin' : ''} />
刷新日报
</button>
</section>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<MetricTile
label="考核车辆当日里程"
value={fmtKm(report.totalToday)}
unit="km"
helper={`${report.activeCount}/${report.assessmentVehicleCount} 台产生里程`}
icon={Route}
tone="blue"
/>
<MetricTile
label="当日任务缺口"
value={fmtKm(report.shortfall)}
unit="km"
helper={`任务基准 ${fmtKm(report.dailyNeed)} km`}
icon={AlertTriangle}
tone={report.shortfall > 0 ? 'rose' : 'emerald'}
/>
<MetricTile
label="零里程车辆"
value={report.zeroCount}
unit="台"
helper={`考核车辆共 ${report.assessmentVehicleCount} 台`}
icon={Truck}
tone={report.zeroCount > 0 ? 'amber' : 'emerald'}
/>
<MetricTile
label="考核加权完成率"
value={report.completionRate.toFixed(1)}
unit="%"
helper={`${report.qualifiedCount} 台达标 · ${report.halfQualifiedCount} 台过半`}
icon={CircleGauge}
tone="emerald"
/>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
<div className="space-y-3">
<SurfaceCard
title="全量监控车辆 7 日趋势"
subtitle={`当前监控范围 ${report.monitoringVehicleCount} 台,趋势不等同于考核车辆范围`}
>
<div className="h-[250px] px-2 py-3">
{report.trend.length === 0 && trendLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-xs font-bold text-slate-400">
<RefreshCw size={14} className="animate-spin" />
正在加载趋势
</div>
) : report.trend.length === 0 ? (
<div className="flex h-full items-center justify-center text-xs font-bold text-slate-400">
暂无趋势数据
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={report.trend} margin={{ top: 12, right: 12, left: -12, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
<Tooltip
formatter={(value) => [`${fmtKm(Number(value))} km`, '总里程']}
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
/>
<Bar dataKey="value" fill="#2563eb" radius={[4, 4, 0, 0]} maxBarSize={30} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</SurfaceCard>
<SurfaceCard title="考核目标表现" subtitle="按当日任务缺口排序,点击进入考核统计">
<div className="divide-y divide-slate-100">
{report.models.map((model, index) => (
<motion.button
type="button"
key={model.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.025 }}
onClick={() => openTarget(model.id)}
className="grid w-full grid-cols-[minmax(0,1fr)_72px_78px_20px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50"
>
<span className="min-w-0">
<span className="block truncate text-xs font-black text-slate-800">{model.name}</span>
<span className="mt-0.5 block text-[9px] font-bold text-slate-400">
{model.active}/{model.count} 台有里程 · {model.zero} 台零里程
{model.missingDailyTaskCount > 0 ? ` · ${model.missingDailyTaskCount} 台缺任务值` : ''}
</span>
</span>
<span className="text-right">
<span className="block text-[11px] font-black text-blue-600">{fmtKm(model.today)}</span>
<span className="block text-[9px] font-bold text-slate-400">当日 km</span>
</span>
<span className="text-right">
<span className={`block text-[11px] font-black ${model.shortfall > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
{fmtKm(model.shortfall)}
</span>
<span className="block text-[9px] font-bold text-slate-400">缺口 km</span>
</span>
<ChevronRight size={14} className="text-slate-300" />
</motion.button>
))}
</div>
</SurfaceCard>
</div>
<div className="space-y-3">
<SurfaceCard title="当日里程 TOP 5" subtitle="考核车辆范围">
<div className="divide-y divide-slate-100">
{report.topVehicles.map(vehicle => (
<VehicleRow key={vehicle.plate} vehicle={vehicle} mode="top" onClick={() => openVehicle(vehicle)} />
))}
</div>
</SurfaceCard>
<SurfaceCard title="任务缺口车辆" subtitle="按车辆当日任务缺口排序,仅含已返回任务值的车辆">
<div className="divide-y divide-slate-100">
{report.riskVehicles.map(vehicle => (
<VehicleRow key={vehicle.plate} vehicle={vehicle} mode="risk" onClick={() => openVehicle(vehicle)} />
))}
</div>
</SurfaceCard>
<section className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="flex items-start gap-3">
<Database size={16} className="mt-0.5 shrink-0 text-slate-400" />
<div className="min-w-0 text-[10px] font-bold leading-relaxed text-slate-500">
<div>考核车辆 {report.assessmentVehicleCount} · 监控车辆 {report.monitoringVehicleCount} </div>
<div className="mt-1">重复考核归属 {report.duplicateAssignmentCount} </div>
<div className={report.missingDailyTaskCount > 0 ? 'mt-1 text-amber-600' : 'mt-1'}>
缺少车辆级任务值 {report.missingDailyTaskCount}
</div>
{error && <div className="mt-1 text-rose-600">最近刷新失败:{error}</div>}
</div>
<Activity size={14} className="ml-auto shrink-0 text-emerald-500" />
</div>
</section>
</div>
</div>
</div>
);
}