1366 lines
75 KiB
TypeScript
1366 lines
75 KiB
TypeScript
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>;
|
||
}
|