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

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