feat(platform): add realtime time-window monitoring
This commit is contained in:
@@ -287,6 +287,12 @@ export default function App() {
|
|||||||
if (filters.issueType) {
|
if (filters.issueType) {
|
||||||
issueFilters.issueType = 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);
|
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) {
|
function parseVehicleTime(value?: string) {
|
||||||
const normalized = value?.trim();
|
const normalized = value?.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -585,6 +615,7 @@ export function Realtime({
|
|||||||
const [selectedMapPointId, setSelectedMapPointId] = useState('');
|
const [selectedMapPointId, setSelectedMapPointId] = useState('');
|
||||||
const [lastRefreshAt, setLastRefreshAt] = useState('');
|
const [lastRefreshAt, setLastRefreshAt] = useState('');
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [timeWindow, setTimeWindow] = useState(defaultTimeWindow);
|
||||||
const refreshIntervalSeconds = 15;
|
const refreshIntervalSeconds = 15;
|
||||||
const amapConfig = getAMapConfig();
|
const amapConfig = getAMapConfig();
|
||||||
const runtime = opsHealth?.runtime;
|
const runtime = opsHealth?.runtime;
|
||||||
@@ -678,6 +709,114 @@ export function Realtime({
|
|||||||
const defaultMapRow = mapServiceRows[0] ?? rows.find((row) => isValidCoordinate(row) && canOpenVehicle(row.vin));
|
const defaultMapRow = mapServiceRows[0] ?? rows.find((row) => isValidCoordinate(row) && canOpenVehicle(row.vin));
|
||||||
const selectedMapRow = rows.find((row, index) => realtimeMapPointId(row, index) === selectedMapPointId) ?? defaultMapRow;
|
const selectedMapRow = rows.find((row, index) => realtimeMapPointId(row, index) === selectedMapPointId) ?? defaultMapRow;
|
||||||
const selectedMapPointKey = selectedMapRow ? realtimeMapPointId(selectedMapRow, rows.indexOf(selectedMapRow)) : selectedMapPointId;
|
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) => ({
|
const mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({
|
||||||
id: realtimeMapPointId(row, index),
|
id: realtimeMapPointId(row, index),
|
||||||
label: row.plate || row.vin || 'unknown',
|
label: row.plate || row.vin || 'unknown',
|
||||||
@@ -1075,6 +1214,74 @@ export function Realtime({
|
|||||||
disabled: rows.length === 0
|
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') {
|
if (mode === 'map') {
|
||||||
return (
|
return (
|
||||||
@@ -1225,6 +1432,8 @@ export function Realtime({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{timeWindowMonitorBlock}
|
||||||
|
|
||||||
<div className="vp-map-vehicle-workbench">
|
<div className="vp-map-vehicle-workbench">
|
||||||
<div className="vp-map-vehicle-workbench-head">
|
<div className="vp-map-vehicle-workbench-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -1397,6 +1606,7 @@ export function Realtime({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
{timeWindowMonitorBlock}
|
||||||
<Card bordered>
|
<Card bordered>
|
||||||
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||||||
const nextFilters = values as Record<string, string>;
|
const nextFilters = values as Record<string, string>;
|
||||||
|
|||||||
@@ -1042,6 +1042,144 @@ body {
|
|||||||
align-self: end;
|
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 {
|
.vp-realtime-command-board {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -4917,6 +5055,9 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
.vp-time-audit-grid,
|
.vp-time-audit-grid,
|
||||||
.vp-realtime-customer-board .semi-card-body,
|
.vp-realtime-customer-board .semi-card-body,
|
||||||
.vp-realtime-customer-steps,
|
.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-board,
|
||||||
.vp-realtime-command-grid,
|
.vp-realtime-command-grid,
|
||||||
.vp-realtime-impact-board,
|
.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('定位影响:1 辆有有效坐标'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('服务影响:0 辆降级 / 1 辆超时'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('服务影响:0 辆降级 / 1 辆超时'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('地图能力:高德地图未配置,使用坐标预览'));
|
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 () => {
|
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;
|
} 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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
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('【地图客户决策说明】'));
|
||||||
});
|
});
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('定位判断:2 辆有有效坐标 / 0 辆无坐标'));
|
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 () => {
|
test('exports current realtime vehicles as CSV', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user