Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
2026-07-04 08:20:24 +08:00

333 lines
15 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 { IconCopy } from '@douyinfe/semi-icons';
import { Button, Card, Form, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import type { VehicleCoverageRow, VehicleCoverageSummary } from '../api/types';
import { DataEmpty } from '../components/DataEmpty';
import { PageHeader } from '../components/PageHeader';
import { SourceStatusTags } from '../components/SourceStatusTags';
import { StatusTag } from '../components/StatusTag';
function vehicleServiceStatus(row: VehicleCoverageRow) {
if (row.serviceStatus) {
return {
label: row.serviceStatus.title,
color: row.serviceStatus.severity === 'ok' ? 'green' as const : row.serviceStatus.severity === 'error' ? 'red' as const : 'orange' as const
};
}
if (row.bindingStatus !== 'bound') {
return { label: '身份未绑定', color: 'orange' as const };
}
if (row.sourceCount <= 0) {
return { label: '暂无数据来源', color: 'orange' as const };
}
if (row.onlineSourceCount <= 0) {
return { label: '车辆离线', color: 'red' as const };
}
if (row.onlineSourceCount < row.sourceCount) {
return { label: '来源不完整', color: 'orange' as const };
}
return { label: '服务正常', color: 'green' as const };
}
function sourceEvidenceText(row: VehicleCoverageRow) {
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
}
const coverageLabel: Record<string, string> = {
single: '单源',
multi: '多源'
};
const serviceStatusLabel: Record<string, string> = {
healthy: '服务正常',
degraded: '来源不完整',
offline: '车辆离线',
no_data: '暂无数据来源',
identity_required: '身份未绑定'
};
const onlineLabel: Record<string, string> = {
online: '在线',
offline: '离线'
};
const bindingStatusLabel: Record<string, string> = {
bound: '已绑定',
unbound: '未绑定'
};
function sourceDiagnosisText(row: VehicleCoverageRow) {
const parts = [sourceEvidenceText(row)];
if ((row.missingProtocols ?? []).length > 0) {
parts.push(`${row.missingProtocols.join('、')}`);
} else if (row.sourceCount > 0) {
parts.push('来源完整');
} else {
parts.push('暂无来源');
}
return parts.join('');
}
function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record<string, string>) => void) {
const consistency = row.sourceConsistency;
if (!consistency) {
return <Tag color="grey"></Tag>;
}
const color = consistency.severity === 'ok' ? 'green' as const : consistency.severity === 'error' ? 'red' as const : 'orange' as const;
const label = consistency.title || consistency.status;
if ((consistency.missingProtocols ?? []).length > 0) {
return (
<Button
size="small"
theme="light"
type={color === 'red' ? 'danger' : color === 'orange' ? 'warning' : 'primary'}
onClick={() => onFilter({ serviceStatus: 'degraded', missingProtocol: consistency.missingProtocols[0] })}
>
{label}
</Button>
);
}
return <Tag color={color}>{label}</Tag>;
}
async function copyVehicleShareURL() {
try {
await navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}${window.location.hash}`);
Toast.success('已复制筛选链接');
} catch {
Toast.error('复制筛选链接失败');
}
}
export function Vehicles({
onOpenVehicle,
onFiltersChange,
initialFilters = {}
}: {
onOpenVehicle: (vin: string, protocol?: string) => void;
onFiltersChange?: (filters: Record<string, string>) => void;
initialFilters?: Record<string, string>;
}) {
const [rows, setRows] = useState<VehicleCoverageRow[]>([]);
const [summary, setSummary] = useState<VehicleCoverageSummary | null>(null);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
const resultSummary = useMemo<Array<{ label: string; value: string; filters: Record<string, string> }>>(() => {
const items: Array<{ label: string; value: string; filters: Record<string, string> }> = [
{ label: '过滤车辆', value: (summary?.totalVehicles ?? pagination.total).toLocaleString(), filters: {} },
{ label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString(), filters: { online: 'online' } },
{ label: '单源车辆', value: (summary?.singleSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'single' } },
{ label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'multi' } },
{ label: '暂无来源车辆', value: (summary?.noDataVehicles ?? 0).toLocaleString(), filters: { serviceStatus: 'no_data' } },
{ label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } }
];
for (const source of summary?.missingSources ?? []) {
if (source.count <= 0) continue;
items.push({
label: `${source.protocol}`,
value: source.count.toLocaleString(),
filters: { missingProtocol: source.protocol }
});
}
return items;
}, [pagination.total, summary]);
const filterSummary = [
filters.keyword ? `关键词:${filters.keyword}` : '',
filters.protocol ? `数据来源:${filters.protocol}` : '',
filters.coverage ? `来源覆盖:${coverageLabel[filters.coverage] ?? filters.coverage}` : '',
filters.missingProtocol ? `缺失来源:${filters.missingProtocol}` : '',
filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : '',
filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '',
filters.bindingStatus ? `绑定:${bindingStatusLabel[filters.bindingStatus] ?? filters.bindingStatus}` : ''
].filter(Boolean);
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
setLoading(true);
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
if (values?.keyword) params.set('keyword', values.keyword);
if (values?.protocol) params.set('protocol', values.protocol);
if (values?.coverage) params.set('coverage', values.coverage);
if (values?.missingProtocol) params.set('missingProtocol', values.missingProtocol);
if (values?.online) params.set('online', values.online);
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
const summaryParams = new URLSearchParams(params);
summaryParams.delete('limit');
summaryParams.delete('offset');
Promise.all([api.vehicleCoverage(params), api.vehicleCoverageSummary(summaryParams)])
.then(([nextPage, nextSummary]) => {
setRows(nextPage.items);
setSummary(nextSummary);
setPagination({ currentPage: page, pageSize, total: nextPage.total });
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoading(false));
};
const applyFilters = (nextFilters: Record<string, string>) => {
setFilters(nextFilters);
onFiltersChange?.(nextFilters);
load(nextFilters, 1, pagination.pageSize);
};
useEffect(() => {
setFilters(initialFilters);
load(initialFilters, 1, pagination.pageSize);
}, [JSON.stringify(initialFilters)]);
const columns = useMemo(
() => [
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{ title: '车牌', dataIndex: 'plate', width: 120 },
{ title: '手机号', dataIndex: 'phone', width: 130 },
{ title: 'OEM', dataIndex: 'oem', width: 120 },
{
title: '车辆服务状态',
width: 130,
render: (_: unknown, row: VehicleCoverageRow) => {
const status = vehicleServiceStatus(row);
return <Tag color={status.color}>{status.label}</Tag>;
}
},
{
title: '来源证据',
width: 300,
render: (_: unknown, row: VehicleCoverageRow) => (
<SourceStatusTags sourceStatus={row.sourceStatus} protocols={row.protocols} lastSeen={row.lastSeen} />
)
},
{
title: '缺失来源',
width: 220,
render: (_: unknown, row: VehicleCoverageRow) => (
<Space spacing={4} wrap>
{(row.missingProtocols ?? []).length > 0
? row.missingProtocols.map((protocol) => (
<Button
key={protocol}
size="small"
theme="light"
type="warning"
onClick={() => applyFilters({ ...filters, missingProtocol: protocol })}
>
{protocol}
</Button>
))
: <Tag color="green"></Tag>}
</Space>
)
},
{
title: '来源一致性',
width: 140,
render: (_: unknown, row: VehicleCoverageRow) => sourceConsistencyAction(row, (nextFilters) => applyFilters({ ...filters, ...nextFilters }))
},
{ title: '诊断摘要', width: 240, render: (_: unknown, row: VehicleCoverageRow) => sourceDiagnosisText(row) },
{ title: '证据覆盖', width: 120, render: (_: unknown, row: VehicleCoverageRow) => sourceEvidenceText(row) },
{ title: '在线', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
{ title: '最后在线', dataIndex: 'lastSeen', width: 170 },
{ title: '绑定', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <Tag color={row.bindingStatus === 'bound' ? 'green' : 'orange'}>{row.bindingStatus === 'bound' ? '已绑定' : '未绑定'}</Tag> },
{ title: '操作', width: 110, render: (_: unknown, row: VehicleCoverageRow) => <Button onClick={() => onOpenVehicle(row.vin, filters.protocol)}></Button> }
],
[filters, onOpenVehicle]
);
return (
<div className="vp-page">
<PageHeader title="车辆服务" description="以 VIN 为主对象维护车辆身份、数据来源覆盖、在线状态和绑定状态" />
<Card bordered>
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
const nextFilters = values as Record<string, string>;
applyFilters(nextFilters);
}}>
<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.Select field="coverage" label="来源覆盖" placeholder="全部" style={{ width: 130 }}>
<Select.Option value="single"></Select.Option>
<Select.Option value="multi"></Select.Option>
</Form.Select>
<Form.Select field="missingProtocol" label="缺失来源" placeholder="全部" style={{ width: 170 }} data-testid="missing-protocol-filter">
<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.Select field="serviceStatus" label="服务状态" placeholder="全部" style={{ width: 150 }} data-testid="service-status-filter">
<Select.Option value="healthy"></Select.Option>
<Select.Option value="degraded"></Select.Option>
<Select.Option value="offline">线</Select.Option>
<Select.Option value="no_data"></Select.Option>
<Select.Option value="identity_required"></Select.Option>
</Form.Select>
<Form.Select field="online" label="在线" placeholder="全部" style={{ width: 130 }}>
<Select.Option value="online">线</Select.Option>
<Select.Option value="offline">线</Select.Option>
</Form.Select>
<Form.Select field="bindingStatus" label="绑定" placeholder="全部" style={{ width: 130 }}>
<Select.Option value="bound"></Select.Option>
<Select.Option value="unbound"></Select.Option>
</Form.Select>
<Space>
<Button htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconCopy />} onClick={copyVehicleShareURL}></Button>
<Button onClick={() => {
applyFilters({});
}}></Button>
</Space>
</Form>
</Card>
{filterSummary.length > 0 ? (
<Card bordered title="当前车辆筛选" style={{ marginTop: 16 }}>
<Space wrap>
{filterSummary.map((item) => (
<Tag key={item} color="blue">{item}</Tag>
))}
<Button size="small" onClick={() => applyFilters({})}></Button>
</Space>
</Card>
) : null}
<Card bordered title="当前车辆结果" style={{ marginTop: 16 }}>
<div className="vp-result-summary-grid">
{resultSummary.map((item) => (
<button
key={item.label}
className="vp-result-summary-item vp-result-summary-button"
type="button"
aria-label={`${item.label} ${item.value}`}
onClick={() => applyFilters({ ...filters, ...item.filters })}
>
<div className="vp-result-summary-value">{item.value}</div>
<div className="vp-result-summary-label">{item.label}</div>
</button>
))}
</div>
</Card>
<Card bordered style={{ marginTop: 16 }}>
{rows.length === 0 && !loading ? (
<DataEmpty />
) : (
<Table
loading={loading}
rowKey="vin"
dataSource={rows}
columns={columns}
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)
}}
/>
)}
</Card>
</div>
);
}