import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { Button, Card, 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 { WorkspaceMetricRail } from '../shared/WorkspaceMetricRail'; 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; 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, 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) { 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[]; 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
数据源策略同车同日按优先级选择一个里程来源
} onCancel={() => setOpen(false)} footer={
同车同日只使用一个来源,避免重复累计;至少保留一个来源。
} >
{value.map((source, index) => toggle(source.protocol)} />
{source.label}{source.mileageType}{source.protocol}
{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}
)}
; } 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(); 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 ; } 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 ({ label, value, note, className: `is-${['fleet', 'range', 'average'][index]}` }))} />; } type VehicleMileageMatrix = VehicleOption & { days: Map; sources: Map; 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 }) { 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) => {row.plate || '未绑定'} }, ...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
; }); export default function StatisticsPage() { const [searchParams, setSearchParams] = useSearchParams(); const monitorReturn = monitorReturnFromParams(searchParams); const [draft, setDraft] = useState(() => initialCriteria(searchParams)); const [criteria, setCriteria] = useState(() => initialCriteria(searchParams)); const [page, setPage] = useState(1); const [isExporting, setIsExporting] = useState(false); const [exportFeedback, setExportFeedback] = useState(''); const [exportProgress, setExportProgress] = useState(); const [validationError, setValidationError] = useState(''); const [filtersCollapsed, setFiltersCollapsed] = useState(true); const exportControllerRef = useRef(null); const pageRef = useRef(null); const tableScrollRef = useRef(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(() => 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>({ 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>(statisticsScope, 2) }); const dates = useMemo(() => criteriaError ? [] : rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo, criteriaError]); const mileageByVin = useMemo(() => { const index = new Map; sources: Map }>(); 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(); const sources = daily?.sources ?? new Map(); 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
setFiltersCollapsed((value) => !value)} >
setDraft((current) => ({ ...current, vehicles }))} /> setDraft((current) => ({ ...current, sources }))} />
快捷范围
{validationError || criteriaError ?

{validationError || criteriaError}

: null}
{statistics.isError || mileage.isError || fleetVehicles.isError ? { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null} {hasVehicles ? `${totalVehicles} 辆` : `${displayVehicles.length} / ${totalVehicles} 辆`}{dates.length} 个自然日} actionsClassName="v2-mileage-result-actions" actions={<>} /> {exportProgress ?
{exportProgress.label}{exportPercent == null ? '处理中' : `${exportPercent}%`}
: null} {resultsLoading ? : displayVehicles.length ? : null} {!resultsLoading && !displayVehicles.length ? : null}
{!hasVehicles && totalVehicles ? : 已选择 {totalVehicles.toLocaleString('zh-CN')} 辆车辆{exportFeedback ? ` · ${exportFeedback}` : ''}}
数据更新时间:{statistics.data?.asOf || '—'}来源优先级:{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' > ')}当前筛选复用 1 分钟 · 离开后释放明细
; }