315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|