import { Button, Card, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui'; import { useEffect, useMemo, useState } from 'react'; import { api } from '../api/client'; import type { DailyMileageRow, MileageStatistics, MileageTrendPoint } from '../api/types'; import { PageHeader } from '../components/PageHeader'; const DAY = 86_400_000; type MileageFilters = { keyword: string; protocol: string; dateFrom: string; dateTo: string; }; type MileageProps = { initialVin: string; initialProtocol?: string; initialFilters?: Record; onFiltersChange?: (filters: Record) => void; onOpenVehicle: (vin: string, protocol?: string) => void; onOpenHistory?: (filters: Record) => void; onOpenRaw?: (filters: Record) => void; }; function localDate(value = new Date()) { const offset = value.getTimezoneOffset() * 60_000; return new Date(value.getTime() - offset).toISOString().slice(0, 10); } function defaultWindow(days = 30) { const end = new Date(); return { dateFrom: localDate(new Date(end.getTime() - (days - 1) * DAY)), dateTo: localDate(end) }; } function initialMileageFilters(initialVin: string, initialProtocol = '', initialFilters: Record = {}): MileageFilters { const defaults = defaultWindow(); return { keyword: initialFilters.keyword ?? initialVin, protocol: initialFilters.protocol ?? initialProtocol, dateFrom: initialFilters.dateFrom ?? defaults.dateFrom, dateTo: initialFilters.dateTo ?? defaults.dateTo }; } function queryParams(filters: MileageFilters, pageSize?: number, offset?: number) { const params = new URLSearchParams({ dateFrom: filters.dateFrom, dateTo: filters.dateTo }); if (filters.keyword.trim()) params.set('keyword', filters.keyword.trim()); if (filters.protocol) params.set('protocol', filters.protocol); if (pageSize != null) params.set('limit', String(pageSize)); if (offset != null) params.set('offset', String(offset)); return params; } function sharedFilters(filters: MileageFilters): Record { return Object.fromEntries(Object.entries(filters).filter(([, value]) => value.trim())); } function formatKm(value?: number) { if (value == null || !Number.isFinite(value)) return '—'; return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value); } function MileageTrendChart({ points }: { points: MileageTrendPoint[] }) { if (!points.length) { return
当前日期范围没有里程数据
; } const width = 960; const height = 280; const left = 62; const right = 22; const top = 18; const bottom = 42; const max = Math.max(...points.map((point) => point.mileageKm), 1); const x = (index: number) => left + (width - left - right) * (points.length === 1 ? 0.5 : index / (points.length - 1)); const y = (value: number) => top + (height - top - bottom) * (1 - value / max); const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${x(index).toFixed(1)},${y(point.mileageKm).toFixed(1)}`).join(' '); const labelIndexes = Array.from(new Set([0, Math.floor((points.length - 1) / 2), points.length - 1])); return (
{[0, 0.5, 1].map((ratio) => ( {ratio === 0 ? '0' : formatKm(max * ratio)} ))} {points.length <= 45 ? points.map((point, index) => ( {point.date}:{formatKm(point.mileageKm)} km,{point.vehicles} 辆 )) : null} {labelIndexes.map((index) => ( {points[index].date} ))}
); } export function Mileage({ initialVin, initialProtocol, initialFilters = {}, onFiltersChange }: MileageProps) { const initial = useMemo( () => initialMileageFilters(initialVin, initialProtocol, initialFilters), [initialVin, initialProtocol, JSON.stringify(initialFilters)] ); const [draft, setDraft] = useState(initial); const [filters, setFilters] = useState(initial); const [statistics, setStatistics] = useState(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); const load = async (nextFilters: MileageFilters, page = 1, pageSize = pagination.pageSize) => { setLoading(true); try { const [stats, detail] = await Promise.all([ api.mileageStatistics(queryParams(nextFilters)), api.dailyMileage(queryParams(nextFilters, pageSize, (page - 1) * pageSize)) ]); setStatistics(stats); setRows(detail.items ?? []); setPagination({ currentPage: page, pageSize, total: detail.total ?? 0 }); } catch (error) { Toast.error(error instanceof Error ? error.message : '里程数据加载失败'); setStatistics(undefined); setRows([]); setPagination((current) => ({ ...current, currentPage: page, pageSize, total: 0 })); } finally { setLoading(false); } }; useEffect(() => { setDraft(initial); setFilters(initial); void load(initial, 1, pagination.pageSize); }, [initial]); const applyFilters = (next: MileageFilters) => { if (!next.dateFrom || !next.dateTo) { Toast.warning('请选择完整的日期范围'); return; } if (next.dateFrom > next.dateTo) { Toast.warning('开始日期不能晚于结束日期'); return; } const days = Math.floor((new Date(next.dateTo).getTime() - new Date(next.dateFrom).getTime()) / DAY) + 1; if (days > 366) { Toast.warning('单次最多查询 366 个自然日'); return; } const normalized = { ...next, keyword: next.keyword.trim() }; setDraft(normalized); setFilters(normalized); onFiltersChange?.(sharedFilters(normalized)); void load(normalized, 1, pagination.pageSize); }; const setQuickRange = (days: number) => { const range = defaultWindow(days); applyFilters({ ...draft, ...range }); }; const summaryItems = [ { label: '区间总里程', value: `${formatKm(statistics?.periodMileageKm)} km`, note: '按车辆和自然日归并' }, { label: '统计车辆', value: statistics ? `${(statistics.vehicleCount ?? 0).toLocaleString()} 辆` : '—', note: `${statistics?.recordCount ?? 0} 个有效车辆日` }, { label: '单车平均', value: `${formatKm(statistics?.averageMileagePerVin)} km`, note: '区间总里程 / 车辆数' }, { label: '车日均里程', value: `${formatKm(statistics?.averageDailyMileageKm)} km`, note: '有效车辆日平均' } ]; return (
{ event.preventDefault(); applyFilters(draft); }}>
{summaryItems.map((item, index) => (
{item.label} {item.value} {item.note}
))}
每日里程趋势{filters.dateFrom} 至 {filters.dateTo}
{statistics?.trend?.length ?? 0} 个有效自然日
)} >
里程明细按日期倒序展示每辆车的日里程
共 {pagination.total.toLocaleString()} 条 )} > row ? `${row.vin}-${row.date}-${row.source}` : 'mileage-row'} loading={loading} dataSource={rows} scroll={{ x: 900 }} pagination={{ currentPage: pagination.currentPage, pageSize: pagination.pageSize, total: pagination.total, showSizeChanger: true, onPageChange: (page) => void load(filters, page, pagination.pageSize), onPageSizeChange: (pageSize) => void load(filters, 1, pageSize) }} empty={
当前日期范围没有里程明细
} columns={[ { title: '日期', dataIndex: 'date', width: 120 }, { title: '车辆', width: 220, render: (_value, row: DailyMileageRow) =>
{row.plate || '未绑定车牌'}{row.vin}
}, { title: '起始里程 (km)', dataIndex: 'startMileageKm', width: 140, render: (value: number) => formatKm(value) }, { title: '结束里程 (km)', dataIndex: 'endMileageKm', width: 140, render: (value: number) => formatKm(value) }, { title: '日里程 (km)', dataIndex: 'dailyMileageKm', width: 130, render: (value: number) => {formatKm(value)} }, { title: '来源', dataIndex: 'source', width: 120, render: (value: string) => {value || '未知'} } ]} />
趋势按车辆和自然日去重汇总 更新时间:{statistics?.asOf || '—'} 日期范围最多 366 天
); }