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);
|
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) {
|
function priorityIssueVehicleLabel(row: QualityIssueRow) {
|
||||||
const identity = row.vin?.trim() && row.vin !== 'unknown' ? row.vin.trim() : row.phone?.trim();
|
const identity = row.vin?.trim() && row.vin !== 'unknown' ? row.vin.trim() : row.phone?.trim();
|
||||||
return [row.plate?.trim(), identity].filter(Boolean).join(' / ') || row.sourceEndpoint || '-';
|
return [row.plate?.trim(), identity].filter(Boolean).join(' / ') || row.sourceEndpoint || '-';
|
||||||
@@ -237,6 +255,13 @@ export function Dashboard({
|
|||||||
const [locations, setLocations] = useState<VehicleRealtimeRow[]>([]);
|
const [locations, setLocations] = useState<VehicleRealtimeRow[]>([]);
|
||||||
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
|
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
|
||||||
const [opsHealth, setOpsHealth] = useState<OpsHealth | null>(null);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [coverageLoading, setCoverageLoading] = useState(false);
|
const [coverageLoading, setCoverageLoading] = useState(false);
|
||||||
const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState('');
|
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 = [
|
const dataFlowStages = [
|
||||||
{
|
{
|
||||||
stage: '01',
|
stage: '01',
|
||||||
@@ -1009,13 +1067,48 @@ export function Dashboard({
|
|||||||
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}>导出驾驶舱 CSV</Button>
|
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}>导出驾驶舱 CSV</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</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">
|
<div className="vp-time-monitor-grid">
|
||||||
{[
|
{[
|
||||||
{ title: '今天车辆轨迹', detail: '查看当天活跃车辆的轨迹、速度和里程断点。', action: '查今天', onClick: () => onOpenHistory() },
|
{ title: '今天车辆轨迹', detail: '查看当天活跃车辆的轨迹、速度和里程断点。', action: '查今天', onClick: openTimeMonitorHistory },
|
||||||
{ title: '历史时间窗', detail: '按任意起止时间查询位置历史、原始记录和字段明细。', action: '自定义查询', onClick: () => onOpenHistory({ tab: 'raw', includeFields: 'true' }) },
|
{ title: '历史时间窗', detail: '按任意起止时间查询位置历史、原始记录和字段明细。', action: '自定义查询', onClick: openTimeMonitorRaw },
|
||||||
{ title: '区间里程', detail: '按车辆和时间范围核对区间里程、日统计和异常点。', action: '里程统计', onClick: () => onOpenMileage() },
|
{ title: '区间里程', detail: '按车辆和时间范围核对区间里程、日统计和异常点。', action: '里程统计', onClick: openTimeMonitorMileage },
|
||||||
{ title: '告警复盘', detail: '按车辆或事件回放告警发生前后的轨迹和数据证据。', action: '告警事件', onClick: () => onOpenQuality() }
|
{ title: '告警复盘', detail: '按车辆或事件回放告警发生前后的轨迹和数据证据。', action: '告警事件', onClick: openTimeMonitorAlerts }
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<button key={item.title} type="button" className="vp-time-monitor-item" onClick={item.onClick} aria-label={`时间监控 ${item.title}`}>
|
<button key={item.title} type="button" className="vp-time-monitor-item" onClick={item.onClick} aria-label={`时间监控 ${item.title}`}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -414,6 +414,16 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 12px;
|
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 {
|
.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();
|
||||||
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');
|
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true');
|
||||||
cleanup();
|
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();
|
await renderDashboard();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '时间监控 历史时间窗' }));
|
fireEvent.click(screen.getByRole('button', { name: '时间监控 历史时间窗' }));
|
||||||
expect(window.location.hash.startsWith('#/history-query')).toBe(true);
|
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('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();
|
cleanup();
|
||||||
|
|
||||||
await renderDashboard();
|
await renderDashboard();
|
||||||
|
|||||||
Reference in New Issue
Block a user