refactor: modularize application domains
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user