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