refactor: modularize application domains

This commit is contained in:
kkfluous
2026-08-13 12:03:34 +08:00
parent d610e4b841
commit 06fe75b4c7
142 changed files with 14645 additions and 8885 deletions
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ArrowUpDown, Calendar, Search, Truck, X } from 'lucide-react';
import Blur from '../../../components/Blur';
import type { TargetVehicle } from '../types';
import {
filterAndSortTargetVehicles,
fmtKm,
totalTodayMileage,
type VehicleSort,
} from './model';
interface AllVehiclesPanelProps {
targetId: number | null;
targetName: string;
vehicles: TargetVehicle[];
date: string;
loading: boolean;
onDateChange: (date: string) => void;
onClose: () => void;
}
export default function AllVehiclesPanel({
targetId,
targetName,
vehicles,
date,
loading,
onDateChange,
onClose,
}: AllVehiclesPanelProps) {
const [search, setSearch] = useState('');
const [sort, setSort] = useState<VehicleSort>('desc');
const filteredVehicles = filterAndSortTargetVehicles(vehicles, search, sort);
const totalKm = totalTodayMileage(filteredVehicles);
return (
<AnimatePresence>
{targetId !== null && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
/>
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 bottom-0 w-full max-w-sm z-[120] bg-white shadow-2xl flex flex-col"
>
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="text-lg font-black text-slate-900">{targetName}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">车辆实时明细清单</p>
</div>
<button
onClick={() => {
onClose();
setSearch('');
}}
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
>
<X size={20} />
</button>
</div>
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="搜索车牌号..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
</div>
<div className="relative flex-shrink-0">
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={13} />
<input
type="date"
value={date}
onChange={(event) => onDateChange(event.target.value)}
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{loading ? '加载中...' : `${filteredVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`}
</span>
<button
onClick={() => setSort(previous => previous === 'desc' ? 'asc' : 'desc')}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black active:scale-95 transition-all"
>
<ArrowUpDown size={12} />
{sort === 'desc' ? '降序' : '升序'}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
{filteredVehicles.map(vehicle => (
<div key={vehicle.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${vehicle.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{vehicle.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{vehicle.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{vehicle.rentStatus || ''}{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{vehicle.customer || '-'}</span>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2">
<div className="text-sm font-black text-blue-600">{vehicle.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(vehicle.totalMileage || 0)} km</div>
</div>
</div>
))}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100">
<button
onClick={onClose}
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
>
关闭明细
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,129 @@
import { AnimatePresence, motion } from 'motion/react';
import { Minimize2, RotateCcw } from 'lucide-react';
import type { TargetSummary } from '../types';
import { deriveTargetTableRow, fmtKm, summarizeTargets } from './model';
interface FullscreenTargetTableProps {
isOpen: boolean;
targets: TargetSummary[];
assessmentYearMap: Record<number, number>;
onRefresh: () => void;
onClose: () => void;
}
export default function FullscreenTargetTable({
isOpen,
targets,
assessmentYearMap,
onRefresh,
onClose,
}: FullscreenTargetTableProps) {
const totals = summarizeTargets(targets, assessmentYearMap);
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col overflow-hidden"
>
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
<h2 className="text-white font-bold text-xs">车型考核汇总</h2>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500">今日 <span className="text-white font-black">{fmtKm(totals.today)}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">累计 <span className="text-white font-black">{fmtKm(totals.cumulative)}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">车辆 <span className="text-white font-black">{totals.vehicleCount}</span> 台</span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">所选年度完成率 <span className="text-white font-black">{totals.averageCompletion.toFixed(1)}</span> <span className="text-blue-400">%</span></span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
>
<RotateCcw size={13} />
</button>
<button
onClick={onClose}
className="p-1.5 text-slate-500 hover:text-white transition-colors"
>
<Minimize2 size={14} />
</button>
</div>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">年度进度</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度任务/辆</th>
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">年度达标</th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度目标</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-right">日均需完成</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{targets.map((target, index) => {
const row = deriveTargetTableRow(target, assessmentYearMap[target.id]);
return (
<tr key={index} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{row.assessment?.label || '当前年度'}</div>
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((period, periodIndex) => <span key={periodIndex} className="block">{period}</span>)}</div>
</td>
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${row.completion >= 90 ? 'bg-emerald-500' : row.completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
style={{ width: `${Math.min(row.completion, 100)}%` }}
/>
</div>
<span className="text-[10px] font-black text-white w-10 text-right">{row.completion.toFixed(1)}%</span>
</div>
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
<span>{fmtKm(row.completed)}</span>
<span>/ {fmtKm(row.goal)} km</span>
</div>
</td>
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(row.taskPerVehicle)} km</td>
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{row.qualified}</td>
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{row.halfQualified}</td>
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(row.goal)} km</td>
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(row.completed)} km</td>
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(row.remainingMileage)} km</td>
<td className="px-3 py-3 text-xs text-slate-300 text-center">{row.days}</td>
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
{row.assessment && row.days === 0 ? '考核已到期' : `${fmtKm(row.daily)} km`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,79 @@
import { motion } from 'motion/react';
import type { TargetSummary } from '../types';
import type { SelectedTargetMetrics } from './model';
import { fmtKm, fmtPercent, shortTargetName } from './model';
interface StatisticsOverviewProps {
targets: TargetSummary[];
selectedTargetId: number | null;
selectedTarget: TargetSummary | undefined;
metrics: SelectedTargetMetrics;
onSelectTarget: (targetId: number) => void;
}
export default function StatisticsOverview({
targets,
selectedTargetId,
selectedTarget,
metrics,
onSelectTarget,
}: StatisticsOverviewProps) {
return (
<>
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
{targets.map(target => (
<button
key={target.id}
onClick={() => onSelectTarget(target.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
}`}
>
{shortTargetName(target.targetName)}
</button>
))}
</div>
{selectedTarget && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-2 gap-2 md:grid-cols-4"
>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">考核压力</div>
<div className={`mt-1 text-lg font-black ${metrics.pressureLevel === '健康' ? 'text-emerald-600' : metrics.pressureLevel === '关注' ? 'text-amber-600' : 'text-rose-600'}`}>
{metrics.pressureLevel}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">完成率 {fmtPercent(metrics.completion)}</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">剩余缺口</div>
<div className="mt-1 text-lg font-black text-slate-900">{fmtKm(Math.max(0, metrics.remaining))}<span className="ml-1 text-[10px] text-slate-400">km</span></div>
<div className="mt-1 text-[10px] font-bold text-slate-400">剩余 {metrics.daysLeft} 天</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">日均需完成</div>
<div className="mt-1 text-lg font-black text-blue-600">
{metrics.daysLeft > 0 ? (
<>
{fmtKm(metrics.dailyTarget)}<span className="ml-1 text-[10px] text-slate-400">km</span>
</>
) : '已到期'}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">按当前考核口径</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
<div className={`mt-1 text-lg font-black ${metrics.trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{metrics.trendDelta >= 0 ? '+' : ''}{fmtKm(metrics.trendDelta)}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(metrics.qualifiedRate)}</div>
</div>
</motion.div>
)}
</>
);
}
@@ -0,0 +1,237 @@
import { AnimatePresence, motion } from 'motion/react';
import { ChevronDown, Maximize2, Truck } from 'lucide-react';
import Blur from '../../../components/Blur';
import type { TargetSummary, TargetVehicle } from '../types';
import { fmtDateLabel, fmtKm, fmtPercent, getTargetAssessment } from './model';
interface TargetDetailPanelProps {
target: TargetSummary | undefined;
expandedTargetId: number | null;
selectedYear: number | undefined;
vehicles: TargetVehicle[];
currentDateLabel: string;
onOpenFullscreen: () => void;
onExpandTarget: (targetId: number) => void;
onAssessmentYearChange: (targetId: number, year: number) => void;
onViewAll: (target: TargetSummary) => void;
}
export default function TargetDetailPanel({
target,
expandedTargetId,
selectedYear,
vehicles,
currentDateLabel,
onOpenFullscreen,
onExpandTarget,
onAssessmentYearChange,
onViewAll,
}: TargetDetailPanelProps) {
const assessment = target ? getTargetAssessment(target, selectedYear) : null;
const primaryCompletion = assessment?.completionRate ?? target?.avgCompletion ?? 0;
const primaryQualified = assessment?.qualifiedCount ?? target?.yearQualifiedCount ?? 0;
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
return (
<div className="w-full landscape:w-1/3 flex-shrink-0 space-y-2 flex flex-col landscape:overflow-hidden">
<div className="flex items-center justify-between px-2 flex-shrink-0">
<div className="flex min-w-0 items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<div className="min-w-0">
<h3 className="truncate text-xs font-black text-slate-700">当前车型考核详情</h3>
<p className="mt-0.5 truncate text-[9px] font-bold text-slate-400">{target?.targetName || '请选择车型'}</p>
</div>
</div>
<button
onClick={onOpenFullscreen}
className="p-1.5 bg-white text-slate-400 rounded-lg border border-slate-100 shadow-sm hover:text-blue-600 transition-colors"
>
<Maximize2 size={14} />
</button>
</div>
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
{target && (
<div
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => onExpandTarget(target.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0">
<Truck size={14} className="text-slate-400" />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900">{target.targetName}</span>
<span className="text-[8px] px-1 rounded bg-blue-50 text-blue-600 font-bold">{target.vehicleCount}台</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
</div>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2 flex items-center gap-3">
<div className="flex flex-col items-end">
<div className="text-sm font-black text-slate-900 leading-none mb-0.5">
{fmtKm(target.todayTotal)} <span className="text-[8px] text-slate-300 font-bold uppercase">KM</span>
</div>
<div className="text-[8px] font-bold text-slate-300">
累计: {fmtKm(target.cumulativeTotal)} KM
</div>
</div>
<motion.div
animate={{ rotate: 180 }}
className="text-slate-300"
>
<ChevronDown size={14} />
</motion.div>
</div>
</div>
<AnimatePresence initial={false} mode="wait">
{expandedTargetId === target.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
<span className="text-[10px] font-black text-blue-700">考核年度</span>
<select
value={assessment?.yearNumber || ''}
onClick={(event) => event.stopPropagation()}
onChange={(event) => {
event.stopPropagation();
onAssessmentYearChange(target.id, Number(event.target.value));
}}
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
>
{target.yearlyAssessments.map(item => (
<option key={item.yearNumber} value={item.yearNumber}>
{item.label}
</option>
))}
</select>
</div>
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
{target.periods.map((period, index) => (
<p key={index} className="text-[10px] font-black text-slate-700">{period}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div>
</div>
{assessment ? (
<>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, index) => (
<p key={index} className="text-[10px] font-black text-slate-700">{period}</p>
))}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}任务/辆</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已进入车辆</p>
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} 台</p>
{assessment.vehicleCount < target.vehicleCount && (
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
)}
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}需完成</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已完成</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
<p className="text-[8px] font-bold text-slate-300">
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}完成率</p>
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
</div>
<div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
<p className="text-[10px] font-black text-blue-500">
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
</p>
</div>
</>
) : (
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
</div>
)}
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
</div>
<div className="col-span-2 space-y-2 mt-2">
<div className="flex items-center justify-between px-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">车辆明细 (前5台)</span>
<button
onClick={(event) => {
event.stopPropagation();
onViewAll(target);
}}
className="text-[8px] text-blue-500 font-bold hover:underline"
>
查看全部
</button>
</div>
<div className="space-y-1">
{vehicles.slice(0, 5).map(vehicle => (
<div key={vehicle.plateNumber} className="bg-slate-50/50/50 px-2 py-1.5 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono font-bold text-slate-700"><Blur>{vehicle.plateNumber}</Blur></span>
<span className="text-[7px] px-1 rounded bg-green-100 text-green-600 font-bold">
在线
</span>
</div>
<div className="text-right">
<span className="text-[10px] font-black text-blue-600">{vehicle.todayMileage}</span>
<span className="text-[8px] text-slate-400 ml-1">KM</span>
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,133 @@
import { useState } from 'react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
LabelList,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { TargetSummary, TrendPoint } from '../types';
import { fmtKm, trendDateLabel, type ChartType } from './model';
interface TrendPanelProps {
selectedTarget: TargetSummary | undefined;
selectedCompletion: number;
trendData: TrendPoint[];
}
export default function TrendPanel({
selectedTarget,
selectedCompletion,
trendData,
}: TrendPanelProps) {
const [chartType, setChartType] = useState<ChartType>('bar');
return (
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">今日总里程</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(selectedTarget?.todayTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">累计总里程</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{fmtKm(selectedTarget?.cumulativeTotal ?? 0)}
<span className="text-blue-500 text-[10px] ml-1">KM</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">考核车辆</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{selectedTarget?.vehicleCount ?? 0}
<span className="text-blue-500 text-[10px] ml-1">台</span>
</div>
</div>
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
<div className="text-lg font-black text-slate-900 tracking-tighter">
{selectedCompletion.toFixed(1)}
<span className="text-blue-500 text-[10px] ml-1">%</span>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-[300px] overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-blue-600 rounded-full" />
<h3 className="text-sm font-bold text-slate-800">7天里程趋势</h3>
</div>
<div className="flex bg-slate-50 p-1 rounded-lg">
{(['bar', 'line', 'area'] as const).map(type => (
<button
key={type}
onClick={() => setChartType(type)}
className={`px-2 py-1 rounded-md text-[10px] font-bold transition-all ${
chartType === type ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'
}`}
>
{type === 'bar' ? '柱状' : type === 'line' ? '折线' : '面积'}
</button>
))}
</div>
</div>
<div className="h-[280px] w-full min-w-0">
<ResponsiveContainer width="100%" height={280} minWidth={0}>
{chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={trendDateLabel} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip cursor={{ fill: '#f8fafc', fillOpacity: 0.1 }} contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Bar dataKey="mileage" fill="#3b82f6" radius={[4, 4, 0, 0]} barSize={20}>
<LabelList dataKey="mileage" position="top" style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
{trendData.map((_entry: TrendPoint, index: number) => (
<Cell key={`cell-${index}`} fill={index === trendData.length - 1 ? '#2563eb' : '#60a5fa'} />
))}
</Bar>
</BarChart>
) : chartType === 'line' ? (
<LineChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={trendDateLabel} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Line type="monotone" dataKey="mileage" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4, fill: '#3b82f6' }}>
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Line>
</LineChart>
) : (
<AreaChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorMileage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" strokeOpacity={0.6} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} dy={10} tickFormatter={trendDateLabel} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', backgroundColor: '#1e293b', color: '#fff', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', fontSize: '10px' }} />
<Area type="monotone" dataKey="mileage" stroke="#3b82f6" fillOpacity={1} fill="url(#colorMileage)">
<LabelList dataKey="mileage" position="top" offset={10} style={{ fontSize: 10, fill: '#64748b', fontWeight: 'bold' }} />
</Area>
</AreaChart>
)}
</ResponsiveContainer>
</div>
</div>
</div>
);
}
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment } from '../types';
import {
deriveSelectedTargetMetrics,
deriveTargetTableRow,
filterAndSortTargetVehicles,
fmtDateLabel,
fmtKm,
fmtPercent,
getCurrentDateLabel,
getDefaultDate,
getTargetAssessment,
initialAssessmentYears,
orderTargets,
shortTargetName,
summarizeTargets,
totalTodayMileage,
trendDateLabel,
} from './model';
function assessment(overrides: Partial<TargetYearlyAssessment> = {}): TargetYearlyAssessment {
return {
yearNumber: 1,
label: '考核第1年',
vehicleCount: 8,
target: 8000,
completed: 6000,
remaining: 2000,
completionRate: 75,
qualifiedCount: 4,
qualifiedRate: 50,
halfQualifiedCount: 6,
daysLeft: 20,
dailyTarget: 100,
startDate: '2026-01-01',
endDate: '2026-12-31',
periods: ['2026/1/1-2026/12/31'],
...overrides,
};
}
function target(overrides: Partial<TargetSummary> = {}): TargetSummary {
return {
id: 1,
targetName: '10辆4.5T普货',
vehicleCount: 10,
totalMileagePerVehicle: 2000,
annualMileagePerVehicle: 1000,
assessmentYears: 2,
periods: ['2026/1/1-2027/12/31'],
todayTotal: 300,
cumulativeTotal: 5000,
avgCompletion: 42,
qualifiedCount: 3,
yearQualifiedCount: 2,
halfQualifiedCount: 5,
currentYearTarget: 10000,
currentYearCompleted: 4200,
remaining: 5800,
daysLeft: 50,
dailyTarget: 116,
firstYearVehicleCount: 10,
firstYearTarget: 10000,
firstYearCompleted: 4200,
firstYearRemaining: 5800,
firstYearCompletionRate: 42,
firstYearQualifiedCount: 2,
firstYearQualifiedRate: 20,
firstYearHalfQualifiedCount: 5,
firstYearDaysLeft: 50,
firstYearDailyTarget: 116,
firstYearStartDate: '2026-01-01',
firstYearEndDate: '2026-12-31',
yearlyAssessments: [assessment()],
...overrides,
};
}
function vehicle(plateNumber: string, todayMileage: number): TargetVehicle {
return {
plateNumber,
todayMileage,
totalMileage: 1000,
completionRate: 10,
isQualified: false,
currentYearIsQualified: false,
dailyRequiredMileage: 100,
rentStatus: null,
department: null,
customer: null,
isOnline: false,
};
}
test('statistics dates retain the 05:00 rollover and current local date labels', () => {
assert.equal(getDefaultDate(new Date(2026, 7, 13, 4, 59)), '2026-08-12');
assert.equal(getDefaultDate(new Date(2026, 7, 13, 5, 0)), '2026-08-13');
assert.equal(getCurrentDateLabel(new Date(2026, 7, 13, 23, 0)), '2026.8.13');
assert.equal(fmtDateLabel('2026-08-03'), '2026.8.3');
assert.equal(fmtDateLabel(null), '');
});
test('statistics number, percent, target, and trend labels preserve display precision', () => {
assert.equal(fmtKm(12345.67), '1.23万');
assert.equal(fmtKm(9999.5), '9,999.5');
assert.equal(fmtKm(-12000), '-12,000');
assert.equal(fmtPercent(12.34), '12.3%');
assert.equal(shortTargetName('10辆4.5T普货'), '10台普货');
assert.equal(shortTargetName('8台4.5T冷链车'), '8台冷藏车');
assert.equal(shortTargetName('未命名批次'), '未命名批次');
assert.equal(trendDateLabel('08-03'), '8.3');
});
test('target ordering and initial assessment years retain first-match fallbacks', () => {
const plain = target({ id: 1, targetName: '普通批次' });
const focused = target({ id: 2, targetName: '羚牛136台普货', yearlyAssessments: [] });
assert.deepEqual(orderTargets([plain, focused]).map(item => item.id), [2, 1]);
assert.deepEqual(initialAssessmentYears([plain, focused]), { 1: 1, 2: 1 });
assert.equal(getTargetAssessment(plain, 99)?.yearNumber, 1);
assert.equal(getTargetAssessment(focused, 1), null);
});
test('selected metrics retain assessment priority, trend delta, and pressure thresholds', () => {
const secondYear = assessment({
yearNumber: 2,
label: '考核第2年',
completionRate: 92,
remaining: -50,
daysLeft: 0,
dailyTarget: 7,
qualifiedRate: 80,
});
const metrics = deriveSelectedTargetMetrics(
target({ yearlyAssessments: [assessment(), secondYear] }),
2,
[{ date: '08-01', mileage: 500 }, { date: '08-02', mileage: 420 }],
);
assert.deepEqual(metrics, {
assessment: secondYear,
completion: 92,
remaining: -50,
daysLeft: 0,
dailyTarget: 7,
qualifiedRate: 80,
trendDelta: -80,
pressureLevel: '健康',
});
const fallback = deriveSelectedTargetMetrics(target({ yearlyAssessments: [] }), undefined, []);
assert.equal(fallback.completion, 42);
assert.equal(fallback.dailyTarget, 0);
assert.equal(fallback.qualifiedRate, 20);
assert.equal(fallback.pressureLevel, '高压');
});
test('table rows and fullscreen totals preserve unweighted selected-year calculations', () => {
const first = target({ id: 1, todayTotal: 100, cumulativeTotal: 1000 });
const second = target({
id: 2,
vehicleCount: 30,
todayTotal: 200,
cumulativeTotal: 3000,
yearlyAssessments: [assessment({ yearNumber: 2, completionRate: 95 })],
});
const row = deriveTargetTableRow(second, 2);
assert.equal(row.completion, 95);
assert.equal(row.taskPerVehicle, 2000);
assert.equal(row.goal, 8000);
assert.deepEqual(summarizeTargets([first, second], { 1: 1, 2: 2 }), {
today: 300,
cumulative: 4000,
vehicleCount: 40,
averageCompletion: 85,
});
assert.equal(summarizeTargets([], {}).averageCompletion, 0);
});
test('vehicle search and mileage sorting preserve case-insensitive filtering and zero fallback', () => {
const vehicles = [
vehicle('YUE-A2', 20),
vehicle('yue-A1', 0),
vehicle('OTHER', 100),
];
assert.deepEqual(
filterAndSortTargetVehicles(vehicles, 'yue', 'desc').map(item => item.plateNumber),
['YUE-A2', 'yue-A1'],
);
assert.deepEqual(
filterAndSortTargetVehicles(vehicles, 'YUE', 'asc').map(item => item.plateNumber),
['yue-A1', 'YUE-A2'],
);
assert.equal(totalTodayMileage(vehicles), 120);
assert.deepEqual(vehicles.map(item => item.plateNumber), ['YUE-A2', 'yue-A1', 'OTHER']);
});
+185
View File
@@ -0,0 +1,185 @@
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from '../types';
export type ChartType = 'bar' | 'line' | 'area';
export type VehicleSort = 'asc' | 'desc';
export type PressureLevel = '健康' | '关注' | '高压';
export interface SelectedTargetMetrics {
assessment: TargetYearlyAssessment | null;
completion: number;
remaining: number;
daysLeft: number;
dailyTarget: number;
qualifiedRate: number;
trendDelta: number;
pressureLevel: PressureLevel;
}
export interface TargetTableRow {
assessment: TargetYearlyAssessment | null;
completion: number;
qualified: number;
halfQualified: number;
goal: number;
completed: number;
remainingMileage: number;
days: number;
daily: number;
taskPerVehicle: number;
}
export interface TargetTotals {
today: number;
cumulative: number;
vehicleCount: number;
averageCompletion: number;
}
export function getDefaultDate(now = new Date()): string {
const date = new Date(now);
if (date.getHours() < 5) date.setDate(date.getDate() - 1);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
export function getCurrentDateLabel(now = new Date()): string {
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
}
export function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
return value.toLocaleString();
}
export function fmtPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
export function getTargetAssessment(
target: TargetSummary,
selectedYear?: number,
): TargetYearlyAssessment | null {
if (target.yearlyAssessments.length === 0) return null;
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
}
export function fmtDateLabel(date: string | null): string {
if (!date) return '';
const [year, month, day] = date.split('-');
return `${year}.${Number(month)}.${Number(day)}`;
}
export function shortTargetName(name: string): string {
const match = name.match(/(\d+)[辆台](.+)/);
if (!match) return name;
const count = match[1];
let desc = match[2];
desc = desc.replace('4.5T普货', '普货');
desc = desc.replace('4.5T冷链车', '冷藏车');
desc = desc.replace('4.5T冷链', '冷藏车');
desc = desc.replace('18T', '18T');
return `${count}台${desc}`;
}
export function trendDateLabel(value: string): string {
const [month, day] = value.split('-');
return `${parseInt(month)}.${parseInt(day)}`;
}
export function orderTargets(targets: TargetSummary[]): TargetSummary[] {
const focused = targets.find(item => item.targetName.includes('羚牛136')) || targets[0];
return focused
? [focused, ...targets.filter(item => item.id !== focused.id)]
: targets;
}
export function initialAssessmentYears(targets: TargetSummary[]): Record<number, number> {
return Object.fromEntries(
targets.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1]),
);
}
export function deriveSelectedTargetMetrics(
target: TargetSummary | undefined,
selectedYear: number | undefined,
trendData: TrendPoint[],
): SelectedTargetMetrics {
const assessment = target ? getTargetAssessment(target, selectedYear) : null;
const completion = assessment?.completionRate ?? target?.avgCompletion ?? 0;
const remaining = assessment?.remaining ?? 0;
const daysLeft = assessment?.daysLeft ?? target?.daysLeft ?? 0;
const dailyTarget = assessment?.dailyTarget ?? (daysLeft > 0 ? remaining / daysLeft : 0);
const qualifiedRate = assessment?.qualifiedRate
?? (target?.vehicleCount ? target.yearQualifiedCount / target.vehicleCount * 100 : 0);
const latestTrend = trendData[trendData.length - 1];
const previousTrend = trendData[trendData.length - 2];
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
const pressureLevel = completion >= 90 ? '健康' : completion >= 70 ? '关注' : '高压';
return {
assessment,
completion,
remaining,
daysLeft,
dailyTarget,
qualifiedRate,
trendDelta,
pressureLevel,
};
}
export function deriveTargetTableRow(
target: TargetSummary,
selectedYear?: number,
): TargetTableRow {
const assessment = getTargetAssessment(target, selectedYear);
return {
assessment,
completion: assessment?.completionRate ?? target.avgCompletion,
qualified: assessment?.qualifiedCount ?? target.yearQualifiedCount,
halfQualified: assessment?.halfQualifiedCount ?? target.halfQualifiedCount,
goal: assessment?.target ?? target.currentYearTarget,
completed: assessment?.completed ?? target.currentYearCompleted,
remainingMileage: assessment?.remaining ?? target.remaining,
days: assessment?.daysLeft ?? target.daysLeft,
daily: assessment?.dailyTarget ?? target.dailyTarget,
taskPerVehicle: target.annualMileagePerVehicle * (assessment?.yearNumber || 1),
};
}
export function summarizeTargets(
targets: TargetSummary[],
assessmentYearMap: Record<number, number>,
): TargetTotals {
const totals = targets.reduce((result, target) => ({
today: result.today + target.todayTotal,
cumulative: result.cumulative + target.cumulativeTotal,
vehicleCount: result.vehicleCount + target.vehicleCount,
completion: result.completion
+ (getTargetAssessment(target, assessmentYearMap[target.id])?.completionRate ?? target.avgCompletion),
}), { today: 0, cumulative: 0, vehicleCount: 0, completion: 0 });
return {
today: totals.today,
cumulative: totals.cumulative,
vehicleCount: totals.vehicleCount,
averageCompletion: targets.length > 0 ? totals.completion / targets.length : 0,
};
}
export function filterAndSortTargetVehicles(
vehicles: TargetVehicle[],
search: string,
sort: VehicleSort,
): TargetVehicle[] {
return vehicles
.filter(vehicle => vehicle.plateNumber.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => {
const valueA = a.todayMileage || 0;
const valueB = b.todayMileage || 0;
return sort === 'desc' ? valueB - valueA : valueA - valueB;
});
}
export function totalTodayMileage(vehicles: TargetVehicle[]): number {
return vehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
}