Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx
T

1366 lines
75 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, 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<VehicleRow, 'vin' | 'plate'> & { 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<MatrixMetric, { label: string; unit: string; width: number }> = {
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<Criteria, 'dateFrom' | 'dateTo'>, range: { dateFrom: string; dateTo: string }) {
return criteria.dateFrom === range.dateFrom && criteria.dateTo === range.dateTo;
}
function MileageRangeShortcuts({
value,
ranges,
onChange
}: {
value: Pick<Criteria, 'dateFrom' | 'dateTo'>;
ranges: Array<{ label: string; range: { dateFrom: string; dateTo: string } }>;
onChange: (range: { dateFrom: string; dateTo: string }) => void;
}) {
return <div className="v2-mileage-ranges">
<span>快捷日期</span>
<ButtonGroup aria-label="快捷日期范围">
{ranges.map(({ label, range }) => {
const active = isSameRange(value, range);
return <Button
key={label}
theme={active ? 'solid' : 'light'}
htmlType="button"
aria-pressed={active}
onClick={() => onChange(range)}
>
{label}
</Button>;
})}
</ButtonGroup>
</div>;
}
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<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 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<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 mobileLayout = useMobileLayout();
const rootRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const enabled = value.filter((source) => source.enabled);
const primarySource = enabled[0];
const focusTrigger = () => rootRef.current?.querySelector<HTMLButtonElement>('.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 = <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>;
return <div ref={rootRef} className="v2-mileage-source-strategy">
<Button
className="v2-mileage-source-trigger"
theme="light"
icon={<IconSetting />}
aria-label={`数据源策略,${primarySource?.protocol ?? '未配置'} 优先,${enabled.length}/3 已启用`}
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>
{!mobileLayout && open ? <div
ref={popoverRef}
id="v2-mileage-source-strategy"
className="v2-mileage-source-popover"
role="dialog"
aria-modal="false"
aria-labelledby="v2-mileage-source-popover-title"
tabIndex={-1}
>
<header className="v2-mileage-source-popover-header">
<span className="v2-mileage-source-popover-icon" aria-hidden="true"><IconSetting /></span>
<div>
<strong id="v2-mileage-source-popover-title">数据源策略</strong>
<small>排序决定同车同日的里程取值优先级</small>
</div>
<Tag color={enabled.length === 3 ? 'green' : 'blue'} type="light" size="small">{enabled.length}/3 已启用</Tag>
<Button theme="borderless" aria-label="关闭数据源策略" icon={<IconClose />} onClick={closeDesktopPanel} />
</header>
<div className="v2-mileage-source-popover-summary" aria-live="polite">
<span><small>当前首选</small><strong>{primarySource?.protocol ?? '未配置'}</strong><em>{primarySource?.mileageType ?? '至少保留一个来源'}</em></span>
<p><IconInfoCircle />首选无有效增量时,自动尝试下一来源</p>
</div>
{sourceList}
<footer className="v2-mileage-source-popover-footer">
<span>更改即时生效,并保存在此浏览器</span>
<Button theme="borderless" type="tertiary" onClick={closeDesktopPanel}>收起</Button>
</footer>
</div> : null}
{mobileLayout ? <WorkspaceSideSheet
className="v2-mileage-source-sidesheet"
visible={open}
ariaLabel="数据源策略配置"
closeLabel="关闭数据源策略"
dialogId="v2-mileage-source-strategy"
placement="bottom"
height="min(58dvh, 500px)"
title="数据源策略"
description="同车同日优先采用靠前且有有效增量的里程来源"
badge={`${enabled.length}/3 已启用`}
badgeColor={enabled.length === 3 ? 'green' : 'blue'}
summaryItems={[
{ label: '当前首选', value: primarySource?.protocol ?? '未配置', detail: primarySource?.mileageType ?? '至少保留一个来源', tone: 'primary' },
{ label: '启用来源', value: `${enabled.length} / 3`, detail: enabled.map((source) => source.protocol).join(' · '), tone: enabled.length === 3 ? 'success' : 'neutral' },
{ label: '统计口径', value: '单车单日', detail: '仅采用一个优先来源' }
]}
onCancel={() => setOpen(false)}
footerNote="首选来源为 0、其他来源有有效增量时会自动降级;全部为 0 时仍按优先级选择。"
primaryAction={{ label: '完成', onClick: () => setOpen(false) }}
>
{sourceList}
</WorkspaceSideSheet> : null}
</div>;
}
export function parseMileageVehicleIdentifiers(value: string) {
const ignoredHeaders = new Set(['车牌', '车牌号', '车牌号码', 'vin', 'vin码', '车架号', '车辆识别代号', '序号', '备注']);
const identifiers: string[] = [];
const seen = new Set<string>();
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<string>();
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 <WorkspaceSideSheet
className="v2-monitor-batch-sidesheet v2-mileage-batch-sidesheet"
variant="editor"
visible={visible}
ariaLabel="批量添加里程查询车辆"
closeLabel="关闭批量添加车辆"
dialogId="v2-mileage-batch-search"
placement={mobile ? 'bottom' : 'right'}
width={mobile ? undefined : 520}
height={mobile ? 'min(86dvh, 700px)' : undefined}
title="批量添加车辆"
description="直接粘贴 Excel 单列、多列或文本中的车牌 / VIN"
icon={<IconSearch />}
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}
>
<div className="v2-batch-search-dialog">
<label htmlFor="mileage-batch-vehicles"> Excel 复制后直接粘贴,无需整理格式</label>
<TextArea id="mileage-batch-vehicles" autoFocus value={draft} onChange={setDraft} autosize={{ minRows: 8, maxRows: 14 }} resize="vertical" placeholder={'车牌\tVIN\n粤A12345\tLTEST000000000001\n粤B67890'} />
<div className="v2-batch-search-summary" role="status"><span>已解析 <strong>{identifiers.length.toLocaleString('zh-CN')}</strong> 项,重复项已去除</span><em>最多 50,000 </em></div>
{error ? <p className="v2-mileage-batch-error" role="alert">{error}</p> : null}
</div>
</WorkspaceSideSheet>;
}
function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onChange: (vehicles: VehicleOption[]) => void }) {
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [batchFeedback, setBatchFeedback] = useState('');
const mobile = useMobileLayout();
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_MILEAGE_VEHICLES) return;
onChange([...value, { vin: vehicle.vin, plate: vehicle.plate }]);
setSearch('');
};
const visibleVehicles = value.slice(0, 3);
return <div className="v2-mileage-vehicle-field">
<span>车牌</span>
<div className={`v2-mileage-multiselect${open ? ' is-open' : ''}`}>
<IconSearch />
<div className="v2-mileage-selection">
{visibleVehicles.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>)}
{value.length > visibleVehicles.length ? <Tag className="v2-mileage-chip is-overflow" color="blue" type="light">+{(value.length - visibleVehicles.length).toLocaleString('zh-CN')} </Tag> : null}
<Input borderless value={search} onFocus={openPicker} onBlur={closePicker} onChange={(next) => { setSearch(next); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
</div>
<button className="v2-mileage-batch-trigger" type="button" aria-expanded={batchOpen} aria-controls="v2-mileage-batch-search" onClick={() => { setOpen(false); setBatchOpen(true); }}>批量粘贴</button>
{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.toLocaleString('zh-CN')} 已选`}
showProtocols
onSelect={add}
/> : null}
</div>
<small>{batchFeedback || '支持 Excel 粘贴与常见分隔符,最多 50,000 辆'}</small>
<MileageBatchVehicleDialog visible={batchOpen} value={value} mobile={mobile} onClose={() => setBatchOpen(false)} onApply={(vehicles, feedback) => { onChange(vehicles); setBatchFeedback(feedback); }} />
</div>;
}
function MetricViewSwitch({ value, onChange }: { value: MetricView; onChange: (value: MetricView) => void }) {
return <div className="v2-mileage-view-switch" role="group" aria-label="数据视图">
<span>
<strong>{value === 'mileage' ? '里程视图' : '氢耗视图'}</strong>
<small>{value === 'mileage' ? '只看车辆行驶里程' : '按纯氢里程计算百公里氢耗'}</small>
</span>
<ButtonGroup aria-label="切换里程或氢耗视图">
<Button theme={value === 'mileage' ? 'solid' : 'light'} aria-pressed={value === 'mileage'} onClick={() => onChange('mileage')}>里程</Button>
<Button theme={value === 'hydrogen' ? 'solid' : 'light'} aria-pressed={value === 'hydrogen'} onClick={() => onChange('hydrogen')}>氢耗</Button>
</ButtonGroup>
</div>;
}
function HydrogenMetricSelector({ value, onChange }: { value: HydrogenMatrixMetric[]; onChange: (value: HydrogenMatrixMetric[]) => void }) {
const selected = new Set(value);
return <div className="v2-mileage-metric-selector" role="group" aria-label="日期子列">
<span><strong>日期子列</strong><small>里程固定展示</small></span>
<Tag color="blue" type="light" size="small">里程</Tag>
{HYDROGEN_MATRIX_METRICS.map((metric) => {
const active = selected.has(metric.key);
return <Button
key={metric.key}
size="small"
theme={active ? 'solid' : 'light'}
aria-pressed={active}
onClick={() => onChange(active ? value.filter((item) => item !== metric.key) : [...value, metric.key])}
>
{metric.label}
</Button>;
})}
</div>;
}
function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable = false }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number; loading: boolean; view: MetricView; unavailable?: boolean }) {
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
const scopeVehicleCount = criteria.vehicles.length || fleetTotal || 0;
const measuredVehicleCount = data?.vehicleCount ?? 0;
const coveragePercent = scopeVehicleCount ? Math.round(measuredVehicleCount / scopeVehicleCount * 100) : 0;
const pending = loading || unavailable;
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 withUnit = (value: string | number, unit: string) => <>{value}<i className="v2-mileage-summary-unit"> {unit}</i></>;
const items: WorkspaceQueueMetricRailItem[] = view === 'mileage' ? [{
label: '区间总里程',
value: pending ? '—' : withUnit(formatKm(data?.periodMileageKm), 'km'),
note: unavailable ? '汇总暂不可用' : loading ? '正在计算区间汇总' : `${data?.recordCount ?? 0} 条有效车辆日`,
tone: 'primary',
emphasis: 'primary'
},
{
label: '日均里程',
value: pending ? '—' : withUnit(formatKm(data?.averageDailyMileageKm), 'km'),
note: unavailable ? '日均里程暂不可用' : loading ? '正在计算每日均值' : `${days} 个自然日`,
tone: 'primary',
emphasis: 'primary'
},
{
label: '有里程车辆',
value: pending ? '—' : withUnit(`${measuredVehicleCount} / ${scopeVehicleCount}`, '辆'),
note: unavailable ? '覆盖情况暂不可用' : loading ? '正在按新筛选范围查询' : `${coveragePercent}% 覆盖 · 未上报不计为 0`,
tone: 'success',
emphasis: 'primary'
}] : [{
label: '氢耗匹配纯氢里程',
value: pending ? '—' : withUnit(formatKm(data?.hydrogenMatchedMileageKm), 'km'),
note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日纯氢里程' : '纯氢里程=总里程-纯电模式里程',
tone: 'success',
emphasis: 'primary'
}, {
label: '区间氢耗',
value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.periodHydrogenConsumptionKg), 'kg'),
note: unavailable
? '氢耗汇总暂不可用'
: loading
? '正在汇总质量下降'
: data?.hydrogenDataDays
? `${formatKm(data.hydrogenConsumptionKgPer100Km)} kg/100km · ${data.hydrogenDataDays} 个有效车辆日`
: '暂无通过质量校验的氢耗数据',
tone: 'warning',
emphasis: 'primary'
}, {
label: '百公里纯氢耗',
value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.hydrogenConsumptionKgPer100Km), 'kg/100km'),
note: unavailable ? '氢耗效率暂不可用' : loading ? '正在计算氢耗效率' : `${data?.hydrogenDataDays ?? 0} 个有效车辆日`,
tone: 'warning',
emphasis: 'primary'
}];
return <WorkspaceMetricRail
variant="queue"
className="v2-mileage-summary"
ariaLabel="里程查询统计信息"
items={items}
context={<span className="v2-mileage-summary-window">
<small>统计窗口</small>
<strong>{days} </strong>
<em>{summaryRange}</em>
</span>}
/>;
}
type HydrogenDayMetric = {
consumptionKg: number;
rateKgPer100Km?: number;
socBalancedKg?: number;
socBalancedRateKgPer100Km?: number;
evidenceAvailable: boolean;
qualityStatus?: string;
algorithmVersion?: string;
};
type VehicleMileageMatrix = VehicleOption & {
days: Map<string, number>;
pureElectricDays: Map<string, number>;
pureHydrogenDays: Map<string, number>;
hydrogenDays: Map<string, HydrogenDayMetric>;
sources: Map<string, string>;
totalMileageKm?: number;
totalPureElectricMileageKm?: number;
totalPureHydrogenMileageKm?: number;
totalHydrogenConsumptionKg?: number;
totalHydrogenRateKgPer100Km?: 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)}`;
}
function MileageMatrixGuide({
rows,
view,
hydrogenMetrics,
onHydrogenMetricsChange
}: {
rows: VehicleMileageMatrix[];
view: MetricView;
hydrogenMetrics: HydrogenMatrixMetric[];
onHydrogenMetricsChange: (value: HydrogenMatrixMetric[]) => void;
}) {
const vehiclesWithMileage = rows.filter((row) => row.days.size > 0).length;
if (view === 'hydrogen') return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide is-hydrogen has-metric-selector" role="note" aria-label="氢耗矩阵读表说明">
<div className="v2-mileage-matrix-guide-toolbar">
<span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong>本页 {vehiclesWithMileage} / {rows.length} 辆有数据</strong></span>
<HydrogenMetricSelector value={hydrogenMetrics} onChange={onHydrogenMetricsChange} />
</div>
<div className="v2-mileage-metric-logic" aria-label="指标取值逻辑">
<span><strong>里程</strong> 当日末累计里程-同源日基线</span>
<span><strong>用氢量</strong> 有效工作段剩余氢量下降分段累计,加氢与噪声跳过</span>
<span><strong>纯电里程</strong> 总里程-纯氢里程</span>
<span><strong>纯氢里程</strong> 运行模式 0x02 的相邻里程差累计</span>
<span><strong>百公里氢耗</strong> 有效用氢量÷纯氢里程×100</span>
<span><strong></strong> 无有效数据或不满足计算条件</span>
</div>
</aside>;
return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide" role="note" aria-label="里程矩阵读表说明">
<span className="v2-mileage-matrix-guide-title is-desktop-guide"><IconInfoCircle /><strong>本页 {vehiclesWithMileage} / {rows.length} 辆有数据</strong></span>
<span className="is-desktop-guide"><Tag color="blue" type="light" size="small">0 km</Tag>已上报、无里程增量</span>
<span className="is-desktop-guide"><Tag color="grey" type="light" size="small"></Tag>无可用里程</span>
<span className="is-desktop-guide"><Tag color="blue" type="light" size="small">色阶</Tag>颜色越深、里程越高</span>
<span className="is-desktop-guide is-mobile-hint">左右滑动查看日期 · 车牌与总里程固定</span>
<span className="v2-mileage-matrix-guide-mobile" aria-label="移动端里程矩阵读表说明">
<span className="is-mobile-hint"><IconInfoCircle /><strong>本页 {vehiclesWithMileage}/{rows.length} 辆有数据</strong> · 左右滑动日期</span>
<span aria-label="0 公里表示已上报、无里程增量"><Tag color="blue" type="light" size="small">0 km</Tag>无增量</span>
<span aria-label="横线表示无可用里程"><Tag color="grey" type="light" size="small"></Tag>无数据</span>
<span aria-label="颜色越深、里程越高"><Tag color="blue" type="light" size="small">色阶</Tag>深色更高</span>
</span>
</aside>;
}
function scrollMileageMatrixFromKeyboard(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const scroller = event.currentTarget.querySelector<HTMLElement>('.semi-table-body');
if (!scroller || scroller.scrollWidth <= scroller.clientWidth) return;
const maximum = scroller.scrollWidth - scroller.clientWidth;
const next = scroller.scrollLeft + (event.key === 'ArrowRight' ? 96 : -96);
scroller.scrollLeft = Math.max(0, Math.min(maximum, next));
event.preventDefault();
}
function matrixDailyMetricValue(row: VehicleMileageMatrix, date: string, metric: MatrixMetric) {
switch (metric) {
case 'mileage': return row.days.get(date);
case 'consumption': return row.hydrogenDays.get(date)?.consumptionKg;
case 'pureElectric': return row.pureElectricDays.get(date);
case 'pureHydrogen': return row.pureHydrogenDays.get(date);
case 'rate': return row.hydrogenDays.get(date)?.rateKgPer100Km;
}
}
function matrixTotalMetricValue(row: VehicleMileageMatrix, metric: MatrixMetric) {
switch (metric) {
case 'mileage': return row.totalMileageKm;
case 'consumption': return row.totalHydrogenConsumptionKg;
case 'pureElectric': return row.totalPureElectricMileageKm;
case 'pureHydrogen': return row.totalPureHydrogenMileageKm;
case 'rate': return row.totalHydrogenRateKgPer100Km;
}
}
function MatrixMetricHeader({ metric }: { metric: MatrixMetric }) {
const meta = MATRIX_METRIC_META[metric];
return <span className="v2-mileage-metric-header"><strong>{meta.label}</strong><small>{meta.unit}</small></span>;
}
function MatrixMetricValue({ value, metric, onEvidence }: { value?: number; metric: MatrixMetric; onEvidence?: () => void }) {
if (value == null || !Number.isFinite(value)) return <></>;
const content = <strong>{formatKm(value)} {MATRIX_METRIC_META[metric].unit}</strong>;
if (!onEvidence) return content;
return <button className="v2-hydrogen-evidence-trigger" type="button" title="查看计算公式、区间和原始报文标识" onClick={onEvidence}>{content}<small>可溯源</small></button>;
}
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view, hydrogenMetrics, onOpenEvidence }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement>; view: MetricView; hydrogenMetrics: HydrogenMatrixMetric[]; onOpenEvidence: (row: VehicleMileageMatrix, date: string) => void }) {
const { columns, tableWidth } = useMemo(() => {
let maxDailyMileage = 1;
for (const row of rows) {
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
}
const metrics: MatrixMetric[] = view === 'hydrogen' ? ['mileage', ...hydrogenMetrics] : ['mileage'];
const metricsWidth = metrics.reduce((sum, metric) => sum + MATRIX_METRIC_META[metric].width, 0);
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:${date}`,
className: 'is-date-group',
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 指标分组` }),
children: metrics.map((metric) => {
const meta = MATRIX_METRIC_META[metric];
return {
title: <MatrixMetricHeader metric={metric} />,
dataIndex: `${date}:${metric}`,
className: `is-number is-date is-metric-${metric}`,
width: meta.width,
onHeaderCell: () => ({ 'aria-label': `${date} ${meta.label} ${meta.unit}` }),
onCell: (row?: VehicleMileageMatrix) => {
const value = row ? matrixDailyMetricValue(row, date, metric) : undefined;
const mileage = row?.days.get(date);
const source = row?.sources.get(date);
const intensity = metric === 'mileage' && mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return {
className: `is-number is-date is-metric-${metric}${value != null ? ' is-daily' : ' is-empty'}`,
title: value != null ? `${meta.label} ${formatKm(value)} ${meta.unit} · 来源:${source || '—'}` : `${meta.label}无可用数据`,
'aria-label': value != null
? `${date}${meta.label} ${formatKm(value)} ${meta.unit},来源 ${source || '未知'}`
: `${date}${meta.label}无可用数据`,
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => {
const traceable = (metric === 'consumption' || metric === 'rate') && row.hydrogenDays.get(date)?.evidenceAvailable;
return <MatrixMetricValue value={matrixDailyMetricValue(row, date, metric)} metric={metric} onEvidence={traceable ? () => onOpenEvidence(row, date) : undefined} />;
}
};
})
})),
{
title: '区间合计',
dataIndex: 'period',
className: 'is-total is-total-group',
children: metrics.map((metric) => {
const meta = MATRIX_METRIC_META[metric];
return {
title: <MatrixMetricHeader metric={metric} />,
dataIndex: `period:${metric}`,
className: `is-number is-total is-metric-${metric}`,
width: meta.width,
onHeaderCell: () => ({ 'aria-label': `区间${meta.label} ${meta.unit}` }),
onCell: (row?: VehicleMileageMatrix) => {
const value = row ? matrixTotalMetricValue(row, metric) : undefined;
return {
className: `is-number is-period is-total is-metric-${metric}${value == null ? ' is-empty' : ''}`,
'aria-label': value == null
? metric === 'mileage' ? '区间总里程,无可用里程' : `区间${meta.label},无可用数据`
: `区间${meta.label}${formatKm(value)} ${meta.unit}`
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => <MatrixMetricValue value={matrixTotalMetricValue(row, metric)} metric={metric} />
};
})
}
],
tableWidth: 120 + (dates.length + 1) * metricsWidth
};
}, [dates, hydrogenMetrics, onOpenEvidence, rows, view]);
return <div
className={`v2-mileage-table-wrap${view === 'hydrogen' ? ' is-hydrogen-view' : ''}`}
ref={scrollRef}
role="region"
aria-label={`车辆每日${view === 'mileage' ? '里程' : '氢耗'}矩阵,可横向滚动查看日期`}
aria-describedby="v2-mileage-matrix-guide"
tabIndex={0}
onKeyDown={scrollMileageMatrixFromKeyboard}
style={{ '--v2-mileage-mobile-table-width': `${Math.max(556, tableWidth)}px` } as CSSProperties}
>
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
</div>;
});
type HydrogenEvidenceTarget = { vin: string; plate: string; date: string };
function evidenceNumber(value?: number | null, digits = 3) {
if (value == null || !Number.isFinite(value)) return '—';
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function HydrogenEvidenceSheet({ target, onClose }: { target?: HydrogenEvidenceTarget; onClose: () => void }) {
const evidence = useQuery<HydrogenDailyEvidence>({
queryKey: ['hydrogen-daily-evidence', target?.vin, target?.date],
queryFn: ({ signal }) => api.hydrogenDailyEvidence(target!.vin, target!.date, signal),
enabled: Boolean(target),
staleTime: CURRENT_DAY_REFRESH_MS,
gcTime: QUERY_MEMORY.summaryGcTime
});
const data = evidence.data;
return <WorkspaceSideSheet
className="v2-hydrogen-evidence-sheet"
variant="detail"
visible={Boolean(target)}
ariaLabel="氢耗计算溯源"
closeLabel="关闭氢耗计算溯源"
dialogId="v2-hydrogen-evidence-sheet"
title={`${target?.plate || target?.vin || '车辆'} · ${target?.date || ''}`}
description="每日结果、计算参数、有效区间及原始报文标识"
icon={<IconInfoCircle />}
badge={data?.qualityStatus || '读取中'}
badgeColor={data?.qualityStatus === 'OK' ? 'green' : data?.qualityStatus === 'SUSPECT' ? 'orange' : 'grey'}
width="min(760px, 94vw)"
summaryItems={data ? [
{ label: '物理耗氢', value: `${evidenceNumber(data.rawConsumptionKg)} kg`, detail: 'NIST压力—质量差分段累计', tone: 'primary' },
{ label: 'SOC平衡氢耗', value: data.socBalancedConsumptionKg == null ? '未计算' : `${evidenceNumber(data.socBalancedConsumptionKg)} kg`, detail: data.parameters.batteryCapacityKWh > 0 ? `电池 ${evidenceNumber(data.parameters.batteryCapacityKWh, 2)} kWh` : '缺少经确认的电池容量', tone: 'warning' },
{ label: '混动里程', value: `${evidenceNumber(data.mixedMileageKm)} km`, detail: `${data.validSegmentCount} 个有效区间`, tone: 'success' }
] : []}
footerNote="物理耗氢与SOC修正值分开保存;点击区间中的报文ID可复制后到原始报文查询中检索。"
primaryAction={{ label: '关闭', onClick: onClose }}
onCancel={onClose}
>
{evidence.isLoading ? <PanelLoading title="正在读取计算证据" description="加载每日参数与区间明细。" /> : null}
{evidence.error ? <InlineError message={evidence.error instanceof Error ? evidence.error.message : '氢耗计算证据加载失败'} onRetry={() => evidence.refetch()} /> : null}
{data ? <div className="v2-hydrogen-evidence-content">
<section>
<header><strong>计算公式与口径</strong><Tag color="blue" type="light" size="small">{data.algorithmVersion}</Tag></header>
<code>m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Zₙᵢₛₜ)</code>
<code>区间物理耗氢 = 起始质量 结束质量</code>
<code>SOC平衡氢耗 = 物理耗氢 + 电池容量 × (起始SOC 结束SOC) ÷ 100 ÷ {evidenceNumber(data.parameters.hydrogenEnergyKWhPerKg, 1)}</code>
<p>{data.qualityReason || '数据通过完整性、运行状态、加氢、充电、异常跳变和端点中位数校验。'}</p>
</section>
<section>
<header><strong>参数与质量计数</strong></header>
<dl className="v2-hydrogen-evidence-parameters">
<div><dt>上电稳定</dt><dd>{data.parameters.powerOnDelaySeconds}s</dd></div>
<div><dt>下电提前</dt><dd>{data.parameters.powerOffLeadSeconds}s</dd></div>
<div><dt>加氢识别</dt><dd>+{data.parameters.refuelRiseMpa}MPa / {data.parameters.refuelSustainSeconds}s</dd></div>
<div><dt>纯电异常</dt><dd>-{data.parameters.pureElectricDropMpa}MPa / {data.parameters.pureElectricWindowSeconds}s</dd></div>
<div><dt>样本</dt><dd>{data.sampleCount}</dd></div>
<div><dt>加氢 / 充电</dt><dd>{data.refuelCount} / {data.chargeCount}</dd></div>
<div><dt>无效区间</dt><dd>{data.invalidSegmentCount}</dd></div>
<div><dt>计算时间</dt><dd>{data.calculatedAt}</dd></div>
</dl>
</section>
<section>
<header><strong>区间计算证据</strong><small>{data.intervals.length} 个区间</small></header>
<div className="v2-hydrogen-evidence-intervals">
{data.intervals.map((interval) => <article key={`${interval.index}-${interval.startTime}`} className={`is-${interval.qualityStatus.toLowerCase()}`}>
<header><span><Tag color={interval.type === 'MIXED' ? 'green' : 'blue'} type="light" size="small">{interval.type === 'MIXED' ? '混动' : '纯电'}</Tag><strong>区间 {interval.index}</strong></span><Tag color={interval.qualityStatus === 'OK' ? 'green' : 'orange'} type="light" size="small">{interval.qualityStatus}</Tag></header>
<p>{interval.startTime} {interval.endTime} · {interval.sampleCount} 条样本</p>
<div className="v2-hydrogen-evidence-metrics">
<span><small>压力</small><strong>{evidenceNumber(interval.startPressureMpa)} {evidenceNumber(interval.endPressureMpa)} MPa</strong></span>
<span><small>温度</small><strong>{evidenceNumber(interval.startTemperatureC, 1)} {evidenceNumber(interval.endTemperatureC, 1)} </strong></span>
<span><small>质量</small><strong>{evidenceNumber(interval.startMassKg)} {evidenceNumber(interval.endMassKg)} kg</strong></span>
<span><small>物理耗氢</small><strong>{evidenceNumber(interval.rawHydrogenConsumptionKg)} kg</strong></span>
<span><small>SOC</small><strong>{evidenceNumber(interval.startSocPercent, 1)} {evidenceNumber(interval.endSocPercent, 1)} %</strong></span>
<span><small>区间里程</small><strong>{evidenceNumber(interval.mileageKm)} km</strong></span>
<span><small>电池折氢</small><strong>{evidenceNumber(interval.batteryEquivalentKg)} kg</strong></span>
<span><small>SOC平衡氢耗</small><strong>{evidenceNumber(interval.socBalancedConsumptionKg)} kg</strong></span>
</div>
<div className="v2-hydrogen-evidence-events">
<button type="button" onClick={() => navigator.clipboard?.writeText(interval.startEventId)}><small>起始报文</small><code>{interval.startEventId || '—'}</code></button>
<button type="button" onClick={() => navigator.clipboard?.writeText(interval.endEventId)}><small>结束报文</small><code>{interval.endEventId || '—'}</code></button>
</div>
{interval.qualityReason ? <p className="is-reason">{interval.qualityReason}</p> : null}
</article>)}
</div>
</section>
</div> : null}
</WorkspaceSideSheet>;
}
export default function StatisticsPage() {
const { session } = usePlatformSession();
const [searchParams, setSearchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const vehicleReturn = vehicleReturnFromParams(searchParams);
const mobileLayout = useMobileLayout();
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 [metricView, setMetricView] = useState<MetricView>('mileage');
const [hydrogenMatrixMetrics, setHydrogenMatrixMetrics] = useState<HydrogenMatrixMetric[]>(DEFAULT_HYDROGEN_MATRIX_METRICS);
const [hydrogenEvidenceTarget, setHydrogenEvidenceTarget] = useState<HydrogenEvidenceTarget>();
const hydrogenViewAllowed = session.role !== 'customer' && session.userType !== 'customer';
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const exportControllerRef = useRef<AbortController | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(null);
const openHydrogenEvidence = useCallback((row: VehicleMileageMatrix, date: string) => {
setHydrogenEvidenceTarget({ vin: row.vin, plate: row.plate, date });
}, []);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
exportControllerRef.current?.abort();
};
}, []);
useEffect(() => {
if (!hydrogenViewAllowed && metricView !== 'mileage') setMetricView('mileage');
}, [hydrogenViewAllowed, metricView]);
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.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
: (fleetVehicles.data?.items ?? []).map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })), [criteria.vehicles, fleetVehicles.data?.items, hasVehicles, page]);
const statisticsQuery = useMemo(() => mileageQuery(criteria, -1), [criteria]);
const statisticsScope = JSON.stringify(statisticsQuery);
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
const rowsQuery = useMemo(() => mileageQuery(rowsCriteria, 0), [rowsCriteria]);
const currentDayRange = !criteriaError && mileageRangeContainsDate(criteria);
const currentDayRefreshInterval = currentDayRange ? CURRENT_DAY_REFRESH_MS : false;
const statistics = useQuery({
queryKey: ['mileage-statistics', statisticsScope],
queryFn: ({ signal }) => api.mileageStatistics(statisticsQuery, signal),
enabled: !criteriaError,
staleTime: CURRENT_DAY_REFRESH_MS,
gcTime: QUERY_MEMORY.summaryGcTime,
refetchInterval: currentDayRefreshInterval,
refetchIntervalInBackground: false
});
const mileage = useQuery<Page<DailyMileageRow>>({
queryKey: ['daily-mileage-query', statisticsScope, rowsQuery],
queryFn: ({ signal }) => api.dailyMileage(rowsQuery, signal),
enabled: !criteriaError && displayVehicles.length > 0,
staleTime: CURRENT_DAY_REFRESH_MS,
gcTime: QUERY_MEMORY.highVolumeGcTime,
placeholderData: retainPreviousPageWithinScope<Page<DailyMileageRow>>(statisticsScope, 2),
refetchInterval: currentDayRefreshInterval,
refetchIntervalInBackground: false
});
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>;
pureElectricDays: Map<string, number>;
pureHydrogenDays: Map<string, number>;
hydrogenDays: Map<string, HydrogenDayMetric>;
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(), pureElectricDays: new Map(), pureHydrogenDays: new Map(), hydrogenDays: 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);
const pureHydrogenMileage = Math.max(0, Math.min(row.dailyMileageKm, row.pureHydrogenMileageKm ?? 0));
entry.pureHydrogenDays.set(row.date, pureHydrogenMileage);
entry.pureElectricDays.set(row.date, Math.max(0, row.dailyMileageKm - pureHydrogenMileage));
if (row.hydrogenConsumptionKg != null) {
entry.hydrogenDays.set(row.date, {
consumptionKg: row.hydrogenConsumptionKg,
rateKgPer100Km: row.hydrogenConsumptionKgPer100Km ?? undefined,
socBalancedKg: row.hydrogenSocBalancedKg ?? undefined,
socBalancedRateKgPer100Km: row.hydrogenSocBalancedKgPer100Km ?? undefined,
evidenceAvailable: row.hydrogenEvidenceAvailable === true,
qualityStatus: row.hydrogenQualityStatus,
algorithmVersion: row.hydrogenAlgorithmVersion
});
}
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 pureElectricDays = daily?.pureElectricDays ?? new Map<string, number>();
const pureHydrogenDays = daily?.pureHydrogenDays ?? new Map<string, number>();
const hydrogenDays = daily?.hydrogenDays ?? new Map<string, HydrogenDayMetric>();
const sources = daily?.sources ?? new Map<string, string>();
let dailyTotal = 0;
let hydrogenTotal = 0;
let hydrogenRatedMileage = 0;
let pureElectricTotal = 0;
let pureHydrogenTotal = 0;
for (const value of days.values()) dailyTotal += value;
for (const value of pureElectricDays.values()) pureElectricTotal += value;
for (const value of pureHydrogenDays.values()) pureHydrogenTotal += value;
for (const [date, value] of hydrogenDays) {
const matchedMileage = pureHydrogenDays.get(date) ?? 0;
if (matchedMileage <= 0) continue;
hydrogenTotal += value.consumptionKg;
hydrogenRatedMileage += matchedMileage;
}
return {
...vehicle,
plate: vehicle.plate || daily?.plate || ranking?.plate || '',
days,
pureElectricDays,
pureHydrogenDays,
hydrogenDays,
sources,
totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined,
totalPureElectricMileageKm: pureElectricDays.size ? pureElectricTotal : undefined,
totalPureHydrogenMileageKm: pureHydrogenDays.size ? pureHydrogenTotal : undefined,
totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : undefined,
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenTotal * 100 / hydrogenRatedMileage : undefined
};
}), [displayVehicles, mileageByVin, rankingByVin]);
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE));
const applyDraft = () => {
const error = mileageDateRangeError(draft);
setValidationError(error);
if (error) return;
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(preserveVehicleReturn(preserveMonitorReturn(mileageRouteParams(draft), monitorReturn), vehicleReturn), { replace: true }); setFiltersCollapsed(true);
};
const resetDraft = () => {
setDraft(initialCriteria(new URLSearchParams()));
setValidationError('');
};
const submit = (event: FormEvent) => { event.preventDefault(); applyDraft(); };
const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(preserveVehicleReturn(preserveMonitorReturn(mileageRouteParams(next), monitorReturn), vehicleReturn), { replace: true }); setFiltersCollapsed(true); };
const closeMobileFilters = () => {
setDraft(criteria);
setValidationError('');
setFiltersCollapsed(true);
};
const toggleFilters = () => {
if (!mobileLayout) {
setFiltersCollapsed((value) => !value);
return;
}
if (mobileFiltersOpen) {
closeMobileFilters();
return;
}
setDraft(criteria);
setValidationError('');
setFiltersCollapsed(false);
};
const todayRange = defaultWindow(1);
const yesterdayRange = defaultWindow(1, 1);
const sevenDayRange = defaultWindow(7);
const thirtyDayRange = defaultWindow(30);
const ninetyDayRange = defaultWindow(90);
const quickRanges = [
{ label: '今天', range: todayRange },
{ label: '昨天', range: yesterdayRange },
{ label: '近 7 天', range: sevenDayRange },
{ label: '近 30 天', range: thirtyDayRange },
{ label: '近 90 天', range: ninetyDayRange }
];
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
// Fleet statistics are supplementary to the paged mileage matrix. A large
// authorized fleet may time out while the current page of vehicle rows is
// still available, so keep the summary-level unavailable state from hiding
// otherwise usable daily mileage data.
const resultsError = mileage.error ?? (!hasVehicles ? fleetVehicles.error : undefined);
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[];
if (criteria.vehicles.length) {
const archiveByVIN = new Map<string, VehicleOption>();
reportProgress({ label: '正在读取车辆档案', completed: 0, total: criteria.vehicles.length });
for (let start = 0; start < criteria.vehicles.length; start += BATCH_RESOLVE_SIZE) {
const batch = criteria.vehicles.slice(start, start + BATCH_RESOLVE_SIZE);
const result = await api.vehicleCoverage(new URLSearchParams({
keywords: batch.map((vehicle) => vehicle.vin).join(','),
limit: String(batch.length),
offset: '0',
bindingStatus: 'bound'
}), controller.signal);
for (const vehicle of result.items) archiveByVIN.set(vehicle.vin, {
vin: vehicle.vin,
plate: vehicle.plate,
brandName: vehicle.brandName,
modelName: vehicle.modelName
});
reportProgress({ label: '正在读取车辆档案', completed: Math.min(start + batch.length, criteria.vehicles.length), total: criteria.vehicles.length });
}
vehicles = criteria.vehicles.map((vehicle) => ({ ...vehicle, ...archiveByVIN.get(vehicle.vin), vin: vehicle.vin }));
} else {
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,
brandName: vehicle.brandName,
modelName: vehicle.modelName
})));
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[]];
const exportWindows = vehicleBatches.flatMap((vehicleBatch, batchIndex) => mileageExportDateWindows(
criteria.dateFrom,
criteria.dateTo,
vehicleBatch.length || vehicles.length
).map((window) => ({ vehicleBatch, batchIndex, window })));
let completedWindows = 0;
for (const { vehicleBatch, batchIndex, window } of exportWindows) {
let offset = 0;
const batchLabel = vehicleBatches.length > 1 ? ` · 车辆批次 ${batchIndex + 1}/${vehicleBatches.length}` : '';
const mileageLabel = `正在读取里程数据(${window.dateFrom}${window.dateTo}${batchLabel}`;
reportProgress({ label: mileageLabel, completed: completedWindows, total: exportWindows.length });
while (true) {
const query = mileageQuery({ ...criteria, ...window, vehicles: vehicleBatch }, offset);
const result = await api.dailyMileage(query, 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;
if (!result.items.length || offset >= result.total) break;
}
completedWindows++;
reportProgress({ label: mileageLabel, completed: completedWindows, total: exportWindows.length });
}
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();
};
const rangeSummary = criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天`;
const filterForm = <form className="v2-mileage-filter" onSubmit={submit}>
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
<label className="v2-mileage-date-field">
<span id="v2-mileage-date-range-label">日期范围</span>
<DatePicker
className="v2-mileage-date-range"
type="dateRange"
density="compact"
format="yyyy-MM-dd"
value={[draft.dateFrom, draft.dateTo]}
placeholder={['开始日期', '结束日期']}
showClear={false}
onChangeWithDateFirst
aria-labelledby="v2-mileage-date-range-label"
onChange={(value, formattedValue) => {
const range = datePickerRange(value, formattedValue);
if (range) setDraft((current) => ({ ...current, ...range }));
}}
/>
</label>
<Button className="v2-primary-button v2-mileage-inline-submit" theme="solid" htmlType="submit">查询</Button>
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
<MileageRangeShortcuts value={draft} ranges={quickRanges} onChange={setRange} />
</form>;
const filterValidation = validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null;
return <div className="v2-mileage-page" ref={pageRef}>
<MonitorReturnBar />
<div className="v2-mileage-discovery-shell">
{mobileLayout ? <div className="v2-mileage-mobile-discovery">
<MobileFilterToggle
title="里程范围"
summary={rangeSummary}
expanded={mobileFiltersOpen}
controls="v2-mileage-filter-panel"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={toggleFilters}
/>
{hydrogenViewAllowed ? <MetricViewSwitch value={metricView} onChange={setMetricView} /> : null}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} view={metricView} unavailable={statistics.isError} />
<MobileFilterSheet
className="v2-mileage-filter-sidesheet"
visible={mobileFiltersOpen}
height="min(82dvh, 690px)"
ariaLabel="里程范围筛选"
dialogId="v2-mileage-filter-panel"
closeLabel="关闭里程范围筛选"
title="里程范围"
description="选择车辆、自然日与数据源"
onCancel={closeMobileFilters}
secondaryAction={{ label: '重置条件', onClick: resetDraft }}
primaryAction={{ label: '应用并查询', onClick: applyDraft }}
>
<MobileFilterSheetSection ariaLabel="里程查询条件" title="查询范围" description="支持批量粘贴最多 50,000 辆,按自然日比较优先数据源">
{filterForm}
</MobileFilterSheetSection>
{filterValidation}
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-mileage-query-panel"
title="里程范围"
description="车牌、自然日与里程数据源策略"
mobileSummary={rangeSummary}
expanded={!filtersCollapsed}
status={`${criteria.vehicles.length ? `${criteria.vehicles.length} 辆` : '全部授权车辆'} · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天`}
statusColor={criteria.vehicles.length ? 'blue' : 'grey'}
collapsedLabel="修改"
onToggle={toggleFilters}
>
{filterForm}
{filterValidation}
{hydrogenViewAllowed ? <MetricViewSwitch value={metricView} onChange={setMetricView} /> : null}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} view={metricView} unavailable={statistics.isError} />
</WorkspaceFilterPanel>}
</div>
<Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
eyebrow={metricView === 'mileage' ? '每日里程' : '每日氢耗'}
tone={metricView === 'mileage' ? 'primary' : 'health'}
title={metricView === 'mileage' ? '车辆每日里程' : '车辆每日氢耗'}
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>
{exportPercent == null
? <Spin className="v2-mileage-export-spinner" size="small" />
: <Progress className="v2-mileage-export-bar" percent={exportPercent} showInfo={false} size="small" strokeLinecap="round" aria-label={`${exportProgress.label} ${exportPercent}%`} />}
</div> : null}
{resultsError ? <InlineError message={resultsError instanceof Error ? resultsError.message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
{!resultsError && !resultsLoading && displayVehicles.length ? <MileageMatrixGuide rows={matrixRows} view={metricView} hydrogenMetrics={hydrogenMatrixMetrics} onHydrogenMetricsChange={setHydrogenMatrixMetrics} /> : null}
{!resultsError && (resultsLoading ? <PanelLoading className="v2-mileage-loading" title={`正在查询${metricView === 'mileage' ? '里程' : '氢耗'}`} description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} view={metricView} hydrogenMetrics={hydrogenMatrixMetrics} onOpenEvidence={openHydrogenEvidence} /> : null)}
{!resultsError && !resultsLoading && !displayVehicles.length ? <PanelEmpty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
<footer>{totalVehicles > PAGE_SIZE ? <TablePagination page={page} totalPages={totalPages} info={`${hasVehicles ? '已选择' : '共'} ${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE}${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={mileage.isFetching || (!hasVehicles && fleetVehicles.isFetching)} onPageChange={setPage} /> : <span className="v2-table-pagination-info">{hasVehicles ? '已选择' : '共'} {totalVehicles.toLocaleString('zh-CN')} 辆车辆{exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>
</Card>
<HydrogenEvidenceSheet target={hydrogenEvidenceTarget} onClose={() => setHydrogenEvidenceTarget(undefined)} />
<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>;
}