feat(platform): add dashboard time window monitor
This commit is contained in:
@@ -107,6 +107,24 @@ function nextDate(value: string) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function todayDateString() {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function previousDateString(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
|
||||
if (!match) return value;
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]) - 1);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function priorityIssueVehicleLabel(row: QualityIssueRow) {
|
||||
const identity = row.vin?.trim() && row.vin !== 'unknown' ? row.vin.trim() : row.phone?.trim();
|
||||
return [row.plate?.trim(), identity].filter(Boolean).join(' / ') || row.sourceEndpoint || '-';
|
||||
@@ -237,6 +255,13 @@ export function Dashboard({
|
||||
const [locations, setLocations] = useState<VehicleRealtimeRow[]>([]);
|
||||
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
|
||||
const [opsHealth, setOpsHealth] = useState<OpsHealth | null>(null);
|
||||
const [timeMonitorFilters, setTimeMonitorFilters] = useState<Record<string, string>>(() => {
|
||||
const today = todayDateString();
|
||||
return {
|
||||
dateFrom: previousDateString(today),
|
||||
dateTo: today
|
||||
};
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [coverageLoading, setCoverageLoading] = useState(false);
|
||||
const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState('');
|
||||
@@ -655,6 +680,39 @@ export function Dashboard({
|
||||
]
|
||||
}
|
||||
];
|
||||
const timeMonitorScope = (values = timeMonitorFilters) => {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo'] as const) {
|
||||
const value = String(values[key] ?? '').trim();
|
||||
if (value) {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
const openTimeMonitorHistory = () => {
|
||||
onOpenHistory(timeMonitorScope());
|
||||
};
|
||||
const openTimeMonitorMileage = () => {
|
||||
onOpenMileage(timeMonitorScope());
|
||||
};
|
||||
const openTimeMonitorRaw = () => {
|
||||
onOpenHistory({ ...timeMonitorScope(), tab: 'raw', includeFields: 'true' });
|
||||
};
|
||||
const openTimeMonitorAlerts = () => {
|
||||
const scope = timeMonitorScope();
|
||||
onOpenQuality({
|
||||
...(scope.keyword ? { keyword: scope.keyword } : {}),
|
||||
...(scope.protocol ? { protocol: scope.protocol } : {})
|
||||
});
|
||||
};
|
||||
const timeMonitorSummary = (() => {
|
||||
const scope = timeMonitorScope();
|
||||
const vehicle = scope.keyword || '全部车辆';
|
||||
const source = scope.protocol || '全部数据通道';
|
||||
const range = scope.dateFrom || scope.dateTo ? `${scope.dateFrom || '-'} 至 ${scope.dateTo || '-'}` : '全部时间';
|
||||
return `${vehicle} / ${source} / ${range}`;
|
||||
})();
|
||||
const dataFlowStages = [
|
||||
{
|
||||
stage: '01',
|
||||
@@ -1009,13 +1067,48 @@ export function Dashboard({
|
||||
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}>导出驾驶舱 CSV</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered title="自定义时间监控" style={{ marginBottom: 16 }}>
|
||||
<Card
|
||||
bordered
|
||||
title={<Space><span>自定义时间监控</span><Tag color="blue">{timeMonitorSummary}</Tag></Space>}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Form
|
||||
initValues={timeMonitorFilters}
|
||||
layout="horizontal"
|
||||
onSubmit={(values) => setTimeMonitorFilters(timeMonitorScope(values as Record<string, string>))}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Form.Input field="keyword" label="车辆" placeholder="VIN / 车牌 / 手机号" style={{ width: 240 }} />
|
||||
<Form.Select field="protocol" label="数据通道" placeholder="全部通道" style={{ width: 150 }}>
|
||||
<Select.Option value="GB32960">GB32960</Select.Option>
|
||||
<Select.Option value="JT808">JT808</Select.Option>
|
||||
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
|
||||
</Form.Select>
|
||||
<Form.Input field="dateFrom" label="开始日期" placeholder="2026-07-01" style={{ width: 150 }} />
|
||||
<Form.Input field="dateTo" label="结束日期" placeholder="2026-07-05" style={{ width: 150 }} />
|
||||
<Space>
|
||||
<Button htmlType="submit" theme="solid" type="primary">应用时间窗</Button>
|
||||
<Button onClick={() => {
|
||||
const today = todayDateString();
|
||||
setTimeMonitorFilters({ dateFrom: previousDateString(today), dateTo: today });
|
||||
}}>重置最近一天</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
<div className="vp-time-monitor-actions">
|
||||
<Button theme="solid" type="primary" onClick={openTimeMonitorHistory}>查看轨迹</Button>
|
||||
<Button theme="light" type="primary" onClick={openTimeMonitorMileage}>统计里程</Button>
|
||||
<Button theme="light" type="primary" onClick={openTimeMonitorRaw}>导出原始记录</Button>
|
||||
<Button theme="light" type="warning" onClick={openTimeMonitorAlerts}>查看告警</Button>
|
||||
</div>
|
||||
<Typography.Text type="secondary">
|
||||
同一时间窗会带入轨迹回放、里程统计和历史数据导出,避免跨页面重复输入。
|
||||
</Typography.Text>
|
||||
<div className="vp-time-monitor-grid">
|
||||
{[
|
||||
{ title: '今天车辆轨迹', detail: '查看当天活跃车辆的轨迹、速度和里程断点。', action: '查今天', onClick: () => onOpenHistory() },
|
||||
{ title: '历史时间窗', detail: '按任意起止时间查询位置历史、原始记录和字段明细。', action: '自定义查询', onClick: () => onOpenHistory({ tab: 'raw', includeFields: 'true' }) },
|
||||
{ title: '区间里程', detail: '按车辆和时间范围核对区间里程、日统计和异常点。', action: '里程统计', onClick: () => onOpenMileage() },
|
||||
{ title: '告警复盘', detail: '按车辆或事件回放告警发生前后的轨迹和数据证据。', action: '告警事件', onClick: () => onOpenQuality() }
|
||||
{ title: '今天车辆轨迹', detail: '查看当天活跃车辆的轨迹、速度和里程断点。', action: '查今天', onClick: openTimeMonitorHistory },
|
||||
{ title: '历史时间窗', detail: '按任意起止时间查询位置历史、原始记录和字段明细。', action: '自定义查询', onClick: openTimeMonitorRaw },
|
||||
{ title: '区间里程', detail: '按车辆和时间范围核对区间里程、日统计和异常点。', action: '里程统计', onClick: openTimeMonitorMileage },
|
||||
{ title: '告警复盘', detail: '按车辆或事件回放告警发生前后的轨迹和数据证据。', action: '告警事件', onClick: openTimeMonitorAlerts }
|
||||
].map((item) => (
|
||||
<button key={item.title} type="button" className="vp-time-monitor-item" onClick={item.onClick} aria-label={`时间监控 ${item.title}`}>
|
||||
<div>
|
||||
|
||||
@@ -414,6 +414,16 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.vp-time-monitor-actions {
|
||||
min-height: 36px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vp-time-monitor-item {
|
||||
|
||||
@@ -792,6 +792,11 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
|
||||
expect(screen.getByText('历史数据导出')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆服务入口')).toBeInTheDocument();
|
||||
expect(screen.getByText('自定义时间监控')).toBeInTheDocument();
|
||||
expect(screen.getByText('应用时间窗')).toBeInTheDocument();
|
||||
expect(screen.getByText('重置最近一天')).toBeInTheDocument();
|
||||
expect(screen.getByText('查看轨迹')).toBeInTheDocument();
|
||||
expect(screen.getByText('统计里程')).toBeInTheDocument();
|
||||
expect(screen.getByText('导出原始记录')).toBeInTheDocument();
|
||||
expect(screen.getByText('今天车辆轨迹')).toBeInTheDocument();
|
||||
expect(screen.getByText('历史时间窗')).toBeInTheDocument();
|
||||
expect(screen.getByText('区间里程')).toBeInTheDocument();
|
||||
@@ -856,10 +861,35 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true');
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看轨迹' }));
|
||||
expect(window.location.hash.startsWith('#/history')).toBe(true);
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('dateFrom')).toBeTruthy();
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('dateTo')).toBeTruthy();
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '统计里程' }));
|
||||
expect(window.location.hash.startsWith('#/mileage')).toBe(true);
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('dateFrom')).toBeTruthy();
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('dateTo')).toBeTruthy();
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出原始记录' }));
|
||||
expect(window.location.hash.startsWith('#/history-query')).toBe(true);
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('tab')).toBe('raw');
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true');
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('dateFrom')).toBeTruthy();
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('dateTo')).toBeTruthy();
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '时间监控 历史时间窗' }));
|
||||
expect(window.location.hash.startsWith('#/history-query')).toBe(true);
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true');
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('dateFrom')).toBeTruthy();
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('dateTo')).toBeTruthy();
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
|
||||
Reference in New Issue
Block a user