diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx index c7264afa..7c64146d 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx @@ -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({ diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx index cb1196ef..b0668cb2 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -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 ?
在线覆盖{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}
: null} +
当前证据

{source.evidence}

+
建议动作

{source.action}

+ + ; +} + +function SourceReadinessCard({ source }: { source: SourceReadinessRow }) { + return {source.protocol}{source.role}} + headerExtraContent={{source.status || statusLabel(source.severity)}} + headerLine + > + + ; +} + +function SourceReadinessCollapseHeader({ source }: { source: SourceReadinessRow }) { + const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate); + return + {source.protocol}{source.role} + {source.status || statusLabel(source.severity)} + {source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''} + ; +} + +function SourceReadinessCollapse({ sources }: { sources: SourceReadinessRow[] }) { + return + {sources.map((source) => } + > +
+
)} +
; +} + 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() { } /> - 0 ? 'warning' : sourceRows.length ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : sourceRows.length ? `${sourceRows.length} 个协议正常` : '暂无协议来源' : '正在读取'}} />{sources ? sortedSources.length ? {sortedSources.map((source) => { - const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate); - return {source.protocol}{source.role}} headerExtraContent={{source.status || statusLabel(source.severity)}} headerLine> -
在线覆盖{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}
-
当前证据

{source.evidence}

建议动作

{source.action}

验收标准{source.acceptance}
-
; - })}
: } title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? 0 ? 'warning' : sourceRows.length ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : sourceRows.length ? `${sourceRows.length} 个协议正常` : '暂无协议来源' : '正在读取'}} />{sources ? sortedSources.length ? mobileLayout + ? + : {sortedSources.map((source) => )} + : } title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? } diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index 0b6c7947..53362bb0 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -396,7 +396,8 @@ describe('V2 production entry', () => { expect(corePageSources.OperationsPage).toContain('
生产数据模式
'); diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css index f97dbd8e..9f050ce4 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -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);