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,45 @@
import { RefreshCw } from 'lucide-react';
import { motion } from 'motion/react';
import { fmtDate } from './utils';
export default function DailyReportLoadingOverlay({ reportDate }: { reportDate: string }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16, ease: 'easeOut' }}
role="status"
aria-live="polite"
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/20 p-5 backdrop-blur-[2px]"
>
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
className="w-full max-w-[260px] overflow-hidden rounded-xl border border-slate-100 bg-white p-5 text-center shadow-2xl"
>
<div className="relative mx-auto flex h-12 w-12 items-center justify-center">
<motion.span
className="absolute inset-0 rounded-full border-2 border-blue-100"
animate={{ scale: [0.86, 1.15], opacity: [0.9, 0] }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeOut' }}
/>
<span className="relative flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 text-blue-600">
<RefreshCw size={19} className="animate-spin" />
</span>
</div>
<div className="mt-3 text-sm font-black text-slate-900">正在加载日报</div>
<div className="mt-1 text-[11px] font-bold text-slate-400">正在汇总 {fmtDate(reportDate)} 的里程数据</div>
<div className="mt-4 h-1 overflow-hidden rounded-full bg-slate-100">
<motion.div
className="h-full w-1/2 rounded-full bg-blue-500"
animate={{ x: ['-100%', '220%'] }}
transition={{ duration: 1.15, repeat: Infinity, ease: 'easeInOut' }}
/>
</div>
</motion.div>
</motion.div>
);
}
@@ -0,0 +1,192 @@
import type { ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
ChevronDown,
CircleGauge,
MapPin,
Truck,
} from 'lucide-react';
import type { MileageReportGroup } from '../api';
import VehicleTable from './VehicleTable';
import {
currentAssessmentYearLabel,
dailyMileageStatus,
fmtKm,
} from './utils';
interface GroupRowProps {
group: MileageReportGroup;
reportDate: string;
expanded: boolean;
onToggle: () => void;
}
function BreakdownList({
title,
icon,
items,
showAverage = true,
}: {
title: string;
icon: ReactNode;
items: MileageReportGroup['departments'];
showAverage?: boolean;
}) {
const max = Math.max(1, ...items.map(item => item.vehicleCount));
return (
<div className="min-w-0">
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
<span className="text-slate-400">{icon}</span>
{title}
</div>
{items.length === 0 ? (
<div className="rounded-lg bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无数据</div>
) : (
<div className="space-y-2.5">
{items.map(item => (
<div key={item.name}>
<div className="mb-1 flex items-center justify-between gap-3 text-[11px] font-bold">
<span className="truncate text-slate-600">{item.name}</span>
<span className="shrink-0 tabular-nums text-slate-500">
{item.vehicleCount} 台{showAverage ? ` · 均 ${fmtKm(item.averageMileage)} km` : ''}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-blue-500"
style={{ width: `${Math.max(4, (item.vehicleCount / max) * 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
);
}
export default function GroupRow({
group,
reportDate,
expanded,
onToggle,
}: GroupRowProps) {
const highRate = group.operatingCount > 0 ? (group.highMileageCount / group.operatingCount) * 100 : 0;
const qualifiedRate = group.vehicleCount > 0 ? (group.qualifiedCount / group.vehicleCount) * 100 : 0;
const assessmentYearLabel = currentAssessmentYearLabel(group.assessmentPeriods, reportDate);
const dailyStatus = dailyMileageStatus(group);
const dailyStatusClass = dailyStatus.tone === 'positive'
? 'border-emerald-100 bg-emerald-50/70 text-emerald-700'
: dailyStatus.tone === 'warning'
? 'border-amber-100 bg-amber-50/70 text-amber-700'
: 'border-slate-100 bg-slate-50 text-slate-600';
return (
<div className="border-b border-slate-100 last:border-0">
<button
type="button"
onClick={onToggle}
className="grid w-full grid-cols-[minmax(170px,1.3fr)_82px_100px_88px_100px_96px_24px] items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-50 max-lg:grid-cols-[minmax(150px,1fr)_82px_96px_24px]"
aria-expanded={expanded}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-xs font-black text-slate-900">{group.displayName}</span>
{assessmentYearLabel ? (
<span
className="shrink-0 rounded-md bg-blue-50 px-1.5 py-0.5 text-[9px] font-black text-blue-700 ring-1 ring-blue-100"
title={`当前考核年度:${assessmentYearLabel}`}
>
{assessmentYearLabel}
</span>
) : null}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-1 text-[10px] font-bold text-slate-400">
<span>{group.vehicleCount} 台 · 运营 {group.operatingCount} · 库存 {group.inventoryCount}</span>
<span className="text-emerald-600">· 达标 {group.qualifiedCount} 台</span>
</span>
</span>
<span className="text-right">
<span className="block text-xs font-black tabular-nums text-blue-600">{group.dailyMileage}</span>
<span className="text-[9px] font-bold text-slate-400">km</span>
</span>
<span className="text-right max-lg:hidden">
<span className={`block text-xs font-black tabular-nums ${group.dayOverDayDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{group.dayOverDayDelta >= 0 ? '+' : ''}{fmtKm(group.dayOverDayDelta)}
</span>
<span className="text-[9px] font-bold text-slate-400">环比变化</span>
</span>
<span className="text-right max-lg:hidden">
<span className="block text-xs font-black tabular-nums text-slate-700">{group.lowMileageCount}/{group.highMileageCount}</span>
<span className="text-[9px] font-bold text-slate-400">低/高里程</span>
</span>
<span className="text-right">
<span className="block text-xs font-black tabular-nums text-slate-700">{highRate.toFixed(1)}%</span>
<span className="text-[9px] font-bold text-slate-400">高里程占比</span>
</span>
<span className="text-right max-lg:hidden">
<span className="block text-xs font-black tabular-nums text-slate-700">{group.qualifiedCount}</span>
<span className="text-[9px] font-bold text-slate-400">年度达标</span>
</span>
<ChevronDown size={16} className={`text-slate-400 transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence initial={false}>
{expanded ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="bg-slate-50/70 px-4 py-4">
<div className="grid gap-4 xl:grid-cols-[1fr_1fr_1.1fr]">
<BreakdownList title="业务部门" icon={<Truck size={15} />} items={group.departments} />
<BreakdownList title="库存地区" icon={<MapPin size={15} />} items={group.inventoryRegions} showAverage={false} />
<div>
<div className="mb-3 flex items-center gap-2 text-xs font-black text-slate-700">
<CircleGauge size={15} className="text-slate-400" />考核进度
</div>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg border border-slate-100 bg-white p-3">
<div className="text-[10px] font-bold text-slate-400">累计完成率</div>
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.completionRate.toFixed(1)}%</div>
</div>
<div className="rounded-lg border border-slate-100 bg-white p-3">
<div className="text-[10px] font-bold text-slate-400">达标50%</div>
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{group.halfQualifiedCount} 台</div>
</div>
<div className="rounded-lg border border-slate-100 bg-white p-3">
<div className="text-[10px] font-bold text-slate-400">剩余任务</div>
<div className="mt-1 text-base font-black tabular-nums text-slate-900">{fmtKm(group.remainingMileage)} km</div>
</div>
<div className="rounded-lg border border-slate-100 bg-white p-3">
<div className="text-[10px] font-bold text-slate-400">每日应完成</div>
<div className="mt-1 text-base font-black tabular-nums text-blue-600">{fmtKm(group.dailyRequiredMileage)} km</div>
</div>
<div className={`col-span-2 flex flex-wrap items-end justify-between gap-x-4 gap-y-2 rounded-lg border p-3 ${dailyStatusClass}`}>
<div>
<div className="text-[10px] font-black">当日目标执行 · {dailyStatus.label}</div>
<div className="mt-1 text-[10px] font-bold opacity-80">当日实际 {fmtKm(group.dailyMileage)} km / 应完成 {fmtKm(group.dailyRequiredMileage)} km</div>
</div>
<div className="max-w-[260px] text-[10px] font-bold leading-relaxed opacity-80">{dailyStatus.detail}</div>
</div>
<div className="col-span-2 flex items-end justify-between gap-3 rounded-lg border border-blue-100 bg-blue-50/50 p-3">
<div>
<div className="text-[10px] font-bold text-slate-500">累计达标率</div>
<div className="mt-1 text-[10px] font-bold text-slate-500">已达标 {group.qualifiedCount} / {group.vehicleCount} 台</div>
</div>
<div className="text-lg font-black tabular-nums text-blue-700">{qualifiedRate.toFixed(1)}%</div>
</div>
</div>
<div className="mt-2 text-[10px] font-bold leading-relaxed text-slate-400">{group.assessmentPeriods.join(';')}</div>
</div>
</div>
<VehicleTable group={group} />
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,16 @@
import { Info } from 'lucide-react';
export default function MileageBandDefinition() {
return (
<section className="flex flex-col gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2.5 sm:flex-row sm:items-center sm:gap-5">
<div className="flex shrink-0 items-center gap-2 text-[11px] font-black text-slate-700">
<Info size={14} className="text-blue-600" />里程分档口径
</div>
<div className="flex flex-wrap gap-x-5 gap-y-1 text-[10px] font-bold text-slate-500">
<span><strong className="text-slate-800">低里程</strong>:当日里程 ≤100km</span>
<span><strong className="text-blue-600">高里程</strong>:当日里程 &gt;100km</span>
<span>仅统计运营车辆,库存车辆单独统计</span>
</div>
</section>
);
}
@@ -0,0 +1,122 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { SurfaceCard } from '../../../components/ui/surface';
import type { DailyMileageReport } from '../api';
import { fmtDate, fmtKm } from './utils';
interface ReportChartsProps {
report: DailyMileageReport;
trendTargetId: number | null;
onTrendTargetChange: (targetId: number | null) => void;
}
export default function ReportCharts({
report,
trendTargetId,
onTrendTargetChange,
}: ReportChartsProps) {
const comparisonData = report.groups.map(group => ({
name: group.displayName,
运营里程: group.operatingMileage,
库存里程: group.inventoryMileage,
}));
const trendGroup = trendTargetId == null
? null
: report.groups.find(group => group.targetId === trendTargetId) || null;
const trendData = trendGroup?.trend || report.trend;
const trendName = trendGroup?.displayName || '总计';
const trendSubtitle = '选择总计或车型查看近 7 日每日里程,包含库存车辆里程';
const trendColor = trendGroup ? '#0f766e' : '#2563eb';
return (
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(360px,0.65fr)]">
<SurfaceCard
title="近 7 日里程趋势"
subtitle={trendSubtitle}
actions={(
<div className="flex max-w-full gap-1 overflow-x-auto rounded-lg bg-slate-100 p-1">
<button
type="button"
onClick={() => onTrendTargetChange(null)}
className={`h-7 shrink-0 rounded-md px-2.5 text-[10px] font-black transition-colors ${trendGroup == null ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
总计
</button>
{report.groups.map(group => (
<button
key={group.targetId}
type="button"
onClick={() => onTrendTargetChange(group.targetId)}
className={`h-7 shrink-0 rounded-md px-2.5 text-[10px] font-black transition-colors ${trendTargetId === group.targetId ? 'bg-white text-emerald-700 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
{group.displayName}
</button>
))}
</div>
)}
>
<div className="h-[270px] px-2 py-3">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={{ width: 640, height: 246 }}
>
<LineChart data={trendData} margin={{ top: 14, right: 18, left: -4, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtDate} />
<YAxis axisLine={false} tickLine={false} width={58} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
<Tooltip
labelFormatter={label => fmtDate(String(label))}
formatter={value => [`${fmtKm(Number(value))} km`, trendName]}
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
/>
<Line type="monotone" dataKey="totalMileage" stroke={trendColor} strokeWidth={2.5} dot={{ r: 3, fill: trendColor }} activeDot={{ r: 5 }} />
</LineChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
<SurfaceCard
title={(
<span className="flex flex-wrap items-center gap-2">
<span>车型里程构成</span>
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-black text-blue-600 ring-1 ring-blue-100">统计日期 {fmtDate(report.reportDate)}</span>
</span>
)}
subtitle="运营与库存里程分开展示"
>
<div className="h-[270px] px-2 py-3">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={{ width: 480, height: 246 }}
>
<BarChart data={comparisonData} layout="vertical" margin={{ top: 6, right: 14, left: 8, bottom: 0 }}>
<CartesianGrid horizontal={false} stroke="#e2e8f0" strokeDasharray="3 3" />
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
<YAxis type="category" dataKey="name" width={92} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b', fontWeight: 700 }} />
<Tooltip formatter={(value, name) => [`${fmtKm(Number(value))} km`, String(name)]} contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 10, fontWeight: 700 }} />
<Bar dataKey="运营里程" stackId="mileage" fill="#10b981" radius={[3, 0, 0, 3]} />
<Bar dataKey="库存里程" stackId="mileage" fill="#f59e0b" radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
</div>
);
}
@@ -0,0 +1,113 @@
import { useState } from 'react';
import {
CalendarDays,
ChevronDown,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { monthShift, reportDateLabel } from './utils';
interface ReportDatePickerProps {
value: string;
maxDate: string;
onChange: (date: string) => void;
}
export default function ReportDatePicker({
value,
maxDate,
onChange,
}: ReportDatePickerProps) {
const [open, setOpen] = useState(false);
const [month, setMonth] = useState(value.slice(0, 7));
const monthStart = new Date(`${month}-01T00:00:00Z`);
const firstGridDate = new Date(monthStart);
firstGridDate.setUTCDate(1 - monthStart.getUTCDay());
const days = Array.from({ length: 42 }, (_, index) => {
const date = new Date(firstGridDate);
date.setUTCDate(firstGridDate.getUTCDate() + index);
return date;
});
const weekdayLabels = ['日', '一', '二', '三', '四', '五', '六'];
const monthLabel = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
}).format(monthStart);
return (
<div className="relative min-w-0 flex-1 sm:flex-none">
<button
type="button"
onClick={() => {
setMonth(value.slice(0, 7));
setOpen(current => !current);
}}
className="flex h-9 w-full min-w-[190px] items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-left text-xs font-black text-slate-700 transition-colors hover:border-blue-200 hover:bg-blue-50/50 sm:w-[208px]"
aria-expanded={open}
aria-haspopup="dialog"
aria-label="选择日报日期"
>
<CalendarDays size={15} className="shrink-0 text-blue-500" />
<span className="min-w-0 flex-1 truncate">{reportDateLabel(value)}</span>
<ChevronDown size={14} className={`shrink-0 text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open ? (
<div role="dialog" aria-label="日报日期日历" className="absolute right-0 top-full z-40 mt-2 w-[308px] rounded-xl border border-slate-200 bg-white p-3 shadow-xl">
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={() => setMonth(current => monthShift(current, -1))}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100"
title="上个月"
aria-label="上个月"
>
<ChevronLeft size={16} />
</button>
<div className="text-sm font-black text-slate-900">{monthLabel}</div>
<button
type="button"
onClick={() => setMonth(current => monthShift(current, 1))}
disabled={month >= maxDate.slice(0, 7)}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-30"
title="下个月"
aria-label="下个月"
>
<ChevronRight size={16} />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center">
{weekdayLabels.map(day => <span key={day} className="py-1 text-[10px] font-black text-slate-400">{day}</span>)}
{days.map(date => {
const dateValue = date.toISOString().slice(0, 10);
const inMonth = dateValue.slice(0, 7) === month;
const selected = dateValue === value;
const disabled = dateValue > maxDate;
return (
<button
key={dateValue}
type="button"
disabled={disabled}
onClick={() => {
onChange(dateValue);
setOpen(false);
}}
className={`flex h-9 items-center justify-center rounded-md text-xs font-black transition-colors ${
selected
? 'bg-blue-600 text-white shadow-sm'
: inMonth
? 'text-slate-700 hover:bg-blue-50 hover:text-blue-600'
: 'text-slate-300 hover:bg-slate-50'
} disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent`}
>
{date.getUTCDate()}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,69 @@
import { Archive, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import type { DailyMileageReport } from '../api';
import { dateShift, fmtDate, fmtDateTime } from './utils';
import ReportDatePicker from './ReportDatePicker';
interface ReportHeaderProps {
report: DailyMileageReport;
selectedDate: string;
maxDate: string;
onDateChange: (date: string) => void;
}
export default function ReportHeader({
report,
selectedDate,
maxDate,
onDateChange,
}: ReportHeaderProps) {
return (
<section className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-base font-black text-slate-950">{report.title}</h2>
<span className={`inline-flex h-6 items-center gap-1 rounded-md px-2 text-[10px] font-black ${
report.status === 'ARCHIVED' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
}`}>
{report.status === 'ARCHIVED' ? <Archive size={12} /> : <RefreshCw size={12} />}
{report.status === 'ARCHIVED' ? '已归档' : '预览'}
</span>
</div>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{fmtDate(report.reportDate)} · {report.source === 'XLSX_IMPORT'
? '人工台账归档'
: report.status === 'ARCHIVED'
? `归档于 ${fmtDateTime(report.generatedAt)}`
: `仪表数据更新至 ${fmtDateTime(report.sourceUpdatedAt)}`}
</p>
<p className="mt-1 text-[10px] font-bold text-slate-500">
统计周期:自然日 · 汇总频率:每日 06:30 汇总前一日里程
</p>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onDateChange(dateShift(selectedDate, -1))}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"
title="前一天"
aria-label="前一天"
>
<ChevronLeft size={16} />
</button>
<ReportDatePicker value={selectedDate} maxDate={maxDate} onChange={onDateChange} />
<button
type="button"
onClick={() => onDateChange(dateShift(selectedDate, 1))}
disabled={selectedDate >= maxDate}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-30"
title="后一天"
aria-label="后一天"
>
<ChevronRight size={16} />
</button>
</div>
</div>
</section>
);
}
@@ -0,0 +1,38 @@
import { AlertTriangle, CircleGauge, TrendingDown, TrendingUp } from 'lucide-react';
import type { DailyMileageReport } from '../api';
function InsightIcon({ tone }: { tone: string }) {
if (tone === 'positive') return <TrendingUp size={16} />;
if (tone === 'critical') return <AlertTriangle size={16} />;
if (tone === 'warning') return <TrendingDown size={16} />;
return <CircleGauge size={16} />;
}
export default function ReportInsights({
insights,
}: {
insights: DailyMileageReport['insights'];
}) {
return (
<section className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 xl:grid-cols-4">
{insights.map(insight => {
const toneClass = insight.tone === 'positive'
? 'text-emerald-600 bg-emerald-50'
: insight.tone === 'critical'
? 'text-rose-600 bg-rose-50'
: insight.tone === 'warning'
? 'text-amber-600 bg-amber-50'
: 'text-blue-600 bg-blue-50';
return (
<div key={`${insight.title}-${insight.detail}`} className="flex min-h-[96px] gap-2.5 bg-white p-3 xl:min-h-[88px]">
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${toneClass}`}><InsightIcon tone={insight.tone} /></span>
<div className="min-w-0">
<div className="text-xs font-black text-slate-800">{insight.title}</div>
<div className="mt-1 text-[10px] font-bold leading-relaxed text-slate-500">{insight.detail}</div>
</div>
</div>
);
})}
</section>
);
}
@@ -0,0 +1,141 @@
import { CircleGauge, Route, TrendingUp, Truck, Warehouse } from 'lucide-react';
import { MetricTile } from '../../../components/ui/surface';
import type { DailyMileageReport } from '../api';
import { deltaLabel, fmtKm } from './utils';
interface ReportOverviewProps {
report: DailyMileageReport;
highMileageRate: number;
}
function MobileDailyOverview({ report, highMileageRate }: ReportOverviewProps) {
const operatingRate = (report.totals.operatingCount / Math.max(1, report.totals.vehicleCount)) * 100;
const stats = [
{
label: '运营车辆',
value: report.totals.operatingCount,
unit: '台',
detail: `${report.totals.vehicleCount} 台考核车辆 · ${operatingRate.toFixed(1)}%`,
icon: Truck,
tone: 'text-emerald-600 bg-emerald-50',
},
{
label: '库存车辆',
value: report.totals.inventoryCount,
unit: '台',
detail: `产生 ${fmtKm(report.totals.inventoryMileage)} km`,
icon: Warehouse,
tone: 'text-rose-600 bg-rose-50',
},
{
label: '运营单车均值',
value: fmtKm(report.totals.averageOperatingMileage),
unit: 'km/台',
detail: `运营里程 ${fmtKm(report.totals.operatingMileage)} km`,
icon: CircleGauge,
tone: 'text-slate-600 bg-slate-100',
},
{
label: '高里程车辆',
value: report.totals.highMileageCount,
unit: '台',
detail: `${highMileageRate.toFixed(1)}% · 低里程 ${report.totals.lowMileageCount} 台`,
icon: TrendingUp,
tone: 'text-blue-600 bg-blue-50',
},
];
return (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm md:hidden">
<div className="flex items-start justify-between gap-3 px-4 py-3.5">
<div>
<div className="text-[11px] font-black text-slate-400">当日总里程</div>
<div className="mt-1 flex items-end gap-1">
<span className="whitespace-nowrap text-[clamp(2rem,10vw,2.45rem)] font-black leading-none tabular-nums text-slate-950">{fmtKm(report.totals.dailyMileage)}</span>
<span className="mb-0.5 text-xs font-black text-slate-400">km</span>
</div>
</div>
<div className={`mt-1 rounded-lg px-2.5 py-1.5 text-right ${report.totals.dayOverDayDelta >= 0 ? 'bg-emerald-50' : 'bg-rose-50'}`}>
<div className="text-[9px] font-black text-slate-400">较前一日</div>
<div className={`mt-0.5 text-[11px] font-black tabular-nums ${report.totals.dayOverDayDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{deltaLabel(report.totals.dayOverDayDelta, report.totals.dayOverDayRate)}
</div>
</div>
</div>
<div className="grid grid-cols-2 border-t border-slate-100">
{stats.map((stat, index) => {
const Icon = stat.icon;
return (
<div key={stat.label} className={`min-w-0 px-3 py-2.5 ${index < 2 ? 'border-b border-slate-100' : ''} ${index % 2 === 0 ? 'border-r border-slate-100' : ''}`}>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[10px] font-black text-slate-400">{stat.label}</span>
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md ${stat.tone}`}><Icon size={13} /></span>
</div>
<div className="mt-1.5 flex items-end gap-1">
<span className="min-w-0 truncate text-xl font-black leading-none tabular-nums text-slate-900">{stat.value}</span>
<span className="mb-px shrink-0 text-[10px] font-black text-slate-400">{stat.unit}</span>
</div>
<div className="mt-1 truncate text-[9px] font-bold text-slate-500">{stat.detail}</div>
</div>
);
})}
</div>
</section>
);
}
export default function ReportOverview({ report, highMileageRate }: ReportOverviewProps) {
return (
<>
<MobileDailyOverview report={report} highMileageRate={highMileageRate} />
<div className="hidden grid-cols-2 gap-2 md:grid xl:grid-cols-5">
<MetricTile
label="当日总里程"
value={fmtKm(report.totals.dailyMileage)}
unit="km"
helper={deltaLabel(report.totals.dayOverDayDelta, report.totals.dayOverDayRate)}
icon={Route}
tone="blue"
className="col-span-2 xl:col-span-1"
/>
<MetricTile
label="运营车辆"
value={report.totals.operatingCount}
unit="台"
helper={`${report.totals.vehicleCount} 台考核车辆 · 运营率 ${((report.totals.operatingCount / Math.max(1, report.totals.vehicleCount)) * 100).toFixed(1)}%`}
icon={Truck}
tone="emerald"
compact
/>
<MetricTile
label="库存车辆"
value={report.totals.inventoryCount}
unit="台"
helper={`库存产生 ${fmtKm(report.totals.inventoryMileage)} km`}
icon={Warehouse}
tone={report.totals.inventoryMileage > 0 ? 'rose' : 'amber'}
compact
/>
<MetricTile
label="运营单车均值"
value={fmtKm(report.totals.averageOperatingMileage)}
unit="km/台"
helper={`运营里程 ${fmtKm(report.totals.operatingMileage)} km`}
icon={CircleGauge}
tone="slate"
compact
/>
<MetricTile
label="高里程车辆"
value={report.totals.highMileageCount}
unit="台"
helper={`占运营车辆 ${highMileageRate.toFixed(1)}% · ≤100km ${report.totals.lowMileageCount} 台`}
icon={TrendingUp}
tone="blue"
compact
/>
</div>
</>
);
}
@@ -0,0 +1,302 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
ArrowDown,
ArrowDownUp,
ArrowUp,
BarChart3,
ChevronLeft,
ChevronRight,
Search,
X,
} from 'lucide-react';
import {
Bar,
BarChart,
Cell,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import Blur from '../../../components/Blur';
import type { MileageReportGroup } from '../api';
import {
filterAndSortVehicles,
fmtDate,
fmtKm,
roundMileageForDisplay,
sevenDayMileageOf,
type SortDirection,
type VehicleFilter,
type VehicleSortKey,
} from './utils';
const VEHICLE_PAGE_SIZE = 20;
const VEHICLE_FILTERS: { id: VehicleFilter; label: string }[] = [
{ id: 'ALL', label: '全部' },
{ id: 'LOW', label: '≤100km' },
{ id: 'HIGH', label: '>100km' },
{ id: 'INVENTORY', label: '库存' },
];
function SortableVehicleHeader({
label,
sortKey,
activeSortKey,
sortDirection,
onSort,
}: {
label: string;
sortKey: VehicleSortKey;
activeSortKey: VehicleSortKey;
sortDirection: SortDirection;
onSort: (sortKey: VehicleSortKey) => void;
}) {
const active = activeSortKey === sortKey;
const Icon = !active ? ArrowDownUp : sortDirection === 'asc' ? ArrowUp : ArrowDown;
const order = active ? (sortDirection === 'asc' ? '升序' : '降序') : '未排序';
return (
<button
type="button"
onClick={() => onSort(sortKey)}
className={`ml-auto flex items-center gap-1 rounded-md px-1 py-0.5 transition-colors ${active ? 'text-blue-600' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'}`}
title={`${label}:${order},点击切换`}
aria-label={`${label}:${order},点击切换`}
>
<span>{label}</span>
<Icon size={12} strokeWidth={active ? 2.5 : 2} />
</button>
);
}
export default function VehicleTable({ group }: { group: MileageReportGroup }) {
const [filter, setFilter] = useState<VehicleFilter>('ALL');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<VehicleSortKey>('dailyMileage');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [trendPlate, setTrendPlate] = useState<string | null>(null);
const trendPanelRef = useRef<HTMLDivElement>(null);
const vehicles = useMemo(
() => filterAndSortVehicles(group.vehicles, filter, search, sortKey, sortDirection),
[filter, group.vehicles, search, sortDirection, sortKey],
);
const totalPages = Math.max(1, Math.ceil(vehicles.length / VEHICLE_PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const displayedVehicles = vehicles.slice(
(currentPage - 1) * VEHICLE_PAGE_SIZE,
currentPage * VEHICLE_PAGE_SIZE,
);
const firstVehicleIndex = vehicles.length === 0 ? 0 : (currentPage - 1) * VEHICLE_PAGE_SIZE + 1;
const lastVehicleIndex = Math.min(currentPage * VEHICLE_PAGE_SIZE, vehicles.length);
const toggleSort = (nextSortKey: VehicleSortKey) => {
setPage(1);
if (nextSortKey === sortKey) {
setSortDirection(current => current === 'asc' ? 'desc' : 'asc');
return;
}
setSortKey(nextSortKey);
setSortDirection('desc');
};
const trendVehicle = trendPlate ? group.vehicles.find(vehicle => vehicle.plate === trendPlate) || null : null;
// 旧日报快照没有逐车趋势时,只在报告日回填当日里程,保持原有兼容展示。
const trendData = trendVehicle?.trend?.length
? trendVehicle.trend
: group.trend.map((point, index) => ({
date: point.date,
totalMileage: index === group.trend.length - 1 ? trendVehicle?.dailyMileage || 0 : 0,
}));
const sevenDayTotal = roundMileageForDisplay(trendData.reduce((total, point) => total + point.totalMileage, 0));
useEffect(() => {
if (!trendPlate) return;
const frame = window.requestAnimationFrame(() => {
trendPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
return () => window.cancelAnimationFrame(frame);
}, [trendPlate]);
return (
<div className="mt-4 border-t border-slate-100 pt-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 gap-1 overflow-x-auto rounded-lg bg-slate-100 p-1">
{VEHICLE_FILTERS.map(item => (
<button
key={item.id}
type="button"
onClick={() => {
setFilter(item.id);
setPage(1);
}}
className={`h-8 shrink-0 rounded-md px-3 text-[11px] font-black transition-colors ${
filter === item.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{item.label}
</button>
))}
</div>
<label className="flex h-9 min-w-0 items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 sm:w-64">
<Search size={14} className="shrink-0 text-slate-400" />
<input
value={search}
onChange={event => {
setSearch(event.target.value);
setPage(1);
}}
placeholder="车牌、客户或部门"
className="min-w-0 flex-1 border-0 bg-transparent text-xs font-bold text-slate-700 outline-none placeholder:text-slate-300"
/>
</label>
</div>
<AnimatePresence initial={false}>
{trendVehicle ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div ref={trendPanelRef} className="mt-3 scroll-mt-20 rounded-lg border border-blue-100 bg-white p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-black text-slate-900">
<BarChart3 size={15} className="text-blue-600" />
<Blur>{trendVehicle.plate}</Blur>
<span className="font-bold text-slate-400">近7日里程</span>
</div>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[10px] font-bold text-slate-500">
<span>区间累计 <strong className="text-slate-900">{fmtKm(sevenDayTotal)} km</strong></span>
<span>日均 <strong className="text-slate-900">{fmtKm(roundMileageForDisplay(sevenDayTotal / Math.max(1, trendData.length)))} km</strong></span>
<span>当日 <strong className="text-blue-600">{fmtKm(trendVehicle.dailyMileage)} km</strong></span>
</div>
</div>
<button
type="button"
onClick={() => setTrendPlate(null)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-600"
title="关闭趋势图"
aria-label="关闭趋势图"
>
<X size={15} />
</button>
</div>
<div className="mt-2 h-[190px] min-w-0">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={{ width: 760, height: 190 }}
>
<BarChart data={trendData} margin={{ top: 10, right: 12, left: -18, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8' }} tickFormatter={fmtDate} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8' }} />
<Tooltip
labelFormatter={label => fmtDate(String(label))}
formatter={value => [`${fmtKm(Number(value))} km`, '当日里程']}
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
/>
<ReferenceLine y={100} stroke="#f59e0b" strokeDasharray="4 4" label={{ value: '100km', fill: '#d97706', fontSize: 9, position: 'insideTopRight' }} />
<Bar dataKey="totalMileage" radius={[4, 4, 0, 0]} maxBarSize={42}>
{trendData.map(point => (
<Cell key={point.date} fill={point.totalMileage > 100 ? '#2563eb' : '#94a3b8'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
<div className="mt-3 max-h-[420px] overflow-auto rounded-lg border border-slate-100">
<div className="sticky top-0 z-10 grid min-w-[720px] grid-cols-[112px_88px_minmax(140px,1fr)_82px_90px_80px_28px] gap-3 bg-slate-50 px-3 py-2 text-[10px] font-black text-slate-400 md:min-w-[930px] md:grid-cols-[110px_72px_100px_minmax(160px,1fr)_90px_100px_88px_32px]">
<span className="sticky left-3 z-20 -my-2 flex items-center !bg-slate-50 py-2 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)]"><span className="md:hidden">车牌/状态</span><span className="hidden md:inline">车牌</span></span>
<span className="sticky left-[110px] z-20 -my-2 hidden items-center bg-slate-50 py-2 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex">状态</span>
<span>部门/地区</span><span>客户</span>
<SortableVehicleHeader label="当日里程" sortKey="dailyMileage" activeSortKey={sortKey} sortDirection={sortDirection} onSort={toggleSort} />
<SortableVehicleHeader label="近7日累计" sortKey="sevenDayMileage" activeSortKey={sortKey} sortDirection={sortDirection} onSort={toggleSort} />
<SortableVehicleHeader label="考核进度" sortKey="completionRate" activeSortKey={sortKey} sortDirection={sortDirection} onSort={toggleSort} />
<span />
</div>
<div className="min-w-[720px] divide-y divide-slate-100 bg-white md:min-w-[930px]">
{displayedVehicles.map(vehicle => {
const sevenDayMileage = sevenDayMileageOf(vehicle);
const selected = trendPlate === vehicle.plate;
const frozenCellClass = selected ? '!bg-blue-50' : '!bg-white';
return (
<div
key={vehicle.plate}
role="button"
tabIndex={0}
aria-pressed={selected}
aria-label={`选择${vehicle.plate}查看近7日每日里程`}
onClick={() => setTrendPlate(vehicle.plate)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setTrendPlate(vehicle.plate);
}
}}
className={`grid cursor-pointer grid-cols-[112px_88px_minmax(140px,1fr)_82px_90px_80px_28px] items-center gap-3 px-3 py-2.5 text-[11px] font-bold outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 md:grid-cols-[110px_72px_100px_minmax(160px,1fr)_90px_100px_88px_32px] ${selected ? 'bg-blue-50/60' : ''}`}
>
<span className={`sticky left-3 z-10 -my-2.5 flex self-stretch flex-col justify-center font-mono font-black text-slate-800 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex-row md:items-center md:shadow-none ${frozenCellClass}`}>
<Blur>{vehicle.plate}</Blur>
<span className={`mt-0.5 font-sans text-[9px] md:hidden ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
</span>
<span className={`sticky left-[110px] z-10 -my-2.5 hidden self-stretch items-center shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex ${frozenCellClass} ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
<span className="truncate text-slate-500">{vehicle.department || vehicle.inventoryRegion || '未标注'}</span>
<span className="truncate text-slate-500"><Blur>{vehicle.customer || '未绑定客户'}</Blur></span>
<span className={`text-right tabular-nums ${vehicle.mileageBand === 'HIGH' ? 'text-blue-600' : vehicle.mileageBand === 'INVENTORY' && vehicle.dailyMileage > 0 ? 'text-rose-600' : 'text-slate-700'}`}>
{fmtKm(vehicle.dailyMileage)} km
</span>
<span className="text-right tabular-nums font-black text-slate-700">{fmtKm(sevenDayMileage)} km</span>
<span className="text-right tabular-nums text-slate-600">{vehicle.completionRate.toFixed(1)}%</span>
<span className={`flex h-8 w-8 items-center justify-center rounded-md ${selected ? 'bg-blue-600 text-white' : 'text-slate-400'}`} aria-hidden="true">
<BarChart3 size={15} />
</span>
</div>
);
})}
{vehicles.length === 0 ? <div className="px-4 py-8 text-center text-xs font-bold text-slate-400">没有符合条件的车辆</div> : null}
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-400">
<span>显示 {firstVehicleIndex}-{lastVehicleIndex} / {vehicles.length} 台</span>
{totalPages > 1 ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setPage(current => Math.max(1, current - 1))}
disabled={currentPage === 1}
title="上一页"
aria-label="上一页"
className="flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft size={14} />
</button>
<span className="min-w-8 text-center tabular-nums text-slate-500">{currentPage}/{totalPages}</span>
<button
type="button"
onClick={() => setPage(current => Math.min(totalPages, current + 1))}
disabled={currentPage === totalPages}
title="下一页"
aria-label="下一页"
className="flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronRight size={14} />
</button>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MileageReportVehicle } from '../api';
import {
currentAssessmentYearLabel,
dailyMileageStatus,
dateShift,
deltaLabel,
filterAndSortVehicles,
fmtDate,
fmtKm,
initialReportDate,
isVisibleQualityNote,
monthShift,
roundMileageForDisplay,
sevenDayMileageOf,
shanghaiYmd,
} from './utils';
function vehicle(overrides: Partial<MileageReportVehicle>): MileageReportVehicle {
return {
plate: '粤A00000',
status: '运营',
customer: null,
department: null,
inventoryRegion: null,
dailyMileage: 0,
cumulativeMileage: 0,
completionRate: 0,
mileageBand: 'LOW',
trend: [],
...overrides,
};
}
test('日报日期始终按上海自然日计算,并支持跨月偏移', () => {
const now = new Date('2026-08-12T16:30:00.000Z');
assert.equal(shanghaiYmd(0, now), '2026-08-13');
assert.equal(shanghaiYmd(-1, now), '2026-08-12');
assert.equal(dateShift('2026-03-01', -1), '2026-02-28');
assert.equal(monthShift('2026-01', -1), '2025-12');
});
test('URL 中合法日报日期优先,非法值回退到前一日', () => {
assert.equal(initialReportDate('?mileageReportDate=2026-03-31', '2026-08-12'), '2026-03-31');
assert.equal(initialReportDate('?mileageReportDate=bad-date', '2026-08-12'), '2026-08-12');
assert.equal(initialReportDate('', '2026-08-12'), '2026-08-12');
});
test('里程和日期文案保持现有显示精度', () => {
assert.equal(fmtKm(88123.46), '8.81万');
assert.equal(fmtKm(5182.04), '5,182');
assert.equal(fmtDate('2026-08-11'), '8月11日');
assert.equal(deltaLabel(16197.7, 22.54), '+1.62万 km · +22.5%');
assert.equal(roundMileageForDisplay(12.345), 12.3);
});
test('分批车辆可同时展示多个当前考核年度', () => {
const periods = [
'2024/6/13-2029/6/12',
'2025/12/24-2027/12/23',
];
assert.equal(currentAssessmentYearLabel(periods, '2026-03-31'), '考核第1/2年');
assert.equal(currentAssessmentYearLabel(periods, '2030-01-01'), null);
});
test('每日目标状态区分已完成、缺截止日、达标和未达标', () => {
assert.deepEqual(
dailyMileageStatus({ dailyRequiredMileage: 0, remainingMileage: 0, dailyMileage: 0 }),
{ label: '本年度已完成', detail: '当前年度剩余任务为 0,无需设置每日应完成里程。', tone: 'positive' },
);
assert.deepEqual(
dailyMileageStatus({ dailyRequiredMileage: 0, remainingMileage: 100, dailyMileage: 0 }),
{ label: '暂无法计算', detail: '缺少有效的当前考核年度截止日,暂不设每日应完成里程。', tone: 'warning' },
);
assert.deepEqual(
dailyMileageStatus({ dailyRequiredMileage: 100, remainingMileage: 1000, dailyMileage: 120 }),
{ label: '今日已达标', detail: '超出应完成 20 km,完成 120.0%。', tone: 'positive' },
);
assert.deepEqual(
dailyMileageStatus({ dailyRequiredMileage: 100, remainingMileage: 1000, dailyMileage: 80 }),
{ label: '今日未达标', detail: '距应完成差 20 km,完成 80.0%。', tone: 'warning' },
);
});
test('车辆列表保持筛选、搜索、排序和车牌兜底顺序', () => {
const vehicles = [
vehicle({ plate: '粤A00002', dailyMileage: 80, completionRate: 20, customer: '甲客户', trend: [{ date: '2026-08-11', totalMileage: 20 }] }),
vehicle({ plate: '粤A00001', dailyMileage: 180, completionRate: 10, department: '业务一部', mileageBand: 'HIGH', trend: [{ date: '2026-08-11', totalMileage: 30 }] }),
vehicle({ plate: '粤A00003', dailyMileage: 0, completionRate: 30, mileageBand: 'INVENTORY' }),
];
assert.deepEqual(
filterAndSortVehicles(vehicles, 'ALL', '', 'dailyMileage', 'desc').map(item => item.plate),
['粤A00001', '粤A00002', '粤A00003'],
);
assert.deepEqual(
filterAndSortVehicles(vehicles, 'HIGH', '业务一部', 'completionRate', 'asc').map(item => item.plate),
['粤A00001'],
);
assert.equal(sevenDayMileageOf(vehicles[1]), 30);
});
test('人工汇报口径旧提示继续隐藏,其他质量提示保留', () => {
assert.equal(isVisibleQualityNote('日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。'), false);
assert.equal(isVisibleQualityNote('部分车辆缺少仪表数据'), true);
});
+170
View File
@@ -0,0 +1,170 @@
import type {
MileageReportGroup,
MileageReportVehicle,
} from '../api';
export type VehicleFilter = 'ALL' | 'LOW' | 'HIGH' | 'INVENTORY';
export type VehicleSortKey = 'dailyMileage' | 'sevenDayMileage' | 'completionRate';
export type SortDirection = 'asc' | 'desc';
const REPORT_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const HIDDEN_QUALITY_NOTE = '日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。';
export function shanghaiYmd(offsetDays = 0, now = new Date()): string {
// 日报按上海自然日归档,不能依赖浏览器所在时区计算“昨天”。
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
const date = new Date(`${parts}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
export function dateShift(date: string, days: number): string {
const value = new Date(`${date}T00:00:00Z`);
value.setUTCDate(value.getUTCDate() + days);
return value.toISOString().slice(0, 10);
}
export function monthShift(month: string, months: number): string {
const value = new Date(`${month}-01T00:00:00Z`);
value.setUTCMonth(value.getUTCMonth() + months);
return value.toISOString().slice(0, 7);
}
export function reportDateLabel(value: string): string {
const date = new Date(`${value}T00:00:00Z`);
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short',
}).format(date).replace(/日(?=周)/, '日 ');
}
export function initialReportDate(search: string, fallbackDate = shanghaiYmd(-1)): string {
const value = new URLSearchParams(search).get('mileageReportDate');
return REPORT_DATE_PATTERN.test(value || '') ? value! : fallbackDate;
}
export function fmtKm(value: number): string {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(2)}万`;
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
}
export function fmtDate(value: string): string {
const [, month, day] = value.split('-');
return `${Number(month)}月${Number(day)}日`;
}
export function fmtDateTime(value: string | null): string {
if (!value) return '数据时间未返回';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
export function deltaLabel(value: number, rate: number | null): string {
const sign = value >= 0 ? '+' : '';
return `${sign}${fmtKm(value)} km${rate == null ? '' : ` · ${sign}${rate.toFixed(1)}%`}`;
}
export function currentAssessmentYearLabel(periods: string[], reportDate: string): string | null {
const report = new Date(`${reportDate}T00:00:00Z`);
if (Number.isNaN(report.getTime())) return null;
const years = new Set<number>();
// 同一车型可能分批交付,因此一个日报日期可能同时落在多个考核年度。
for (const period of periods) {
const dates = Array.from(period.matchAll(/(\d{4})[/-](\d{1,2})[/-](\d{1,2})/g));
const start = dates[0];
const end = dates[1];
if (!start || !end) continue;
const startDate = new Date(Date.UTC(Number(start[1]), Number(start[2]) - 1, Number(start[3])));
const endDate = new Date(Date.UTC(Number(end[1]), Number(end[2]) - 1, Number(end[3])));
if (report < startDate || report > endDate) continue;
let yearNumber = report.getUTCFullYear() - startDate.getUTCFullYear() + 1;
if (
report.getUTCMonth() < startDate.getUTCMonth()
|| (report.getUTCMonth() === startDate.getUTCMonth() && report.getUTCDate() < startDate.getUTCDate())
) yearNumber -= 1;
if (yearNumber > 0) years.add(yearNumber);
}
if (years.size === 0) return null;
const labels = Array.from(years).sort((a, b) => a - b).map(year => String(year));
return `考核第${labels.join('/')}年`;
}
export function dailyMileageStatus(group: Pick<
MileageReportGroup,
'dailyRequiredMileage' | 'remainingMileage' | 'dailyMileage'
>): {
label: string;
detail: string;
tone: 'positive' | 'warning' | 'neutral';
} {
if (group.dailyRequiredMileage <= 0) {
if (group.remainingMileage <= 0) {
return { label: '本年度已完成', detail: '当前年度剩余任务为 0,无需设置每日应完成里程。', tone: 'positive' };
}
return { label: '暂无法计算', detail: '缺少有效的当前考核年度截止日,暂不设每日应完成里程。', tone: 'warning' };
}
const gap = group.dailyMileage - group.dailyRequiredMileage;
const rate = (group.dailyMileage / group.dailyRequiredMileage) * 100;
if (gap >= 0) {
return { label: '今日已达标', detail: `超出应完成 ${fmtKm(gap)} km,完成 ${rate.toFixed(1)}%。`, tone: 'positive' };
}
return { label: '今日未达标', detail: `距应完成差 ${fmtKm(Math.abs(gap))} km,完成 ${rate.toFixed(1)}%。`, tone: 'warning' };
}
export function sevenDayMileageOf(vehicle: MileageReportVehicle): number {
return (vehicle.trend || []).reduce((total, point) => total + point.totalMileage, 0);
}
function vehicleSortValue(vehicle: MileageReportVehicle, sortKey: VehicleSortKey): number {
return sortKey === 'sevenDayMileage' ? sevenDayMileageOf(vehicle) : vehicle[sortKey];
}
export function filterAndSortVehicles(
vehicles: MileageReportVehicle[],
filter: VehicleFilter,
search: string,
sortKey: VehicleSortKey,
sortDirection: SortDirection,
): MileageReportVehicle[] {
const query = search.trim().toLowerCase();
return vehicles
.filter(vehicle => {
if (filter !== 'ALL' && vehicle.mileageBand !== filter) return false;
if (!query) return true;
return vehicle.plate.toLowerCase().includes(query)
|| (vehicle.customer || '').toLowerCase().includes(query)
|| (vehicle.department || '').toLowerCase().includes(query);
})
.sort((left, right) => {
const difference = vehicleSortValue(left, sortKey) - vehicleSortValue(right, sortKey);
if (difference !== 0) return sortDirection === 'asc' ? difference : -difference;
return left.plate.localeCompare(right.plate, 'zh-CN');
});
}
export function roundMileageForDisplay(value: number): number {
return Math.round(value * 10) / 10;
}
export function isVisibleQualityNote(message: string): boolean {
return message !== HIDDEN_QUALITY_NOTE;
}