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

161 lines
7.3 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, Form, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { DailyMileageRow, MileageSummary } from '../api/types';
import { PageHeader } from '../components/PageHeader';
const emptySummary: MileageSummary = {
vehicleCount: 0,
recordCount: 0,
sourceCount: 0,
totalMileageKm: 0,
averageMileagePerVin: 0
};
function mileageParams(values: Record<string, string>, pageSize?: number, offset?: number) {
const params = new URLSearchParams();
if (pageSize != null) params.set('limit', String(pageSize));
if (offset != null) params.set('offset', String(offset));
if (values?.keyword) params.set('keyword', values.keyword);
if (values?.protocol) params.set('protocol', values.protocol);
if (values?.dateFrom) params.set('dateFrom', values.dateFrom);
if (values?.dateTo) params.set('dateTo', values.dateTo);
return params;
}
function formatKm(value: number) {
return Number.isFinite(value) ? value.toLocaleString(undefined, { maximumFractionDigits: 1 }) : '0';
}
function canOpenVehicle(vin?: string) {
const value = vin?.trim();
return Boolean(value && value !== 'unknown');
}
export function Mileage({ initialVin, initialProtocol, onOpenVehicle }: { initialVin: string; initialProtocol?: string; onOpenVehicle: (vin: string, protocol?: string) => void }) {
const [rows, setRows] = useState<DailyMileageRow[]>([]);
const [summary, setSummary] = useState<MileageSummary>(emptySummary);
const [loading, setLoading] = useState(true);
const [summaryLoading, setSummaryLoading] = useState(true);
const [filters, setFilters] = useState<Record<string, string>>({ keyword: initialVin, protocol: initialProtocol ?? '' });
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
const currentProtocol = filters.protocol?.trim() ?? '';
const loadSummary = (values: Record<string, string> = filters) => {
setSummaryLoading(true);
api.mileageSummary(mileageParams(values))
.then(setSummary)
.catch((error: Error) => Toast.error(error.message))
.finally(() => setSummaryLoading(false));
};
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
setLoading(true);
api.dailyMileage(mileageParams(values, pageSize, (page - 1) * pageSize))
.then((nextPage) => {
setRows(nextPage.items);
setPagination({ currentPage: page, pageSize, total: nextPage.total });
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoading(false));
};
useEffect(() => {
const nextFilters = { keyword: initialVin, protocol: initialProtocol ?? '' };
setFilters(nextFilters);
loadSummary(nextFilters);
load(nextFilters, 1, pagination.pageSize);
}, [initialVin, initialProtocol]);
return (
<div className="vp-page">
<PageHeader
title="里程统计"
description="按车辆汇总每日里程、区间里程和异常差值分析"
actions={(
<Button disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword)}>
</Button>
)}
/>
<div className="vp-scope-bar">
<span className="vp-scope-label">{currentVehicleKeyword || '-'}</span>
<Tag color={currentProtocol ? 'blue' : 'green'}>{currentProtocol || '全部来源'}</Tag>
<Typography.Text type="tertiary"></Typography.Text>
</div>
<Card bordered>
<Form key={filters.keyword ?? ''} initValues={filters} layout="horizontal" onSubmit={(values) => {
const nextFilters = values as Record<string, string>;
setFilters(nextFilters);
loadSummary(nextFilters);
load(nextFilters, 1, pagination.pageSize);
}}>
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
<Select.Option value="GB32960">GB32960</Select.Option>
<Select.Option value="JT808">JT808</Select.Option>
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
</Form.Select>
<Form.Input field="dateFrom" label="开始日期" placeholder="2026-07-01" style={{ width: 160 }} />
<Form.Input field="dateTo" label="结束日期" placeholder="2026-07-03" style={{ width: 160 }} />
<Space>
<Button htmlType="submit" theme="solid" type="primary"></Button>
<Button onClick={() => {
const nextFilters = { keyword: initialVin, protocol: initialProtocol ?? '' };
setFilters(nextFilters);
loadSummary(nextFilters);
load(nextFilters, 1, pagination.pageSize);
}}></Button>
</Space>
</Form>
</Card>
<div className="vp-kpi-grid" style={{ marginTop: 16 }}>
{[
{ label: '车辆数', value: summary.vehicleCount.toLocaleString() },
{ label: '累计里程 km', value: formatKm(summary.totalMileageKm) },
{ label: '单车平均 km', value: formatKm(summary.averageMileagePerVin) },
{ label: '来源 / 记录', value: `${summary.sourceCount}/${summary.recordCount.toLocaleString()}` }
].map((item) => (
<Card key={item.label} bordered loading={summaryLoading}>
<div className="vp-kpi-value">{item.value}</div>
<div className="vp-kpi-label">{item.label}</div>
</Card>
))}
</div>
<Card bordered style={{ marginTop: 16 }}>
<Table
loading={loading}
rowKey={(row?: DailyMileageRow) => `${row?.date ?? ''}-${row?.vin ?? ''}-${row?.source ?? ''}`}
dataSource={rows}
pagination={{
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
onPageChange: (page) => load(filters, page, pagination.pageSize),
onPageSizeChange: (pageSize) => load(filters, 1, pageSize)
}}
columns={[
{ title: '日期', dataIndex: 'date' },
{ title: 'VIN', dataIndex: 'vin' },
{ title: '车牌', dataIndex: 'plate' },
{ title: '起始里程', dataIndex: 'startMileageKm' },
{ title: '结束里程', dataIndex: 'endMileageKm' },
{ title: '日里程', dataIndex: 'dailyMileageKm' },
{ title: '来源', dataIndex: 'source' },
{ title: '异常', render: (_: unknown, row: DailyMileageRow) => row.anomalySeverity ? <Tag color="orange">{row.anomalySeverity}</Tag> : <Tag color="green"></Tag> },
{
title: '操作',
width: 110,
render: (_: unknown, row: DailyMileageRow) => (
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin)}></Button>
)
}
]}
/>
</Card>
</div>
);
}