feat(platform): add realtime source coverage board

This commit is contained in:
lingniu
2026-07-04 21:07:14 +08:00
parent fa9c088dec
commit 09c86022b2
3 changed files with 119 additions and 0 deletions

View File

@@ -168,6 +168,15 @@ const realtimeExportColumns: CsvColumn<VehicleRealtimeRow>[] = [
{ title: '绑定状态', value: (row) => row.bindingStatus }
];
type RealtimeSourceCoverage = {
protocol: string;
total: number;
online: number;
located: number;
degraded: number;
stale: number;
};
function realtimeExportFileName(filters: Record<string, string>) {
const keyword = filters.keyword?.trim() || 'all';
const protocol = filters.protocol?.trim() || 'all-source';
@@ -387,6 +396,26 @@ function realtimeDutyHandoffText({
return lines.join('\n');
}
function buildRealtimeSourceCoverage(rows: VehicleRealtimeRow[]) {
const sourceMap = new Map<string, RealtimeSourceCoverage>();
rows.forEach((row) => {
const protocols = row.sourceStatus?.length
? row.sourceStatus.map((source) => source.protocol)
: row.primaryProtocol ? [row.primaryProtocol] : [];
protocols.forEach((protocol) => {
const source = row.sourceStatus?.find((item) => item.protocol === protocol);
const current = sourceMap.get(protocol) ?? { protocol, total: 0, online: 0, located: 0, degraded: 0, stale: 0 };
current.total += 1;
current.online += (source ? source.online : row.online) ? 1 : 0;
current.located += isValidCoordinate(row) ? 1 : 0;
current.degraded += (!source || !source.online || !source.hasRealtime || row.onlineSourceCount < row.sourceCount) ? 1 : 0;
current.stale += dataFreshness(row).stale ? 1 : 0;
sourceMap.set(protocol, current);
});
});
return [...sourceMap.values()].sort((left, right) => right.total - left.total || left.protocol.localeCompare(right.protocol));
}
async function copyText(value: string, label: string) {
const text = value.trim();
if (!text) {
@@ -499,6 +528,7 @@ export function Realtime({
const degradedRate = rows.length > 0 ? (degradedCount / rows.length) * 100 : 0;
const pageCoverageRate = pagination.total > 0 ? (rows.length / pagination.total) * 100 : 0;
const primaryProtocols = new Set(rows.map((row) => row.primaryProtocol).filter(Boolean));
const sourceCoverageRows = buildRealtimeSourceCoverage(rows);
const sourceIssueRows = rows
.filter((row) => canOpenVehicle(row.vin) && hasSourceIssue(row))
.sort((a, b) => {
@@ -705,6 +735,33 @@ export function Realtime({
</div>
</div>
</div>
<div className="vp-source-coverage-board">
<div className="vp-current-service-title"></div>
<div className="vp-source-coverage-grid">
{sourceCoverageRows.length === 0 ? (
<Typography.Text type="tertiary"></Typography.Text>
) : sourceCoverageRows.map((item) => (
<button
key={item.protocol}
type="button"
className="vp-source-coverage-item"
aria-label={`筛选协议 ${item.protocol}`}
onClick={() => applyFilters({ ...filters, protocol: item.protocol })}
>
<div className="vp-source-coverage-item-head">
<Tag color={item.degraded > 0 ? 'orange' : 'green'}>{item.protocol}</Tag>
<span>{item.total.toLocaleString()} </span>
</div>
<div className="vp-source-coverage-bars">
<span>线 {item.online.toLocaleString()}</span>
<span> {item.located.toLocaleString()}</span>
<span> {item.degraded.toLocaleString()}</span>
<span> {item.stale.toLocaleString()}</span>
</div>
</button>
))}
</div>
</div>
<div className="vp-monitor-layout">
<div className="vp-monitor-map">
<div className="vp-monitor-map-header">

View File

@@ -344,6 +344,61 @@ button.vp-realtime-command-item:focus-visible {
line-height: 28px;
}
.vp-source-coverage-board {
margin-bottom: 16px;
padding: 14px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: var(--vp-surface);
}
.vp-source-coverage-grid {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.vp-source-coverage-item {
min-height: 98px;
padding: 12px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
text-align: left;
font: inherit;
cursor: pointer;
}
.vp-source-coverage-item:hover,
.vp-source-coverage-item:focus-visible {
border-color: rgba(22, 100, 255, 0.42);
background: #f5f9ff;
outline: none;
}
.vp-source-coverage-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.vp-source-coverage-item-head span {
color: var(--vp-text);
font-weight: 700;
}
.vp-source-coverage-bars {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
color: var(--vp-text-muted);
font-size: 12px;
line-height: 18px;
}
.vp-result-summary-item {
min-height: 72px;
padding: 12px;
@@ -1402,6 +1457,7 @@ button.vp-realtime-command-item:focus-visible {
.vp-current-service-grid,
.vp-realtime-command-board,
.vp-realtime-command-grid,
.vp-source-coverage-grid,
.vp-runbook-grid,
.vp-workbench-grid,
.vp-scenario-grid,

View File

@@ -8511,6 +8511,10 @@ test('copies realtime operations summary from realtime page', async () => {
expect(screen.getByRole('button', { name: '实时筛选 降级服务 2' })).toBeInTheDocument();
expect(screen.getByText('建议处置')).toBeInTheDocument();
expect(screen.getByText('排查降级服务 2')).toBeInTheDocument();
expect(screen.getByText('协议地图覆盖')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '筛选协议 GB32960' })).toHaveTextContent('GB32960');
expect(screen.getByRole('button', { name: '筛选协议 JT808' })).toHaveTextContent('2 辆');
expect(screen.getByRole('button', { name: '筛选协议 YUTONG_MQTT' })).toHaveTextContent('超时 1');
expect(screen.getByText('实时覆盖判读')).toBeInTheDocument();
expect(screen.getByText('在线率')).toBeInTheDocument();
expect(screen.getAllByText('66.7%').length).toBeGreaterThanOrEqual(3);
@@ -8552,6 +8556,8 @@ test('copies realtime operations summary from realtime page', async () => {
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('历史RAWhttp://localhost:3000/api/history/raw-frames?protocol=JT808&vin=VIN-RT-SUMMARY-003&limit=20&includeFields=true'));
fireEvent.click(screen.getByRole('button', { name: '实时筛选 降级服务 2' }));
expect(window.location.hash).toBe('#/realtime?protocol=JT808&serviceStatus=degraded&online=online');
fireEvent.click(screen.getByRole('button', { name: '筛选协议 GB32960' }));
expect(window.location.hash).toBe('#/realtime?protocol=GB32960&serviceStatus=degraded&online=online');
});
test('shows production AMap integration status on realtime page', async () => {