feat(web): condense mobile protocol health

This commit is contained in:
lingniu
2026-07-20 09:12:28 +08:00
parent ebf682d863
commit 70211e1d7c
4 changed files with 249 additions and 9 deletions

View File

@@ -312,6 +312,52 @@ test('includes protocol readiness in the global health verdict and prioritizes i
expect(Boolean(sourcePanel!.compareDocumentPosition(infrastructureGrid!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
});
test('keeps every protocol scannable on mobile and expands evidence on demand', async () => {
layout.mobile = true;
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({
totalVehicles: 1024, boundVehicles: 1024, identityRequiredVehicles: 0, onlineVehicles: 234,
sources: [
{
protocol: 'YUTONG_MQTT', role: '宇通补充来源', total: 50, online: 12, missingVehicles: 38, severity: 'error',
status: '链路异常', evidence: '投影停滞', action: '恢复投影与回补', acceptance: '日里程恢复'
},
{
protocol: 'GB32960', role: '仪表盘里程与整车状态', total: 494, online: 123, missingVehicles: 371, severity: 'warning',
status: '覆盖不足', evidence: '在线覆盖偏低', action: '核对接入范围', acceptance: '缺失车辆下降'
},
{
protocol: 'JT808', role: 'GPS 里程与位置', total: 480, online: 99, missingVehicles: 381, severity: 'ok',
status: '生产可用', evidence: '实时来源稳定', action: '保持巡检', acceptance: '消费积压持续为零'
}
]
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderOperations(client, ['/operations?view=health']);
expect(await screen.findByText('先看覆盖与状态,点击协议展开证据和处置口径')).toBeInTheDocument();
const yutong = await screen.findByRole('button', { name: /YUTONG_MQTT/ });
expect(document.querySelector('.v2-ops-source-collapse.semi-collapse')).toBeInTheDocument();
expect(document.querySelector('.v2-ops-source-list.semi-card-group')).not.toBeInTheDocument();
for (const protocol of ['YUTONG_MQTT', 'GB32960', 'JT808']) {
expect(screen.getByRole('button', { name: new RegExp(protocol) })).toHaveAttribute('aria-expanded', 'false');
}
expect(screen.queryByText('投影停滞')).not.toBeInTheDocument();
fireEvent.click(yutong);
expect(yutong).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByText('投影停滞')).toBeInTheDocument();
expect(screen.getByText('恢复投影与回补')).toBeInTheDocument();
expect(screen.getByText('日里程恢复')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /GB32960/ }));
expect(yutong).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /GB32960/ })).toHaveAttribute('aria-expanded', 'true');
});
test('keeps health evidence visible when source readiness fails and retries that section', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({

View File

@@ -1,5 +1,5 @@
import { IconAlertTriangle, IconPulse, IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Input, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Checkbox, Collapse, Descriptions, Input, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -58,6 +58,49 @@ function sourceOnlineRate(online: number, total: number, onlineRate?: number) {
return total > 0 ? Math.max(0, Math.min(100, (online / total) * 100)) : 0;
}
function SourceReadinessDetails({ source, showCoverage = true }: { source: SourceReadinessRow; showCoverage?: boolean }) {
const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
return <>
{showCoverage ? <div className="v2-ops-source-coverage"><span><strong>线</strong><b>{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}</b></span><Progress aria-label={`${source.protocol} 在线覆盖率`} percent={onlineRate} showInfo={false} stroke="var(--v2-blue)" /><small>{number(onlineRate)}% 线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}</small></div> : null}
<div className="v2-ops-source-evidence"><strong></strong><p>{source.evidence}</p></div>
<div className="v2-ops-source-action"><strong></strong><p>{source.action}</p></div>
<footer><Tag color="green" type="light" size="small"></Tag><span>{source.acceptance}</span></footer>
</>;
}
function SourceReadinessCard({ source }: { source: SourceReadinessRow }) {
return <Card
className={`v2-ops-source-card is-${source.severity}`}
title={<span className="v2-ops-source-title"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.role}</small></span>}
headerExtraContent={<HealthTag status={source.severity}>{source.status || statusLabel(source.severity)}</HealthTag>}
headerLine
>
<SourceReadinessDetails source={source} />
</Card>;
}
function SourceReadinessCollapseHeader({ source }: { source: SourceReadinessRow }) {
const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
return <span className="v2-ops-source-collapse-header">
<span className="v2-ops-source-collapse-identity"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.role}</small></span>
<HealthTag status={source.severity}>{source.status || statusLabel(source.severity)}</HealthTag>
<span className="v2-ops-source-collapse-coverage"><strong>{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}</strong><small>{number(onlineRate)}% 线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}</small></span>
</span>;
}
function SourceReadinessCollapse({ sources }: { sources: SourceReadinessRow[] }) {
return <Collapse className="v2-ops-source-collapse" accordion lazyRender>
{sources.map((source) => <Collapse.Panel
className={`is-${source.severity}`}
itemKey={source.protocol}
key={source.protocol}
header={<SourceReadinessCollapseHeader source={source} />}
>
<div className="v2-ops-source-collapse-detail"><SourceReadinessDetails source={source} showCoverage={false} /></div>
</Collapse.Panel>)}
</Collapse>;
}
function SourcePolicyEditor({ vin, source, diagnostic, editable, onSaved }: {
vin: string;
source: VehicleLocationSourceEvidence;
@@ -433,6 +476,7 @@ function SourceDiagnosticWorkspace() {
}
export default function OperationsPage() {
const mobileLayout = useMobileLayout();
const [searchParams, setSearchParams] = useSearchParams();
const requestedWorkspace = searchParams.get('view') as OperationsWorkspace | null;
const workspace = requestedWorkspace && operationsWorkspaces.has(requestedWorkspace) ? requestedWorkspace : 'reconciliation';
@@ -588,13 +632,10 @@ export default function OperationsPage() {
</span>}
/>
</Card>
<Card className={`v2-ops-panel v2-ops-sources${sourceIssueCount || readinessUnavailable ? ' has-attention' : ''}`} bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="协议来源就绪度" description="优先展示覆盖不足的协议,并给出处置动作与验收口径" meta={<HealthTag status={readinessUnavailable ? 'error' : sourceIssueCount > 0 ? 'warning' : sourceRows.length ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : sourceRows.length ? `${sourceRows.length} 个协议正常` : '暂无协议来源' : '正在读取'}</HealthTag>} />{sources ? sortedSources.length ? <CardGroup className="v2-ops-source-list" type="grid" spacing={0}>{sortedSources.map((source) => {
const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
return <Card className={`v2-ops-source-card is-${source.severity}`} key={source.protocol} title={<span className="v2-ops-source-title"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.role}</small></span>} headerExtraContent={<HealthTag status={source.severity}>{source.status || statusLabel(source.severity)}</HealthTag>} headerLine>
<div className="v2-ops-source-coverage"><span><strong>线</strong><b>{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}</b></span><Progress aria-label={`${source.protocol} 在线覆盖率`} percent={onlineRate} showInfo={false} stroke="var(--v2-blue)" /><small>{number(onlineRate)}% 线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}</small></div>
<div className="v2-ops-source-evidence"><strong></strong><p>{source.evidence}</p></div><div className="v2-ops-source-action"><strong></strong><p>{source.action}</p></div><footer><Tag color="green" type="light" size="small"></Tag><span>{source.acceptance}</span></footer>
</Card>;
})}</CardGroup> : <PanelEmpty className="v2-ops-source-empty" icon={<IconPulse />} title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? <PanelEmpty
<Card className={`v2-ops-panel v2-ops-sources${sourceIssueCount || readinessUnavailable ? ' has-attention' : ''}`} bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="协议来源就绪度" description={mobileLayout ? '先看覆盖与状态,点击协议展开证据和处置口径' : '优先展示覆盖不足的协议,并给出处置动作与验收口径'} meta={<HealthTag status={readinessUnavailable ? 'error' : sourceIssueCount > 0 ? 'warning' : sourceRows.length ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : sourceRows.length ? `${sourceRows.length} 个协议正常` : '暂无协议来源' : '正在读取'}</HealthTag>} />{sources ? sortedSources.length ? mobileLayout
? <SourceReadinessCollapse sources={sortedSources} />
: <CardGroup className="v2-ops-source-list" type="grid" spacing={0}>{sortedSources.map((source) => <SourceReadinessCard source={source} key={source.protocol} />)}</CardGroup>
: <PanelEmpty className="v2-ops-source-empty" icon={<IconPulse />} title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? <PanelEmpty
className="v2-ops-source-empty is-error"
tone="danger"
icon={<IconAlertTriangle />}

View File

@@ -396,7 +396,8 @@ describe('V2 production entry', () => {
expect(corePageSources.OperationsPage).toContain('<Descriptions className="v2-ops-runtime-descriptions"');
expect(corePageSources.OperationsPage).toContain('<PanelEmpty className="v2-ops-capacity-empty"');
expect(corePageSources.OperationsPage).toContain('<CardGroup className="v2-ops-source-list"');
expect(corePageSources.OperationsPage).toContain('<Card className={`v2-ops-source-card');
expect(corePageSources.OperationsPage).toContain('<SourceReadinessCard source={source}');
expect(corePageSources.OperationsPage).toContain('<Collapse className="v2-ops-source-collapse"');
expect(corePageSources.OperationsPage).not.toContain('<article key={item.name}');
expect(corePageSources.OperationsPage).not.toContain('<article key={source.protocol}');
expect(corePageSources.OperationsPage).not.toContain('<dl><div><dt>生产数据模式</dt>');

View File

@@ -26385,7 +26385,159 @@
min-width: 0;
}
.v2-ops-sources > .semi-card-body > .v2-ops-source-collapse.semi-collapse {
display: grid;
border: 0;
background: #f7f9fc;
padding: 9px;
}
.v2-ops-source-collapse > .semi-collapse-item {
overflow: hidden;
border: 1px solid #dfe6ef;
border-radius: 10px;
background: #fff;
box-shadow: 0 3px 12px rgba(31, 53, 80, .035);
}
.v2-ops-source-collapse > .semi-collapse-item + .semi-collapse-item {
margin-top: 7px;
}
.v2-ops-source-collapse > .semi-collapse-item.is-warning {
border-color: #ecd7ad;
}
.v2-ops-source-collapse > .semi-collapse-item.is-error {
border-color: #efc9c7;
}
.v2-ops-source-collapse > .semi-collapse-item.is-ok {
border-color: #cce8dd;
}
.v2-ops-source-collapse > .semi-collapse-item > .semi-collapse-header {
min-height: 72px;
gap: 8px;
padding: 9px 11px;
}
.v2-ops-source-collapse > .semi-collapse-item > .semi-collapse-header:focus-visible {
outline: 2px solid rgba(18, 104, 243, .32);
outline-offset: -2px;
}
.v2-ops-source-collapse .semi-collapse-header-icon {
width: 28px;
height: 28px;
flex: 0 0 28px;
border-radius: 8px;
background: #f1f5fa;
color: #5f7289;
}
.v2-ops-source-collapse-header {
display: grid;
min-width: 0;
flex: 1;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px 8px;
}
.v2-ops-source-collapse-identity {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.v2-ops-source-collapse-identity > .semi-tag {
min-height: 22px;
flex: 0 0 auto;
border-radius: 6px;
font-size: 9px;
}
.v2-ops-source-collapse-identity > small {
overflow: hidden;
color: #6f8094;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-ops-source-collapse-header > .v2-ops-health-tag.semi-tag {
min-height: 22px;
border-radius: 999px;
font-size: 8px;
}
.v2-ops-source-collapse-coverage {
display: flex;
min-width: 0;
grid-column: 1 / -1;
align-items: baseline;
gap: 8px;
}
.v2-ops-source-collapse-coverage > strong {
color: #283f59;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.v2-ops-source-collapse-coverage > small {
overflow: hidden;
color: #79889b;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-ops-source-collapse .semi-collapse-content-wrapper {
padding: 0;
}
.v2-ops-source-collapse-detail {
display: grid;
border-top: 1px solid #e7edf4;
}
.v2-ops-source-collapse-detail > :is(.v2-ops-source-evidence, .v2-ops-source-action) {
min-height: 0;
padding: 11px 12px;
}
.v2-ops-source-collapse-detail > footer {
display: grid;
min-height: 0;
grid-template-columns: auto minmax(0, 1fr);
align-items: flex-start;
gap: 8px;
border-top: 1px solid #e9eef4;
background: #f5faf8;
padding: 11px 12px;
}
.v2-ops-source-collapse-detail > footer > .semi-tag {
min-height: 22px;
border-radius: 999px;
font-size: 8px;
}
.v2-ops-source-collapse-detail > footer > span {
color: #557567;
font-size: 10px;
line-height: 1.55;
}
@media (max-width: 680px) {
.v2-ops-sources > .semi-card-body > .v2-workspace-panel-header {
min-height: 56px;
padding: 8px 11px;
}
.v2-ops-health-metric-rail.is-queue.has-context > .semi-card-body {
min-height: 0;
grid-template-columns: minmax(0, 1fr);