349 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|