diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 65aa79e1..d53efbc7 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -419,6 +419,21 @@ func TestNormalizeMileageStatisticsWindow(t *testing.T) { } } +func TestNormalizeDailyMileageWindowBoundsOnlyDateFilteredQueries(t *testing.T) { + now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.Local) + unfiltered, err := normalizeDailyMileageWindow(url.Values{"limit": {"20"}}, now) + if err != nil || unfiltered.Get("dateFrom") != "" || unfiltered.Get("dateTo") != "" { + t.Fatalf("unfiltered daily mileage must stay compatible: %v, err=%v", unfiltered, err) + } + toOnly, err := normalizeDailyMileageWindow(url.Values{"dateTo": {"2026-07-14"}}, now) + if err != nil || toOnly.Get("dateFrom") != "2026-06-15" { + t.Fatalf("dateTo-only daily mileage should receive a bounded default start: %v, err=%v", toOnly, err) + } + if _, err := normalizeDailyMileageWindow(url.Values{"dateFrom": {"2025-01-01"}, "dateTo": {"2026-07-14"}}, now); err == nil { + t.Fatal("date-filtered daily mileage over 366 days must be rejected") + } +} + func TestNormalizeMileageVINSelection(t *testing.T) { normalized, err := normalizeMileageVINSelection(url.Values{"vins": {" VIN001,VIN002,VIN001 "}}) if err != nil || normalized.Get("vins") != "VIN001,VIN002" { diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 00bfe273..a4848947 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -3177,6 +3177,10 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail if err != nil { return Page[DailyMileageRow]{}, err } + resolvedQuery, err = normalizeDailyMileageWindow(resolvedQuery, time.Now()) + if err != nil { + return Page[DailyMileageRow]{}, err + } return s.store.DailyMileage(ctx, resolvedQuery) } @@ -3243,6 +3247,13 @@ func normalizeMileageStatisticsWindow(query url.Values, now time.Time) (url.Valu return next, nil } +func normalizeDailyMileageWindow(query url.Values, now time.Time) (url.Values, error) { + if strings.TrimSpace(query.Get("dateFrom")) == "" && strings.TrimSpace(query.Get("dateTo")) == "" { + return cloneValues(query), nil + } + return normalizeMileageStatisticsWindow(query, now) +} + func (s *Service) OnlineStatisticsSummary(ctx context.Context, query url.Values) (OnlineStatisticsSummary, error) { resolvedQuery, err := s.resolveVehicleQuery(ctx, query) if err != nil { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx index 18d60b78..ebbfde56 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx @@ -56,6 +56,17 @@ test('renders only the desktop matrix with dates as columns and a period total', expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT'); }); +test('rejects overlong mileage ranges before querying or rendering a huge matrix', async () => { + prepareData(); + const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2025-01-01&dateTo=2026-07-14'); + + expect(await screen.findByRole('alert')).toHaveTextContent('单次最多查询 366 个自然日'); + expect(mocks.mileageStatistics).not.toHaveBeenCalled(); + expect(mocks.dailyMileage).not.toHaveBeenCalled(); + expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(0); + expect(screen.getByRole('button', { name: '导出 Excel' })).toBeDisabled(); +}); + test('removes the previous mileage scope while a new date range is loading', async () => { prepareData(); renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx index d33e46d3..e200907d 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -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; 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) { + 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(); + const [validationError, setValidationError] = useState(''); const exportControllerRef = useRef(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>({ 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>(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>({ 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>(statisticsScope, 2) }); + const dates = useMemo(() => criteriaError ? [] : rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo, criteriaError]); const mileageByVin = useMemo(() => { const index = new Map; sources: Map }>(); 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() { setDraft((current) => ({ ...current, sources }))} />
快捷范围
+ {validationError || criteriaError ?

{validationError || criteriaError}

: null} {statistics.isError || mileage.isError || fleetVehicles.isError ? { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
-
车辆每日里程{criteria.dateFrom} 至 {criteria.dateTo}
{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · {dates.length} 个自然日
+
车辆每日里程{criteria.dateFrom} 至 {criteria.dateTo}
{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · {dates.length} 个自然日
{exportProgress ?
{exportProgress.label}{exportPercent == null ? '处理中' : `${exportPercent}%`} diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index a51df8fb..22025f19 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -993,6 +993,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-mileage-filter > label > input { width: 100%; height: 40px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 0 11px; color: #253248; outline: 0; font-family: inherit; font-size: 13px; transition: border-color .16s ease, box-shadow .16s ease; } .v2-mileage-filter > label > input:focus, .v2-mileage-multiselect:focus-within { border-color: #6d9ff1; box-shadow: 0 0 0 3px rgba(37,99,235,.08); } .v2-mileage-filter > .v2-primary-button { height: 40px; margin-top: 18px; border-radius: 7px; padding: 0 22px; box-shadow: 0 4px 10px rgba(37,99,235,.16); font-size: 12px; } +.v2-mileage-validation { margin: -5px 20px 14px; border: 1px solid #f1c5c8; border-radius: 7px; background: #fff7f7; padding: 9px 11px; color: #b54046; font-size: 11px; line-height: 1.5; } .v2-mileage-vehicle-field { position: relative; } .v2-mileage-vehicle-field > small { color: #96a1b0; font-size: 9px; font-weight: 400; } .v2-mileage-multiselect { position: relative; display: flex; min-height: 40px; align-items: flex-start; gap: 8px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 6px 10px; color: #8a98aa; transition: border-color .16s ease, box-shadow .16s ease; }