feat(platform): add online statistics operations board
This commit is contained in:
@@ -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">
|
||||
{[
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 在线 Key:92'));
|
||||
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. JT808:2/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'));
|
||||
|
||||
Reference in New Issue
Block a user