|
|
|
|
@@ -16,6 +16,7 @@ const PAGE_SIZE = 20;
|
|
|
|
|
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
|
|
|
|
|
const EXPORT_VIN_BATCH_SIZE = 50;
|
|
|
|
|
const MOBILE_MILEAGE_QUERY = '(max-width: 680px)';
|
|
|
|
|
const MAX_MILEAGE_RANGE_DAYS = 366;
|
|
|
|
|
|
|
|
|
|
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
|
|
|
|
|
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
|
|
|
|
|
@@ -46,9 +47,17 @@ function formatKm(value?: number) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function inclusiveDays(dateFrom: string, dateTo: string) {
|
|
|
|
|
const from = Date.parse(`${dateFrom}T00:00:00`);
|
|
|
|
|
const to = Date.parse(`${dateTo}T00:00:00`);
|
|
|
|
|
return Number.isFinite(from) && Number.isFinite(to) ? Math.max(1, Math.round((to - from) / DAY) + 1) : 0;
|
|
|
|
|
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) {
|
|
|
|
|
@@ -219,9 +228,12 @@ function useMobileMileageLayout() {
|
|
|
|
|
|
|
|
|
|
function rangeDates(dateFrom: string, dateTo: string) {
|
|
|
|
|
const dates: string[] = [];
|
|
|
|
|
const cursor = new Date(`${dateFrom}T00:00:00`);
|
|
|
|
|
const end = new Date(`${dateTo}T00:00:00`);
|
|
|
|
|
while (cursor <= end) { dates.push(localDate(cursor)); cursor.setDate(cursor.getDate() + 1); }
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -257,6 +269,7 @@ export default function StatisticsPage() {
|
|
|
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
|
|
|
const [exportFeedback, setExportFeedback] = useState('');
|
|
|
|
|
const [exportProgress, setExportProgress] = useState<ExportProgress>();
|
|
|
|
|
const [validationError, setValidationError] = useState('');
|
|
|
|
|
const exportControllerRef = useRef<AbortController | null>(null);
|
|
|
|
|
const mountedRef = useRef(true);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
@@ -267,6 +280,7 @@ export default function StatisticsPage() {
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
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()],
|
|
|
|
|
@@ -282,9 +296,9 @@ export default function StatisticsPage() {
|
|
|
|
|
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), 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: displayVehicles.length > 0, staleTime: 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime, placeholderData: retainPreviousPageWithinScope<Page<DailyMileageRow>>(statisticsScope, 2) });
|
|
|
|
|
const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]);
|
|
|
|
|
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 ?? []) {
|
|
|
|
|
@@ -310,8 +324,14 @@ export default function StatisticsPage() {
|
|
|
|
|
}), [displayVehicles, mileageByVin, rankingByVin]);
|
|
|
|
|
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
|
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE));
|
|
|
|
|
const submit = (event: FormEvent) => { event.preventDefault(); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); };
|
|
|
|
|
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
|
|
|
|
|
const submit = (event: FormEvent) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const error = mileageDateRangeError(draft);
|
|
|
|
|
setValidationError(error);
|
|
|
|
|
if (error) return;
|
|
|
|
|
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true });
|
|
|
|
|
};
|
|
|
|
|
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
|
|
|
|
|
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
|
|
|
|
|
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
|
|
|
|
|
const exportPercent = exportProgress?.total
|
|
|
|
|
@@ -319,7 +339,7 @@ export default function StatisticsPage() {
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
const exportExcel = async () => {
|
|
|
|
|
if (exportControllerRef.current || !totalVehicles) return;
|
|
|
|
|
if (exportControllerRef.current || !totalVehicles || criteriaError) return;
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
exportControllerRef.current = controller;
|
|
|
|
|
const reportProgress = (progress: ExportProgress) => {
|
|
|
|
|
@@ -406,11 +426,12 @@ export default function StatisticsPage() {
|
|
|
|
|
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
|
|
|
|
|
<div className="v2-mileage-ranges"><span>快捷范围</span><button type="button" onClick={() => setDays(7)}>近 7 天</button><button type="button" onClick={() => setDays(30)}>近 30 天</button><button type="button" onClick={() => setDays(90)}>近 90 天</button></div>
|
|
|
|
|
</form>
|
|
|
|
|
{validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null}
|
|
|
|
|
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
|
|
|
|
|
</section>
|
|
|
|
|
{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}
|
|
|
|
|
<section className="v2-mileage-results">
|
|
|
|
|
<header><div><strong>车辆每日里程</strong><span>{criteria.dateFrom} 至 {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · {dates.length} 个自然日</em><button type="button" aria-label={isExporting ? '取消导出' : '导出 Excel'} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles}>{isExporting ? <IconClose /> : <IconDownload />}{isExporting ? '取消导出' : '导出 Excel'}</button></div></header>
|
|
|
|
|
<header><div><strong>车辆每日里程</strong><span>{criteria.dateFrom} 至 {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · {dates.length} 个自然日</em><button type="button" aria-label={isExporting ? '取消导出' : '导出 Excel'} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles || Boolean(criteriaError)}>{isExporting ? <IconClose /> : <IconDownload />}{isExporting ? '取消导出' : '导出 Excel'}</button></div></header>
|
|
|
|
|
{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>
|
|
|
|
|
|