Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Mileage.tsx

315 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string>;
onFiltersChange?: (filters: Record<string, string>) => void;
onOpenVehicle: (vin: string, protocol?: string) => void;
onOpenHistory?: (filters: Record<string, string>) => void;
onOpenRaw?: (filters: Record<string, string>) => 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<string, string> = {}): 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<string, string> {
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 <div className="vp-mileage-report-empty"></div>;
}
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 (
<div className="vp-mileage-report-chart-wrap">
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="每日里程趋势图">
<defs>
<linearGradient id="mileage-area" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3370ff" stopOpacity="0.28" />
<stop offset="100%" stopColor="#3370ff" stopOpacity="0.02" />
</linearGradient>
</defs>
{[0, 0.5, 1].map((ratio) => (
<g key={ratio}>
<line className="vp-mileage-report-grid-line" x1={left} x2={width - right} y1={y(max * ratio)} y2={y(max * ratio)} />
<text className="vp-mileage-report-axis-text" x={left - 10} y={y(max * ratio) + 4} textAnchor="end">
{ratio === 0 ? '0' : formatKm(max * ratio)}
</text>
</g>
))}
<path className="vp-mileage-report-area" d={`${path} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`} />
<path className="vp-mileage-report-line" d={path} />
{points.length <= 45 ? points.map((point, index) => (
<circle key={point.date} className="vp-mileage-report-point" cx={x(index)} cy={y(point.mileageKm)} r="3.5">
<title>{point.date}{formatKm(point.mileageKm)} km{point.vehicles} </title>
</circle>
)) : null}
{labelIndexes.map((index) => (
<text
key={index}
className="vp-mileage-report-axis-text"
x={x(index)}
y={height - 12}
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
>
{points[index].date}
</text>
))}
</svg>
</div>
);
}
export function Mileage({
initialVin,
initialProtocol,
initialFilters = {},
onFiltersChange
}: MileageProps) {
const initial = useMemo(
() => initialMileageFilters(initialVin, initialProtocol, initialFilters),
[initialVin, initialProtocol, JSON.stringify(initialFilters)]
);
const [draft, setDraft] = useState<MileageFilters>(initial);
const [filters, setFilters] = useState<MileageFilters>(initial);
const [statistics, setStatistics] = useState<MileageStatistics>();
const [rows, setRows] = useState<DailyMileageRow[]>([]);
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 (
<div className="vp-page vp-mileage-report">
<PageHeader
title="车辆里程统计"
description="选择车辆和日期范围,查看每日里程趋势与明细列表"
/>
<Card bordered className="vp-mileage-report-filter-card" bodyStyle={{ padding: 0 }}>
<form className="vp-mileage-report-filter" onSubmit={(event) => { event.preventDefault(); applyFilters(draft); }}>
<label className="vp-mileage-report-vehicle-field">
<span></span>
<input
value={draft.keyword}
onChange={(event) => setDraft((current) => ({ ...current, keyword: event.target.value }))}
placeholder="输入车牌或 VIN留空查询全部车辆"
/>
</label>
<label>
<span></span>
<input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} />
</label>
<label>
<span></span>
<input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} />
</label>
<label>
<span></span>
<Select
value={draft.protocol}
placeholder="全部来源"
onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value ?? '') }))}
style={{ width: '100%' }}
>
<Select.Option value=""></Select.Option>
<Select.Option value="GB32960">GB32960</Select.Option>
<Select.Option value="JT808">JT808</Select.Option>
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
</Select>
</label>
<Button htmlType="submit" theme="solid" type="primary" loading={loading}></Button>
<div className="vp-mileage-report-quick-ranges">
<button type="button" onClick={() => setQuickRange(7)}> 7 </button>
<button type="button" onClick={() => setQuickRange(30)}> 30 </button>
<button type="button" onClick={() => setQuickRange(90)}> 90 </button>
</div>
</form>
</Card>
<section className="vp-mileage-report-kpis" aria-label="里程统计摘要">
{summaryItems.map((item, index) => (
<article key={item.label} className={index === 0 ? 'is-primary' : ''}>
<span>{item.label}</span>
<strong>{item.value}</strong>
<small>{item.note}</small>
</article>
))}
</section>
<Card
bordered
loading={loading}
className="vp-mileage-report-chart-card"
title={(
<div className="vp-mileage-report-card-title">
<div><strong></strong><span>{filters.dateFrom} {filters.dateTo}</span></div>
<Tag color="blue">{statistics?.trend?.length ?? 0} </Tag>
</div>
)}
>
<MileageTrendChart points={statistics?.trend ?? []} />
</Card>
<Card
bordered
className="vp-mileage-report-table-card"
title={(
<div className="vp-mileage-report-card-title">
<div><strong></strong><span></span></div>
<Typography.Text type="tertiary"> {pagination.total.toLocaleString()} </Typography.Text>
</div>
)}
>
<Table
rowKey={(row) => 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={<div className="vp-mileage-report-empty"></div>}
columns={[
{ title: '日期', dataIndex: 'date', width: 120 },
{ title: '车辆', width: 220, render: (_value, row: DailyMileageRow) => <div className="vp-mileage-report-vehicle"><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div> },
{ 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) => <strong className="vp-mileage-report-daily-value">{formatKm(value)}</strong> },
{ title: '来源', dataIndex: 'source', width: 120, render: (value: string) => <Tag color="blue">{value || '未知'}</Tag> }
]}
/>
</Card>
<footer className="vp-mileage-report-footnote">
<span></span>
<span>{statistics?.asOf || '—'}</span>
<span> 366 </span>
</footer>
</div>
);
}