import { useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
AlertTriangle,
ArrowDown,
ArrowDownUp,
ArrowUp,
Archive,
BarChart3,
CalendarDays,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleGauge,
Info,
MapPin,
RefreshCw,
Route,
Search,
TrendingDown,
TrendingUp,
Truck,
Warehouse,
X,
} from 'lucide-react';
import {
Bar,
BarChart,
Cell,
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import Blur from '../../components/Blur';
import {
ErrorState,
MetricTile,
SurfaceCard,
} from '../../components/ui/surface';
import {
fetchDailyMileageReport,
type DailyMileageReport,
type MileageReportGroup,
type MileageReportVehicle,
} from './api';
type VehicleFilter = 'ALL' | 'LOW' | 'HIGH' | 'INVENTORY';
type VehicleSortKey = 'dailyMileage' | 'sevenDayMileage' | 'completionRate';
type SortDirection = 'asc' | 'desc';
const VEHICLE_PAGE_SIZE = 20;
const MIN_REPORT_LOADING_MS = 280;
const VEHICLE_FILTERS: { id: VehicleFilter; label: string }[] = [
{ id: 'ALL', label: '全部' },
{ id: 'LOW', label: '≤100km' },
{ id: 'HIGH', label: '>100km' },
{ id: 'INVENTORY', label: '库存' },
];
function shanghaiYmd(offsetDays = 0): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const date = new Date(`${parts}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
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);
}
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);
}
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(/日(?=周)/, '日 ');
}
function initialReportDate(): string {
const params = new URLSearchParams(window.location.search);
const value = params.get('mileageReportDate');
return /^\d{4}-\d{2}-\d{2}$/.test(value || '') ? value! : shanghaiYmd(-1);
}
function ReportDatePicker({
value,
maxDate,
onChange,
}: {
value: string;
maxDate: string;
onChange: (date: string) => void;
}) {
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 (
{open ? (
{monthLabel}
{weekdayLabels.map(day => {day})}
{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 (
);
})}
) : null}
);
}
function DailyReportLoadingOverlay({ reportDate }: { reportDate: string }) {
return (
正在加载日报
正在汇总 {fmtDate(reportDate)} 的里程数据
);
}
function updateReportDateInUrl(date: string): void {
const url = new URL(window.location.href);
url.searchParams.set('mileageReportDate', date);
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
}
function fmtKm(value: number): string {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(2)}万`;
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
}
function fmtDate(value: string): string {
const [, month, day] = value.split('-');
return `${Number(month)}月${Number(day)}日`;
}
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,
});
}
function deltaLabel(value: number, rate: number | null): string {
const sign = value >= 0 ? '+' : '';
return `${sign}${fmtKm(value)} km${rate == null ? '' : ` · ${sign}${rate.toFixed(1)}%`}`;
}
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();
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('/')}年`;
}
function dailyMileageStatus(group: MileageReportGroup): {
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' };
}
function InsightIcon({ tone }: { tone: string }) {
if (tone === 'positive') return ;
if (tone === 'critical') return ;
if (tone === 'warning') return ;
return ;
}
function sevenDayMileageOf(vehicle: MileageReportVehicle): number {
return (vehicle.trend || []).reduce((total, point) => total + point.totalMileage, 0);
}
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 (
);
}
function MobileDailyOverview({
report,
highMileageRate,
}: {
report: DailyMileageReport;
highMileageRate: number;
}) {
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 (
当日总里程
{fmtKm(report.totals.dailyMileage)}
km
= 0 ? 'bg-emerald-50' : 'bg-rose-50'}`}>
较前一日
= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{deltaLabel(report.totals.dayOverDayDelta, report.totals.dayOverDayRate)}
{stats.map((stat, index) => {
const Icon = stat.icon;
return (
{stat.label}
{stat.value}
{stat.unit}
{stat.detail}
);
})}
);
}
function BreakdownList({
title,
icon,
items,
showAverage = true,
}: {
title: string;
icon: React.ReactNode;
items: MileageReportGroup['departments'];
showAverage?: boolean;
}) {
const max = Math.max(1, ...items.map(item => item.vehicleCount));
return (
{icon}
{title}
{items.length === 0 ? (
暂无数据
) : (
{items.map(item => (
{item.name}
{item.vehicleCount} 台{showAverage ? ` · 均 ${fmtKm(item.averageMileage)} km` : ''}
))}
)}
);
}
function VehicleTable({ group }: { group: MileageReportGroup }) {
const [filter, setFilter] = useState('ALL');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState('dailyMileage');
const [sortDirection, setSortDirection] = useState('desc');
const [trendPlate, setTrendPlate] = useState(null);
const trendPanelRef = useRef(null);
const filteredVehicles = useMemo(() => {
const query = search.trim().toLowerCase();
return group.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);
});
}, [filter, group.vehicles, search]);
const vehicles = useMemo(() => [...filteredVehicles].sort((left, right) => {
const leftValue = sortKey === 'sevenDayMileage' ? sevenDayMileageOf(left) : left[sortKey];
const rightValue = sortKey === 'sevenDayMileage' ? sevenDayMileageOf(right) : right[sortKey];
const difference = leftValue - rightValue;
if (difference !== 0) return sortDirection === 'asc' ? difference : -difference;
return left.plate.localeCompare(right.plate, 'zh-CN');
}), [filteredVehicles, 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 (
{trendVehicle ? (
{trendVehicle.plate}
近7日里程
区间累计 {fmtKm(sevenDayTotal)} km
日均 {fmtKm(roundMileageForDisplay(sevenDayTotal / Math.max(1, trendData.length)))} km
当日 {fmtKm(trendVehicle.dailyMileage)} km
fmtDate(String(label))}
formatter={value => [`${fmtKm(Number(value))} km`, '当日里程']}
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
/>
{trendData.map(point => (
| 100 ? '#2563eb' : '#94a3b8'} />
))}
|
) : null}
车牌/状态车牌
状态
部门/地区客户
{displayedVehicles.map(vehicle => {
const sevenDayMileage = sevenDayMileageOf(vehicle);
const selected = trendPlate === vehicle.plate;
const frozenCellClass = selected ? '!bg-blue-50' : '!bg-white';
return (
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' : ''}`}
>
{vehicle.plate}
{vehicle.status}
{vehicle.status}
{vehicle.department || vehicle.inventoryRegion || '未标注'}
{vehicle.customer || '未绑定客户'}
0 ? 'text-rose-600' : 'text-slate-700'}`}>
{fmtKm(vehicle.dailyMileage)} km
{fmtKm(sevenDayMileage)} km
{vehicle.completionRate.toFixed(1)}%
);})}
{vehicles.length === 0 ?
没有符合条件的车辆
: null}
显示 {firstVehicleIndex}-{lastVehicleIndex} / {vehicles.length} 台
{totalPages > 1 ? (
{currentPage}/{totalPages}
) : null}
);
}
function GroupRow({
group,
reportDate,
expanded,
onToggle,
}: {
group: MileageReportGroup;
reportDate: string;
expanded: boolean;
onToggle: () => void;
}) {
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 (
{expanded ? (
} items={group.departments} />
} items={group.inventoryRegions} showAverage={false} />
考核进度
累计完成率
{group.completionRate.toFixed(1)}%
过半车辆
{group.halfQualifiedCount} 台
剩余任务
{fmtKm(group.remainingMileage)} km
每日应完成
{fmtKm(group.dailyRequiredMileage)} km
当日目标执行 · {dailyStatus.label}
当日实际 {fmtKm(group.dailyMileage)} km / 应完成 {fmtKm(group.dailyRequiredMileage)} km
{dailyStatus.detail}
累计达标率
已达标 {group.qualifiedCount} / {group.vehicleCount} 台
{qualifiedRate.toFixed(1)}%
{group.assessmentPeriods.join(';')}
) : null}
);
}
function roundMileageForDisplay(value: number): number {
return Math.round(value * 10) / 10;
}
export default function DailyReportView() {
const maxDate = shanghaiYmd(-1);
const [selectedDate, setSelectedDate] = useState(initialReportDate);
const [report, setReport] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [expandedTargetId, setExpandedTargetId] = useState(null);
const [trendTargetId, setTrendTargetId] = useState(null);
useEffect(() => {
let active = true;
let finishTimer: number | undefined;
const loadingStartedAt = performance.now();
setLoading(true);
setError('');
updateReportDateInUrl(selectedDate);
fetchDailyMileageReport(selectedDate)
.then(data => {
if (!active) return;
setReport(data);
setExpandedTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null);
setTrendTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null);
})
.catch(cause => {
if (!active) return;
setError(cause instanceof Error ? cause.message : '日报加载失败');
})
.finally(() => {
if (!active) return;
const delay = Math.max(0, MIN_REPORT_LOADING_MS - (performance.now() - loadingStartedAt));
finishTimer = window.setTimeout(() => {
if (active) setLoading(false);
}, delay);
});
return () => {
active = false;
if (finishTimer != null) window.clearTimeout(finishTimer);
};
}, [selectedDate]);
const highMileageRate = report && report.totals.operatingCount > 0
? (report.totals.highMileageCount / report.totals.operatingCount) * 100
: 0;
const comparisonData = report?.groups.map(group => ({
name: group.displayName,
运营里程: group.operatingMileage,
库存里程: group.inventoryMileage,
})) || [];
const visibleQualityNotes = report?.qualityNotes.filter(
note => note.message !== '日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。',
) || [];
if (loading && !report) return ;
if (!report) return ;
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 (
{report.title}
{report.status === 'ARCHIVED' ? : }
{report.status === 'ARCHIVED' ? '已归档' : '预览'}
{fmtDate(report.reportDate)} · {report.source === 'XLSX_IMPORT'
? '人工台账归档'
: report.status === 'ARCHIVED'
? `归档于 ${fmtDateTime(report.generatedAt)}`
: `仪表数据更新至 ${fmtDateTime(report.sourceUpdatedAt)}`}
统计周期:自然日 · 汇总频率:每日 00:30 汇总前一日里程
{visibleQualityNotes.length > 0 ? (
{visibleQualityNotes.map(note => (
{note.level === 'warning' ?
:
}
{note.message}
))}
) : null}
0 ? 'rose' : 'amber'}
compact
/>
{report.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 (
{insight.title}
{insight.detail}
);
})}
{report.groups.map(group => (
))}
)}
>
fmtDate(String(label))}
formatter={value => [`${fmtKm(Number(value))} km`, trendName]}
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
/>
车型里程构成
统计日期 {fmtDate(report.reportDate)}
)}
subtitle="运营与库存里程分开展示"
>
[`${fmtKm(Number(value))} km`, String(name)]} contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }} />
里程分档口径
低里程:当日里程 ≤100km
高里程:当日里程 >100km
仅统计运营车辆,库存车辆单独统计
车型运营明细
统计日期 {fmtDate(report.reportDate)}
)}
subtitle="点击车型查看部门、库存地区、考核进度及车辆近7日趋势"
>
考核车型当日里程环比变化低/高里程高里程占比年度达标
{report.groups.map(group => (
setExpandedTargetId(current => current === group.targetId ? null : group.targetId)}
/>
))}
{loading ? : null}
{error ? : null}
);
}