feat(platform): add online statistics operations board

This commit is contained in:
lingniu
2026-07-04 22:59:48 +08:00
parent c0f5ec186e
commit f65b035be7
3 changed files with 195 additions and 1 deletions

View File

@@ -399,6 +399,55 @@ function onlineStatusHandoffText({
].join('\n');
}
function onlineOperationsReportText({
filters,
summary,
onlineRows,
total,
statisticsURL,
vehicleURL
}: {
filters: Record<string, string>;
summary: OnlineStatisticsSummary;
onlineRows: OnlineVehicleStatusRow[];
total: number;
statisticsURL: string;
vehicleURL?: string;
}) {
const keyword = filters.keyword?.trim() || '全部车辆';
const protocol = filters.protocol?.trim() || '全部来源';
const protocolStats = Array.isArray(summary.protocolStats) ? summary.protocolStats : [];
const offlineRows = onlineRows.filter((row) => !row.online);
const longestOffline = offlineRows.reduce<OnlineVehicleStatusRow | undefined>((current, row) => {
if (!current) return row;
return Number(row.offlineDurationMinutes ?? 0) > Number(current.offlineDurationMinutes ?? 0) ? row : current;
}, undefined);
return [
'【在线统计运营态势】',
`车辆范围:${keyword}`,
`数据来源:${protocol}`,
`在线概览:${summary.onlineVehicleCount.toLocaleString()} 在线 / ${summary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(summary.onlineRatePercent)}`,
`Redis 在线 Key${summary.redisOnlineKeys == null ? '-' : summary.redisOnlineKeys.toLocaleString()}`,
`统计证据:${summary.evidence || '-'}`,
`当前页车辆:${onlineRows.length.toLocaleString()} / 当前筛选 ${total.toLocaleString()}`,
`当前页离线:${offlineRows.length.toLocaleString()}`,
`最长离线:${longestOffline ? `${longestOffline.plate || longestOffline.vin} / ${formatOfflineDuration(longestOffline.offlineDurationMinutes)} / ${longestOffline.lastSeen || '-'}` : '-'}`,
'',
'协议在线:',
...(protocolStats.length > 0 ? protocolStats.map((item, index) => {
const rate = item.total > 0 ? (item.online / item.total) * 100 : 0;
return `${index + 1}. ${item.protocol}${item.online.toLocaleString()}/${item.total.toLocaleString()},在线率 ${formatPercent(rate)}`;
}) : ['暂无协议在线统计']),
'',
'处置建议:',
'1. 在线率下降时先检查 Redis online key、协议接入和最后上报时间',
'2. 离线车辆优先打开车辆服务核对实时、轨迹、RAW 和告警证据',
'3. 统计发布前确认离线车辆是否影响当日里程和区间闭合',
`统计查询:${statisticsURL}`,
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
].join('\n');
}
async function copyText(value: string, label: string) {
const text = value.trim();
if (!text) {
@@ -474,6 +523,11 @@ export function Mileage({
redisOnlineKeyText,
onlineSummary.evidence
].filter(Boolean).join('');
const onlineProtocolStats = Array.isArray(onlineSummary.protocolStats) ? onlineSummary.protocolStats : [];
const topOfflineRow = onlineRows.filter((row) => !row.online).reduce<OnlineVehicleStatusRow | undefined>((current, row) => {
if (!current) return row;
return Number(row.offlineDurationMinutes ?? 0) > Number(current.offlineDurationMinutes ?? 0) ? row : current;
}, undefined);
const publishAuditItems: MileagePublishAuditItem[] = [
{
key: 'closure',
@@ -723,6 +777,22 @@ export function Mileage({
'在线状态交接'
);
};
const copyOnlineOperationsReport = () => {
const vehicleURL = currentVehicleKeyword
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
: undefined;
copyText(
onlineOperationsReportText({
filters,
summary: onlineSummary,
onlineRows,
total: onlinePagination.total,
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
vehicleURL
}),
'在线统计运营态势'
);
};
useEffect(() => {
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters);
@@ -865,6 +935,69 @@ export function Mileage({
</Card>
))}
</div>
<Card
bordered
loading={onlineLoading}
title={<Space><span>线</span><Button size="small" onClick={copyOnlineOperationsReport}>线</Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-online-ops-board">
<div className="vp-online-ops-summary">
<Space wrap>
<Tag color={onlineRatePercent >= 90 ? 'green' : onlineRatePercent >= 60 ? 'orange' : 'red'}>
线 {formatPercent(onlineRatePercent)}
</Tag>
<Tag color={onlineSummary.redisOnlineKeys == null ? 'grey' : 'blue'}>
Redis {onlineSummary.redisOnlineKeys == null ? '-' : onlineSummary.redisOnlineKeys.toLocaleString()}
</Tag>
</Space>
<div className="vp-monitor-metric-value">{onlineVehicleCount.toLocaleString()} 线</div>
<Typography.Text type="secondary">
线线
</Typography.Text>
</div>
<div className="vp-online-ops-grid">
{[
{
label: '离线车辆',
value: offlineVehicleCount.toLocaleString(),
detail: '离线车辆会影响实时统计、位置查询和当日里程完整性。',
color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const
},
{
label: '协议覆盖',
value: onlineProtocolStats.length.toLocaleString(),
detail: onlineProtocolStats.length > 0 ? onlineProtocolStats.map((item) => `${item.protocol} ${item.online}/${item.total}`).join('') : '暂无协议在线统计。',
color: onlineProtocolStats.length > 0 ? 'blue' as const : 'grey' as const
},
{
label: '当前页离线',
value: onlineRows.filter((row) => !row.online).length.toLocaleString(),
detail: `${onlineRows.length.toLocaleString()} 条在线状态明细参与当前页复核。`,
color: onlineRows.some((row) => !row.online) ? 'orange' as const : 'green' as const
},
{
label: '最长离线',
value: topOfflineRow ? formatOfflineDuration(topOfflineRow.offlineDurationMinutes) : '-',
detail: topOfflineRow ? `${topOfflineRow.plate || topOfflineRow.vin} / ${topOfflineRow.lastSeen || '-'}` : '当前页暂无离线车辆。',
color: topOfflineRow ? 'orange' as const : 'green' as const
},
{
label: '统计证据',
value: onlineSummary.evidence || '-',
detail: '后端在线统计证据,可与 Redis key 和车辆快照互相校验。',
color: onlineSummary.evidence ? 'blue' as const : 'grey' as const
}
].map((item) => (
<div key={item.label} className="vp-online-ops-item">
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
</div>
))}
</div>
</div>
</Card>
<Card bordered title="统计闭合工作台" loading={summaryLoading || loading} style={{ marginTop: 16 }}>
<div className="vp-stat-closure-board">
{[

View File

@@ -1094,6 +1094,48 @@ button.vp-realtime-command-item:focus-visible {
margin-top: 16px;
}
.vp-online-ops-board {
display: grid;
grid-template-columns: minmax(260px, 0.72fr) minmax(0, 1.28fr);
gap: 16px;
}
.vp-online-ops-summary {
min-height: 166px;
padding: 12px;
border: 1px solid rgba(10, 168, 107, 0.28);
border-radius: var(--vp-radius);
background: #f4fbf8;
display: grid;
gap: 10px;
align-content: start;
}
.vp-online-ops-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.vp-online-ops-item {
min-height: 166px;
padding: 12px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
display: grid;
gap: 10px;
align-content: start;
}
.vp-online-ops-item strong {
color: var(--vp-text);
font-size: 20px;
line-height: 26px;
font-weight: 700;
word-break: break-word;
}
.vp-stat-chart {
display: grid;
gap: 12px;
@@ -2227,6 +2269,8 @@ button.vp-realtime-command-item:focus-visible {
.vp-ops-action-grid,
.vp-alert-ops-grid,
.vp-stat-workspace,
.vp-online-ops-board,
.vp-online-ops-grid,
.vp-stat-insight-grid,
.vp-stat-definition-grid,
.vp-stat-impact-board,

View File

@@ -7443,6 +7443,13 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
expect(screen.getByText('闭合影响')).toBeInTheDocument();
expect(screen.getByText('异常影响')).toBeInTheDocument();
expect(screen.getAllByText('发布判断').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('在线统计运营态势')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '复制在线态势' })).toBeInTheDocument();
expect(screen.getByText('离线车辆')).toBeInTheDocument();
expect(screen.getByText('协议覆盖')).toBeInTheDocument();
expect(screen.getByText('当前页离线')).toBeInTheDocument();
expect(screen.getByText('最长离线')).toBeInTheDocument();
expect(screen.getByText('统计证据')).toBeInTheDocument();
expect(await screen.findByText('75%')).toBeInTheDocument();
expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText((content) => content.includes('Redis 在线 92 key')).length).toBeGreaterThanOrEqual(1);
@@ -7457,10 +7464,20 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('在线状态3 在线 / 1 离线 / 在线率 75%'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布判断:复核后发布'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('业务影响当前统计范围需要先复核轨迹、RAW和离线车辆影响'));
fireEvent.click(screen.getByRole('button', { name: '复制在线态势' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【在线统计运营态势】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围VIN-STATS-SERVICE'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('数据来源JT808'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('在线概览3 在线 / 1 离线 / 在线率 75%'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Redis 在线 Key92'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('当前页车辆2 / 当前筛选 2'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('最长离线粤A离线1 / 2 小时 / 2026-07-03 18:12:10'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. JT8082/3在线率 66.7%'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('2. 离线车辆优先打开车辆服务核对实时、轨迹、RAW 和告警证据'));
expect(await screen.findByText('在线状态明细')).toBeInTheDocument();
expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument();
expect(screen.getByText('粤A离线1')).toBeInTheDocument();
expect(screen.getByText('2 小时')).toBeInTheDocument();
expect(screen.getAllByText('2 小时').length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: '复制在线状态清单' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆在线状态交接】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围VIN-STATS-SERVICE'));