Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx
2026-07-18 07:50:25 +08:00

538 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Empty, Input, SideSheet, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { type CSSProperties, FormEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
import { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport';
import { formatZhNumber } from '../domain/formatters';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const DAY = 86_400_000;
const DETAIL_LIMIT = 10_000;
const MAX_SELECTED_VEHICLES = 20;
const PAGE_SIZE = 20;
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
const EXPORT_VIN_BATCH_SIZE = 50;
const MAX_MILEAGE_RANGE_DAYS = 366;
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
type ExportProgress = { label: string; completed?: number; total?: number };
const SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy';
const DEFAULT_SOURCES: MileageSourceOption[] = [
{ protocol: 'GB32960', label: '国标 GB32960', mileageType: '仪表盘里程', enabled: true },
{ protocol: 'JT808', label: '交通部 JT/T 808', mileageType: 'GPS 里程', enabled: true },
{ protocol: 'YUTONG_MQTT', label: '宇通 MQTT', mileageType: '仪表盘里程', enabled: true }
];
function localDate(value = new Date()) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 10);
}
function defaultWindow(days = 7, endOffsetDays = 0) {
const end = new Date(Date.now() - endOffsetDays * DAY);
return { dateFrom: localDate(new Date(end.getTime() - (days - 1) * DAY)), dateTo: localDate(end) };
}
function isSameRange(criteria: Pick<Criteria, 'dateFrom' | 'dateTo'>, range: { dateFrom: string; dateTo: string }) {
return criteria.dateFrom === range.dateFrom && criteria.dateTo === range.dateTo;
}
function formatKm(value?: number) {
if (value == null || !Number.isFinite(value)) return '—';
return formatZhNumber(value, 1);
}
function inclusiveDays(dateFrom: string, dateTo: string) {
const from = Date.parse(`${dateFrom}T00:00:00Z`);
const to = Date.parse(`${dateTo}T00:00:00Z`);
return Number.isFinite(from) && Number.isFinite(to) && to >= from ? Math.round((to - from) / DAY) + 1 : 0;
}
function mileageDateRangeError(criteria: Pick<Criteria, 'dateFrom' | 'dateTo'>) {
if (!criteria.dateFrom || !criteria.dateTo) return '请选择完整的开始和结束日期';
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
if (!days) return '结束日期不能早于开始日期';
if (days > MAX_MILEAGE_RANGE_DAYS) return `单次最多查询 ${MAX_MILEAGE_RANGE_DAYS} 个自然日,请缩小日期范围`;
return '';
}
function mileageParams(criteria: Criteria, offset = 0) {
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
if (criteria.vehicles.length) params.set('vins', criteria.vehicles.map((vehicle) => vehicle.vin).join(','));
else params.set('vehicleScope', 'bound');
params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(','));
if (offset >= 0) {
params.set('deduplicate', '1');
params.set('limit', String(DETAIL_LIMIT));
params.set('offset', String(offset));
}
return params;
}
function initialCriteria(searchParams: URLSearchParams): Criteria {
const defaults = defaultWindow();
const vins = (searchParams.get('vins') ?? '').split(',').map((vin) => vin.trim()).filter(Boolean).slice(0, MAX_SELECTED_VEHICLES);
const requestedProtocols = (searchParams.get('protocols') ?? '').split(',').filter((protocol): protocol is MileageProtocol => DEFAULT_SOURCES.some((source) => source.protocol === protocol));
let sources = DEFAULT_SOURCES.map((source) => ({ ...source }));
if (requestedProtocols.length) {
sources = [...requestedProtocols.map((protocol) => ({ ...DEFAULT_SOURCES.find((source) => source.protocol === protocol)!, enabled: true })), ...DEFAULT_SOURCES.filter((source) => !requestedProtocols.includes(source.protocol)).map((source) => ({ ...source, enabled: false }))];
} else {
try {
const stored = JSON.parse(window.localStorage.getItem(SOURCE_STORAGE_KEY) ?? '[]') as Partial<MileageSourceOption>[];
const normalized = stored.flatMap((item) => {
const source = DEFAULT_SOURCES.find((candidate) => candidate.protocol === item.protocol);
return source ? [{ ...source, enabled: item.enabled !== false }] : [];
});
if (normalized.length === DEFAULT_SOURCES.length && normalized.some((source) => source.enabled)) sources = normalized;
} catch { /* retain safe defaults */ }
}
return {
vehicles: vins.map((vin) => ({ vin, plate: '' })),
dateFrom: searchParams.get('dateFrom') ?? defaults.dateFrom,
dateTo: searchParams.get('dateTo') ?? defaults.dateTo,
sources
};
}
function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onChange: (sources: MileageSourceOption[]) => void }) {
const [open, setOpen] = useState(false);
const enabled = value.filter((source) => source.enabled);
const primarySource = enabled[0];
useSideSheetA11y(open, '.v2-mileage-source-sidesheet', 'v2-mileage-source-strategy', '数据源策略配置', '关闭数据源策略');
const update = (sources: MileageSourceOption[]) => {
onChange(sources);
try { window.localStorage.setItem(SOURCE_STORAGE_KEY, JSON.stringify(sources)); } catch { /* preference persistence is optional */ }
};
const move = (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= value.length) return;
const next = [...value];
[next[index], next[target]] = [next[target], next[index]];
update(next);
};
const toggle = (protocol: MileageProtocol) => {
const current = value.find((source) => source.protocol === protocol);
if (current?.enabled && enabled.length === 1) return;
update(value.map((source) => source.protocol === protocol ? { ...source, enabled: !source.enabled } : source));
};
return <div className="v2-mileage-source-strategy">
<Button className="v2-mileage-source-trigger" theme="light" icon={<IconSetting />} aria-haspopup="dialog" aria-expanded={open} aria-controls="v2-mileage-source-strategy" title={`当前优先:${primarySource?.label ?? '未配置'}`} onClick={() => setOpen((current) => !current)}>
<span></span><em>{primarySource?.protocol ?? '未配置'} </em><b>{enabled.length}/3</b>
</Button>
<SideSheet
className="v2-mileage-source-sidesheet"
visible={open}
aria-label="数据源策略"
width={430}
title={<div className="v2-mileage-source-title"><strong></strong><span></span></div>}
onCancel={() => setOpen(false)}
footer={<div className="v2-mileage-source-footer"><span>使</span><Button theme="solid" onClick={() => setOpen(false)}></Button></div>}
>
<div className="v2-mileage-source-list">{value.map((source, index) => <Card key={source.protocol} className={`v2-mileage-source-card${source.enabled ? '' : ' is-disabled'}`} bodyStyle={{ padding: 0 }}>
<Switch className="v2-mileage-source-switch" checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onChange={() => toggle(source.protocol)} />
<div className="v2-mileage-source-copy"><strong>{source.label}</strong><small><Tag color="blue" type="light" size="small">{source.mileageType}</Tag><code>{source.protocol}</code></small></div>
<Tag className="v2-mileage-source-priority" color={source.enabled ? 'blue' : 'grey'} type="light" size="small">{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</Tag>
<div className="v2-mileage-source-order"><Button theme="borderless" aria-label={`上移 ${source.label}`} icon={<IconArrowUp />} disabled={index === 0} onClick={() => move(index, -1)} /><Button theme="borderless" aria-label={`下移 ${source.label}`} icon={<IconArrowDown />} disabled={index === value.length - 1} onClick={() => move(index, 1)} /></div>
</Card>)}</div>
</SideSheet>
</div>;
}
function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onChange: (vehicles: VehicleOption[]) => void }) {
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const closeTimerRef = useRef<number>();
useEffect(() => { const timer = window.setTimeout(() => setDebounced(search.trim()), 220); return () => window.clearTimeout(timer); }, [search]);
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
const openPicker = () => {
window.clearTimeout(closeTimerRef.current);
setOpen(true);
};
const closePicker = () => {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = window.setTimeout(() => setOpen(false), 120);
};
const candidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: '12', offset: '0' });
if (debounced) params.set('keyword', debounced);
return params;
}, [debounced]);
const candidates = useQuery({
queryKey: ['mileage-vehicle-options', candidateParams.toString()],
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
enabled: open,
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]);
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const add = (vehicle: VehicleRow) => {
if (selected.has(vehicle.vin) || value.length >= MAX_SELECTED_VEHICLES) return;
onChange([...value, { vin: vehicle.vin, plate: vehicle.plate }]);
setSearch('');
};
return <label className="v2-mileage-vehicle-field">
<span></span>
<div className={`v2-mileage-multiselect${open ? ' is-open' : ''}`}>
<IconSearch />
<div className="v2-mileage-selection">
{value.map((vehicle) => <Tag
key={vehicle.vin}
className="v2-mileage-chip"
color="blue"
type="light"
closable
onClose={(_, event) => {
event.stopPropagation();
onChange(value.filter((item) => item.vin !== vehicle.vin));
}}
>
{vehicle.plate || vehicle.vin}
</Tag>)}
<Input borderless value={search} onFocus={openPicker} onBlur={closePicker} onChange={(next) => { setSearch(next); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
</div>
{open ? <VehicleCandidateList
className="v2-mileage-options"
items={options}
loading={candidates.isLoading}
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车牌候选加载失败') : undefined}
onRetry={() => candidates.refetch()}
emptyText="没有匹配的车牌"
selectedVins={selected}
disableSelected
header="车牌候选"
meta={`${value.length}/${MAX_SELECTED_VEHICLES} 已选`}
showProtocols
onSelect={add}
/> : null}
</div>
<small> {MAX_SELECTED_VEHICLES} </small>
</label>;
}
function SummaryRail({ data, criteria, fleetTotal, loading }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number; loading: boolean }) {
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
const summaryRange = criteria.dateFrom.slice(0, 4) === criteria.dateTo.slice(0, 4)
? `${criteria.dateFrom.replace(/-/g, '/')}${criteria.dateTo.slice(5).replace('-', '/')}`
: `${criteria.dateFrom.replace(/-/g, '/')}${criteria.dateTo.replace(/-/g, '/')}`;
const items = loading ? [
['已绑定主车辆', '—', '正在按新筛选范围查询'],
['统计天数', `${days}`, summaryRange],
['区间总里程', '—', '正在计算区间汇总'],
['日均里程', '—', '正在计算有效车辆日']
] : [
['已绑定主车辆', `${vehicleCount}`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length}` : `档案口径 · ${data?.vehicleCount ?? 0} 辆有里程`],
['统计天数', `${days}`, summaryRange],
['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`],
['日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '按有效车辆日平均']
];
const primary = items[2];
const secondary = [items[0], items[1], items[3]];
return <Card className="v2-mileage-summary" aria-label="里程查询统计信息" bodyStyle={{ padding: 0 }}>
<Card className="v2-mileage-summary-card is-primary" bodyStyle={{ padding: 0 }} aria-label={`${primary[0]}${primary[1]}${primary[2]}`}><small>{primary[0]}</small><strong className="v2-mileage-summary-value">{primary[1]}</strong><span title={primary[2]}>{primary[2]}</span></Card>
<CardGroup className="v2-mileage-summary-secondary" type="grid" spacing={0}>{secondary.map(([label, value, note], index) => <Card className={`v2-mileage-summary-card is-${['fleet', 'range', 'average'][index]}`} bodyStyle={{ padding: 0 }} aria-label={`${label}${value}${note}`} key={label}><small>{label}</small><strong>{value}</strong><span title={note}>{note}</span></Card>)}</CardGroup>
</Card>;
}
type VehicleMileageMatrix = VehicleOption & { days: Map<string, number>; sources: Map<string, string>; totalMileageKm: number };
function rangeDates(dateFrom: string, dateTo: string) {
const dates: string[] = [];
const cursor = new Date(`${dateFrom}T00:00:00Z`);
const end = new Date(`${dateTo}T00:00:00Z`);
while (cursor <= end && dates.length < MAX_MILEAGE_RANGE_DAYS) {
dates.push(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return dates;
}
function dateLabel(date: string) {
const [, month, day] = date.split('-');
return `${Number(month)}/${Number(day)}`;
}
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement> }) {
const { columns, tableWidth } = useMemo(() => {
let maxDailyMileage = 1;
for (const row of rows) {
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
}
return {
columns: [
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
...dates.map((date) => ({
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: 96,
onHeaderCell: () => ({ title: date }),
onCell: (row?: VehicleMileageMatrix) => {
const mileage = row?.days.get(date);
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return {
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
title: mileage != null ? `来源:${row?.sources.get(date) || '—'}` : undefined,
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => {
const mileage = row.days.get(date);
return mileage != null ? `${formatKm(mileage)} km` : '—';
}
})),
{ title: '区间总里程', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: 128, onCell: () => ({ className: 'is-number is-period is-total' }), render: (value: number) => `${formatKm(value)} km` }
],
tableWidth: 120 + dates.length * 96 + 128
};
}, [dates, rows]);
return <div className="v2-mileage-table-wrap" ref={scrollRef} style={{ '--v2-mileage-mobile-table-width': `${96 + dates.length * 78 + 104}px` } as CSSProperties}>
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
</div>;
});
export default function StatisticsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams));
const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams));
const [page, setPage] = useState(1);
const [isExporting, setIsExporting] = useState(false);
const [exportFeedback, setExportFeedback] = useState('');
const [exportProgress, setExportProgress] = useState<ExportProgress>();
const [validationError, setValidationError] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const exportControllerRef = useRef<AbortController | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
exportControllerRef.current?.abort();
};
}, []);
useEffect(() => {
const page = pageRef.current;
if (!page) return;
const redirectWheelToTable = (event: WheelEvent) => {
if (window.matchMedia('(max-width: 680px)').matches || event.ctrlKey || event.shiftKey || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
if (event.target instanceof Element && event.target.closest('input,button,select,textarea,[role="dialog"],[role="listbox"]')) return;
const scroller = tableScrollRef.current;
if (!scroller) return;
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroller.scrollTop + event.deltaY));
if (nextScrollTop === scroller.scrollTop) return;
scroller.scrollTop = nextScrollTop;
event.preventDefault();
};
page.addEventListener('wheel', redirectWheelToTable, { passive: false });
return () => page.removeEventListener('wheel', redirectWheelToTable);
}, []);
const hasVehicles = criteria.vehicles.length > 0;
const criteriaError = mileageDateRangeError(criteria);
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
const fleetVehicles = useQuery({
queryKey: ['mileage-fleet-page', fleetParams.toString()],
queryFn: ({ signal }) => api.vehicleCoverage(fleetParams, signal),
enabled: !hasVehicles,
staleTime: 60_000,
gcTime: QUERY_MEMORY.summaryGcTime
});
const displayVehicles = useMemo<VehicleOption[]>(() => hasVehicles
? criteria.vehicles
: (fleetVehicles.data?.items ?? []).map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })), [criteria.vehicles, fleetVehicles.data?.items, hasVehicles]);
const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]);
const statisticsScope = statisticsParams.toString();
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]);
const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsScope], queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal), enabled: !criteriaError, staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
const mileage = useQuery<Page<DailyMileageRow>>({ queryKey: ['daily-mileage-query', statisticsScope, rowsParams.toString()], queryFn: ({ signal }) => api.dailyMileage(rowsParams, signal), enabled: !criteriaError && displayVehicles.length > 0, staleTime: 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime, placeholderData: retainPreviousPageWithinScope<Page<DailyMileageRow>>(statisticsScope, 2) });
const dates = useMemo(() => criteriaError ? [] : rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo, criteriaError]);
const mileageByVin = useMemo(() => {
const index = new Map<string, { plate: string; days: Map<string, number>; sources: Map<string, string> }>();
for (const row of mileage.data?.items ?? []) {
let entry = index.get(row.vin);
if (!entry) {
entry = { plate: row.plate || '', days: new Map(), sources: new Map() };
index.set(row.vin, entry);
} else if (!entry.plate && row.plate) entry.plate = row.plate;
entry.days.set(row.date, row.dailyMileageKm);
entry.sources.set(row.date, row.source);
}
return index;
}, [mileage.data?.items]);
const rankingByVin = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row])), [statistics.data?.ranking]);
const matrixRows = useMemo(() => displayVehicles.map((vehicle) => {
const daily = mileageByVin.get(vehicle.vin);
const ranking = rankingByVin.get(vehicle.vin);
const days = daily?.days ?? new Map<string, number>();
const sources = daily?.sources ?? new Map<string, string>();
let dailyTotal = 0;
for (const value of days.values()) dailyTotal += value;
return { ...vehicle, plate: vehicle.plate || daily?.plate || ranking?.plate || '', days, sources, totalMileageKm: ranking?.mileageKm ?? dailyTotal };
}), [displayVehicles, mileageByVin, rankingByVin]);
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE));
const submit = (event: FormEvent) => {
event.preventDefault();
const error = mileageDateRangeError(draft);
setValidationError(error);
if (error) return;
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(preserveMonitorReturn(mileageParams(draft, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true);
};
const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(preserveMonitorReturn(mileageParams(next, -1), monitorReturn), { replace: true }); };
const todayRange = defaultWindow(1);
const yesterdayRange = defaultWindow(1, 1);
const sevenDayRange = defaultWindow(7);
const thirtyDayRange = defaultWindow(30);
const ninetyDayRange = defaultWindow(90);
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
const exportPercent = exportProgress?.total
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
: undefined;
const exportExcel = async () => {
if (exportControllerRef.current || !totalVehicles || criteriaError) return;
const controller = new AbortController();
exportControllerRef.current = controller;
const reportProgress = (progress: ExportProgress) => {
if (mountedRef.current && !controller.signal.aborted) setExportProgress(progress);
};
setIsExporting(true);
setExportFeedback('');
let exportStream: MileageExportStream | undefined;
try {
let vehicles: VehicleOption[] = criteria.vehicles.map((vehicle) => ({ ...vehicle }));
if (!vehicles.length) {
vehicles = [];
let offset = 0;
reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles });
while (offset < totalVehicles) {
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }), controller.signal);
vehicles.push(...result.items.map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })));
reportProgress({ label: '正在读取车辆档案', completed: vehicles.length, total: result.total });
if (!result.items.length) break;
offset += result.items.length;
if (offset >= result.total) break;
}
}
const plateByVin = new Map(vehicles.filter((vehicle) => vehicle.plate).map((vehicle) => [vehicle.vin, vehicle.plate]));
exportStream = createMileageExportStream({
dateFrom: criteria.dateFrom,
dateTo: criteria.dateTo,
dates,
sources: criteria.sources.filter((source) => source.enabled),
exportedAt: new Date()
}, controller.signal);
const vehicleBatches = criteria.vehicles.length
? Array.from({ length: Math.ceil(vehicles.length / EXPORT_VIN_BATCH_SIZE) }, (_, index) => vehicles.slice(index * EXPORT_VIN_BATCH_SIZE, (index + 1) * EXPORT_VIN_BATCH_SIZE))
: [[] as VehicleOption[]];
for (const [batchIndex, vehicleBatch] of vehicleBatches.entries()) {
let offset = 0;
const mileageLabel = vehicleBatches.length > 1
? `正在读取里程数据(${batchIndex + 1}/${vehicleBatches.length} 批)`
: '正在读取全部车辆里程';
reportProgress({ label: mileageLabel });
while (true) {
const params = mileageParams({ ...criteria, vehicles: vehicleBatch }, offset);
const result = await api.dailyMileage(params, controller.signal);
for (const row of result.items) if (row.plate) plateByVin.set(row.vin, row.plate);
exportStream.appendRows(result.items);
offset += result.items.length;
reportProgress({ label: mileageLabel, completed: offset, total: result.total });
if (!result.items.length || offset >= result.total) break;
}
}
vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' }));
reportProgress({ label: '正在后台生成 Excel 文件' });
await exportStream.finish(vehicles);
exportStream = undefined;
if (mountedRef.current) setExportFeedback(`已导出 ${vehicles.length} 辆车`);
} catch (error) {
if (mountedRef.current) {
setExportFeedback(controller.signal.aborted
? '已取消导出'
: error instanceof Error ? `导出失败:${error.message}` : '导出失败,请稍后重试');
}
} finally {
exportStream?.dispose();
if (exportControllerRef.current === controller) exportControllerRef.current = null;
if (mountedRef.current) {
setIsExporting(false);
setExportProgress(undefined);
}
}
};
const cancelExport = () => {
if (!exportControllerRef.current || exportControllerRef.current.signal.aborted) return;
setExportProgress({ label: '正在取消导出' });
exportControllerRef.current.abort();
};
return <div className="v2-mileage-page" ref={pageRef}>
<MonitorReturnBar />
<WorkspaceFilterPanel
className="v2-mileage-query-panel"
title="里程范围"
description="车牌、自然日与里程数据源策略"
mobileSummary={criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`}
expanded={!filtersCollapsed}
status={`${criteria.vehicles.length ? `${criteria.vehicles.length}` : '全部授权车辆'} · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`}
statusColor={criteria.vehicles.length ? 'blue' : 'grey'}
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form className={`v2-mileage-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
<label><span></span><Input aria-label="开始日期" type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /></label>
<label><span></span><Input aria-label="结束日期" type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></label>
<Button className="v2-primary-button" theme="solid" htmlType="submit"></Button>
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
<div className="v2-mileage-ranges"><span></span><Button theme={isSameRange(draft, todayRange) ? 'solid' : 'light'} onClick={() => setRange(todayRange)}></Button><Button theme={isSameRange(draft, yesterdayRange) ? 'solid' : 'light'} onClick={() => setRange(yesterdayRange)}></Button><Button theme={isSameRange(draft, sevenDayRange) ? 'solid' : 'light'} onClick={() => setRange(sevenDayRange)}> 7 </Button><Button theme={isSameRange(draft, thirtyDayRange) ? 'solid' : 'light'} onClick={() => setRange(thirtyDayRange)}> 30 </Button><Button theme={isSameRange(draft, ninetyDayRange) ? 'solid' : 'light'} onClick={() => setRange(ninetyDayRange)}> 90 </Button></div>
</form>
{validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
</WorkspaceFilterPanel>
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
<Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="车辆每日里程"
description={`${criteria.dateFrom}${criteria.dateTo}`}
meta={<span className="v2-workspace-result-meta"><Tag color="blue" type="light" size="small">{hasVehicles ? `${totalVehicles}` : `${displayVehicles.length} / ${totalVehicles}`}</Tag><span>{dates.length} </span></span>}
actionsClassName="v2-mileage-result-actions"
actions={<><Button className="is-refresh" theme="borderless" aria-label="刷新里程数据" icon={<IconRefresh />} onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}>{refreshing ? '更新中' : '刷新'}</Button><Button theme="light" aria-label={isExporting ? '取消导出' : '导出 Excel'} icon={isExporting ? <IconClose /> : <IconDownload />} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles || Boolean(criteriaError)}>{isExporting ? '取消导出' : '导出 Excel'}</Button></>}
/>
{exportProgress ? <div className="v2-mileage-export-progress" role="progressbar" aria-label={exportProgress.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={exportPercent}>
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
<i className={exportPercent == null ? 'is-indeterminate' : ''}><b style={exportPercent == null ? undefined : { width: `${exportPercent}%` }} /></i>
</div> : null}
{resultsLoading ? <PanelLoading className="v2-mileage-loading" title="正在查询里程" description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} /> : null}
{!resultsLoading && !displayVehicles.length ? <PanelEmpty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
<footer>{!hasVehicles && totalVehicles ? <TablePagination page={page} totalPages={totalPages} info={`${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE}${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={fleetVehicles.isFetching} onPageChange={setPage} /> : <span className="v2-table-pagination-info"> {totalVehicles.toLocaleString('zh-CN')} {exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>
</Card>
<footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 · </span></footer>
</div>;
}