From 2c9399941787195cfa3e677912de329670a9d92e Mon Sep 17 00:00:00 2001 From: lingniu Date: Sun, 19 Jul 2026 18:56:35 +0800 Subject: [PATCH] refactor(ui): converge semi operations workspace --- .../apps/web/src/api/client.test.ts | 42 ++++++++ .../apps/web/src/api/client.ts | 19 +++- .../web/src/v2/pages/OperationsPage.test.tsx | 21 ++++ .../apps/web/src/v2/pages/OperationsPage.tsx | 36 ++++--- .../apps/web/src/v2/styles/workspace.css | 97 +++++++++++++++++++ 5 files changed, 198 insertions(+), 17 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 2bca8374..9fdfe4d3 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -605,6 +605,48 @@ test('sourceReadiness reads ops source readiness plan', async () => { expect(result.sources[0].protocol).toBe('GB32960'); }); +test('normalizes nullable operations evidence arrays at the API boundary', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + linkHealth: null, + kafkaLag: 0, + activeConnections: 10, + capacityFindings: null, + redisOnlineKeys: 5, + tdengineWritable: true, + mysqlWritable: true, + runtime: { platformRelease: 'test', dataMode: 'production', requestTimeoutMs: 5000 } + } + }) + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + totalVehicles: 1024, + boundVehicles: 1024, + identityRequiredVehicles: 0, + onlineVehicles: 234, + kafkaLag: 0, + activeConnections: 10, + redisOnlineKeys: 5, + platformRelease: 'test', + sources: null + } + }) + } as Response); + + const [health, readiness] = await Promise.all([api.opsHealth(), api.sourceReadiness()]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(health.linkHealth).toEqual([]); + expect(health.capacityFindings).toEqual([]); + expect(readiness.sources).toEqual([]); +}); + test('api errors include backend message, detail, and trace id', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 6f1dfedb..548e54f6 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -179,6 +179,21 @@ function withSignal(init: RequestInit | undefined, signal?: AbortSignal) { return signal ? { ...init, signal } : init; } +function normalizeOpsHealth(value: OpsHealth): OpsHealth { + return { + ...value, + linkHealth: Array.isArray(value.linkHealth) ? value.linkHealth : [], + capacityFindings: Array.isArray(value.capacityFindings) ? value.capacityFindings : [] + }; +} + +function normalizeSourceReadiness(value: SourceReadinessPlan): SourceReadinessPlan { + return { + ...value, + sources: Array.isArray(value.sources) ? value.sources : [] + }; +} + async function responseErrorMessage(response: Response) { try { const envelope = (await response.json()) as ApiErrorEnvelope; @@ -347,6 +362,6 @@ export const api = { alertEvents: (params = new URLSearchParams()) => request>(`/api/alert-events?${params.toString()}`), alertEventNotificationPlan: (params = new URLSearchParams()) => request(`/api/alert-events/notification-plan?${params.toString()}`), reverseGeocode: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/map/reverse-geocode?${params.toString()}`, withSignal(undefined, signal)), - opsHealth: (signal?: AbortSignal) => request('/api/ops/health', withSignal(undefined, signal)), - sourceReadiness: (signal?: AbortSignal) => request('/api/ops/source-readiness', withSignal(undefined, signal)) + opsHealth: (signal?: AbortSignal) => request('/api/ops/health', withSignal(undefined, signal)).then(normalizeOpsHealth), + sourceReadiness: (signal?: AbortSignal) => request('/api/ops/source-readiness', withSignal(undefined, signal)).then(normalizeSourceReadiness) }; 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 7b44aa54..a892ebe0 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 @@ -253,6 +253,27 @@ test('reconciles service identities with bound and identity-required vehicles', expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument(); }); +test('normalizes nullable health evidence arrays without crashing the workspace', async () => { + seedSession(); + mocks.opsHealth.mockResolvedValue({ + linkHealth: null, kafkaLag: 0, activeConnections: 10, capacityFindings: null, 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, + kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: null + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + renderOperations(client, ['/operations?view=health']); + + expect(await screen.findByRole('heading', { name: '运行概览', level: 5 })).toBeInTheDocument(); + expect(await screen.findAllByText('暂无协议来源')).toHaveLength(2); + expect(screen.getByText('暂无链路探针')).toBeInTheDocument(); + expect(screen.getByText('容量检查通过')).toBeInTheDocument(); + expect(screen.queryByText('当前模块暂时无法显示')).not.toBeInTheDocument(); +}); + test('includes protocol readiness in the global health verdict and prioritizes its evidence', 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 d69c3554..6f6ca571 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; -import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types'; +import type { LinkHealth, SourceReadinessRow, VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types'; import { InlineError } from '../shared/AsyncState'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; @@ -22,6 +22,9 @@ import ReconciliationCenter from './ReconciliationCenter'; type OperationsWorkspace = 'reconciliation' | 'diagnostic' | 'health'; const operationsWorkspaces = new Set(['reconciliation', 'diagnostic', 'health']); +const emptyLinkHealthRows: LinkHealth[] = []; +const emptyCapacityFindings: string[] = []; +const emptySourceReadinessRows: SourceReadinessRow[] = []; function statusLabel(status: string) { return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status; @@ -443,14 +446,17 @@ export default function OperationsPage() { const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY }); const reconciliation = useQuery({ queryKey: ['reconciliation-summary', 30], queryFn: ({ signal }) => api.reconciliationSummary(30, signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime }); const data = health.data; const sources = readiness.data; + const linkHealthRows = data?.linkHealth ?? emptyLinkHealthRows; + const capacityFindings = data?.capacityFindings ?? emptyCapacityFindings; + const sourceRows = sources?.sources ?? emptySourceReadinessRows; const refresh = () => Promise.all([health.refetch(), readiness.refetch(), reconciliation.refetch()]); - const linkIssueCount = data?.linkHealth.filter((item) => item.status !== 'ok').length ?? 0; + const linkIssueCount = linkHealthRows.filter((item) => item.status !== 'ok').length; const runtimeIssueCount = data ? Number(!data.mysqlWritable) + Number(!data.tdengineWritable) + Number(data.runtime.dataMode !== 'production') : 0; - const capacityIssueCount = data?.capacityFindings.length ?? 0; + const capacityIssueCount = capacityFindings.length; const infrastructureIssueCount = linkIssueCount + runtimeIssueCount + capacityIssueCount + Number((data?.kafkaLag ?? 0) > 0); - const sourceIssueCount = sources?.sources.filter((item) => item.severity !== 'ok').length ?? 0; - const sourceErrorCount = sources?.sources.filter((item) => item.severity === 'error').length ?? 0; - const sourceHealthyCount = sources?.sources.filter((item) => item.severity === 'ok').length ?? 0; + const sourceIssueCount = sourceRows.filter((item) => item.severity !== 'ok').length; + const sourceErrorCount = sourceRows.filter((item) => item.severity === 'error').length; + const sourceHealthyCount = sourceRows.filter((item) => item.severity === 'ok').length; const readinessUnavailable = readiness.isError; const healthIssueCount = infrastructureIssueCount + sourceIssueCount + Number(readinessUnavailable); const healthEvidencePending = !data || readiness.isPending; @@ -485,9 +491,9 @@ export default function OperationsPage() { }, { label: '协议就绪 / 总数', - value: sources?.sources.length ? `${sourceHealthyCount} / ${sources.sources.length}` : readinessUnavailable ? '不可用' : '—', - note: readinessUnavailable ? '来源证据读取失败' : sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需要处置` : sources?.sources.length ? '全部协议达到当前口径' : '等待协议覆盖证据', - tone: readinessUnavailable || sourceErrorCount > 0 ? 'danger' : sourceIssueCount > 0 ? 'warning' : sources?.sources.length ? 'success' : 'neutral', + value: sourceRows.length ? `${sourceHealthyCount} / ${sourceRows.length}` : readinessUnavailable ? '不可用' : '—', + note: readinessUnavailable ? '来源证据读取失败' : sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需要处置` : sourceRows.length ? '全部协议达到当前口径' : '等待协议覆盖证据', + tone: readinessUnavailable || sourceErrorCount > 0 ? 'danger' : sourceIssueCount > 0 ? 'warning' : sourceRows.length ? 'success' : 'neutral', emphasis: 'primary' }, { @@ -503,14 +509,14 @@ export default function OperationsPage() { emphasis: 'secondary' } ]; - const sortedLinks = useMemo(() => [...(data?.linkHealth ?? [])].sort((left, right) => { + const sortedLinks = useMemo(() => [...linkHealthRows].sort((left, right) => { const priority = { error: 0, warning: 1, ok: 2 } as Record; return (priority[left.status] ?? 3) - (priority[right.status] ?? 3); - }), [data?.linkHealth]); - const sortedSources = useMemo(() => [...(sources?.sources ?? [])].sort((left, right) => { + }), [linkHealthRows]); + const sortedSources = useMemo(() => [...sourceRows].sort((left, right) => { const priority = { error: 0, warning: 1, ok: 2 } as Record; return (priority[left.severity] ?? 3) - (priority[right.severity] ?? 3); - }), [sources?.sources]); + }), [sourceRows]); const workspaceContext = workspace === 'reconciliation' ? { description: '每日自动对账', @@ -577,7 +583,7 @@ export default function OperationsPage() { } /> - 0 ? 'warning' : sources ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : `${sources.sources.length} 个协议正常` : '正在读取'}} />{sources ? sortedSources.length ? {sortedSources.map((source) => { + 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')} 辆待恢复` : ''}
@@ -594,7 +600,7 @@ export default function OperationsPage() { { key: 'TDengine 写探针', value: {data?.tdengineWritable ? '正常' : '异常'} }, { key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` }, { key: '高德安全代理', value: {data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'} } - ]} />{data?.capacityFindings?.length ?
{data.capacityFindings.map((item) => 待处理

{item}

)}
: } title="容量检查通过" description="当前没有需要处理的容量风险。" />}
+ ]} />{capacityFindings.length ?
{capacityFindings.map((item) => 待处理

{item}

)}
: } title="容量检查通过" description="当前没有需要处理的容量风险。" />}
: null} ; 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 411d3f8f..528a3736 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -25149,3 +25149,100 @@ padding: 8px; } } + +/* + * Semi UI operations health summary. + * Health facts must remain legible on a phone: the shared compact rail hides + * secondary metrics and its context by default, which removed vehicle coverage + * and compressed the two primary metrics into narrow slivers. + */ +.v2-ops-overview.semi-card, +.v2-ops-panel.semi-card { + border-color: #dce5ef; + border-radius: 12px; + box-shadow: 0 5px 18px rgba(31, 53, 80, .04); +} + +.v2-ops-health-metric-rail .v2-workspace-metric-list > span { + min-width: 0; +} + +@media (max-width: 680px) { + .v2-ops-health-metric-rail.is-queue.has-context > .semi-card-body { + min-height: 0; + grid-template-columns: minmax(0, 1fr); + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list { + width: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span, + .v2-ops-health-metric-rail .v2-workspace-metric-list > span.is-secondary { + display: grid; + min-width: 0; + min-height: 68px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 14px 24px 14px; + align-content: center; + gap: 0; + padding: 8px 11px; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span:nth-child(odd) { + border-left: 0; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span:nth-child(n + 3) { + border-top: 1px solid #e5ebf2; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span > :is(small, strong, em) { + min-width: 0; + grid-column: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span > small { + grid-row: 1; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span > strong, + .v2-ops-health-metric-rail .v2-workspace-metric-list > span.is-secondary > strong { + grid-row: 2; + font-size: 18px; + line-height: 24px; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-list > span > em { + display: block; + grid-row: 3; + font-size: 8px; + line-height: 14px; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-context { + display: flex; + min-height: 46px; + border-top: 1px solid #e5ebf2; + border-left: 0; + padding: 7px 11px; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-context > span { + width: 100%; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 6px; + } + + .v2-ops-health-metric-rail .v2-workspace-metric-context em { + min-width: 0; + grid-column: 3; + overflow: hidden; + text-overflow: ellipsis; + } +}