feat(platform): add online statistics operations board
This commit is contained in:
@@ -399,6 +399,55 @@ function onlineStatusHandoffText({
|
|||||||
].join('\n');
|
].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) {
|
async function copyText(value: string, label: string) {
|
||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -474,6 +523,11 @@ export function Mileage({
|
|||||||
redisOnlineKeyText,
|
redisOnlineKeyText,
|
||||||
onlineSummary.evidence
|
onlineSummary.evidence
|
||||||
].filter(Boolean).join(',');
|
].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[] = [
|
const publishAuditItems: MileagePublishAuditItem[] = [
|
||||||
{
|
{
|
||||||
key: 'closure',
|
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(() => {
|
useEffect(() => {
|
||||||
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters);
|
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters);
|
||||||
@@ -865,6 +935,69 @@ export function Mileage({
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 }}>
|
<Card bordered title="统计闭合工作台" loading={summaryLoading || loading} style={{ marginTop: 16 }}>
|
||||||
<div className="vp-stat-closure-board">
|
<div className="vp-stat-closure-board">
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -1094,6 +1094,48 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
margin-top: 16px;
|
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 {
|
.vp-stat-chart {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -2227,6 +2269,8 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
.vp-ops-action-grid,
|
.vp-ops-action-grid,
|
||||||
.vp-alert-ops-grid,
|
.vp-alert-ops-grid,
|
||||||
.vp-stat-workspace,
|
.vp-stat-workspace,
|
||||||
|
.vp-online-ops-board,
|
||||||
|
.vp-online-ops-grid,
|
||||||
.vp-stat-insight-grid,
|
.vp-stat-insight-grid,
|
||||||
.vp-stat-definition-grid,
|
.vp-stat-definition-grid,
|
||||||
.vp-stat-impact-board,
|
.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.getByText('异常影响')).toBeInTheDocument();
|
expect(screen.getByText('异常影响')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('发布判断').length).toBeGreaterThanOrEqual(1);
|
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(await screen.findByText('75%')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getAllByText((content) => content.includes('Redis 在线 92 key')).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('在线状态:3 在线 / 1 离线 / 在线率 75%'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布判断:复核后发布'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布判断:复核后发布'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('业务影响:当前统计范围需要先复核轨迹、RAW和离线车辆影响'));
|
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('在线状态明细')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument();
|
expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument();
|
||||||
expect(screen.getByText('粤A离线1')).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: '复制在线状态清单' }));
|
fireEvent.click(screen.getByRole('button', { name: '复制在线状态清单' }));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆在线状态交接】'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆在线状态交接】'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围:VIN-STATS-SERVICE'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围:VIN-STATS-SERVICE'));
|
||||||
|
|||||||
Reference in New Issue
Block a user