feat(platform): add focus vehicle service card
This commit is contained in:
@@ -48,6 +48,20 @@ type DashboardSnapshotRow = {
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type FocusVehicleService = {
|
||||
label: string;
|
||||
lookupKey: string;
|
||||
protocol?: string;
|
||||
reason: string;
|
||||
statusLabel: string;
|
||||
statusColor: 'green' | 'orange' | 'red' | 'grey' | 'blue';
|
||||
realtimeEvidence: string;
|
||||
historyEvidence: string;
|
||||
alertEvidence: string;
|
||||
statisticEvidence: string;
|
||||
issueType?: string;
|
||||
};
|
||||
|
||||
const dashboardSnapshotColumns: CsvColumn<DashboardSnapshotRow>[] = [
|
||||
{ title: '模块', value: (row) => row.section },
|
||||
{ title: '指标', value: (row) => row.item },
|
||||
@@ -163,6 +177,43 @@ function priorityIssueNotificationText(row: QualityIssueRow) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function focusVehicleFromIssue(row: QualityIssueRow): FocusVehicleService {
|
||||
const lookup = qualityIssueVehicleLookup(row);
|
||||
const filters = priorityIssueEvidenceFilters(row);
|
||||
const label = priorityIssueVehicleLabel(row);
|
||||
return {
|
||||
label,
|
||||
lookupKey: lookup.key,
|
||||
protocol: row.protocol,
|
||||
reason: `${row.severity === 'error' ? 'P0' : 'P1'} ${qualityIssueLabel(row.issueType)}`,
|
||||
statusLabel: row.severity === 'error' ? '优先处置' : '持续跟踪',
|
||||
statusColor: row.severity === 'error' ? 'red' : 'orange',
|
||||
realtimeEvidence: `来源 ${qualityProtocolLabel(row.protocol)} / 最后 ${row.lastSeen || '-'}`,
|
||||
historyEvidence: filters.dateFrom ? `${filters.dateFrom} 至 ${filters.dateTo}` : '待确认时间窗',
|
||||
alertEvidence: row.detail || qualityIssueLabel(row.issueType),
|
||||
statisticEvidence: '统计查询需回溯车辆口径',
|
||||
issueType: row.issueType
|
||||
};
|
||||
}
|
||||
|
||||
function focusVehicleFromRealtime(row: VehicleRealtimeRow): FocusVehicleService {
|
||||
const status = rowServiceStatus(row);
|
||||
const label = [row.plate?.trim(), row.vin?.trim()].filter(Boolean).join(' / ') || row.phone || '-';
|
||||
const location = hasValidCoordinate(row) ? `${row.longitude.toFixed(6)}, ${row.latitude.toFixed(6)}` : '无有效坐标';
|
||||
return {
|
||||
label,
|
||||
lookupKey: row.vin || row.phone || row.plate,
|
||||
protocol: row.primaryProtocol,
|
||||
reason: row.online ? '最新在线车辆' : '最新车辆',
|
||||
statusLabel: status.label,
|
||||
statusColor: status.color,
|
||||
realtimeEvidence: `${row.onlineSourceCount}/${row.sourceCount} 来源在线 / ${row.lastSeen || '-'}`,
|
||||
historyEvidence: location,
|
||||
alertEvidence: row.onlineSourceCount < row.sourceCount ? '来源不完整,建议检查缺失协议' : '暂无高优先级告警',
|
||||
statisticEvidence: `速度 ${formatCount(row.speedKmh)} km/h / 里程 ${formatCount(row.totalMileageKm)} km`
|
||||
};
|
||||
}
|
||||
|
||||
function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record<string, string>) => void) {
|
||||
const consistency = row.sourceConsistency;
|
||||
if (!consistency) {
|
||||
@@ -316,6 +367,7 @@ export function Dashboard({
|
||||
const highPriorityIssue = qualityIssues.find((item) => item.severity === 'error') ?? qualityIssues[0];
|
||||
const highPriorityLookup = highPriorityIssue ? qualityIssueVehicleLookup(highPriorityIssue) : undefined;
|
||||
const highPriorityEvidenceFilters = highPriorityIssue ? priorityIssueEvidenceFilters(highPriorityIssue) : undefined;
|
||||
const focusVehicle = highPriorityIssue ? focusVehicleFromIssue(highPriorityIssue) : locations[0] ? focusVehicleFromRealtime(locations[0]) : undefined;
|
||||
const unhealthyLinkCount = (summary?.linkHealth ?? []).filter((item) => item.status !== 'ok').length;
|
||||
const commandMapPoints: VehicleMapPoint[] = locations.map((row, index) => ({
|
||||
id: row.vin || `${row.primaryProtocol || 'source'}-${index}`,
|
||||
@@ -602,6 +654,30 @@ export function Dashboard({
|
||||
];
|
||||
copyText(lines.join('\n'), '功能蓝图');
|
||||
};
|
||||
const copyFocusVehicleService = () => {
|
||||
if (!focusVehicle) {
|
||||
Toast.warning('当前没有重点车辆服务可复制');
|
||||
return;
|
||||
}
|
||||
const commonFilters = { keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '' };
|
||||
const lines = [
|
||||
'【重点车辆服务处置卡】',
|
||||
`车辆:${focusVehicle.label}`,
|
||||
`原因:${focusVehicle.reason}`,
|
||||
`服务状态:${focusVehicle.statusLabel}`,
|
||||
`实时证据:${focusVehicle.realtimeEvidence}`,
|
||||
`轨迹证据:${focusVehicle.historyEvidence}`,
|
||||
`告警证据:${focusVehicle.alertEvidence}`,
|
||||
`统计证据:${focusVehicle.statisticEvidence}`,
|
||||
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol }))}`,
|
||||
`实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol }))}`,
|
||||
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol }))}`,
|
||||
`RAW证据:${appURL(buildAppHash({ page: 'history-query', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol, filters: { ...commonFilters, tab: 'raw', includeFields: 'true' } }))}`,
|
||||
`统计查询:${appURL(buildAppHash({ page: 'mileage', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol }))}`,
|
||||
`告警事件:${appURL(buildAppHash({ page: 'alert-events', keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol, filters: focusVehicle.issueType ? { issueType: focusVehicle.issueType } : {} }))}`
|
||||
];
|
||||
copyText(lines.join('\n'), '重点车辆服务处置卡');
|
||||
};
|
||||
const buildDashboardSnapshotRows = () => {
|
||||
const unhealthyLinks = (summary?.linkHealth ?? []).filter((item) => item.status !== 'ok');
|
||||
const priorityAction = serviceActionQueue[0];
|
||||
@@ -738,6 +814,42 @@ export function Dashboard({
|
||||
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}>导出驾驶舱 CSV</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
{focusVehicle ? (
|
||||
<Card
|
||||
bordered
|
||||
title={<Space><span>重点车辆服务</span><Tag color={focusVehicle.statusColor}>{focusVehicle.statusLabel}</Tag><Button size="small" onClick={copyFocusVehicleService}>复制处置卡</Button></Space>}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<div className="vp-focus-service">
|
||||
<div className="vp-focus-service-main">
|
||||
<Tag color="blue">{focusVehicle.reason}</Tag>
|
||||
<Typography.Title heading={5} style={{ margin: '8px 0 4px' }}>{focusVehicle.label}</Typography.Title>
|
||||
<Typography.Text type="secondary">把协议来源作为证据,把实时、轨迹、RAW、统计和告警集中到同一辆车处理。</Typography.Text>
|
||||
</div>
|
||||
<div className="vp-focus-service-evidence">
|
||||
{[
|
||||
{ label: '实时证据', value: focusVehicle.realtimeEvidence },
|
||||
{ label: '轨迹证据', value: focusVehicle.historyEvidence },
|
||||
{ label: '告警证据', value: focusVehicle.alertEvidence },
|
||||
{ label: '统计证据', value: focusVehicle.statisticEvidence }
|
||||
].map((item) => (
|
||||
<div key={item.label} className="vp-focus-service-evidence-item">
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button size="small" theme="solid" type="primary" aria-label="重点车辆 车辆服务" disabled={!focusVehicle.lookupKey} onClick={() => focusVehicle.lookupKey && onOpenVehicle(focusVehicle.lookupKey, focusVehicle.protocol)}>车辆服务</Button>
|
||||
<Button size="small" theme="light" aria-label="重点车辆 实时监控" disabled={!focusVehicle.lookupKey} onClick={() => onOpenRealtime({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '' })}>实时监控</Button>
|
||||
<Button size="small" theme="light" aria-label="重点车辆 轨迹回放" disabled={!focusVehicle.lookupKey} onClick={() => onOpenHistory({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '' })}>轨迹回放</Button>
|
||||
<Button size="small" theme="light" aria-label="重点车辆 RAW证据" disabled={!focusVehicle.lookupKey} onClick={() => onOpenHistory({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '', tab: 'raw', includeFields: 'true' })}>RAW证据</Button>
|
||||
<Button size="small" theme="light" aria-label="重点车辆 统计查询" disabled={!focusVehicle.lookupKey} onClick={() => onOpenMileage({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '' })}>统计查询</Button>
|
||||
<Button size="small" theme="light" type="warning" aria-label="重点车辆 告警事件" onClick={() => onOpenQuality({ keyword: focusVehicle.lookupKey, protocol: focusVehicle.protocol ?? '', ...(focusVehicle.issueType ? { issueType: focusVehicle.issueType } : {}) })}>告警事件</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
<Card bordered title="车辆服务作业台" style={{ marginBottom: 16 }}>
|
||||
<div className="vp-workbench-grid">
|
||||
{operationWorkbench.map((item) => (
|
||||
|
||||
@@ -1354,6 +1354,52 @@ button.vp-realtime-command-item:focus-visible {
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.vp-focus-service {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.8fr) minmax(420px, 1.6fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vp-focus-service-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vp-focus-service-main .semi-typography {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vp-focus-service-evidence {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-focus-service-evidence-item {
|
||||
min-height: 78px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-focus-service-evidence-item span {
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.vp-focus-service-evidence-item strong {
|
||||
color: var(--vp-text);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.vp-operation-flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
@@ -1579,6 +1625,8 @@ button.vp-realtime-command-item:focus-visible {
|
||||
.vp-source-coverage-grid,
|
||||
.vp-runbook-grid,
|
||||
.vp-workbench-grid,
|
||||
.vp-focus-service,
|
||||
.vp-focus-service-evidence,
|
||||
.vp-scenario-grid,
|
||||
.vp-operation-flow,
|
||||
.vp-service-model-grid,
|
||||
|
||||
@@ -1015,6 +1015,11 @@ test('dashboard exposes end-to-end operations workflow entries', async () => {
|
||||
|
||||
test('dashboard surfaces highest priority quality issue with evidence shortcuts', async () => {
|
||||
window.history.replaceState(null, '', '/#/dashboard');
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path.includes('/api/ops/health')) {
|
||||
@@ -1116,10 +1121,21 @@ test('dashboard surfaces highest priority quality issue with evidence shortcuts'
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('重点车辆服务')).toBeInTheDocument();
|
||||
expect(screen.getByText('把协议来源作为证据,把实时、轨迹、RAW、统计和告警集中到同一辆车处理。')).toBeInTheDocument();
|
||||
expect(screen.getByText('实时证据')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('轨迹证据').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('告警证据')).toBeInTheDocument();
|
||||
expect(screen.getByText('统计证据')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制处置卡' }));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【重点车辆服务处置卡】'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆:粤A告警1 / 13307795425'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('实时监控:http://localhost:3000/#/realtime?keyword=%E7%B2%A4A%E5%91%8A%E8%AD%A61&protocol=JT808'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('RAW证据:http://localhost:3000/#/history-query?keyword=%E7%B2%A4A%E5%91%8A%E8%AD%A61&protocol=JT808&tab=raw&includeFields=true'));
|
||||
expect(await screen.findByText('最高优先级告警')).toBeInTheDocument();
|
||||
expect(screen.getByText('粤A告警1 / 13307795425')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('粤A告警1 / 13307795425').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('VIN 缺失')).toBeInTheDocument();
|
||||
expect(screen.getByText('JT808 数据缺少 VIN 映射')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('JT808 数据缺少 VIN 映射').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '首页告警轨迹证据' }));
|
||||
|
||||
@@ -2470,7 +2486,7 @@ test('shows row service status in dashboard vehicle previews', async () => {
|
||||
expect(screen.getAllByText('1/2 来源在线').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('覆盖部分离线')).toBeInTheDocument();
|
||||
expect(screen.getByText('VIN-REALTIME-OFFLINE')).toBeInTheDocument();
|
||||
expect(screen.getByText('实时车辆离线')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('实时车辆离线').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('filters dashboard coverage from source consistency diagnosis', async () => {
|
||||
|
||||
Reference in New Issue
Block a user