import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag, TextArea } from '@douyinfe/semi-ui'; import { useQuery } from '@tanstack/react-query'; import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api, type MileageQuery } from '../../api/client'; import type { DailyMileageRow, HydrogenDailyEvidence, 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 { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; import { TablePagination } from '../shared/TablePagination'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext'; import { preserveVehicleReturn, vehicleReturnFromParams } from '../routing/vehicleContext'; import { useMobileLayout } from '../hooks/useMobileLayout'; import { usePlatformSession } from '../auth/AuthGate'; const DAY = 86_400_000; const DETAIL_LIMIT = 10_000; const MAX_MILEAGE_VEHICLES = 50_000; const LEGACY_URL_VEHICLE_LIMIT = 20; const BATCH_RESOLVE_SIZE = 200; const PAGE_SIZE = 20; const EXPORT_VEHICLE_PAGE_SIZE = 2_000; const EXPORT_VIN_BATCH_SIZE = 50; const EXPORT_QUERY_TARGET_ROWS = 8_000; const EXPORT_MAX_DAYS_PER_QUERY = 31; const MAX_MILEAGE_RANGE_DAYS = 366; const CURRENT_DAY_REFRESH_MS = 60_000; type VehicleOption = Pick & { brandName?: string; modelName?: string }; 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 }; type MetricView = 'mileage' | 'hydrogen'; type HydrogenMatrixMetric = 'consumption' | 'pureElectric' | 'pureHydrogen' | 'rate'; type MatrixMetric = 'mileage' | HydrogenMatrixMetric; const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string }> = [ { key: 'consumption', label: '用氢量' }, { key: 'pureElectric', label: '纯电里程' }, { key: 'pureHydrogen', label: '纯氢里程' }, { key: 'rate', label: '百公里氢耗' } ]; const DEFAULT_HYDROGEN_MATRIX_METRICS: HydrogenMatrixMetric[] = ['consumption', 'rate']; const MATRIX_METRIC_META: Record = { mileage: { label: '里程', unit: 'km', width: 92 }, consumption: { label: '用氢量', unit: 'kg', width: 88 }, pureElectric: { label: '纯电里程', unit: 'km', width: 96 }, pureHydrogen: { label: '纯氢里程', unit: 'km', width: 96 }, rate: { label: '百公里氢耗', unit: 'kg/100km', width: 112 } }; 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 MileageRangeShortcuts({ value, ranges, onChange }: { value: Pick; ranges: Array<{ label: string; range: { dateFrom: string; dateTo: string } }>; onChange: (range: { dateFrom: string; dateTo: string }) => void; }) { return
快捷日期 {ranges.map(({ label, range }) => { const active = isSameRange(value, range); return ; })}
; } export function mileageRangeContainsDate( range: { dateFrom: string; dateTo: string }, date = localDate() ) { return Boolean(range.dateFrom && range.dateTo && range.dateFrom <= date && range.dateTo >= date); } function formatKm(value?: number | null) { 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 datePickerRange(dateValue?: Date | Date[] | string | string[], formattedValue?: string | string[] | Date | Date[]) { const values = Array.isArray(formattedValue) && formattedValue.length === 2 ? formattedValue : Array.isArray(dateValue) && dateValue.length === 2 ? dateValue : []; if (values.length !== 2) return; const normalized = values.map((value) => { if (value instanceof Date) return localDate(value); if (typeof value === 'number') return localDate(new Date(value)); return String(value).slice(0, 10); }); if (normalized.some((value) => !/^\d{4}-\d{2}-\d{2}$/.test(value))) return; return { dateFrom: normalized[0], dateTo: normalized[1] }; } function mileageQuery(criteria: Criteria, offset = 0): MileageQuery { const query: MileageQuery = { dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, protocols: criteria.sources.filter((source) => source.enabled).map((source) => source.protocol) }; if (criteria.vehicles.length) query.vins = criteria.vehicles.map((vehicle) => vehicle.vin); else query.vehicleScope = 'bound'; if (offset >= 0) { query.deduplicate = true; query.limit = DETAIL_LIMIT; query.offset = offset; } return query; } export function mileageExportDateWindows(dateFrom: string, dateTo: string, vehicleCount: number) { const start = new Date(`${dateFrom}T00:00:00Z`); const end = new Date(`${dateTo}T00:00:00Z`); if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || start > end) return []; const daysPerQuery = Math.max(1, Math.min(EXPORT_MAX_DAYS_PER_QUERY, Math.floor(EXPORT_QUERY_TARGET_ROWS / Math.max(1, vehicleCount)))); const windows: Array<{ dateFrom: string; dateTo: string }> = []; for (let cursor = start; cursor <= end;) { const windowEnd = new Date(Math.min(end.getTime(), cursor.getTime() + (daysPerQuery - 1) * DAY)); windows.push({ dateFrom: cursor.toISOString().slice(0, 10), dateTo: windowEnd.toISOString().slice(0, 10) }); cursor = new Date(windowEnd.getTime() + DAY); } return windows; } function mileageRouteParams(criteria: Criteria) { const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo }); params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(',')); if (criteria.vehicles.length <= LEGACY_URL_VEHICLE_LIMIT) params.set('vins', criteria.vehicles.map((vehicle) => vehicle.vin).join(',')); return params; } function initialCriteria(searchParams: URLSearchParams): Criteria { const defaults = defaultWindow(); const vins = (searchParams.get('vins') ?? '').split(',').map((vin) => vin.trim()).filter(Boolean).slice(0, LEGACY_URL_VEHICLE_LIMIT); 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 mobileLayout = useMobileLayout(); const rootRef = useRef(null); const popoverRef = useRef(null); const enabled = value.filter((source) => source.enabled); const primarySource = enabled[0]; const focusTrigger = () => rootRef.current?.querySelector('.v2-mileage-source-trigger')?.focus(); const closeDesktopPanel = () => { setOpen(false); window.requestAnimationFrame(focusTrigger); }; 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)); }; useEffect(() => { if (!open || mobileLayout) return; popoverRef.current?.focus(); const closeOnOutsideClick = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; const closeOnEscape = (event: globalThis.KeyboardEvent) => { if (event.key !== 'Escape') return; event.preventDefault(); setOpen(false); focusTrigger(); }; document.addEventListener('mousedown', closeOnOutsideClick); document.addEventListener('keydown', closeOnEscape); return () => { document.removeEventListener('mousedown', closeOnOutsideClick); document.removeEventListener('keydown', closeOnEscape); }; }, [mobileLayout, open]); const sourceList =
{value.map((source, index) => toggle(source.protocol)} />
{source.label}{source.mileageType}{source.protocol}
{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}
)}
; return
{!mobileLayout && open ? : null} {mobileLayout ? source.protocol).join(' · '), tone: enabled.length === 3 ? 'success' : 'neutral' }, { label: '统计口径', value: '单车单日', detail: '仅采用一个优先来源' } ]} onCancel={() => setOpen(false)} footerNote="首选来源为 0、其他来源有有效增量时会自动降级;全部为 0 时仍按优先级选择。" primaryAction={{ label: '完成', onClick: () => setOpen(false) }} > {sourceList} : null}
; } export function parseMileageVehicleIdentifiers(value: string) { const ignoredHeaders = new Set(['车牌', '车牌号', '车牌号码', 'vin', 'vin码', '车架号', '车辆识别代号', '序号', '备注']); const identifiers: string[] = []; const seen = new Set(); for (const raw of value.split(/[\s,,、;;|]+/u)) { const identifier = raw.trim(); const key = identifier.toLocaleUpperCase(); if (!identifier || ignoredHeaders.has(key.toLocaleLowerCase()) || seen.has(key)) continue; seen.add(key); identifiers.push(key); if (identifiers.length === MAX_MILEAGE_VEHICLES) break; } return identifiers; } function isVIN(value: string) { return /^[A-HJ-NPR-Z0-9]{17}$/i.test(value); } async function resolveMileageVehicles(identifiers: string[]) { const vehicles: VehicleOption[] = []; const missing: string[] = []; const seenVINs = new Set(); const plates = identifiers.filter((identifier) => !isVIN(identifier)); for (const vin of identifiers.filter(isVIN)) { const normalized = vin.toLocaleUpperCase(); if (!seenVINs.has(normalized)) { seenVINs.add(normalized); vehicles.push({ vin: normalized, plate: '' }); } } for (let start = 0; start < plates.length; start += BATCH_RESOLVE_SIZE * 4) { const group = Array.from({ length: 4 }, (_, index) => plates.slice(start + index * BATCH_RESOLVE_SIZE, start + (index + 1) * BATCH_RESOLVE_SIZE)).filter((batch) => batch.length); const pages = await Promise.all(group.map((keywords) => api.vehicleServiceOverviews({ keywords, limit: BATCH_RESOLVE_SIZE, offset: 0 }))); pages.forEach((page, pageIndex) => { const keywords = group[pageIndex]; keywords.forEach((keyword, index) => { const match = page.items[index]; const vin = match?.vin?.trim().toLocaleUpperCase(); if (!vin) { missing.push(keyword); return; } if (!seenVINs.has(vin)) { seenVINs.add(vin); vehicles.push({ vin, plate: match.plate || keyword }); } }); }); } return { vehicles, missing }; } function MileageBatchVehicleDialog({ visible, value, mobile, onApply, onClose }: { visible: boolean; value: VehicleOption[]; mobile: boolean; onApply: (vehicles: VehicleOption[], feedback: string) => void; onClose: () => void }) { const [draft, setDraft] = useState(''); const [resolving, setResolving] = useState(false); const [error, setError] = useState(''); const identifiers = useMemo(() => parseMileageVehicleIdentifiers(draft), [draft]); const apply = async () => { if (!identifiers.length || resolving) return; setResolving(true); setError(''); try { const resolved = await resolveMileageVehicles(identifiers); const merged = [...value]; const seen = new Set(value.map((vehicle) => vehicle.vin)); for (const vehicle of resolved.vehicles) if (!seen.has(vehicle.vin)) { seen.add(vehicle.vin); merged.push(vehicle); } const added = merged.length - value.length; onApply(merged, resolved.missing.length ? `已新增 ${added.toLocaleString('zh-CN')} 辆,${resolved.missing.length.toLocaleString('zh-CN')} 项未识别` : `已新增 ${added.toLocaleString('zh-CN')} 辆,重复项已自动去除`); onClose(); } catch (cause) { setError(cause instanceof Error ? cause.message : '批量车牌解析失败,请稍后重试'); } finally { setResolving(false); } }; return } badge={`${identifiers.length.toLocaleString('zh-CN')} 项`} badgeColor={identifiers.length ? 'blue' : 'grey'} summaryItems={[ { label: '已解析', value: identifiers.length.toLocaleString('zh-CN'), detail: '车牌与 VIN 可混合', tone: identifiers.length ? 'primary' : 'neutral' }, { label: '重复处理', value: '自动去重', detail: '含已有车辆与重复行', tone: 'success' }, { label: '单次容量', value: '50,000 辆', detail: '查询使用 POST 请求体' } ]} footerNote="支持换行、Tab、空格、中英文逗号、分号、顿号和竖线。" secondaryActions={[{ label: '取消', onClick: onClose }]} primaryAction={{ label: `解析并添加(${identifiers.length.toLocaleString('zh-CN')})`, loading: resolving, disabled: !identifiers.length, onClick: apply }} onCancel={onClose} >