From f423240e6fe250fd97ec28e23d12b4d9bafca2c7 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 11:53:19 +0800 Subject: [PATCH] polish operations quality workspace --- .../web/src/v2/pages/OperationsPage.test.tsx | 33 ++++++++++----- .../apps/web/src/v2/pages/OperationsPage.tsx | 39 ++++++++++++++---- .../web/src/v2/pages/ReconciliationCenter.tsx | 9 +---- .../apps/web/src/v2/styles/workspace.css | 40 +++++++++++++++++-- 4 files changed, 93 insertions(+), 28 deletions(-) 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 0e50b814..347e9f5a 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 @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { afterEach, expect, test, vi } from 'vitest'; import OperationsPage from './OperationsPage'; @@ -14,6 +15,15 @@ vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobil afterEach(() => { cleanup(); layout.mobile = false; Object.values(mocks).forEach((mock) => mock.mockReset()); }); +function LocationProbe() { + const location = useLocation(); + return {location.pathname}{location.search}; +} + +function renderOperations(client: QueryClient, initialEntries = ['/operations']) { + return render(); +} + function seedSession() { mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] }); mocks.reconciliationSummary.mockResolvedValue({ @@ -56,7 +66,7 @@ test('renders reconciliation queue, loads evidence on demand and records review version: 2 }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + renderOperations(client); expect(await screen.findByText('质量问题队列')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '运行与数据质量', level: 5 })).toBeInTheDocument(); @@ -64,8 +74,8 @@ test('renders reconciliation queue, loads evidence on demand and records review const navigation = screen.getByRole('navigation', { name: '运维质量视图' }); expect(navigation).toHaveClass('v2-ops-navigation'); expect(within(navigation).getByText('每日自动对账')).toBeInTheDocument(); - expect(within(navigation).getByText('证据处置')).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: '差异处置', selected: true })).toHaveClass('semi-button'); + expect(await within(navigation).findByText('1 项待复核')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: '差异处置 1', selected: true })).toHaveClass('semi-button'); expect(screen.getByRole('tabpanel', { name: '差异处置' })).toHaveAttribute('tabindex', '0'); expect(document.querySelector('.v2-ops-page')).toHaveClass('is-reconciliation'); expect(screen.queryByText('先选择一辆车')).not.toBeInTheDocument(); @@ -73,7 +83,8 @@ test('renders reconciliation queue, loads evidence on demand and records review expect(screen.getByText('质量问题队列').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading'); expect(document.querySelector('.v2-reconcile-kpi-card small')?.textContent).toBe('待复核'); expect(document.querySelectorAll('.v2-reconcile-kpi-card')).toHaveLength(4); - expect(screen.getByText('超过 24 小时').closest('.semi-card')).toHaveClass('v2-reconcile-sla'); + expect(screen.getByText('SLA 超时').closest('.semi-card')).toHaveClass('v2-reconcile-kpi-card', 'is-sla', 'is-overdue'); + expect(screen.queryByText('活跃问题', { exact: true })).not.toBeInTheDocument(); expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument(); const trendButton = screen.getByRole('button', { name: '30 天趋势' }); expect(trendButton).toHaveAttribute('aria-expanded', 'false'); @@ -125,7 +136,7 @@ test('renders only the compact mobile reconciliation surface and defers secondar }); mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + renderOperations(client); expect(await screen.findByText('多来源实时位置漂移')).toBeInTheDocument(); expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).toBeInTheDocument(); @@ -173,9 +184,10 @@ test('reconciles service identities with bound and identity-required vehicles', }] }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + renderOperations(client, ['/operations?view=health']); - fireEvent.click(screen.getByRole('tab', { name: '全局健康' })); + expect(screen.getByRole('tab', { name: '全局健康', selected: true })).toBeInTheDocument(); + expect(screen.getByTestId('operations-location')).toHaveTextContent('/operations?view=health'); expect(await screen.findByText('1024 / 234')).toBeInTheDocument(); expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('15 秒刷新运行证据')).toBeInTheDocument(); expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('运行正常')).toBeInTheDocument(); @@ -207,6 +219,7 @@ test('reconciles service identities with bound and identity-required vehicles', expect(screen.getByText('验收标准').closest('.semi-card')).toHaveClass('v2-ops-source-card'); expect(screen.queryByText('单车多来源诊断')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: '单车诊断' })); + expect(screen.getByTestId('operations-location')).toHaveTextContent('/operations?view=diagnostic'); expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('按需读取单车来源')).toBeInTheDocument(); expect(within(screen.getByRole('navigation', { name: '运维质量视图' })).getByText('精确诊断')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '诊断车辆', level: 5 })).toBeInTheDocument(); @@ -228,7 +241,7 @@ test('keeps health evidence visible when source readiness fails and retries that kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: [] }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + renderOperations(client); fireEvent.click(screen.getByRole('tab', { name: '全局健康' })); expect(await screen.findByText('来源就绪度暂时不可用')).toBeInTheDocument(); @@ -269,7 +282,7 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async mocks.vehicleSourceDiagnostic.mockResolvedValue(diagnostic); mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + renderOperations(client); fireEvent.click(screen.getByRole('tab', { name: '单车诊断' })); fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } }); const candidateButton = await waitFor(() => { @@ -341,7 +354,7 @@ test('allows provider maintenance but keeps canonical source policy read only', mocks.vehicleSourceDiagnostic.mockResolvedValue(diagnostic); mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + renderOperations(client); fireEvent.click(screen.getByRole('tab', { name: '单车诊断' })); fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } }); const candidateButton = await waitFor(() => { 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 ba333b68..b240c0a7 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -1,7 +1,8 @@ import { IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons'; import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { FormEvent, useDeferredValue, useEffect, useState } from 'react'; +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 { InlineError } from '../shared/AsyncState'; @@ -17,6 +18,7 @@ import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import ReconciliationCenter from './ReconciliationCenter'; type OperationsWorkspace = 'reconciliation' | 'diagnostic' | 'health'; +const operationsWorkspaces = new Set(['reconciliation', 'diagnostic', 'health']); function statusLabel(status: string) { return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status; @@ -324,18 +326,39 @@ function SourceDiagnosticWorkspace() { } export default function OperationsPage() { - const [workspace, setWorkspace] = useState('reconciliation'); + const [searchParams, setSearchParams] = useSearchParams(); + const requestedWorkspace = searchParams.get('view') as OperationsWorkspace | null; + const workspace = requestedWorkspace && operationsWorkspaces.has(requestedWorkspace) ? requestedWorkspace : 'reconciliation'; + const setWorkspace = (next: OperationsWorkspace) => { + const copy = new URLSearchParams(searchParams); + if (next === 'reconciliation') copy.delete('view'); + else copy.set('view', next); + setSearchParams(copy, { replace: true }); + }; const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY }); 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 refresh = () => Promise.all([health.refetch(), readiness.refetch()]); + const refresh = () => Promise.all([health.refetch(), readiness.refetch(), reconciliation.refetch()]); const linkIssueCount = data?.linkHealth.filter((item) => item.status !== 'ok').length ?? 0; const runtimeIssueCount = data ? Number(!data.mysqlWritable) + Number(!data.tdengineWritable) + Number(data.runtime.dataMode !== 'production') : 0; const capacityIssueCount = data?.capacityFindings.length ?? 0; const healthIssueCount = linkIssueCount + runtimeIssueCount + capacityIssueCount + Number((data?.kafkaLag ?? 0) > 0); const overallStatus = !data ? 'unknown' : healthIssueCount > 0 ? 'warning' : 'ok'; + const sortedLinks = useMemo(() => [...(data?.linkHealth ?? [])].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) => { + const priority = { error: 0, warning: 1, ok: 2 } as Record; + return (priority[left.severity] ?? 3) - (priority[right.severity] ?? 3); + }), [sources?.sources]); const workspaceContext = workspace === 'reconciliation' - ? { description: '每日自动对账', status: '证据处置', color: 'blue' as const } + ? { + description: '每日自动对账', + status: reconciliation.data ? `${reconciliation.data.pending.toLocaleString('zh-CN')} 项待复核` : '读取差异证据', + color: reconciliation.data?.overSla ? 'orange' as const : 'blue' as const + } : workspace === 'diagnostic' ? { description: '按需读取单车来源', status: '精确诊断', color: 'cyan' as const } : { @@ -362,9 +385,9 @@ export default function OperationsPage() { value={workspace} onChange={setWorkspace} items={[ - { key: 'reconciliation', label: '差异处置' }, + { key: 'reconciliation', label: '差异处置', count: reconciliation.data?.pending || undefined }, { key: 'diagnostic', label: '单车诊断' }, - { key: 'health', label: '全局健康' } + { key: 'health', label: '全局健康', count: healthIssueCount || undefined } ]} />
@@ -392,7 +415,7 @@ export default function OperationsPage() { -
0 ? 'warning' : 'ok'}>{!data ? '读取中' : linkIssueCount > 0 ? `${linkIssueCount} 条异常` : '链路正常'}} />
{data?.linkHealth.length ? data.linkHealth.map((item) =>
{item.name}

{item.detail || '无补充信息'}

) : }
+
0 ? 'warning' : 'ok'}>{!data ? '读取中' : linkIssueCount > 0 ? `${linkIssueCount} 条异常` : '链路正常'}} />
{sortedLinks.length ? sortedLinks.map((item) =>
{item.name}

{item.detail || '无补充信息'}

) : }
0 ? 'warning' : 'ok'}>{!data ? '读取中' : runtimeIssueCount + capacityIssueCount > 0 ? `${runtimeIssueCount + capacityIssueCount} 项关注` : '配置正常'}} />{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'} }, { key: 'MySQL 写探针', value: {data?.mysqlWritable ? '正常' : '异常'} }, @@ -400,7 +423,7 @@ export default function OperationsPage() { { key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` }, { key: '高德安全代理', value: {data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'} } ]} />{data?.capacityFindings?.length ?
{data.capacityFindings.map((item) => 待处理

{item}

)}
: } title="容量检查通过" description="当前没有需要处理的容量风险。" />}
- {sources ? `${sources.sources.length} 个协议` : '正在读取'}} />{sources ? sources.sources.length ? {sources.sources.map((source) => { + {sources ? `${sources.sources.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')} 辆待恢复` : ''}
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx b/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx index afb2602b..b87256b5 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/ReconciliationCenter.tsx @@ -357,16 +357,11 @@ export default function ReconciliationCenter() { {summary.isError ? summary.refetch()} /> : null}
- 待复核{data?.pending.toLocaleString('zh-CN') ?? '—'}尚未形成结论 - 活跃问题{data?.active.toLocaleString('zh-CN') ?? '—'}待复核与已确认来源 + 待复核{data?.pending.toLocaleString('zh-CN') ?? '—'}活跃问题 {data?.active.toLocaleString('zh-CN') ?? '—'} + SLA 超时{data?.overSla.toLocaleString('zh-CN') ?? '—'}{data?.overSla ? '超过 24 小时,优先复核' : '当前没有超时差异'} 已确认来源{data?.confirmed.toLocaleString('zh-CN') ?? '—'}保留为活跃差异 已自动恢复{data?.recovered.toLocaleString('zh-CN') ?? '—'}规则已不再命中
- - SLA超过 24 小时 - {data?.overSla.toLocaleString('zh-CN') ?? '—'} -

{data?.overSla ? '优先复核高等级差异' : '当前没有超时差异'}

-
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 974d28f4..6a82064a 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -1697,7 +1697,7 @@ .v2-reconcile-overview { display: grid; overflow: hidden; - grid-template-columns: minmax(0, 4fr) minmax(170px, 1fr); + grid-template-columns: minmax(0, 1fr); gap: 0; border-bottom: 1px solid #e3eaf2; background: #fff; @@ -1860,7 +1860,7 @@ @media (max-width: 900px) { .v2-reconcile-overview { - grid-template-columns: minmax(0, 4fr) minmax(156px, 1fr); + grid-template-columns: minmax(0, 1fr); } .v2-assigned-vins { @@ -9074,7 +9074,7 @@ .v2-reconcile-overview { height: 68px; - grid-template-columns: minmax(0, 4fr) minmax(66px, 1fr); + grid-template-columns: minmax(0, 1fr); gap: 0; border-bottom: 1px solid #e3eaf2; background: #fff; @@ -10849,6 +10849,23 @@ font-size: 13px; } +.v2-ops-navigation .v2-ops-tabs .v2-segmented-tab-count { + min-width: 21px; + min-height: 19px; + border: 1px solid #d5e2f2; + background: #fff; + padding-inline: 5px; + color: #57708f; + font-size: 8px; + font-weight: 700; +} + +.v2-ops-navigation .v2-ops-tabs > .semi-button.is-active .v2-segmented-tab-count { + border-color: rgba(255, 255, 255, .28); + background: rgba(255, 255, 255, .18); + color: #fff; +} + .v2-ops-navigation-meta { display: flex; min-width: 0; @@ -10999,6 +11016,10 @@ font-size: 11px; } + .v2-ops-navigation .v2-ops-tabs .v2-segmented-tab-count { + display: none; + } + .v2-ops-navigation-meta { display: none; } @@ -11778,6 +11799,19 @@ color: #155fc5; } +.v2-reconcile-overview .v2-reconcile-kpi-card.is-sla { + background: linear-gradient(145deg, #fff 0%, #fff9f8 100%); +} + +.v2-reconcile-overview .v2-reconcile-kpi-card.is-sla.is-overdue { + box-shadow: inset 0 -3px #e2554f; +} + +.v2-reconcile-overview .v2-reconcile-kpi-card.is-sla.is-overdue small, +.v2-reconcile-overview .v2-reconcile-kpi-card.is-sla.is-overdue strong { + color: #c2413d; +} + .v2-reconcile-result-bar { display: flex; min-height: 34px;