feat(platform): add realtime time-window monitoring
This commit is contained in:
@@ -287,6 +287,12 @@ export default function App() {
|
||||
if (filters.issueType) {
|
||||
issueFilters.issueType = filters.issueType;
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
issueFilters.dateFrom = filters.dateFrom;
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
issueFilters.dateTo = filters.dateTo;
|
||||
}
|
||||
replaceHash('alert-events', keyword, protocol, issueFilters);
|
||||
};
|
||||
|
||||
|
||||
@@ -84,6 +84,36 @@ function formatRefreshTime(date: Date) {
|
||||
});
|
||||
}
|
||||
|
||||
function padDatePart(value: number) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDateTime(value: Date) {
|
||||
return [
|
||||
value.getFullYear(),
|
||||
padDatePart(value.getMonth() + 1),
|
||||
padDatePart(value.getDate())
|
||||
].join('-') + ` ${padDatePart(value.getHours())}:${padDatePart(value.getMinutes())}:${padDatePart(value.getSeconds())}`;
|
||||
}
|
||||
|
||||
function defaultTimeWindow() {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
|
||||
return { dateFrom: formatDateTime(start), dateTo: formatDateTime(end) };
|
||||
}
|
||||
|
||||
function timeWindowDurationText(dateFrom: string, dateTo: string) {
|
||||
const start = new Date(dateFrom.replace(' ', 'T')).getTime();
|
||||
const end = new Date(dateTo.replace(' ', 'T')).getTime();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
||||
return '时间窗待确认';
|
||||
}
|
||||
const minutes = Math.round((end - start) / 60000);
|
||||
if (minutes < 60) return `${minutes} 分钟窗口`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)} 小时窗口`;
|
||||
return `${Math.round(minutes / 1440)} 天窗口`;
|
||||
}
|
||||
|
||||
function parseVehicleTime(value?: string) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) {
|
||||
@@ -585,6 +615,7 @@ export function Realtime({
|
||||
const [selectedMapPointId, setSelectedMapPointId] = useState('');
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [timeWindow, setTimeWindow] = useState(defaultTimeWindow);
|
||||
const refreshIntervalSeconds = 15;
|
||||
const amapConfig = getAMapConfig();
|
||||
const runtime = opsHealth?.runtime;
|
||||
@@ -678,6 +709,114 @@ export function Realtime({
|
||||
const defaultMapRow = mapServiceRows[0] ?? rows.find((row) => isValidCoordinate(row) && canOpenVehicle(row.vin));
|
||||
const selectedMapRow = rows.find((row, index) => realtimeMapPointId(row, index) === selectedMapPointId) ?? defaultMapRow;
|
||||
const selectedMapPointKey = selectedMapRow ? realtimeMapPointId(selectedMapRow, rows.indexOf(selectedMapRow)) : selectedMapPointId;
|
||||
const timeWindowRow = selectedMapRow && canOpenVehicle(selectedMapRow.vin)
|
||||
? selectedMapRow
|
||||
: rows.find((row) => canOpenVehicle(row.vin));
|
||||
const timeWindowKeyword = timeWindowRow?.vin || filters.keyword || '';
|
||||
const timeWindowProtocol = timeWindowRow ? filters.protocol || timeWindowRow.primaryProtocol || '' : filters.protocol || '';
|
||||
const timeWindowReady = Boolean(timeWindowKeyword && timeWindow.dateFrom && timeWindow.dateTo);
|
||||
const timeWindowDuration = timeWindowDurationText(timeWindow.dateFrom, timeWindow.dateTo);
|
||||
const setTimeWindowPreset = (preset: '15m' | '1h' | 'today' | '24h') => {
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
if (preset === '15m') start.setMinutes(end.getMinutes() - 15);
|
||||
if (preset === '1h') start.setHours(end.getHours() - 1);
|
||||
if (preset === '24h') start.setDate(end.getDate() - 1);
|
||||
if (preset === 'today') {
|
||||
start.setHours(0, 0, 0, 0);
|
||||
}
|
||||
setTimeWindow({ dateFrom: formatDateTime(start), dateTo: formatDateTime(end) });
|
||||
};
|
||||
const timeWindowFilters = (extra: Record<string, string> = {}) => ({
|
||||
keyword: timeWindowKeyword,
|
||||
protocol: timeWindowProtocol,
|
||||
dateFrom: timeWindow.dateFrom,
|
||||
dateTo: timeWindow.dateTo,
|
||||
...extra
|
||||
});
|
||||
const openTimeWindowHistory = () => {
|
||||
if (!timeWindowReady) {
|
||||
Toast.warning('请先选择车辆和时间窗');
|
||||
return;
|
||||
}
|
||||
window.location.hash = buildAppHash({ page: 'history', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() });
|
||||
};
|
||||
const openTimeWindowRaw = () => {
|
||||
if (!timeWindowReady) {
|
||||
Toast.warning('请先选择车辆和时间窗');
|
||||
return;
|
||||
}
|
||||
window.location.hash = buildAppHash({
|
||||
page: 'history-query',
|
||||
keyword: timeWindowKeyword,
|
||||
protocol: timeWindowProtocol,
|
||||
filters: timeWindowFilters({ tab: 'raw', includeFields: 'true' })
|
||||
});
|
||||
};
|
||||
const openTimeWindowMileage = () => {
|
||||
if (!timeWindowReady) {
|
||||
Toast.warning('请先选择车辆和时间窗');
|
||||
return;
|
||||
}
|
||||
window.location.hash = buildAppHash({ page: 'mileage', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() });
|
||||
};
|
||||
const openTimeWindowQuality = () => {
|
||||
if (!timeWindowReady || !onOpenQuality) {
|
||||
Toast.warning('请先选择车辆和时间窗');
|
||||
return;
|
||||
}
|
||||
onOpenQuality(timeWindowFilters());
|
||||
};
|
||||
const copyTimeWindowPackage = () => copyText([
|
||||
'【客户时间窗监控包】',
|
||||
`车辆:${timeWindowRow ? [timeWindowRow.plate, timeWindowRow.vin].filter(Boolean).join(' / ') : timeWindowKeyword || '-'}`,
|
||||
`数据通道:${timeWindowProtocol || '全部数据通道'}`,
|
||||
`时间窗:${timeWindow.dateFrom} 至 ${timeWindow.dateTo}`,
|
||||
`时间窗判定:${timeWindowDuration}`,
|
||||
`实时状态:${timeWindowRow ? `${vehicleServiceStatus(timeWindowRow).label} / ${dataFreshness(timeWindowRow).detail}` : '未选车辆'}`,
|
||||
`轨迹回放:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'history', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }) : '-'}`,
|
||||
`历史数据:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'history-query', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters({ tab: 'raw', includeFields: 'true' }) }) : '-'}`,
|
||||
`里程统计:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'mileage', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }) : '-'}`,
|
||||
`告警通知:${timeWindowReady ? window.location.origin + window.location.pathname + buildAppHash({ page: 'alert-events', keyword: timeWindowKeyword, protocol: timeWindowProtocol, filters: timeWindowFilters() }) : '-'}`
|
||||
].join('\n'), '客户时间窗监控包');
|
||||
const timeWindowWorkItems = [
|
||||
{
|
||||
title: '轨迹回放',
|
||||
value: timeWindowDuration,
|
||||
detail: '按时间窗回放位置、速度、里程断点。',
|
||||
action: '打开轨迹',
|
||||
color: 'blue' as const,
|
||||
disabled: !timeWindowReady,
|
||||
onClick: openTimeWindowHistory
|
||||
},
|
||||
{
|
||||
title: '历史数据',
|
||||
value: timeWindowProtocol || '全部通道',
|
||||
detail: '查看该时间窗内的历史明细和字段。',
|
||||
action: '查询历史',
|
||||
color: 'blue' as const,
|
||||
disabled: !timeWindowReady,
|
||||
onClick: openTimeWindowRaw
|
||||
},
|
||||
{
|
||||
title: '里程统计',
|
||||
value: timeWindowRow?.totalMileageKm != null ? `${timeWindowRow.totalMileageKm} km` : '待核对',
|
||||
detail: '核对区间里程、日里程和总里程差值。',
|
||||
action: '核对里程',
|
||||
color: 'orange' as const,
|
||||
disabled: !timeWindowReady,
|
||||
onClick: openTimeWindowMileage
|
||||
},
|
||||
{
|
||||
title: '告警通知',
|
||||
value: timeWindowRow ? realtimeIssueLabels(timeWindowRow)[0] : '待选择',
|
||||
detail: '查看时间窗内断链、离线、定位异常事件。',
|
||||
action: '查看告警',
|
||||
color: timeWindowRow && hasSourceIssue(timeWindowRow) ? 'orange' as const : 'green' as const,
|
||||
disabled: !timeWindowReady || !onOpenQuality,
|
||||
onClick: openTimeWindowQuality
|
||||
}
|
||||
];
|
||||
const mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({
|
||||
id: realtimeMapPointId(row, index),
|
||||
label: row.plate || row.vin || 'unknown',
|
||||
@@ -1075,6 +1214,74 @@ export function Realtime({
|
||||
disabled: rows.length === 0
|
||||
}
|
||||
];
|
||||
const timeWindowMonitorBlock = (
|
||||
<Card bordered className="vp-time-window-monitor">
|
||||
<div className="vp-time-window-summary">
|
||||
<Space wrap>
|
||||
<Tag color="blue">自定义时间监控</Tag>
|
||||
<Tag color={timeWindowReady ? 'green' : 'orange'}>{timeWindowReady ? '时间窗可用' : '先选车辆'}</Tag>
|
||||
<Tag color="grey">{timeWindowDuration}</Tag>
|
||||
</Space>
|
||||
<Typography.Title heading={5} style={{ margin: 0 }}>按车辆和时间窗复盘发生了什么</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
客户问某辆车、某段时间发生了什么时,直接串起轨迹回放、历史数据、里程统计和告警通知。
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Button size="small" theme="solid" type="primary" disabled={!timeWindowReady} onClick={openTimeWindowHistory}>轨迹回放</Button>
|
||||
<Button size="small" theme="light" type="primary" disabled={!timeWindowReady} onClick={openTimeWindowRaw}>历史数据</Button>
|
||||
<Button size="small" theme="light" icon={<IconCopy />} onClick={copyTimeWindowPackage}>复制时间窗包</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="vp-time-window-main">
|
||||
<div className="vp-time-window-controls">
|
||||
<div className="vp-time-window-target">
|
||||
<span>监控车辆</span>
|
||||
<strong>{timeWindowRow ? [timeWindowRow.plate, timeWindowRow.vin].filter(Boolean).join(' / ') : timeWindowKeyword || '未选择车辆'}</strong>
|
||||
<small>{timeWindowProtocol || '全部数据通道'}</small>
|
||||
</div>
|
||||
<label>
|
||||
<span>开始时间</span>
|
||||
<input
|
||||
aria-label="时间窗开始"
|
||||
value={timeWindow.dateFrom}
|
||||
onChange={(event) => setTimeWindow((current) => ({ ...current, dateFrom: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>结束时间</span>
|
||||
<input
|
||||
aria-label="时间窗结束"
|
||||
value={timeWindow.dateTo}
|
||||
onChange={(event) => setTimeWindow((current) => ({ ...current, dateTo: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="vp-time-window-presets">
|
||||
<Button size="small" onClick={() => setTimeWindowPreset('15m')}>15分钟</Button>
|
||||
<Button size="small" onClick={() => setTimeWindowPreset('1h')}>1小时</Button>
|
||||
<Button size="small" onClick={() => setTimeWindowPreset('today')}>今天</Button>
|
||||
<Button size="small" onClick={() => setTimeWindowPreset('24h')}>近24小时</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vp-time-window-actions">
|
||||
{timeWindowWorkItems.map((item) => (
|
||||
<button
|
||||
key={item.title}
|
||||
type="button"
|
||||
className="vp-time-window-action"
|
||||
disabled={item.disabled}
|
||||
onClick={item.onClick}
|
||||
aria-label={`时间窗监控 ${item.title} ${item.action}`}
|
||||
>
|
||||
<Tag color={item.color}>{item.title}</Tag>
|
||||
<strong>{item.value}</strong>
|
||||
<span>{item.detail}</span>
|
||||
<em>{item.action}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (mode === 'map') {
|
||||
return (
|
||||
@@ -1225,6 +1432,8 @@ export function Realtime({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeWindowMonitorBlock}
|
||||
|
||||
<div className="vp-map-vehicle-workbench">
|
||||
<div className="vp-map-vehicle-workbench-head">
|
||||
<div>
|
||||
@@ -1397,6 +1606,7 @@ export function Realtime({
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
{timeWindowMonitorBlock}
|
||||
<Card bordered>
|
||||
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||||
const nextFilters = values as Record<string, string>;
|
||||
|
||||
@@ -1042,6 +1042,144 @@ body {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.vp-time-window-monitor {
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vp-time-window-monitor .semi-card-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.44fr) minmax(0, 1fr);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.vp-time-window-summary {
|
||||
padding: 18px;
|
||||
border-right: 1px solid var(--vp-border);
|
||||
background: #f9fbff;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-time-window-main {
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.vp-time-window-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.1fr) minmax(190px, 1fr) minmax(190px, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.vp-time-window-target,
|
||||
.vp-time-window-controls label {
|
||||
min-height: 72px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.vp-time-window-target span,
|
||||
.vp-time-window-controls label span {
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.vp-time-window-target strong {
|
||||
color: var(--vp-text);
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vp-time-window-target small {
|
||||
color: var(--vp-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vp-time-window-controls input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--vp-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vp-time-window-controls input:focus {
|
||||
color: #1664ff;
|
||||
}
|
||||
|
||||
.vp-time-window-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.vp-time-window-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-time-window-action {
|
||||
min-height: 128px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-time-window-action:hover,
|
||||
.vp-time-window-action:focus-visible {
|
||||
border-color: rgba(22, 100, 255, 0.45);
|
||||
box-shadow: var(--vp-shadow-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vp-time-window-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.vp-time-window-action strong {
|
||||
color: var(--vp-text);
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.vp-time-window-action span {
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.vp-time-window-action em {
|
||||
color: #1664ff;
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.vp-realtime-command-board {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
@@ -4917,6 +5055,9 @@ button.vp-realtime-command-item:focus-visible {
|
||||
.vp-time-audit-grid,
|
||||
.vp-realtime-customer-board .semi-card-body,
|
||||
.vp-realtime-customer-steps,
|
||||
.vp-time-window-monitor .semi-card-body,
|
||||
.vp-time-window-controls,
|
||||
.vp-time-window-actions,
|
||||
.vp-realtime-command-board,
|
||||
.vp-realtime-command-grid,
|
||||
.vp-realtime-impact-board,
|
||||
|
||||
@@ -9106,6 +9106,22 @@ test('frames realtime page as one vehicle service with source evidence', async (
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('定位影响:1 辆有有效坐标'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('服务影响:0 辆降级 / 1 辆超时'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('地图能力:高德地图未配置,使用坐标预览'));
|
||||
expect(screen.getByText('自定义时间监控')).toBeInTheDocument();
|
||||
expect(screen.getByText('按车辆和时间窗复盘发生了什么')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '时间窗监控 轨迹回放 打开轨迹' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '时间窗监控 历史数据 查询历史' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /复制时间窗包/ }));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户时间窗监控包】'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('历史数据:http://localhost:3000/#/history-query?keyword=VIN-RT-SERVICE&protocol=GB32960'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '时间窗监控 历史数据 查询历史' }));
|
||||
await waitFor(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] ?? '');
|
||||
expect(window.location.hash).toContain('#/history-query?');
|
||||
expect(params.get('keyword')).toBe('VIN-RT-SERVICE');
|
||||
expect(params.get('protocol')).toBe('GB32960');
|
||||
expect(params.get('dateFrom')).toBeTruthy();
|
||||
expect(params.get('dateTo')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('refreshes realtime vehicle data from the monitoring workspace', async () => {
|
||||
@@ -9224,6 +9240,36 @@ test('shows realtime freshness status for recently updated and stale vehicles',
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/alert-events/summary')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { issueVehicleCount: 0, issueRecordCount: 0, errorCount: 0, warningCount: 0, protocols: [], issueTypes: [] },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/alert-events/notification-plan')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { rules: [], policies: [], priorityIssues: [], activeRuleCount: 0, p0RuleCount: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/alert-events')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 0, limit: 50, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -9285,6 +9331,20 @@ test('shows realtime freshness status for recently updated and stale vehicles',
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【地图客户决策说明】'));
|
||||
});
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('定位判断:2 辆有有效坐标 / 0 辆无坐标'));
|
||||
expect(screen.getByText('自定义时间监控')).toBeInTheDocument();
|
||||
expect(screen.getByText('按车辆和时间窗复盘发生了什么')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /复制时间窗包/ }));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户时间窗监控包】'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警通知:http://localhost:3000/#/alert-events?keyword=VIN-STALE-001&protocol=JT808'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '时间窗监控 告警通知 查看告警' }));
|
||||
await waitFor(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] ?? '');
|
||||
expect(window.location.hash).toContain('#/alert-events?');
|
||||
expect(params.get('keyword')).toBe('VIN-STALE-001');
|
||||
expect(params.get('protocol')).toBe('JT808');
|
||||
expect(params.get('dateFrom')).toBeTruthy();
|
||||
expect(params.get('dateTo')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('exports current realtime vehicles as CSV', async () => {
|
||||
|
||||
Reference in New Issue
Block a user