refactor: modularize application domains

This commit is contained in:
kkfluous
2026-08-13 12:03:34 +08:00
parent d610e4b841
commit 06fe75b4c7
142 changed files with 14645 additions and 8885 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+92 -703
View File
@@ -1,62 +1,18 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, LineChart, Line, AreaChart, Area,
Cell, LabelList,
} from 'recharts';
import {
Truck, ChevronDown, Maximize2, Minimize2,
Search, ArrowUpDown, X, RotateCcw, Calendar,
} from 'lucide-react';
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
import { useEffect, useState } from 'react';
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import Blur from '../../components/Blur';
function getDefaultDate(): string {
const now = new Date();
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function getCurrentDateLabel(): string {
const now = new Date();
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
}
function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
return value.toLocaleString();
}
function fmtPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
function getTargetAssessment(target: TargetSummary, selectedYear?: number): TargetYearlyAssessment | null {
if (target.yearlyAssessments.length === 0) return null;
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
}
function fmtDateLabel(date: string | null): string {
if (!date) return '';
const [year, month, day] = date.split('-');
return `${year}.${Number(month)}.${Number(day)}`;
}
function shortTargetName(name: string): string {
// Extract the number and a short description
const match = name.match(/(\d+)[辆台](.+)/);
if (!match) return name;
const count = match[1];
let desc = match[2];
// Simplify common patterns
desc = desc.replace('4.5T普货', '普货');
desc = desc.replace('4.5T冷链车', '冷藏车');
desc = desc.replace('4.5T冷链', '冷藏车');
desc = desc.replace('18T', '18T');
return `${count}台${desc}`;
}
import AllVehiclesPanel from './statistics/AllVehiclesPanel';
import FullscreenTargetTable from './statistics/FullscreenTargetTable';
import StatisticsOverview from './statistics/StatisticsOverview';
import TargetDetailPanel from './statistics/TargetDetailPanel';
import TrendPanel from './statistics/TrendPanel';
import {
deriveSelectedTargetMetrics,
getCurrentDateLabel,
getDefaultDate,
initialAssessmentYears,
orderTargets,
} from './statistics/model';
export default function StatisticsView() {
const currentDateLabel = getCurrentDateLabel();
@@ -64,44 +20,33 @@ export default function StatisticsView() {
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
const [viewAllSearch, setViewAllSearch] = useState('');
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false);
const selectedTarget = targets.find(t => t.id === selectedTargetId);
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
const selectedRemaining = selectedAssessment?.remaining ?? 0;
const selectedDaysLeft = selectedAssessment?.daysLeft ?? selectedTarget?.daysLeft ?? 0;
const selectedDailyTarget = selectedAssessment?.dailyTarget ?? (selectedDaysLeft > 0 ? selectedRemaining / selectedDaysLeft : 0);
const selectedQualifiedRate = selectedAssessment?.qualifiedRate ?? (selectedTarget?.vehicleCount ? selectedTarget.yearQualifiedCount / selectedTarget.vehicleCount * 100 : 0);
const latestTrend = trendData[trendData.length - 1];
const previousTrend = trendData[trendData.length - 2];
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
const selectedTarget = targets.find(target => target.id === selectedTargetId);
const selectedMetrics = deriveSelectedTargetMetrics(
selectedTarget,
selectedTarget ? assessmentYearMap[selectedTarget.id] : undefined,
trendData,
);
// Load targets on mount
useEffect(() => {
fetchTargets().then(data => {
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
const ordered = focused
? [focused, ...data.filter(item => item.id !== focused.id)]
: data;
const ordered = orderTargets(data);
setTargets(ordered);
if (ordered.length > 0 && !selectedTargetId) {
setSelectedTargetId(focused.id);
setExpandedTargetId(focused.id);
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
setAssessmentYearMap(initialAssessmentYears(ordered));
fetchTargetVehicles(focused.id).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
setTargetVehiclesMap(previous => ({ ...previous, [focused.id]: vehicles }));
}).catch(() => {});
}
}).catch(() => {});
@@ -113,7 +58,7 @@ export default function StatisticsView() {
setExpandedTargetId(selectedTargetId);
if (!targetVehiclesMap[selectedTargetId]) {
fetchTargetVehicles(selectedTargetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
setTargetVehiclesMap(previous => ({ ...previous, [selectedTargetId]: vehicles }));
}).catch(() => {});
}
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
@@ -124,640 +69,84 @@ export default function StatisticsView() {
if (viewAllTargetId === null) return;
setViewAllLoading(true);
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
setTargetVehiclesMap(previous => ({ ...previous, [viewAllTargetId]: data }));
}).catch(() => {}).finally(() => setViewAllLoading(false));
}, [viewAllTargetId, viewAllDate]);
function selectTarget(targetId: number) {
setSelectedTargetId(targetId);
setExpandedTargetId(targetId);
}
function expandTarget(targetId: number) {
setExpandedTargetId(targetId);
if (!targetVehiclesMap[targetId]) {
fetchTargetVehicles(targetId).then(data => {
setTargetVehiclesMap(previous => ({ ...previous, [targetId]: data }));
}).catch(() => {});
}
}
function openAllVehicles(target: TargetSummary) {
setViewAllTargetId(target.id);
setViewAllTargetName(target.targetName);
setViewAllDate(getDefaultDate());
}
function refreshTargets() {
fetchTargets().then(data => {
setTargets(data);
}).catch(() => {});
}
return (
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
{/* Project Selector */}
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
{targets.map(target => (
<button
key={target.id}
onClick={() => {
setSelectedTargetId(target.id);
setExpandedTargetId(target.id);
}}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
}`}
>
{shortTargetName(target.targetName)}
</button>
))}
</div>
{selectedTarget && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-2 gap-2 md:grid-cols-4"
>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">考核压力</div>
<div className={`mt-1 text-lg font-black ${pressureLevel === '健康' ? 'text-emerald-600' : pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
{pressureLevel}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">完成率 {fmtPercent(selectedCompletion)}</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">剩余缺口</div>
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, selectedRemaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
<div className="mt-1 text-[10px] font-bold text-slate-400">剩余 {selectedDaysLeft} 天</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">日均需完成</div>
<div className="mt-1 text-lg font-black text-blue-600">
{selectedDaysLeft > 0 ? (
<>
{fmtKm(selectedDailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
</>
) : '已到期'}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">按当前考核口径</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
</div>
</motion.div>
)}
<StatisticsOverview
targets={targets}
selectedTargetId={selectedTargetId}
selectedTarget={selectedTarget}
metrics={selectedMetrics}
onSelectTarget={selectTarget}
/>
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
{/* Left Side: Trend Chart / Dashboard Sidebar */}
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
{/* KPI Cards in Landscape — linked to selected target */}
{(() => {
const sel = selectedTarget;
return (
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">今日总里程</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(sel?.todayTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">累计总里程</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(sel?.cumulativeTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">考核车辆</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{sel?.vehicleCount ?? 0}
<span className="text-blue-500 text-[10px] ml-1">台</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{selectedCompletion.toFixed(1)}
<span className="text-blue-500 text-[10px] ml-1">%</span>
</div>
</div>
</div>
);
})()}
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-sm font-bold text-slate-800">7天里程趋势</h3>
</div>
<div className="flex bg-slate-50 p-1 rounded-lg">
{(['bar', 'line', 'area'] as const).map(type => (
<button
key={type}
onClick={() => setChartType(type)}
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
}`}
>
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
</button>
))}
</div>
</div>
<div className="h-[280px] w-full min-w-0">
<ResponsiveContainer width="100%" height={280} minWidth={0}>
{chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Bar dataKey="mileage" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={20}>
<LabelList dataKey="mileage" position="top" style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
{trendData.map((_entry: TrendPoint, index: number) => (
<Cell key={`cell-${index}`} fill={index === trendData.length - 1 ? '#2563eb' : '#60a5fa'} />
))}
</Bar>
</BarChart>
) : chartType === 'line' ? (
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Line type="monotone" dataKey="mileage" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4, fill: '#3b82f6' }}>
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Line>
</LineChart>
) : (
<AreaChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorMileage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={(val: string) => { const [m, d] = val.split('-'); return `${parseInt(m)}.${parseInt(d)}`; }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Area type="monotone" dataKey="mileage" stroke="#3b82f6" fillOpacity={1} fill="url(#colorMileage)">
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Area>
</AreaChart>
)}
</ResponsiveContainer>
</div>
</div>
</div>
{/* Right Side: Summary Section */}
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
<div className="flex items-center justify-between px-2 flex-shrink-0">
<div className="flex min-w-0 items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<div className="min-w-0">
<h3 className="truncate text-xs font-black text-slate-700">当前车型考核详情</h3>
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{selectedTarget?.targetName || '请选择车型'}</p>
</div>
</div>
<button
onClick={() => setIsTableFullscreen(true)}
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
>
<Maximize2 size={14} />
</button>
</div>
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
{(selectedTarget ? [selectedTarget] : []).map((target, idx) => (
(() => {
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
const primaryCompletion = assessment?.completionRate ?? target.avgCompletion;
const primaryQualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
return (
<div
key={idx}
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => {
setExpandedTargetId(target.id);
if (!targetVehiclesMap[target.id]) {
fetchTargetVehicles(target.id).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
}).catch(() => {});
}
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
<Truck size={14} className="text-slate-400" />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}台</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
</div>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
<div className="flex flex-col items-end">
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
</div>
<div className="text-[8px] font-bold text-slate-300">
累计: {fmtKm(target.cumulativeTotal)} KM
</div>
</div>
<motion.div
animate={{ rotate: 180 }}
className="text-slate-300"
>
<ChevronDown size={14} />
</motion.div>
</div>
</div>
<AnimatePresence initial={false} mode="wait">
{expandedTargetId === target.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
<span className="text-[10px] font-black text-blue-700">考核年度</span>
<select
value={assessment?.yearNumber || ''}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
e.stopPropagation();
setAssessmentYearMap(prev => ({ ...prev, [target.id]: Number(e.target.value) }));
}}
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
>
{target.yearlyAssessments.map(item => (
<option key={item.yearNumber} value={item.yearNumber}>
{item.label}
</option>
))}
</select>
</div>
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
{target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
</div>
{assessment ? (
<>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}任务/辆</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已进入车辆</p>
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} 台</p>
{assessment.vehicleCount < target.vehicleCount && (
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
)}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}需完成</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已完成</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
<p className="text-[8px] font-bold text-slate-300">
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}完成率</p>
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
<p className="text-[10px] font-black text-blue-500">
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
</p>
</div>
</>
) : (
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
</div>
)}
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
</div>
{/* Vehicle List Detail */}
<div className="col-span-2 space-y-2 mt-2">
<div className="flex items-center justify-between px-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆明细 (前5台)</span>
<button
onClick={(e) => {
e.stopPropagation();
setViewAllTargetId(target.id);
setViewAllTargetName(target.targetName);
setViewAllDate(getDefaultDate());
}}
className="text-[8px] text-blue-500 font-bold hover:underline"
>
查看全部
</button>
</div>
<div className="space-y-1">
{(targetVehiclesMap[target.id] || []).slice(0, 5).map(tv => (
<div key={tv.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{tv.plateNumber}</Blur></span>
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
在线
</span>
</div>
<div className="text-right">
<span className="text-[10px] font-black text-blue-600">{tv.todayMileage}</span>
<span className="text-[8px] text-slate-400 ml-1">KM</span>
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})()
))}
</div>
</div>
<TrendPanel
selectedTarget={selectedTarget}
selectedCompletion={selectedMetrics.completion}
trendData={trendData}
/>
<TargetDetailPanel
target={selectedTarget}
expandedTargetId={expandedTargetId}
selectedYear={selectedTarget ? assessmentYearMap[selectedTarget.id] : undefined}
vehicles={selectedTarget ? targetVehiclesMap[selectedTarget.id] || [] : []}
currentDateLabel={currentDateLabel}
onOpenFullscreen={() => setIsTableFullscreen(true)}
onExpandTarget={expandTarget}
onAssessmentYearChange={(targetId, year) => {
setAssessmentYearMap(previous => ({ ...previous, [targetId]: year }));
}}
onViewAll={openAllVehicles}
/>
</div>
{/* Fullscreen Table Overlay */}
<AnimatePresence>
{isTableFullscreen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
>
{/* Top bar: compact inline KPI */}
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
<h2 className="text-white font-bold text-xs">车型考核汇总</h2>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500">今日 <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.todayTotal, 0))}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">累计 <span className="text-white font-black">{fmtKm(targets.reduce((sum, t) => sum + t.cumulativeTotal, 0))}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">车辆 <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> 台</span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">所选年度完成率 <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? t.avgCompletion), 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { fetchTargets().then(data => { setTargets(data); }).catch(() => {}); }}
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
>
<RotateCcw size={13} />
</button>
<button
onClick={() => setIsTableFullscreen(false)}
className="p-1.5 text-slate-500 hover:text-white transition-colors"
>
<Minimize2 size={14} />
</button>
</div>
</div>
<FullscreenTargetTable
isOpen={isTableFullscreen}
targets={targets}
assessmentYearMap={assessmentYearMap}
onRefresh={refreshTargets}
onClose={() => setIsTableFullscreen(false)}
/>
{/* Table Area */}
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">年度进度</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度任务/辆</th>
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">年度达标</th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度目标</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-right">日均需完成</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{targets.map((target, idx) => {
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
const completion = assessment?.completionRate ?? target.avgCompletion;
const qualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
const halfQualified = assessment?.halfQualifiedCount ?? target.halfQualifiedCount;
const goal = assessment?.target ?? target.currentYearTarget;
const completed = assessment?.completed ?? target.currentYearCompleted;
const remainingMileage = assessment?.remaining ?? target.remaining;
const days = assessment?.daysLeft ?? target.daysLeft;
const daily = assessment?.dailyTarget ?? target.dailyTarget;
const taskPerVehicle = target.annualMileagePerVehicle * (assessment?.yearNumber || 1);
return (
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{assessment?.label || '当前年度'}</div>
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
</td>
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
style={{ width: `${Math.min(completion, 100)}%` }}
/>
</div>
<span className="text-[10px] font-black text-white w-10 text-right">{completion.toFixed(1)}%</span>
</div>
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
<span>{fmtKm(completed)}</span>
<span>/ {fmtKm(goal)} km</span>
</div>
</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(taskPerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{qualified}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{halfQualified}</td>
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(goal)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(completed)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(remainingMileage)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{days}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</motion.div>
)}
</AnimatePresence>
{/* View All Vehicles Side Panel */}
<AnimatePresence>
{viewAllTargetId !== null && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setViewAllTargetId(null)}
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
/>
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-[120] bg-white shadow-2xl flex flex-col"
>
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="text-lg font-black text-slate-900">{viewAllTargetName}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">车辆实时明细清单</p>
</div>
<button
onClick={() => {
setViewAllTargetId(null);
setViewAllSearch('');
}}
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
>
<X size={20} />
</button>
</div>
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="搜索车牌号..."
value={viewAllSearch}
onChange={(e) => setViewAllSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
</div>
<div className="relative flex-shrink-0">
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
<input
type="date"
value={viewAllDate}
onChange={(e) => setViewAllDate(e.target.value)}
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{viewAllLoading ? '加载中...' : (() => {
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
})()}
</span>
<button
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
>
<ArrowUpDown size={12} />
{viewAllSort === 'desc' ? '降序' : '升序'}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
).sort((a, b) => {
const valA = a.todayMileage || 0;
const valB = b.todayMileage || 0;
return viewAllSort === 'desc' ? valB - valA : valA - valB;
}).map(tv => (
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{tv.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2">
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(tv.totalMileage || 0)} km</div>
</div>
</div>
))}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100">
<button
onClick={() => setViewAllTargetId(null)}
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
>
关闭明细
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
<AllVehiclesPanel
targetId={viewAllTargetId}
targetName={viewAllTargetName}
vehicles={viewAllTargetId !== null ? targetVehiclesMap[viewAllTargetId] || [] : []}
date={viewAllDate}
loading={viewAllLoading}
onDateChange={setViewAllDate}
onClose={() => setViewAllTargetId(null)}
/>
</div>
);
}
@@ -0,0 +1,45 @@
import { RefreshCw } from 'lucide-react';
import { motion } from 'motion/react';
import { fmtDate } from './utils';
export default 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>
);
}
@@ -0,0 +1,192 @@
import type { ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
ChevronDown,
CircleGauge,
MapPin,
Truck,
} from 'lucide-react';
import type { MileageReportGroup } from '../api';
import VehicleTable from './VehicleTable';
import {
currentAssessmentYearLabel,
dailyMileageStatus,
fmtKm,
} from './utils';
interface GroupRowProps {
group: MileageReportGroup;
reportDate: string;
expanded: boolean;
onToggle: () => void;
}
function BreakdownList({
title,
icon,
items,
showAverage = true,
}: {
title: string;
icon: 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>
);
}
export default function GroupRow({
group,
reportDate,
expanded,
onToggle,
}: GroupRowProps) {
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">达标50%</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>
);
}
@@ -0,0 +1,16 @@
import { Info } from 'lucide-react';
export default function MileageBandDefinition() {
return (
<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>
);
}
@@ -0,0 +1,122 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { SurfaceCard } from '../../../components/ui/surface';
import type { DailyMileageReport } from '../api';
import { fmtDate, fmtKm } from './utils';
interface ReportChartsProps {
report: DailyMileageReport;
trendTargetId: number | null;
onTrendTargetChange: (targetId: number | null) => void;
}
export default function ReportCharts({
report,
trendTargetId,
onTrendTargetChange,
}: ReportChartsProps) {
const comparisonData = report.groups.map(group => ({
name: group.displayName,
运营里程: group.operatingMileage,
库存里程: group.inventoryMileage,
}));
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="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={() => onTrendTargetChange(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={() => onTrendTargetChange(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>
);
}
@@ -0,0 +1,113 @@
import { useState } from 'react';
import {
CalendarDays,
ChevronDown,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { monthShift, reportDateLabel } from './utils';
interface ReportDatePickerProps {
value: string;
maxDate: string;
onChange: (date: string) => void;
}
export default function ReportDatePicker({
value,
maxDate,
onChange,
}: ReportDatePickerProps) {
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>
);
}
@@ -0,0 +1,69 @@
import { Archive, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import type { DailyMileageReport } from '../api';
import { dateShift, fmtDate, fmtDateTime } from './utils';
import ReportDatePicker from './ReportDatePicker';
interface ReportHeaderProps {
report: DailyMileageReport;
selectedDate: string;
maxDate: string;
onDateChange: (date: string) => void;
}
export default function ReportHeader({
report,
selectedDate,
maxDate,
onDateChange,
}: ReportHeaderProps) {
return (
<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">
统计周期:自然日 · 汇总频率:每日 06:30 汇总前一日里程
</p>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onDateChange(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={onDateChange} />
<button
type="button"
onClick={() => onDateChange(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>
);
}
@@ -0,0 +1,38 @@
import { AlertTriangle, CircleGauge, TrendingDown, TrendingUp } from 'lucide-react';
import type { DailyMileageReport } from '../api';
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} />;
}
export default function ReportInsights({
insights,
}: {
insights: DailyMileageReport['insights'];
}) {
return (
<section className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 xl:grid-cols-4">
{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>
);
}
@@ -0,0 +1,141 @@
import { CircleGauge, Route, TrendingUp, Truck, Warehouse } from 'lucide-react';
import { MetricTile } from '../../../components/ui/surface';
import type { DailyMileageReport } from '../api';
import { deltaLabel, fmtKm } from './utils';
interface ReportOverviewProps {
report: DailyMileageReport;
highMileageRate: number;
}
function MobileDailyOverview({ report, highMileageRate }: ReportOverviewProps) {
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>
);
}
export default function ReportOverview({ report, highMileageRate }: ReportOverviewProps) {
return (
<>
<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>
</>
);
}
@@ -0,0 +1,302 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
ArrowDown,
ArrowDownUp,
ArrowUp,
BarChart3,
ChevronLeft,
ChevronRight,
Search,
X,
} from 'lucide-react';
import {
Bar,
BarChart,
Cell,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import Blur from '../../../components/Blur';
import type { MileageReportGroup } from '../api';
import {
filterAndSortVehicles,
fmtDate,
fmtKm,
roundMileageForDisplay,
sevenDayMileageOf,
type SortDirection,
type VehicleFilter,
type VehicleSortKey,
} from './utils';
const VEHICLE_PAGE_SIZE = 20;
const VEHICLE_FILTERS: { id: VehicleFilter; label: string }[] = [
{ id: 'ALL', label: '全部' },
{ id: 'LOW', label: '≤100km' },
{ id: 'HIGH', label: '>100km' },
{ id: 'INVENTORY', label: '库存' },
];
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>
);
}
export default 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 vehicles = useMemo(
() => filterAndSortVehicles(group.vehicles, filter, search, sortKey, sortDirection),
[filter, group.vehicles, search, 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>
);
}
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MileageReportVehicle } from '../api';
import {
currentAssessmentYearLabel,
dailyMileageStatus,
dateShift,
deltaLabel,
filterAndSortVehicles,
fmtDate,
fmtKm,
initialReportDate,
isVisibleQualityNote,
monthShift,
roundMileageForDisplay,
sevenDayMileageOf,
shanghaiYmd,
} from './utils';
function vehicle(overrides: Partial<MileageReportVehicle>): MileageReportVehicle {
return {
plate: '粤A00000',
status: '运营',
customer: null,
department: null,
inventoryRegion: null,
dailyMileage: 0,
cumulativeMileage: 0,
completionRate: 0,
mileageBand: 'LOW',
trend: [],
...overrides,
};
}
test('日报日期始终按上海自然日计算,并支持跨月偏移', () => {
const now = new Date('2026-08-12T16:30:00.000Z');
assert.equal(shanghaiYmd(0, now), '2026-08-13');
assert.equal(shanghaiYmd(-1, now), '2026-08-12');
assert.equal(dateShift('2026-03-01', -1), '2026-02-28');
assert.equal(monthShift('2026-01', -1), '2025-12');
});
test('URL 中合法日报日期优先,非法值回退到前一日', () => {
assert.equal(initialReportDate('?mileageReportDate=2026-03-31', '2026-08-12'), '2026-03-31');
assert.equal(initialReportDate('?mileageReportDate=bad-date', '2026-08-12'), '2026-08-12');
assert.equal(initialReportDate('', '2026-08-12'), '2026-08-12');
});
test('里程和日期文案保持现有显示精度', () => {
assert.equal(fmtKm(88123.46), '8.81万');
assert.equal(fmtKm(5182.04), '5,182');
assert.equal(fmtDate('2026-08-11'), '8月11日');
assert.equal(deltaLabel(16197.7, 22.54), '+1.62万 km · +22.5%');
assert.equal(roundMileageForDisplay(12.345), 12.3);
});
test('分批车辆可同时展示多个当前考核年度', () => {
const periods = [
'2024/6/13-2029/6/12',
'2025/12/24-2027/12/23',
];
assert.equal(currentAssessmentYearLabel(periods, '2026-03-31'), '考核第1/2年');
assert.equal(currentAssessmentYearLabel(periods, '2030-01-01'), null);
});
test('每日目标状态区分已完成、缺截止日、达标和未达标', () => {
assert.deepEqual(
dailyMileageStatus({ dailyRequiredMileage: 0, remainingMileage: 0, dailyMileage: 0 }),
{ label: '本年度已完成', detail: '当前年度剩余任务为 0,无需设置每日应完成里程。', tone: 'positive' },
);
assert.deepEqual(
dailyMileageStatus({ dailyRequiredMileage: 0, remainingMileage: 100, dailyMileage: 0 }),
{ label: '暂无法计算', detail: '缺少有效的当前考核年度截止日,暂不设每日应完成里程。', tone: 'warning' },
);
assert.deepEqual(
dailyMileageStatus({ dailyRequiredMileage: 100, remainingMileage: 1000, dailyMileage: 120 }),
{ label: '今日已达标', detail: '超出应完成 20 km,完成 120.0%。', tone: 'positive' },
);
assert.deepEqual(
dailyMileageStatus({ dailyRequiredMileage: 100, remainingMileage: 1000, dailyMileage: 80 }),
{ label: '今日未达标', detail: '距应完成差 20 km,完成 80.0%。', tone: 'warning' },
);
});
test('车辆列表保持筛选、搜索、排序和车牌兜底顺序', () => {
const vehicles = [
vehicle({ plate: '粤A00002', dailyMileage: 80, completionRate: 20, customer: '甲客户', trend: [{ date: '2026-08-11', totalMileage: 20 }] }),
vehicle({ plate: '粤A00001', dailyMileage: 180, completionRate: 10, department: '业务一部', mileageBand: 'HIGH', trend: [{ date: '2026-08-11', totalMileage: 30 }] }),
vehicle({ plate: '粤A00003', dailyMileage: 0, completionRate: 30, mileageBand: 'INVENTORY' }),
];
assert.deepEqual(
filterAndSortVehicles(vehicles, 'ALL', '', 'dailyMileage', 'desc').map(item => item.plate),
['粤A00001', '粤A00002', '粤A00003'],
);
assert.deepEqual(
filterAndSortVehicles(vehicles, 'HIGH', '业务一部', 'completionRate', 'asc').map(item => item.plate),
['粤A00001'],
);
assert.equal(sevenDayMileageOf(vehicles[1]), 30);
});
test('人工汇报口径旧提示继续隐藏,其他质量提示保留', () => {
assert.equal(isVisibleQualityNote('日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。'), false);
assert.equal(isVisibleQualityNote('部分车辆缺少仪表数据'), true);
});
+170
View File
@@ -0,0 +1,170 @@
import type {
MileageReportGroup,
MileageReportVehicle,
} from '../api';
export type VehicleFilter = 'ALL' | 'LOW' | 'HIGH' | 'INVENTORY';
export type VehicleSortKey = 'dailyMileage' | 'sevenDayMileage' | 'completionRate';
export type SortDirection = 'asc' | 'desc';
const REPORT_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const HIDDEN_QUALITY_NOTE = '日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。';
export function shanghaiYmd(offsetDays = 0, now = new Date()): string {
// 日报按上海自然日归档,不能依赖浏览器所在时区计算“昨天”。
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
const date = new Date(`${parts}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
export 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);
}
export 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);
}
export 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(/日(?=周)/, '日 ');
}
export function initialReportDate(search: string, fallbackDate = shanghaiYmd(-1)): string {
const value = new URLSearchParams(search).get('mileageReportDate');
return REPORT_DATE_PATTERN.test(value || '') ? value! : fallbackDate;
}
export function fmtKm(value: number): string {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(2)}万`;
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
}
export function fmtDate(value: string): string {
const [, month, day] = value.split('-');
return `${Number(month)}月${Number(day)}日`;
}
export 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,
});
}
export function deltaLabel(value: number, rate: number | null): string {
const sign = value >= 0 ? '+' : '';
return `${sign}${fmtKm(value)} km${rate == null ? '' : ` · ${sign}${rate.toFixed(1)}%`}`;
}
export 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('/')}年`;
}
export function dailyMileageStatus(group: Pick<
MileageReportGroup,
'dailyRequiredMileage' | 'remainingMileage' | 'dailyMileage'
>): {
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' };
}
export function sevenDayMileageOf(vehicle: MileageReportVehicle): number {
return (vehicle.trend || []).reduce((total, point) => total + point.totalMileage, 0);
}
function vehicleSortValue(vehicle: MileageReportVehicle, sortKey: VehicleSortKey): number {
return sortKey === 'sevenDayMileage' ? sevenDayMileageOf(vehicle) : vehicle[sortKey];
}
export function filterAndSortVehicles(
vehicles: MileageReportVehicle[],
filter: VehicleFilter,
search: string,
sortKey: VehicleSortKey,
sortDirection: SortDirection,
): MileageReportVehicle[] {
const query = search.trim().toLowerCase();
return 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);
})
.sort((left, right) => {
const difference = vehicleSortValue(left, sortKey) - vehicleSortValue(right, sortKey);
if (difference !== 0) return sortDirection === 'asc' ? difference : -difference;
return left.plate.localeCompare(right.plate, 'zh-CN');
});
}
export function roundMileageForDisplay(value: number): number {
return Math.round(value * 10) / 10;
}
export function isVisibleQualityNote(message: string): boolean {
return message !== HIDDEN_QUALITY_NOTE;
}
@@ -0,0 +1,121 @@
import type { Dispatch, SetStateAction } from 'react';
import type { MonitoringFilters } from '../../types';
import { defaultMileageDate, normalizeRangeLabel } from '../date-range';
type MileageRange = { min: string; max: string };
export default function ActiveFilterTags({
filterTargetNames,
filterOptions,
onFilterTargetNamesChange,
filterRegion,
onFilterRegionChange,
filterPlates,
onFilterPlatesChange,
filterRentStatus,
onFilterRentStatusChange,
filterDept,
onFilterDeptChange,
filterCustomer,
onFilterCustomerChange,
filterBrands,
onFilterBrandsChange,
filterProject,
onFilterProjectChange,
filterEntity,
onFilterEntityChange,
searchTerm,
onSearchTermChange,
onFilterMileageRangeChange,
appliedMileageRange,
onAppliedMileageRangeChange,
filterPlatePrefix,
onFilterPlatePrefixChange,
rangeStart,
rangeEnd,
onRangeStartChange,
onRangeEndChange,
}: {
filterTargetNames: string[];
filterOptions: MonitoringFilters;
onFilterTargetNamesChange: (value: string[]) => void;
filterRegion: string;
onFilterRegionChange: (value: string) => void;
filterPlates: string[];
onFilterPlatesChange: (value: string[]) => void;
filterRentStatus: string;
onFilterRentStatusChange: (value: string) => void;
filterDept: string;
onFilterDeptChange: (value: string) => void;
filterCustomer: string;
onFilterCustomerChange: (value: string) => void;
filterBrands: string[];
onFilterBrandsChange: (value: string[]) => void;
filterProject: string;
onFilterProjectChange: (value: string) => void;
filterEntity: string;
onFilterEntityChange: (value: string) => void;
searchTerm: string;
onSearchTermChange: (value: string) => void;
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
appliedMileageRange: MileageRange;
onAppliedMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
filterPlatePrefix: string;
onFilterPlatePrefixChange: (value: string) => void;
rangeStart: string;
rangeEnd: string;
onRangeStartChange: (value: string) => void;
onRangeEndChange: (value: string) => void;
}) {
const tags: { label: string; onClear: () => void }[] = [];
if (filterTargetNames.length > 0) tags.push({
label: filterTargetNames.length === filterOptions.targetNames.length ? '批次: 全部批次' : `批次: ${filterTargetNames.length === 1 ? filterTargetNames[0] : `${filterTargetNames[0]} 等${filterTargetNames.length}`}`,
onClear: () => onFilterTargetNamesChange([])
});
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => onFilterRegionChange('All') });
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]} 等${filterPlates.length}`}`, onClear: () => onFilterPlatesChange([]) });
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => onFilterRentStatusChange('All') });
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => onFilterDeptChange('All') });
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => onFilterCustomerChange('All') });
if (filterBrands.length > 0) tags.push({
label: `品牌: ${filterBrands.length === 1 ? filterBrands[0] : `${filterBrands[0]} 等${filterBrands.length}`}`,
onClear: () => onFilterBrandsChange([])
});
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => onFilterProjectChange('All') });
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => onFilterEntityChange('All') });
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => onSearchTermChange('') });
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, min: '' })); } });
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, max: '' })); } });
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => onFilterPlatePrefixChange('All') });
if (rangeStart || rangeEnd) tags.push({
label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`,
onClear: () => {
const today = defaultMileageDate();
onRangeStartChange(today);
onRangeEndChange(today);
}
});
if (tags.length === 0) return null;
const clearAll = () => {
onFilterDeptChange('All'); onFilterCustomerChange('All'); onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All');
onFilterPlatesChange([]); onSearchTermChange(''); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All');
onFilterMileageRangeChange({ min: '', max: '' }); onAppliedMileageRangeChange({ min: '', max: '' });
onFilterBrandsChange([]);
const today = defaultMileageDate();
onRangeStartChange(today); onRangeEndChange(today);
};
return (
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
<span className="mr-1 text-[10px] font-black text-slate-500">已选条件</span>
{tags.map((tag, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-2 py-1 text-[10px] font-bold text-blue-600">
{tag.label}
<button onClick={tag.onClear} className="hover:text-blue-800 ml-0.5">&times;</button>
</span>
))}
<button onClick={clearAll} className="text-[10px] font-bold text-rose-500 hover:text-rose-600 ml-auto">
清除全部
</button>
</div>
);
}
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { Check, ChevronDown } from 'lucide-react';
export default function BatchMultiSelect({
options,
selected,
onChange,
placeholder,
}: {
options: string[],
selected: string[],
onChange: (val: string[]) => void,
placeholder: string
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const selectedSet = useMemo(() => new Set(selected), [selected]);
const filtered = useMemo(() => {
if (!search) return options;
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
}, [options, search]);
const label = selected.length === 0
? placeholder
: selected.length === options.length
? '全部批次'
: selected.length === 1
? selected[0]
: `已选 ${selected.length} 个批次`;
const toggle = (opt: string) => {
if (selectedSet.has(opt)) {
onChange(selected.filter(item => item !== opt));
} else {
onChange([...selected, opt]);
}
};
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setIsOpen(false);
setSearch('');
}
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [isOpen]);
return (
<div ref={rootRef} className="relative">
<button
type="button"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 pl-2 pr-6 text-left text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20"
onClick={() => setIsOpen(open => !open)}
>
<span className="block truncate">{label}</span>
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl overflow-hidden"
>
<div className="p-2 border-b border-slate-50">
<input
type="text"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
placeholder="搜索批次"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-slate-50">
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-blue-600 hover:bg-blue-50 rounded-lg"
onClick={() => onChange(options)}
>
全选批次
</button>
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-slate-400 hover:bg-slate-50 rounded-lg"
onClick={() => onChange([])}
>
不限
</button>
</div>
<div className="max-h-44 overflow-y-auto">
{filtered.map((opt: string) => {
const checked = selectedSet.has(opt);
return (
<button
type="button"
key={opt}
className="w-full px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50 flex items-center justify-between gap-2 text-left"
onClick={() => toggle(opt)}
>
<span className="truncate">{opt}</span>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 ${checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200 text-transparent'}`}>
<Check size={10} />
</span>
</button>
);
})}
{filtered.length === 0 && (
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
无匹配项
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,272 @@
import type { Dispatch, SetStateAction } from 'react';
import { motion } from 'motion/react';
import type { MonitoringFilters } from '../../types';
import { defaultMileageDate, type RangePreset } from '../date-range';
import BatchMultiSelect from './BatchMultiSelect';
import SearchableSelect from './SearchableSelect';
type MileageRange = { min: string; max: string };
export default function FilterPanel({
rangeStart,
rangeEnd,
onRangeStartChange,
onRangeEndChange,
onApplyRangePreset,
departments,
filterDept,
onFilterDeptChange,
filterOptions,
filterCustomer,
onFilterCustomerChange,
filterBrands,
onFilterBrandsChange,
filterProject,
onFilterProjectChange,
filterRentStatus,
onFilterRentStatusChange,
filterEntity,
onFilterEntityChange,
filterPlatePrefix,
onFilterPlatePrefixChange,
filterMileageRange,
onFilterMileageRangeChange,
onSearchTermChange,
onFilterPlatesChange,
onFilterTargetNamesChange,
onFilterRegionChange,
onAppliedMileageRangeChange,
onFilterOpenChange,
}: {
rangeStart: string;
rangeEnd: string;
onRangeStartChange: (value: string) => void;
onRangeEndChange: (value: string) => void;
onApplyRangePreset: (preset: RangePreset) => void;
departments: string[];
filterDept: string;
onFilterDeptChange: (value: string) => void;
filterOptions: MonitoringFilters;
filterCustomer: string;
onFilterCustomerChange: (value: string) => void;
filterBrands: string[];
onFilterBrandsChange: (value: string[]) => void;
filterProject: string;
onFilterProjectChange: (value: string) => void;
filterRentStatus: string;
onFilterRentStatusChange: (value: string) => void;
filterEntity: string;
onFilterEntityChange: (value: string) => void;
filterPlatePrefix: string;
onFilterPlatePrefixChange: (value: string) => void;
filterMileageRange: MileageRange;
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
onSearchTermChange: (value: string) => void;
onFilterPlatesChange: (value: string[]) => void;
onFilterTargetNamesChange: (value: string[]) => void;
onFilterRegionChange: (value: string) => void;
onAppliedMileageRangeChange: (value: MileageRange) => void;
onFilterOpenChange: (value: boolean) => void;
}) {
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4">
{/* Date range */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">查询时间区间</label>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<input
type="date"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={rangeStart}
onChange={(e) => onRangeStartChange(e.target.value)}
/>
<span className="text-[10px] font-bold text-slate-300">至</span>
<input
type="date"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={rangeEnd}
onChange={(e) => onRangeEndChange(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 pt-1">
{([
['today', '今天'],
['yesterday', '昨天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last7', '近7天'],
['last15', '近15天'],
] as Array<[RangePreset, string]>).map(([preset, label]) => (
<button
key={preset}
type="button"
className="rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
onClick={() => onApplyRangePreset(preset)}
>
{label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Department */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按部门</label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDept}
onChange={(e) => onFilterDeptChange(e.target.value)}
>
<option value="All">无限制</option>
<option value="__EMPTY__">无值</option>
{departments.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
{/* Customer */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按客户</label>
<SearchableSelect
options={filterOptions.customers}
value={filterCustomer}
onChange={onFilterCustomerChange}
placeholder="搜索客户"
/>
</div>
</div>
{/* Brand multi-select */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">按品牌</label>
<BatchMultiSelect
options={filterOptions.brands}
selected={filterBrands}
onChange={onFilterBrandsChange}
placeholder="选择品牌"
/>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Project */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">项目</label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterProject}
onChange={(e) => onFilterProjectChange(e.target.value)}
>
<option value="All">无限制</option>
{filterOptions.projects.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
{/* Rent Status */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">租赁状态</label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterRentStatus}
onChange={(e) => onFilterRentStatusChange(e.target.value)}
>
<option value="All">无限制</option>
{filterOptions.rentStatuses.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Entity */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">主体查询</label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterEntity}
onChange={(e) => onFilterEntityChange(e.target.value)}
>
<option value="All">无限制</option>
{filterOptions.entities.map(e => <option key={e} value={e}>{e}</option>)}
</select>
</div>
</div>
{/* Plate Prefix */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">车牌区域</label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterPlatePrefix}
onChange={(e) => onFilterPlatePrefixChange(e.target.value)}
>
<option value="All">无限制</option>
{filterOptions.platePrefixes.map(p => <option key={p.prefix} value={p.prefix}>{p.prefix}({p.count})</option>)}
</select>
</div>
{/* Mileage Range */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">里程 ≥ (KM)</label>
<input
type="number"
placeholder="不限"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterMileageRange.min}
onChange={(e) => onFilterMileageRangeChange(prev => ({ ...prev, min: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">里程 ≤ (KM)</label>
<input
type="number"
placeholder="不限"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterMileageRange.max}
onChange={(e) => onFilterMileageRangeChange(prev => ({ ...prev, max: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-50">
<button
onClick={() => {
onSearchTermChange('');
onFilterDeptChange('All');
onFilterPlatesChange([]);
onFilterCustomerChange('All');
onFilterProjectChange('All');
onFilterEntityChange('All');
onFilterPlatePrefixChange('All');
onFilterTargetNamesChange([]);
onFilterRegionChange('All');
onFilterMileageRangeChange({ min: '', max: '' });
onAppliedMileageRangeChange({ min: '', max: '' });
onFilterBrandsChange([]);
const today = defaultMileageDate();
onRangeStartChange(today);
onRangeEndChange(today);
}}
className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
>
重置所有
</button>
<button
onClick={() => {
onAppliedMileageRangeChange({ ...filterMileageRange });
onFilterOpenChange(false);
}}
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100"
>
完成筛选
</button>
</div>
</div>
</motion.div>
);
}
@@ -0,0 +1,330 @@
import type { Dispatch, SetStateAction } from 'react';
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
import { motion } from 'motion/react';
import Blur from '../../../../components/Blur';
import type { MileageSourceGroup, MonitoringFilters, MonitoringStats, MonitoringVehicle } from '../../types';
import { vehicleStatisticTime } from '../oneos-time';
import { MILEAGE_SOURCE_META, vehicleSourceDisplay } from '../source-display';
type SortBy = 'today' | 'total' | 'statisticTime';
type SortOrder = 'asc' | 'desc';
export default function FullscreenMonitoring({
forceLandscape,
fullscreenStats,
sortBy,
sourcePriority,
onRefresh,
fullscreenLoading,
onToggleFullscreen,
filterTargetNames,
onFilterTargetNamesChange,
filterOptions,
filterPlates,
filterCustomer,
onFilterCustomerChange,
filterBrands,
onFilterBrandsChange,
filterRentStatus,
onFilterRentStatusChange,
filterDept,
onFilterDeptChange,
departments,
sortOrder,
onSortByChange,
onSortOrderChange,
fullscreenVehicles,
isHighMileageAlert,
relativeNow,
}: {
forceLandscape: boolean;
fullscreenStats: MonitoringStats;
sortBy: SortBy;
sourcePriority: MileageSourceGroup[];
onRefresh: () => void;
fullscreenLoading: boolean;
onToggleFullscreen: () => void;
filterTargetNames: string[];
onFilterTargetNamesChange: Dispatch<SetStateAction<string[]>>;
filterOptions: MonitoringFilters;
filterPlates: string[];
filterCustomer: string;
onFilterCustomerChange: (value: string) => void;
filterBrands: string[];
onFilterBrandsChange: (value: string[]) => void;
filterRentStatus: string;
onFilterRentStatusChange: (value: string) => void;
filterDept: string;
onFilterDeptChange: (value: string) => void;
departments: string[];
sortOrder: SortOrder;
onSortByChange: Dispatch<SetStateAction<SortBy>>;
onSortOrderChange: Dispatch<SetStateAction<SortOrder>>;
fullscreenVehicles: MonitoringVehicle[];
isHighMileageAlert: (vehicle: MonitoringVehicle) => boolean;
relativeNow: number;
}) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
style={
forceLandscape
? {
// 小程序 webview 无法真横屏,强制 CSS 旋转 90 度模拟横屏
top: 0,
left: '100vw',
width: '100vh',
height: '100vw',
transform: 'rotate(90deg)',
transformOrigin: 'top left',
}
: { top: 0, left: 0, right: 0, bottom: 0 }
}
>
{/* Top bar: compact inline KPI */}
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
<h2 className="text-white font-bold text-xs">全屏监控</h2>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500">区间 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">累计 <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">
来源 <span className="text-white font-black">
{sourcePriority.map(group => MILEAGE_SOURCE_META[group].label).join(' > ')}
</span>
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
>
<RotateCcw size={13} />
</button>
<button onClick={onToggleFullscreen} className="p-1.5 text-slate-500 hover:text-white transition-colors">
<Minimize2 size={14} />
</button>
</div>
</div>
{/* Batch selector + legend */}
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
<button
onClick={() => onFilterTargetNamesChange([])}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.length === 0 ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
>全部</button>
{filterOptions.targetNames.map(n => (
<button
key={n}
onClick={() => onFilterTargetNamesChange(prev => prev.includes(n) ? prev.filter(item => item !== n) : [...prev, n])}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.includes(n) ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
>{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button>
))}
</div>
<div className="flex items-center gap-2 text-[8px] text-slate-500 flex-shrink-0 ml-2">
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block"></span>在线</span>
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-slate-500 inline-block"></span>离线</span>
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block"></span>未对接</span>
</div>
</div>
{/* Table Area */}
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative">
{fullscreenLoading && (
<div className="absolute inset-0 bg-slate-950/60 z-20 flex items-center justify-center">
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
<RotateCcw size={14} className="animate-spin" />
加载中...
</div>
</div>
)}
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase w-12 text-center">状态</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span>车牌号</span>
<span className="text-[9px] text-slate-500 font-normal">
{filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`}
</span>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span>客户</span>
<input
type="text"
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30 w-20"
placeholder={filterCustomer === 'All' ? '全部' : filterCustomer}
defaultValue=""
onBlur={(e) => {
const val = e.target.value.trim();
onFilterCustomerChange(val || 'All');
e.target.value = '';
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
onFilterCustomerChange(val || 'All');
(e.target as HTMLInputElement).value = '';
}
}}
/>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span>品牌</span>
<input
type="text"
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30 w-16"
placeholder={filterBrands.length === 0 ? '全部' : `已选${filterBrands.length}`}
defaultValue=""
onBlur={(e) => {
const val = e.target.value.trim();
if (val) {
onFilterBrandsChange(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]);
}
e.target.value = '';
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value.trim();
if (val) {
onFilterBrandsChange(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]);
}
(e.target as HTMLInputElement).value = '';
}
}}
/>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span>租赁状态</span>
<select
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterRentStatus}
onChange={(e) => onFilterRentStatusChange(e.target.value)}
>
<option value="All">全部状态</option>
{filterOptions.rentStatuses.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1">
<span>部门</span>
<select
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterDept}
onChange={(e) => onFilterDeptChange(e.target.value)}
>
<option value="All">全部部门</option>
<option value="__EMPTY__">无值</option>
{departments.map(d => <option key={d} value={d}>{d.replace('业务', '')}</option>)}
</select>
</div>
</th>
<th
className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
onClick={() => {
if (sortBy === 'today') {
onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc');
} else {
onSortByChange('today');
onSortOrderChange('desc');
}
}}
>
<div className="flex items-center justify-end gap-1">
<span>区间里程</span>
{sortBy === 'today' && (
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
)}
</div>
</th>
<th
className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
onClick={() => {
if (sortBy === 'total') {
onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc');
} else {
onSortByChange('total');
onSortOrderChange('desc');
}
}}
>
<div className="flex items-center justify-end gap-1">
<span>累计里程</span>
{sortBy === 'total' && (
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
)}
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-2 text-center">
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
<span
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title}
>
{sourceDisplay.label}
</span>
</div>
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label}
</div>
</td>
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
</span>
</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-slate-600'}`}>
{v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</motion.div>
);
}
@@ -0,0 +1,116 @@
import { CalendarDays } from 'lucide-react';
import { Bar, BarChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
import type { MonitoringStats } from '../../types';
export default function MonitoringSummary({
pageLoading,
sortBy,
isRangeMode,
stats,
rangeLabel,
rangeDailyTotals,
total,
}: {
pageLoading: boolean;
sortBy: 'today' | 'total' | 'statisticTime';
isRangeMode: boolean;
stats: MonitoringStats;
rangeLabel: string;
rangeDailyTotals: { date: string; totalKm: number }[];
total: number;
}) {
const averageDailyKm = rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
return (
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}总里程</div>
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
</div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div>
<div className="text-[7px] text-slate-400">km/台</div>
</div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase">监控台数</div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : stats.vehicleCount}</div>
<div className="text-[7px] text-slate-400">台</div>
</div>
</div>
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
{pageLoading ? (
<div className="h-[74px] bg-slate-50 animate-pulse" />
) : isRangeMode ? (
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
<div className="min-w-0">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
<CalendarDays size={12} className="text-blue-500" />
区间走势
</div>
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} 天 · km</div>
</div>
<div className="h-[58px] min-w-0">
{rangeDailyTotals.length === 0 ? (
<div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300">暂无趋势</div>
) : (
<ResponsiveContainer width="100%" height={58} minWidth={0}>
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
<XAxis dataKey="date" hide />
<Tooltip
formatter={(value) => [`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']}
labelFormatter={(label) => `日期 ${String(label)}`}
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }}
/>
{averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />}
<Bar dataKey="totalKm" radius={[3, 3, 0, 0]} fill="#38bdf8" />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="text-right">
<div className="text-[9px] font-black text-slate-400">日均</div>
<div className="text-xs font-black text-slate-800">{Math.round(averageDailyKm).toLocaleString()}</div>
<div className="text-[9px] font-bold text-slate-400">km</div>
</div>
</div>
) : (
<div className="grid min-h-[64px] grid-cols-[minmax(0,1fr)_86px_86px] items-stretch gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_110px_110px]">
<div className="flex min-w-0 flex-col justify-center">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
<CalendarDays size={12} className="text-blue-500" />
单日概览
</div>
<div className="mt-1 truncate text-[9px] font-bold text-slate-400">{rangeLabel} · 单位 km</div>
</div>
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-blue-400">当日总计</div>
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
</div>
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-slate-400">日均单车</div>
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between px-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆详情清单</span>
<span className="text-[9px] font-bold text-slate-300">{total} 条</span>
</div>
<div className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid">
<span>车牌 / 数据状态</span>
<span>客户 / 归属</span>
<span className="text-right">{isRangeMode ? '区间里程' : '当日里程'}</span>
<span className="text-right">累计里程</span>
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import type { Dispatch, SetStateAction } from 'react';
import { ArrowDown, ArrowUp, Download, Filter, Maximize2, RefreshCw, RotateCcw } from 'lucide-react';
import PlateMultiSelect from '../../PlateMultiSelect';
import type { MileageSourceGroup, MonitoringFilters } from '../../types';
import { getRangePreset, type RangePreset } from '../date-range';
import BatchMultiSelect from './BatchMultiSelect';
import SearchableSelect from './SearchableSelect';
import SourcePriorityControl from './SourcePriorityControl';
type SortBy = 'today' | 'total' | 'statisticTime';
type SortOrder = 'asc' | 'desc';
export default function MonitoringToolbar({
onToggleFullscreen,
onDownload,
exporting,
lastRefreshedAt,
onManualRefresh,
manualRefreshing,
sortBy,
onSortByChange,
sortOrder,
onSortOrderChange,
sourcePriority,
onSourcePriorityChange,
filterOptions,
filterTargetNames,
onFilterTargetNamesChange,
filterRegion,
onFilterRegionChange,
plateNumbers,
filterPlates,
onFilterPlatesChange,
isFilterOpen,
onFilterOpenChange,
searchTerm,
filterDept,
filterCustomer,
filterRentStatus,
filterProject,
filterBrands,
rangeStart,
rangeEnd,
onApplyRangePreset,
rangeLabel,
}: {
onToggleFullscreen: () => void;
onDownload: () => void;
exporting: boolean;
lastRefreshedAt: Date | null;
onManualRefresh: () => void;
manualRefreshing: boolean;
sortBy: SortBy;
onSortByChange: Dispatch<SetStateAction<SortBy>>;
sortOrder: SortOrder;
onSortOrderChange: Dispatch<SetStateAction<SortOrder>>;
sourcePriority: MileageSourceGroup[];
onSourcePriorityChange: (value: MileageSourceGroup[]) => void;
filterOptions: MonitoringFilters;
filterTargetNames: string[];
onFilterTargetNamesChange: (value: string[]) => void;
filterRegion: string;
onFilterRegionChange: (value: string) => void;
plateNumbers: string[];
filterPlates: string[];
onFilterPlatesChange: (value: string[]) => void;
isFilterOpen: boolean;
onFilterOpenChange: (value: boolean) => void;
searchTerm: string;
filterDept: string;
filterCustomer: string;
filterRentStatus: string;
filterProject: string;
filterBrands: string[];
rangeStart: string;
rangeEnd: string;
onApplyRangePreset: (preset: RangePreset) => void;
rangeLabel: string;
}) {
return (
<div className="flex flex-col gap-3 rounded-2xl border border-gray-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div className="h-8 w-1 flex-shrink-0 rounded-full bg-blue-600" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-lg font-black leading-none text-slate-900">里程看板</h1>
<button onClick={onToggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
<Maximize2 size={14} />
</button>
<button onClick={onDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
</button>
</div>
<div className="mt-1 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<span className="text-[9px] font-bold uppercase tracking-tight text-slate-400">数据监控 • OneOS 实时查询</span>
</div>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
</span>
<button
type="button"
onClick={onManualRefresh}
disabled={manualRefreshing}
className="flex h-7 items-center gap-1 whitespace-nowrap rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60"
title="局部刷新当前筛选数据"
>
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
</button>
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
<button onClick={() => onSortByChange('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>区间</button>
<button onClick={() => onSortByChange('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>累计</button>
<button onClick={() => onSortByChange('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}>统计时间</button>
<button
onClick={() => onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc')}
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
title={sortOrder === 'desc' ? '当前倒序,点击切换正序' : '当前正序,点击切换倒序'}
>
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
</button>
</div>
</div>
</div>
<SourcePriorityControl priority={sourcePriority} onChange={onSourcePriorityChange} />
<div className="flex items-center gap-2">
<div className="grid flex-1 grid-cols-3 gap-1.5">
<BatchMultiSelect options={filterOptions.targetNames} selected={filterTargetNames} onChange={onFilterTargetNamesChange} placeholder="批次型号" />
<SearchableSelect options={filterOptions.regions} value={filterRegion} onChange={onFilterRegionChange} placeholder="运营区域" />
<PlateMultiSelect allPlates={plateNumbers} selected={filterPlates} onChange={onFilterPlatesChange} placeholder="按车牌" />
</div>
<button
onClick={() => onFilterOpenChange(!isFilterOpen)}
className={`flex-shrink-0 rounded-lg border p-1.5 transition-all ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 || filterBrands.length > 0 ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-transparent bg-slate-50 text-slate-400'}`}
title="高级筛选"
>
<Filter size={16} />
</button>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
{([
['today', '今天'],
['yesterday', '昨天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last15', '近15天'],
] as Array<[RangePreset, string]>).map(([preset, label]) => {
const range = getRangePreset(preset);
const active = rangeStart === range.start && rangeEnd === range.end;
return (
<button
key={preset}
type="button"
onClick={() => onApplyRangePreset(preset)}
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${active ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm' : 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'}`}
>
{label}
</button>
);
})}
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span>
</div>
</div>
);
}
@@ -0,0 +1,83 @@
import { useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ChevronDown } from 'lucide-react';
export default function SearchableSelect({
options,
value,
onChange,
placeholder
}: {
options: string[],
value: string,
onChange: (val: string) => void,
placeholder: string
}) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search) return options;
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
}, [options, search]);
return (
<div className="relative">
<div className="relative">
<input
type="text"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
placeholder={value === 'All' ? placeholder : value === '__EMPTY__' ? '无值' : value}
value={search}
onFocus={() => setIsOpen(true)}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => {
// Delay to allow clicking an option
setTimeout(() => setIsOpen(false), 200);
}}
/>
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl max-h-40 overflow-y-auto"
>
<div
className="px-3 py-2 text-[10px] font-bold text-blue-600 hover:bg-slate-50 cursor-pointer"
onClick={() => {
onChange('All');
setSearch('');
setIsOpen(false);
}}
>
无限制
</div>
{filtered.map((opt: string) => (
<div
key={opt}
className="px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50"
onClick={() => {
onChange(opt);
setSearch('');
setIsOpen(false);
}}
>
{opt === '__EMPTY__' ? '无值' : opt}
</div>
))}
{filtered.length === 0 && (
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
无匹配项
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,75 @@
import { ArrowLeftRight, Power } from 'lucide-react';
import type { MileageSourceGroup } from '../../types';
import {
ALL_MILEAGE_SOURCE_GROUPS,
MILEAGE_SOURCE_META,
} from '../source-display';
export default function SourcePriorityControl({
priority,
onChange,
}: {
priority: MileageSourceGroup[];
onChange: (next: MileageSourceGroup[]) => void;
}) {
const ordered = [
...priority,
...ALL_MILEAGE_SOURCE_GROUPS.filter(group => !priority.includes(group)),
];
const toggle = (group: MileageSourceGroup) => {
if (priority.includes(group)) {
if (priority.length === 1) return;
onChange(priority.filter(item => item !== group));
return;
}
onChange([...priority, group]);
};
const swap = () => {
if (priority.length === 2) onChange([priority[1], priority[0]]);
};
return (
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
<span className="shrink-0 text-[9px] font-black text-slate-400">数据来源</span>
{ordered.map(group => {
const enabledIndex = priority.indexOf(group);
const enabled = enabledIndex >= 0;
const meta = MILEAGE_SOURCE_META[group];
return (
<button
key={group}
type="button"
onClick={() => toggle(group)}
className={`flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-1 transition-all ${
enabled
? 'border-blue-100 bg-blue-50 text-blue-700'
: 'border-slate-100 bg-slate-50 text-slate-400'
}`}
title={enabled && priority.length === 1 ? '至少保留一种数据来源' : enabled ? '点击停用' : '点击启用'}
>
<span className={`flex h-4 w-4 items-center justify-center rounded text-[8px] font-black ${
enabled ? 'bg-blue-600 text-white' : 'bg-slate-200 text-slate-500'
}`}>
{enabled ? enabledIndex + 1 : <Power size={9} />}
</span>
<span className="text-left leading-tight">
<span className="block text-[9px] font-black">{meta.label}</span>
<span className="block min-h-[9px] text-[7px] font-bold opacity-60">
{enabled ? meta.protocol : '已停用'}
</span>
</span>
</button>
);
})}
<button
type="button"
onClick={swap}
disabled={priority.length !== 2}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-slate-100 bg-white text-slate-400 transition-all hover:border-blue-100 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-30"
title="切换数据来源优先级"
>
<ArrowLeftRight size={12} />
</button>
</div>
);
}
@@ -0,0 +1,157 @@
import type { RefObject } from 'react';
import { Truck } from 'lucide-react';
import { motion } from 'motion/react';
import Blur from '../../../../components/Blur';
import type { MonitoringVehicle } from '../../types';
import { vehicleStatisticTime } from '../oneos-time';
import { vehicleSourceDisplay } from '../source-display';
export default function VehicleList({
pageLoading,
vehicles,
isHighMileageAlert,
relativeNow,
isRangeMode,
onVehicleClick,
loadingMore,
hasMore,
total,
sentinelRef,
}: {
pageLoading: boolean;
vehicles: MonitoringVehicle[];
isHighMileageAlert: (vehicle: MonitoringVehicle) => boolean;
relativeNow: number;
isRangeMode: boolean;
onVehicleClick: (vehicle: MonitoringVehicle) => void;
loadingMore: boolean;
hasMore: boolean;
total: number;
sentinelRef: RefObject<HTMLDivElement | null>;
}) {
return (
<div className="space-y-1.5">
{pageLoading && (
<div className="space-y-1.5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white px-3 py-3 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between animate-pulse">
<div className="flex items-center gap-3 flex-1">
<div className="w-8 h-8 rounded-lg bg-slate-100"></div>
<div className="space-y-1.5 flex-1">
<div className="h-3 bg-slate-100 rounded w-24"></div>
<div className="h-2 bg-slate-50 rounded w-36"></div>
</div>
</div>
<div className="space-y-1.5 text-right">
<div className="h-4 bg-slate-100 rounded w-16 ml-auto"></div>
<div className="h-2 bg-slate-50 rounded w-20 ml-auto"></div>
</div>
</div>
))}
</div>
)}
<div className="grid grid-cols-1 gap-1.5">
{vehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
key={v.plate}
className="grid cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 shadow-sm transition-all hover:border-blue-200 hover:shadow-md active:bg-slate-50 md:grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] md:px-4"
onClick={() => onVehicleClick(v)}
>
<div className="flex min-w-0 items-center gap-3 overflow-hidden">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${v.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} title={v.isOnline ? '在线' : '离线'}></div>
</div>
<div className="overflow-hidden flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{v.isOnline ? '在线' : '离线'}
</span>
<span
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title}
>
{sourceDisplay.label}
</span>
</div>
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label}
</div>
<div className="flex items-center gap-1.5 md:hidden">
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
</div>
</div>
</div>
<div className="hidden min-w-0 md:block">
<div className="truncate text-xs font-bold text-slate-700"><Blur>{v.customer || '-'}</Blur></div>
<div className="mt-1 truncate text-[9px] font-medium text-slate-400">
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
</div>
</div>
<div className="hidden text-right md:block">
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
{Math.max(0, v.dailyKm || 0).toLocaleString()}
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
</div>
</div>
<div className="hidden text-right md:block">
<div className="text-xs font-black tabular-nums text-slate-700">
{v.totalKm != null ? v.totalKm.toLocaleString() : '-'}
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
</div>
</div>
<div className="flex flex-shrink-0 flex-col items-end text-right md:hidden">
<div className="flex items-center gap-1 mb-0.5">
{!v.isDataSynced && v.totalKm == null && (
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
)}
<span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}>区</span>
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
</div>
</div>
<div className="flex items-center gap-1">
<span className="text-[7px] font-black text-slate-400/60 bg-slate-100 w-3 h-3 rounded flex items-center justify-center leading-none">总</span>
<span className="text-[8px] font-bold text-slate-300">
{v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'}
</span>
</div>
</div>
</motion.div>
);
})}
</div>
{vehicles.length === 0 && !loadingMore && (
<div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100">
<p className="text-xs font-bold text-slate-300">未找到匹配数据</p>
</div>
)}
{/* 加载更多提示 */}
{loadingMore && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-400">加载中...</span>
</div>
)}
{!hasMore && vehicles.length > 0 && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-300">已加载全部 {total} 条</span>
</div>
)}
{/* 哨兵元素:进入视口时触发加载更多 */}
<div ref={sentinelRef} className="h-1" />
</div>
);
}
@@ -0,0 +1,45 @@
export type RangePreset = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
function formatLocalDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
/**
* The monitoring day rolls over at 05:00 local time. Before then the dashboard
* continues to use the previous calendar day, matching the existing UI rule.
*/
export function defaultMileageDate(now = new Date()): string {
const date = new Date(now);
if (date.getHours() < 5) date.setDate(date.getDate() - 1);
return formatLocalDate(date);
}
export function normalizeRangeLabel(start: string, end: string): string {
if (!start && !end) return '最新数据';
if (start && end && start === end) return start;
return `${start || end} 至 ${end || start}`;
}
export function getRangePreset(
preset: RangePreset,
now = new Date(),
): { start: string; end: string } {
const end = new Date(`${defaultMileageDate(now)}T00:00:00`);
const start = new Date(end);
if (preset === 'yesterday') {
start.setDate(start.getDate() - 1);
end.setDate(end.getDate() - 1);
} else if (preset === 'thisWeek') {
const day = start.getDay() || 7;
start.setDate(start.getDate() - day + 1);
} else if (preset === 'thisMonth') {
start.setDate(1);
} else if (preset === 'last7') {
start.setDate(start.getDate() - 6);
} else if (preset === 'last15') {
start.setDate(start.getDate() - 14);
}
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MonitoringVehicle } from '../types';
import { defaultMileageDate, getRangePreset, normalizeRangeLabel } from './date-range';
import { formatAbsoluteTime, parseOneOsTime, vehicleStatisticTime } from './oneos-time';
import {
buildExportMonitoringQuery,
buildFullscreenMonitoringQuery,
buildInitialMonitoringQuery,
buildNextPageMonitoringQuery,
type MonitoringQueryModel,
} from './query-model';
import { vehicleSourceDisplay } from './source-display';
const baseVehicle: MonitoringVehicle = {
plate: '粤A00001',
vin: 'TESTVIN',
dailyKm: 0,
totalKm: 0,
source: 'ONEOS',
sourceProtocol: null,
sourceCategory: null,
dataTime: null,
calculatedAt: null,
updatedAt: null,
isOnline: false,
isDataSynced: false,
customer: null,
department: null,
manager: null,
rentStatus: null,
entity: null,
project: null,
region: null,
inventoryLocation: null,
brand: null,
targetNames: [],
};
const queryModel: MonitoringQueryModel = {
sortBy: 'statisticTime',
sortOrder: 'asc',
searchTerm: '粤A',
department: '业务一部',
customer: '客户甲',
project: '项目甲',
entity: '主体甲',
rentStatus: '租赁',
platePrefix: '粤A',
targetNames: ['批次甲'],
region: '广州',
plates: ['粤A00001', '粤A00002'],
mileageRange: { min: '10', max: '500' },
rangeStart: '2026-08-01',
rangeEnd: '2026-08-12',
brands: ['现代'],
sourcePriority: ['instrument', 'gps'],
};
test('monitoring date rolls over at 05:00 and presets retain existing ranges', () => {
assert.equal(defaultMileageDate(new Date(2026, 7, 13, 4, 59)), '2026-08-12');
assert.equal(defaultMileageDate(new Date(2026, 7, 13, 5, 0)), '2026-08-13');
const now = new Date(2026, 7, 13, 12, 0);
assert.deepEqual(getRangePreset('today', now), { start: '2026-08-13', end: '2026-08-13' });
assert.deepEqual(getRangePreset('yesterday', now), { start: '2026-08-12', end: '2026-08-12' });
assert.deepEqual(getRangePreset('thisWeek', now), { start: '2026-08-10', end: '2026-08-13' });
assert.deepEqual(getRangePreset('thisMonth', now), { start: '2026-08-01', end: '2026-08-13' });
assert.deepEqual(getRangePreset('last7', now), { start: '2026-08-07', end: '2026-08-13' });
assert.deepEqual(getRangePreset('last15', now), { start: '2026-07-30', end: '2026-08-13' });
assert.equal(normalizeRangeLabel('', ''), '最新数据');
assert.equal(normalizeRangeLabel('2026-08-12', '2026-08-12'), '2026-08-12');
assert.equal(normalizeRangeLabel('2026-08-01', '2026-08-12'), '2026-08-01 至 2026-08-12');
});
test('OneOS timestamps keep China Standard Time parsing and relative labels', () => {
assert.equal(
parseOneOsTime('2026-08-13 09:30:05'),
Date.parse('2026-08-13T09:30:05+08:00'),
);
assert.equal(parseOneOsTime('invalid'), null);
assert.equal(formatAbsoluteTime('2026-08-13 09:30:05'), '2026/08/13 09:30:05');
const display = vehicleStatisticTime(
{
...baseVehicle,
dataTime: '2026-08-13 09:30:05',
calculatedAt: '2026-08-13 09:30:06',
updatedAt: '2026-08-13 09:30:07',
isDataSynced: true,
},
Date.parse('2026-08-13T09:31:05+08:00'),
);
assert.deepEqual(display, {
label: '1分钟前',
title: [
'数据时间:2026/08/13 09:30:05',
'计算时间:2026/08/13 09:30:06',
'更新时间:2026/08/13 09:30:07',
].join('\n'),
color: 'text-emerald-500',
});
assert.deepEqual(vehicleStatisticTime({ ...baseVehicle, isDataSynced: true }, Date.now()), {
label: '统计时间待接口',
title: 'OneOS 当前未返回车辆级数据时间',
color: 'text-amber-500',
});
});
test('vehicle source display retains all source and missing-data states', () => {
assert.deepEqual(vehicleSourceDisplay({
...baseVehicle,
sourceCategory: 'INSTRUMENT',
sourceProtocol: 'MQTT',
}), {
label: '仪表数据',
title: 'MQTT',
className: 'bg-violet-50 text-violet-600',
});
assert.equal(vehicleSourceDisplay({ ...baseVehicle, sourceCategory: 'GPS' }).title, 'JT808');
assert.equal(vehicleSourceDisplay({ ...baseVehicle, sourceCategory: 'MIXED' }).label, '仪表+GPS');
assert.equal(vehicleSourceDisplay({ ...baseVehicle, isDataSynced: true }).label, '来源待接口');
assert.equal(vehicleSourceDisplay(baseVehicle).label, '无数据');
});
test('four monitoring query builders preserve pagination and fullscreen differences', () => {
const initial = buildInitialMonitoringQuery(queryModel, 50);
assert.equal(initial.limit, 50);
assert.equal(initial.page, 1);
assert.equal(initial.project, '项目甲');
assert.equal(initial.entity, '主体甲');
assert.equal(initial.mileageMin, '10');
assert.equal(initial.mileageMax, '500');
assert.equal(initial.plate, '粤A00001,粤A00002');
const nextPage = buildNextPageMonitoringQuery(queryModel, 50, 3);
assert.deepEqual(nextPage, { ...initial, page: 3 });
const exported = buildExportMonitoringQuery(queryModel);
assert.deepEqual(exported, { ...initial, limit: 9999 });
const fullscreen = buildFullscreenMonitoringQuery(queryModel);
assert.equal(fullscreen.limit, 9999);
assert.equal(fullscreen.page, 1);
assert.equal(fullscreen.customer, '客户甲');
assert.equal('project' in fullscreen, false);
assert.equal('entity' in fullscreen, false);
assert.equal('mileageMin' in fullscreen, false);
assert.equal('mileageMax' in fullscreen, false);
});
test('query model converts empty selections exactly as the existing requests do', () => {
const query = buildInitialMonitoringQuery({
...queryModel,
searchTerm: '',
department: 'All',
customer: 'All',
project: 'All',
entity: 'All',
rentStatus: 'All',
platePrefix: 'All',
targetNames: [],
region: 'All',
plates: [],
mileageRange: { min: '', max: '' },
rangeStart: '',
rangeEnd: '',
brands: [],
}, 50);
assert.equal(query.search, undefined);
assert.equal(query.dept, undefined);
assert.equal(query.project, undefined);
assert.equal(query.plate, undefined);
assert.equal(query.mileageMin, undefined);
assert.equal(query.startDate, undefined);
assert.equal(query.brands, undefined);
assert.deepEqual(query.sourcePriority, ['instrument', 'gps']);
});
@@ -0,0 +1,74 @@
import type { MonitoringVehicle } from '../types';
export interface StatisticTimeDisplay {
label: string;
title: string;
color: string;
}
/** OneOS timestamps without an offset are defined as China Standard Time. */
export function parseOneOsTime(value: string | null | undefined): number | null {
if (!value) return null;
let normalized = value.trim().replace(' ', 'T');
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) normalized += '+08:00';
const timestamp = Date.parse(normalized);
return Number.isFinite(timestamp) ? timestamp : null;
}
export function formatAbsoluteTime(value: string | null | undefined): string | null {
const timestamp = parseOneOsTime(value);
if (timestamp === null) return null;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(new Date(timestamp));
}
export function vehicleStatisticTime(
vehicle: MonitoringVehicle,
nowMs: number,
): StatisticTimeDisplay {
const primaryTime = vehicle.dataTime || vehicle.updatedAt || vehicle.calculatedAt;
const timestamp = parseOneOsTime(primaryTime);
if (timestamp === null) {
return {
label: vehicle.isDataSynced ? '统计时间待接口' : '无有效数据时间',
title: 'OneOS 当前未返回车辆级数据时间',
color: vehicle.isDataSynced ? 'text-amber-500' : 'text-slate-500',
};
}
const deltaSeconds = Math.floor((nowMs - timestamp) / 1000);
let label: string;
if (deltaSeconds < -60) label = '时间异常';
else if (deltaSeconds < 5) label = '刚刚';
else if (deltaSeconds < 60) label = `${deltaSeconds}秒前`;
else if (deltaSeconds < 3600) label = `${Math.floor(deltaSeconds / 60)}分钟前`;
else if (deltaSeconds < 86400) label = `${Math.floor(deltaSeconds / 3600)}小时前`;
else label = `${Math.floor(deltaSeconds / 86400)}天前`;
const absoluteDataTime = formatAbsoluteTime(vehicle.dataTime);
const absoluteCalculatedAt = formatAbsoluteTime(vehicle.calculatedAt);
const absoluteUpdatedAt = formatAbsoluteTime(vehicle.updatedAt);
const title = [
absoluteDataTime ? `数据时间:${absoluteDataTime}` : null,
absoluteCalculatedAt ? `计算时间:${absoluteCalculatedAt}` : null,
absoluteUpdatedAt ? `更新时间:${absoluteUpdatedAt}` : null,
].filter(Boolean).join('\n');
const ageSeconds = Math.max(0, deltaSeconds);
const color = ageSeconds <= 300
? 'text-emerald-500'
: ageSeconds <= 3600
? 'text-blue-500'
: ageSeconds <= 86400
? 'text-amber-500'
: 'text-rose-500';
return { label, title, color };
}
@@ -0,0 +1,96 @@
import type { fetchMonitoring } from '../api';
import type { MileageSourceGroup } from '../types';
export type MonitoringRequestParams = NonNullable<Parameters<typeof fetchMonitoring>[0]>;
export interface MonitoringQueryModel {
sortBy: 'today' | 'total' | 'statisticTime';
sortOrder: 'asc' | 'desc';
searchTerm: string;
department: string;
customer: string;
project: string;
entity: string;
rentStatus: string;
platePrefix: string;
targetNames: string[];
region: string;
plates: string[];
mileageRange: { min: string; max: string };
rangeStart: string;
rangeEnd: string;
brands: string[];
sourcePriority: MileageSourceGroup[];
}
function optionalValue(value: string): string | undefined {
return value || undefined;
}
function selectedValue(value: string): string | undefined {
return value !== 'All' ? value : undefined;
}
function buildSharedQuery(
model: MonitoringQueryModel,
limit: number,
page: number,
): MonitoringRequestParams {
return {
sortBy: model.sortBy,
sortOrder: model.sortOrder,
limit,
page,
search: optionalValue(model.searchTerm),
dept: selectedValue(model.department),
customer: selectedValue(model.customer),
rentStatus: selectedValue(model.rentStatus),
platePrefix: selectedValue(model.platePrefix),
targetNames: model.targetNames.length > 0 ? model.targetNames : undefined,
region: selectedValue(model.region),
plate: model.plates.length > 0 ? model.plates.join(',') : undefined,
startDate: optionalValue(model.rangeStart),
endDate: optionalValue(model.rangeEnd),
brands: model.brands.length > 0 ? model.brands : undefined,
sourcePriority: model.sourcePriority,
};
}
function buildPagedQuery(
model: MonitoringQueryModel,
limit: number,
page: number,
): MonitoringRequestParams {
return {
...buildSharedQuery(model, limit, page),
project: selectedValue(model.project),
entity: selectedValue(model.entity),
mileageMin: optionalValue(model.mileageRange.min),
mileageMax: optionalValue(model.mileageRange.max),
};
}
export function buildInitialMonitoringQuery(
model: MonitoringQueryModel,
pageSize: number,
): MonitoringRequestParams {
return buildPagedQuery(model, pageSize, 1);
}
export function buildNextPageMonitoringQuery(
model: MonitoringQueryModel,
pageSize: number,
page: number,
): MonitoringRequestParams {
return buildPagedQuery(model, pageSize, page);
}
export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
return buildPagedQuery(model, 9999, 1);
}
export function buildFullscreenMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
// Characterization boundary: fullscreen historically omits these four filters.
// Keep project, entity, mileageMin and mileageMax out until a separate bug fix is approved.
return buildSharedQuery(model, 9999, 1);
}
@@ -0,0 +1,47 @@
import type { MileageSourceGroup, MonitoringVehicle } from '../types';
export const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
export const MILEAGE_SOURCE_META = {
instrument: { label: '仪表数据', protocol: null },
gps: { label: 'GPS数据', protocol: 'JT808' },
} as const;
export interface VehicleSourceDisplay {
label: string;
title: string;
className: string;
}
export function vehicleSourceDisplay(vehicle: MonitoringVehicle): VehicleSourceDisplay {
if (vehicle.sourceCategory === 'INSTRUMENT') {
return {
label: '仪表数据',
title: vehicle.sourceProtocol || 'GB32960 / MQTT',
className: 'bg-violet-50 text-violet-600',
};
}
if (vehicle.sourceCategory === 'GPS') {
return {
label: 'GPS数据',
title: vehicle.sourceProtocol || 'JT808',
className: 'bg-emerald-50 text-emerald-600',
};
}
if (vehicle.sourceCategory === 'MIXED') {
return {
label: '仪表+GPS',
title: '所选区间内包含仪表数据和 GPS 数据',
className: 'bg-blue-50 text-blue-600',
};
}
return {
label: vehicle.isDataSynced ? '来源待接口' : '无数据',
title: vehicle.isDataSynced
? 'OneOS 当前响应未返回 sourceProtocol'
: '所选来源没有有效数据',
className: vehicle.isDataSynced
? 'bg-amber-50 text-amber-600'
: 'bg-slate-100 text-slate-400',
};
}
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ArrowUpDown, Calendar, Search, Truck, X } from 'lucide-react';
import Blur from '../../../components/Blur';
import type { TargetVehicle } from '../types';
import {
filterAndSortTargetVehicles,
fmtKm,
totalTodayMileage,
type VehicleSort,
} from './model';
interface AllVehiclesPanelProps {
targetId: number | null;
targetName: string;
vehicles: TargetVehicle[];
date: string;
loading: boolean;
onDateChange: (date: string) => void;
onClose: () => void;
}
export default function AllVehiclesPanel({
targetId,
targetName,
vehicles,
date,
loading,
onDateChange,
onClose,
}: AllVehiclesPanelProps) {
const [search, setSearch] = useState('');
const [sort, setSort] = useState<VehicleSort>('desc');
const filteredVehicles = filterAndSortTargetVehicles(vehicles, search, sort);
const totalKm = totalTodayMileage(filteredVehicles);
return (
<AnimatePresence>
{targetId !== null && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
/>
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-[120] bg-white shadow-2xl flex flex-col"
>
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="text-lg font-black text-slate-900">{targetName}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">车辆实时明细清单</p>
</div>
<button
onClick={() => {
onClose();
setSearch('');
}}
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
>
<X size={20} />
</button>
</div>
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="搜索车牌号..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
</div>
<div className="relative flex-shrink-0">
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
<input
type="date"
value={date}
onChange={(event) => onDateChange(event.target.value)}
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{loading ? '加载中...' : `${filteredVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`}
</span>
<button
onClick={() => setSort(previous => previous === 'desc' ? 'asc' : 'desc')}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
>
<ArrowUpDown size={12} />
{sort === 'desc' ? '降序' : '升序'}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
{filteredVehicles.map(vehicle => (
<div key={vehicle.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${vehicle.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{vehicle.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{vehicle.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{vehicle.rentStatus || ''}{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{vehicle.customer || '-'}</span>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2">
<div className="text-sm font-black text-blue-600">{vehicle.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(vehicle.totalMileage || 0)} km</div>
</div>
</div>
))}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100">
<button
onClick={onClose}
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
>
关闭明细
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,129 @@
import { AnimatePresence, motion } from 'motion/react';
import { Minimize2, RotateCcw } from 'lucide-react';
import type { TargetSummary } from '../types';
import { deriveTargetTableRow, fmtKm, summarizeTargets } from './model';
interface FullscreenTargetTableProps {
isOpen: boolean;
targets: TargetSummary[];
assessmentYearMap: Record<number, number>;
onRefresh: () => void;
onClose: () => void;
}
export default function FullscreenTargetTable({
isOpen,
targets,
assessmentYearMap,
onRefresh,
onClose,
}: FullscreenTargetTableProps) {
const totals = summarizeTargets(targets, assessmentYearMap);
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
>
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
<h2 className="text-white font-bold text-xs">车型考核汇总</h2>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500">今日 <span className="text-white font-black">{fmtKm(totals.today)}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">累计 <span className="text-white font-black">{fmtKm(totals.cumulative)}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">车辆 <span className="text-white font-black">{totals.vehicleCount}</span> 台</span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">所选年度完成率 <span className="text-white font-black">{totals.averageCompletion.toFixed(1)}</span> <span className="text-blue-400">%</span></span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
>
<RotateCcw size={13} />
</button>
<button
onClick={onClose}
className="p-1.5 text-slate-500 hover:text-white transition-colors"
>
<Minimize2 size={14} />
</button>
</div>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">年度进度</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度任务/辆</th>
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">年度达标</th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度目标</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-right">日均需完成</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{targets.map((target, index) => {
const row = deriveTargetTableRow(target, assessmentYearMap[target.id]);
return (
<tr key={index} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{row.assessment?.label || '当前年度'}</div>
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((period, periodIndex) => <span key={periodIndex} className="block">{period}</span>)}</div>
</td>
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${row.completion >= 90 ? 'bg-emerald-500' : row.completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
style={{ width: `${Math.min(row.completion, 100)}%` }}
/>
</div>
<span className="text-[10px] font-black text-white w-10 text-right">{row.completion.toFixed(1)}%</span>
</div>
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
<span>{fmtKm(row.completed)}</span>
<span>/ {fmtKm(row.goal)} km</span>
</div>
</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(row.taskPerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{row.qualified}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{row.halfQualified}</td>
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(row.goal)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(row.completed)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(row.remainingMileage)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{row.days}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
{row.assessment && row.days === 0 ? '考核已到期' : `${fmtKm(row.daily)} km`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,79 @@
import { motion } from 'motion/react';
import type { TargetSummary } from '../types';
import type { SelectedTargetMetrics } from './model';
import { fmtKm, fmtPercent, shortTargetName } from './model';
interface StatisticsOverviewProps {
targets: TargetSummary[];
selectedTargetId: number | null;
selectedTarget: TargetSummary | undefined;
metrics: SelectedTargetMetrics;
onSelectTarget: (targetId: number) => void;
}
export default function StatisticsOverview({
targets,
selectedTargetId,
selectedTarget,
metrics,
onSelectTarget,
}: StatisticsOverviewProps) {
return (
<>
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
{targets.map(target => (
<button
key={target.id}
onClick={() => onSelectTarget(target.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
}`}
>
{shortTargetName(target.targetName)}
</button>
))}
</div>
{selectedTarget && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-2 gap-2 md:grid-cols-4"
>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">考核压力</div>
<div className={`mt-1 text-lg font-black ${metrics.pressureLevel === '健康' ? 'text-emerald-600' : metrics.pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
{metrics.pressureLevel}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">完成率 {fmtPercent(metrics.completion)}</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">剩余缺口</div>
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, metrics.remaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
<div className="mt-1 text-[10px] font-bold text-slate-400">剩余 {metrics.daysLeft} 天</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">日均需完成</div>
<div className="mt-1 text-lg font-black text-blue-600">
{metrics.daysLeft > 0 ? (
<>
{fmtKm(metrics.dailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
</>
) : '已到期'}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">按当前考核口径</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
<div className={`mt-1 text-lg font-black ${metrics.trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{metrics.trendDelta >= 0 ? '+' : ''}{fmtKm(metrics.trendDelta)}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(metrics.qualifiedRate)}</div>
</div>
</motion.div>
)}
</>
);
}
@@ -0,0 +1,237 @@
import { AnimatePresence, motion } from 'motion/react';
import { ChevronDown, Maximize2, Truck } from 'lucide-react';
import Blur from '../../../components/Blur';
import type { TargetSummary, TargetVehicle } from '../types';
import { fmtDateLabel, fmtKm, fmtPercent, getTargetAssessment } from './model';
interface TargetDetailPanelProps {
target: TargetSummary | undefined;
expandedTargetId: number | null;
selectedYear: number | undefined;
vehicles: TargetVehicle[];
currentDateLabel: string;
onOpenFullscreen: () => void;
onExpandTarget: (targetId: number) => void;
onAssessmentYearChange: (targetId: number, year: number) => void;
onViewAll: (target: TargetSummary) => void;
}
export default function TargetDetailPanel({
target,
expandedTargetId,
selectedYear,
vehicles,
currentDateLabel,
onOpenFullscreen,
onExpandTarget,
onAssessmentYearChange,
onViewAll,
}: TargetDetailPanelProps) {
const assessment = target ? getTargetAssessment(target, selectedYear) : null;
const primaryCompletion = assessment?.completionRate ?? target?.avgCompletion ?? 0;
const primaryQualified = assessment?.qualifiedCount ?? target?.yearQualifiedCount ?? 0;
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
return (
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
<div className="flex items-center justify-between px-2 flex-shrink-0">
<div className="flex min-w-0 items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<div className="min-w-0">
<h3 className="truncate text-xs font-black text-slate-700">当前车型考核详情</h3>
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{target?.targetName || '请选择车型'}</p>
</div>
</div>
<button
onClick={onOpenFullscreen}
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
>
<Maximize2 size={14} />
</button>
</div>
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
{target && (
<div
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => onExpandTarget(target.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
<Truck size={14} className="text-slate-400" />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}台</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
</div>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
<div className="flex flex-col items-end">
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
</div>
<div className="text-[8px] font-bold text-slate-300">
累计: {fmtKm(target.cumulativeTotal)} KM
</div>
</div>
<motion.div
animate={{ rotate: 180 }}
className="text-slate-300"
>
<ChevronDown size={14} />
</motion.div>
</div>
</div>
<AnimatePresence initial={false} mode="wait">
{expandedTargetId === target.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
<span className="text-[10px] font-black text-blue-700">考核年度</span>
<select
value={assessment?.yearNumber || ''}
onClick={(event) => event.stopPropagation()}
onChange={(event) => {
event.stopPropagation();
onAssessmentYearChange(target.id, Number(event.target.value));
}}
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
>
{target.yearlyAssessments.map(item => (
<option key={item.yearNumber} value={item.yearNumber}>
{item.label}
</option>
))}
</select>
</div>
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
{target.periods.map((period, index) => (
<p key={index} className="text-[10px] font-black text-slate-700">{period}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
</div>
{assessment ? (
<>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, index) => (
<p key={index} className="text-[10px] font-black text-slate-700">{period}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}任务/辆</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已进入车辆</p>
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} 台</p>
{assessment.vehicleCount < target.vehicleCount && (
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
)}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}需完成</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已完成</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
<p className="text-[8px] font-bold text-slate-300">
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}完成率</p>
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
<p className="text-[10px] font-black text-blue-500">
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
</p>
</div>
</>
) : (
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
</div>
)}
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
</div>
<div className="col-span-2 space-y-2 mt-2">
<div className="flex items-center justify-between px-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆明细 (前5台)</span>
<button
onClick={(event) => {
event.stopPropagation();
onViewAll(target);
}}
className="text-[8px] text-blue-500 font-bold hover:underline"
>
查看全部
</button>
</div>
<div className="space-y-1">
{vehicles.slice(0, 5).map(vehicle => (
<div key={vehicle.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{vehicle.plateNumber}</Blur></span>
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
在线
</span>
</div>
<div className="text-right">
<span className="text-[10px] font-black text-blue-600">{vehicle.todayMileage}</span>
<span className="text-[8px] text-slate-400 ml-1">KM</span>
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,133 @@
import { useState } from 'react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
LabelList,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { TargetSummary, TrendPoint } from '../types';
import { fmtKm, trendDateLabel, type ChartType } from './model';
interface TrendPanelProps {
selectedTarget: TargetSummary | undefined;
selectedCompletion: number;
trendData: TrendPoint[];
}
export default function TrendPanel({
selectedTarget,
selectedCompletion,
trendData,
}: TrendPanelProps) {
const [chartType, setChartType] = useState<ChartType>('bar');
return (
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">今日总里程</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(selectedTarget?.todayTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">累计总里程</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(selectedTarget?.cumulativeTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">考核车辆</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{selectedTarget?.vehicleCount ?? 0}
<span className="text-blue-500 text-[10px] ml-1">台</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{selectedCompletion.toFixed(1)}
<span className="text-blue-500 text-[10px] ml-1">%</span>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-sm font-bold text-slate-800">7天里程趋势</h3>
</div>
<div className="flex bg-slate-50 p-1 rounded-lg">
{(['bar', 'line', 'area'] as const).map(type => (
<button
key={type}
onClick={() => setChartType(type)}
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
}`}
>
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
</button>
))}
</div>
</div>
<div className="h-[280px] w-full min-w-0">
<ResponsiveContainer width="100%" height={280} minWidth={0}>
{chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={trendDateLabel} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Bar dataKey="mileage" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={20}>
<LabelList dataKey="mileage" position="top" style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
{trendData.map((_entry: TrendPoint, index: number) => (
<Cell key={`cell-${index}`} fill={index === trendData.length - 1 ? '#2563eb' : '#60a5fa'} />
))}
</Bar>
</BarChart>
) : chartType === 'line' ? (
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={trendDateLabel} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Line type="monotone" dataKey="mileage" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4, fill: '#3b82f6' }}>
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Line>
</LineChart>
) : (
<AreaChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorMileage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={trendDateLabel} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Area type="monotone" dataKey="mileage" stroke="#3b82f6" fillOpacity={1} fill="url(#colorMileage)">
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Area>
</AreaChart>
)}
</ResponsiveContainer>
</div>
</div>
</div>
);
}
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment } from '../types';
import {
deriveSelectedTargetMetrics,
deriveTargetTableRow,
filterAndSortTargetVehicles,
fmtDateLabel,
fmtKm,
fmtPercent,
getCurrentDateLabel,
getDefaultDate,
getTargetAssessment,
initialAssessmentYears,
orderTargets,
shortTargetName,
summarizeTargets,
totalTodayMileage,
trendDateLabel,
} from './model';
function assessment(overrides: Partial<TargetYearlyAssessment> = {}): TargetYearlyAssessment {
return {
yearNumber: 1,
label: '考核第1年',
vehicleCount: 8,
target: 8000,
completed: 6000,
remaining: 2000,
completionRate: 75,
qualifiedCount: 4,
qualifiedRate: 50,
halfQualifiedCount: 6,
daysLeft: 20,
dailyTarget: 100,
startDate: '2026-01-01',
endDate: '2026-12-31',
periods: ['2026/1/1-2026/12/31'],
...overrides,
};
}
function target(overrides: Partial<TargetSummary> = {}): TargetSummary {
return {
id: 1,
targetName: '10辆4.5T普货',
vehicleCount: 10,
totalMileagePerVehicle: 2000,
annualMileagePerVehicle: 1000,
assessmentYears: 2,
periods: ['2026/1/1-2027/12/31'],
todayTotal: 300,
cumulativeTotal: 5000,
avgCompletion: 42,
qualifiedCount: 3,
yearQualifiedCount: 2,
halfQualifiedCount: 5,
currentYearTarget: 10000,
currentYearCompleted: 4200,
remaining: 5800,
daysLeft: 50,
dailyTarget: 116,
firstYearVehicleCount: 10,
firstYearTarget: 10000,
firstYearCompleted: 4200,
firstYearRemaining: 5800,
firstYearCompletionRate: 42,
firstYearQualifiedCount: 2,
firstYearQualifiedRate: 20,
firstYearHalfQualifiedCount: 5,
firstYearDaysLeft: 50,
firstYearDailyTarget: 116,
firstYearStartDate: '2026-01-01',
firstYearEndDate: '2026-12-31',
yearlyAssessments: [assessment()],
...overrides,
};
}
function vehicle(plateNumber: string, todayMileage: number): TargetVehicle {
return {
plateNumber,
todayMileage,
totalMileage: 1000,
completionRate: 10,
isQualified: false,
currentYearIsQualified: false,
dailyRequiredMileage: 100,
rentStatus: null,
department: null,
customer: null,
isOnline: false,
};
}
test('statistics dates retain the 05:00 rollover and current local date labels', () => {
assert.equal(getDefaultDate(new Date(2026, 7, 13, 4, 59)), '2026-08-12');
assert.equal(getDefaultDate(new Date(2026, 7, 13, 5, 0)), '2026-08-13');
assert.equal(getCurrentDateLabel(new Date(2026, 7, 13, 23, 0)), '2026.8.13');
assert.equal(fmtDateLabel('2026-08-03'), '2026.8.3');
assert.equal(fmtDateLabel(null), '');
});
test('statistics number, percent, target, and trend labels preserve display precision', () => {
assert.equal(fmtKm(12345.67), '1.23万');
assert.equal(fmtKm(9999.5), '9,999.5');
assert.equal(fmtKm(-12000), '-12,000');
assert.equal(fmtPercent(12.34), '12.3%');
assert.equal(shortTargetName('10辆4.5T普货'), '10台普货');
assert.equal(shortTargetName('8台4.5T冷链车'), '8台冷藏车');
assert.equal(shortTargetName('未命名批次'), '未命名批次');
assert.equal(trendDateLabel('08-03'), '8.3');
});
test('target ordering and initial assessment years retain first-match fallbacks', () => {
const plain = target({ id: 1, targetName: '普通批次' });
const focused = target({ id: 2, targetName: '羚牛136台普货', yearlyAssessments: [] });
assert.deepEqual(orderTargets([plain, focused]).map(item => item.id), [2, 1]);
assert.deepEqual(initialAssessmentYears([plain, focused]), { 1: 1, 2: 1 });
assert.equal(getTargetAssessment(plain, 99)?.yearNumber, 1);
assert.equal(getTargetAssessment(focused, 1), null);
});
test('selected metrics retain assessment priority, trend delta, and pressure thresholds', () => {
const secondYear = assessment({
yearNumber: 2,
label: '考核第2年',
completionRate: 92,
remaining: -50,
daysLeft: 0,
dailyTarget: 7,
qualifiedRate: 80,
});
const metrics = deriveSelectedTargetMetrics(
target({ yearlyAssessments: [assessment(), secondYear] }),
2,
[{ date: '08-01', mileage: 500 }, { date: '08-02', mileage: 420 }],
);
assert.deepEqual(metrics, {
assessment: secondYear,
completion: 92,
remaining: -50,
daysLeft: 0,
dailyTarget: 7,
qualifiedRate: 80,
trendDelta: -80,
pressureLevel: '健康',
});
const fallback = deriveSelectedTargetMetrics(target({ yearlyAssessments: [] }), undefined, []);
assert.equal(fallback.completion, 42);
assert.equal(fallback.dailyTarget, 0);
assert.equal(fallback.qualifiedRate, 20);
assert.equal(fallback.pressureLevel, '高压');
});
test('table rows and fullscreen totals preserve unweighted selected-year calculations', () => {
const first = target({ id: 1, todayTotal: 100, cumulativeTotal: 1000 });
const second = target({
id: 2,
vehicleCount: 30,
todayTotal: 200,
cumulativeTotal: 3000,
yearlyAssessments: [assessment({ yearNumber: 2, completionRate: 95 })],
});
const row = deriveTargetTableRow(second, 2);
assert.equal(row.completion, 95);
assert.equal(row.taskPerVehicle, 2000);
assert.equal(row.goal, 8000);
assert.deepEqual(summarizeTargets([first, second], { 1: 1, 2: 2 }), {
today: 300,
cumulative: 4000,
vehicleCount: 40,
averageCompletion: 85,
});
assert.equal(summarizeTargets([], {}).averageCompletion, 0);
});
test('vehicle search and mileage sorting preserve case-insensitive filtering and zero fallback', () => {
const vehicles = [
vehicle('YUE-A2', 20),
vehicle('yue-A1', 0),
vehicle('OTHER', 100),
];
assert.deepEqual(
filterAndSortTargetVehicles(vehicles, 'yue', 'desc').map(item => item.plateNumber),
['YUE-A2', 'yue-A1'],
);
assert.deepEqual(
filterAndSortTargetVehicles(vehicles, 'YUE', 'asc').map(item => item.plateNumber),
['yue-A1', 'YUE-A2'],
);
assert.equal(totalTodayMileage(vehicles), 120);
assert.deepEqual(vehicles.map(item => item.plateNumber), ['YUE-A2', 'yue-A1', 'OTHER']);
});
+185
View File
@@ -0,0 +1,185 @@
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from '../types';
export type ChartType = 'bar' | 'line' | 'area';
export type VehicleSort = 'asc' | 'desc';
export type PressureLevel = '健康' | '关注' | '高压';
export interface SelectedTargetMetrics {
assessment: TargetYearlyAssessment | null;
completion: number;
remaining: number;
daysLeft: number;
dailyTarget: number;
qualifiedRate: number;
trendDelta: number;
pressureLevel: PressureLevel;
}
export interface TargetTableRow {
assessment: TargetYearlyAssessment | null;
completion: number;
qualified: number;
halfQualified: number;
goal: number;
completed: number;
remainingMileage: number;
days: number;
daily: number;
taskPerVehicle: number;
}
export interface TargetTotals {
today: number;
cumulative: number;
vehicleCount: number;
averageCompletion: number;
}
export function getDefaultDate(now = new Date()): string {
const date = new Date(now);
if (date.getHours() < 5) date.setDate(date.getDate() - 1);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
export function getCurrentDateLabel(now = new Date()): string {
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
}
export function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
return value.toLocaleString();
}
export function fmtPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
export function getTargetAssessment(
target: TargetSummary,
selectedYear?: number,
): TargetYearlyAssessment | null {
if (target.yearlyAssessments.length === 0) return null;
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
}
export function fmtDateLabel(date: string | null): string {
if (!date) return '';
const [year, month, day] = date.split('-');
return `${year}.${Number(month)}.${Number(day)}`;
}
export function shortTargetName(name: string): string {
const match = name.match(/(\d+)[辆台](.+)/);
if (!match) return name;
const count = match[1];
let desc = match[2];
desc = desc.replace('4.5T普货', '普货');
desc = desc.replace('4.5T冷链车', '冷藏车');
desc = desc.replace('4.5T冷链', '冷藏车');
desc = desc.replace('18T', '18T');
return `${count}台${desc}`;
}
export function trendDateLabel(value: string): string {
const [month, day] = value.split('-');
return `${parseInt(month)}.${parseInt(day)}`;
}
export function orderTargets(targets: TargetSummary[]): TargetSummary[] {
const focused = targets.find(item => item.targetName.includes('羚牛136')) || targets[0];
return focused
? [focused, ...targets.filter(item => item.id !== focused.id)]
: targets;
}
export function initialAssessmentYears(targets: TargetSummary[]): Record<number, number> {
return Object.fromEntries(
targets.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1]),
);
}
export function deriveSelectedTargetMetrics(
target: TargetSummary | undefined,
selectedYear: number | undefined,
trendData: TrendPoint[],
): SelectedTargetMetrics {
const assessment = target ? getTargetAssessment(target, selectedYear) : null;
const completion = assessment?.completionRate ?? target?.avgCompletion ?? 0;
const remaining = assessment?.remaining ?? 0;
const daysLeft = assessment?.daysLeft ?? target?.daysLeft ?? 0;
const dailyTarget = assessment?.dailyTarget ?? (daysLeft > 0 ? remaining / daysLeft : 0);
const qualifiedRate = assessment?.qualifiedRate
?? (target?.vehicleCount ? target.yearQualifiedCount / target.vehicleCount * 100 : 0);
const latestTrend = trendData[trendData.length - 1];
const previousTrend = trendData[trendData.length - 2];
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
const pressureLevel = completion >= 90 ? '健康' : completion >= 70 ? '关注' : '高压';
return {
assessment,
completion,
remaining,
daysLeft,
dailyTarget,
qualifiedRate,
trendDelta,
pressureLevel,
};
}
export function deriveTargetTableRow(
target: TargetSummary,
selectedYear?: number,
): TargetTableRow {
const assessment = getTargetAssessment(target, selectedYear);
return {
assessment,
completion: assessment?.completionRate ?? target.avgCompletion,
qualified: assessment?.qualifiedCount ?? target.yearQualifiedCount,
halfQualified: assessment?.halfQualifiedCount ?? target.halfQualifiedCount,
goal: assessment?.target ?? target.currentYearTarget,
completed: assessment?.completed ?? target.currentYearCompleted,
remainingMileage: assessment?.remaining ?? target.remaining,
days: assessment?.daysLeft ?? target.daysLeft,
daily: assessment?.dailyTarget ?? target.dailyTarget,
taskPerVehicle: target.annualMileagePerVehicle * (assessment?.yearNumber || 1),
};
}
export function summarizeTargets(
targets: TargetSummary[],
assessmentYearMap: Record<number, number>,
): TargetTotals {
const totals = targets.reduce((result, target) => ({
today: result.today + target.todayTotal,
cumulative: result.cumulative + target.cumulativeTotal,
vehicleCount: result.vehicleCount + target.vehicleCount,
completion: result.completion
+ (getTargetAssessment(target, assessmentYearMap[target.id])?.completionRate ?? target.avgCompletion),
}), { today: 0, cumulative: 0, vehicleCount: 0, completion: 0 });
return {
today: totals.today,
cumulative: totals.cumulative,
vehicleCount: totals.vehicleCount,
averageCompletion: targets.length > 0 ? totals.completion / targets.length : 0,
};
}
export function filterAndSortTargetVehicles(
vehicles: TargetVehicle[],
search: string,
sort: VehicleSort,
): TargetVehicle[] {
return vehicles
.filter(vehicle => vehicle.plateNumber.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => {
const valueA = a.todayMileage || 0;
const valueB = b.todayMileage || 0;
return sort === 'desc' ? valueB - valueA : valueA - valueB;
});
}
export function totalTodayMileage(vehicles: TargetVehicle[]): number {
return vehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
}