refactor: modularize application domains
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+217
-1333
File diff suppressed because it is too large
Load Diff
@@ -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>:当日里程 >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);
|
||||
});
|
||||
@@ -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">×</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']);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user