feat(platform): add realtime time-window monitoring

This commit is contained in:
lingniu
2026-07-05 13:13:20 +08:00
parent 3cdded42c5
commit c0cc7fc54f
4 changed files with 417 additions and 0 deletions

View File

@@ -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);
};

View File

@@ -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>;

View File

@@ -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,

View File

@@ -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 () => {