feat(platform): add realtime time-window monitoring
This commit is contained in:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user