333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|