From 09e16e8d267769996356a9d15dde74ba7b95de48 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 11:40:35 +0800 Subject: [PATCH] polish access difference workspace --- .../apps/web/src/v2/domain/access.test.ts | 20 ++- .../apps/web/src/v2/domain/access.ts | 28 +++- .../apps/web/src/v2/pages/AccessPage.test.tsx | 14 +- .../apps/web/src/v2/pages/AccessPage.tsx | 70 ++++++---- .../apps/web/src/v2/productionEntry.test.ts | 3 + .../apps/web/src/v2/styles/workspace.css | 125 ++++++++++++++++++ 6 files changed, 230 insertions(+), 30 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/domain/access.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/access.test.ts index 4e4b19ea..67301cbb 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/access.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/access.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { accessRowsToCSV, formatSeconds, thresholdForProtocol, updateProtocolThreshold } from './access'; +import type { AccessVehicleRow } from '../../api/types'; +import { accessIssueSummary, accessRowsToCSV, formatAccessTime, formatSeconds, thresholdForProtocol, updateProtocolThreshold } from './access'; describe('access domain helpers', () => { it('formats duration without hiding sign or long offline windows', () => { @@ -16,6 +17,23 @@ describe('access domain helpers', () => { expect(updateProtocolThreshold(config.protocols, 'JT808', 120)).toEqual([{ protocol: 'JT808', thresholdSec: 120 }]); }); + it('keeps access timestamps and actionable differences explicit', () => { + expect(formatAccessTime('2026-07-18T02:33:00Z')).toBe('2026-07-18 10:33:00'); + const row = { + vin: 'VIN1', + plate: '粤A1', + actualProtocols: ['GB32960', 'JT808'], + masterDataIssues: [], + connectionState: 'degraded', + protocolStatuses: [ + { protocol: 'GB32960', connected: true, onlineState: 'online', delayAbnormal: true }, + { protocol: 'JT808', connected: true, onlineState: 'offline', delayAbnormal: true } + ] + } as unknown as AccessVehicleRow; + expect(accessIssueSummary(row)).toBe('JT808 已离线;GB32960 数据延迟异常'); + expect(accessIssueSummary({ ...row, actualProtocols: [], protocolStatuses: [], connectionState: 'not_connected' })).toBe('尚未发现真实接入来源'); + }); + it('exports explicit state and evidence fields', () => { const csv = accessRowsToCSV([{ vin: 'VIN1', plate: '粤A1', oem: '', model: '', company: '示范企业', protocol: 'JT808', provider: '', source: '', firstSeenAt: '', latestEventAt: '', latestReceivedAt: '', reportIntervalSec: null, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, latestMessageType: '位置,数据', latestEventId: '', latestError: '', delayAbnormal: false, firstSeenEvidence: '', firstSeenSource: '', reportIntervalEvidence: '', reportSampleCount: 2, expectedProtocols: [], actualProtocols: ['JT808'], missingProtocols: [], masterDataIssues: ['车辆品牌未维护'], protocolStatuses: [{ protocol: 'JT808', expected: false, connected: true, provider: 'G7', firstSeenAt: '2026-07-01T00:00:00+08:00', latestEventAt: '', latestReceivedAt: '2026-07-15T09:00:00+08:00', reportIntervalSec: 10, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, delayAbnormal: false, firstSeenEvidence: '网关首次观测', reportIntervalEvidence: '连续样本' }], connectionState: 'incomplete', expectationEvidence: '尚未接入业务应接口径' }]); expect(csv).toContain('在线'); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/access.ts b/vehicle-data-platform/apps/web/src/v2/domain/access.ts index 350f402a..2c8d5f3b 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/access.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/access.ts @@ -1,7 +1,14 @@ import type { AccessProtocolThreshold, AccessThresholdConfig, AccessVehicleRow } from '../../api/types'; const accessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + timeZone: 'Asia/Shanghai' }); export const accessStateLabels: Record = { @@ -29,6 +36,25 @@ export function formatAccessTime(value: string) { return accessTimeFormatter.format(parsed).replace(/\//g, '-'); } +export function accessIssueSummary(row: AccessVehicleRow) { + const issues: string[] = []; + const connected = row.protocolStatuses.filter((status) => status.connected); + const protocols = (items: typeof connected) => items.map((status) => status.protocol).join(' / '); + const offline = connected.filter((status) => status.onlineState === 'offline'); + const uncertain = connected.filter((status) => status.onlineState === 'unknown' || status.onlineState === 'never_reported'); + const delayed = connected.filter((status) => status.delayAbnormal && status.onlineState === 'online'); + + if (!row.actualProtocols.length) issues.push('尚未发现真实接入来源'); + if (offline.length) issues.push(`${protocols(offline)} 已离线`); + if (uncertain.length) issues.push(`${protocols(uncertain)} 状态待确认`); + if (delayed.length) issues.push(`${protocols(delayed)} 数据延迟异常`); + if (row.masterDataIssues.length) issues.push(row.masterDataIssues.slice(0, 2).join('、')); + + if (issues.length) return issues.slice(0, 2).join(';'); + if (row.connectionState === 'healthy') return '已接来源状态正常'; + return '接入状态待核对'; +} + export function thresholdForProtocol(config: AccessThresholdConfig, protocol: string) { return config.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? config.defaultThresholdSec; } diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx index 883362d3..9243804a 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx @@ -57,6 +57,9 @@ test('removes old access rows immediately when the vehicle filter scope changes' expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument(); } expect(screen.getByRole('navigation', { name: '接入差异筛选' })).toHaveClass('v2-access-status-tabs'); + expect(screen.getByRole('columnheader', { name: '差异摘要' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: '真实来源' })).not.toBeInTheDocument(); + expect(screen.getByText('已接来源状态正常')).toBeInTheDocument(); const filterActions = view.container.querySelector('.v2-access-filter-actions'); expect(filterActions).toBeInTheDocument(); expect(within(filterActions!).getByRole('button', { name: '查询' })).toBeInTheDocument(); @@ -68,11 +71,13 @@ test('removes old access rows immediately when the vehicle filter scope changes' expect(desktopRow).toHaveAttribute('aria-expanded', 'false'); fireEvent.keyDown(desktopRow, { key: 'Enter' }); expect(await screen.findByRole('button', { name: '关闭车辆接入详情' })).toBeInTheDocument(); - expect(screen.getByRole('dialog', { name: '车辆接入详情' })).toBeInTheDocument(); + const detailDialog = screen.getByRole('dialog', { name: '车辆接入详情' }); + expect(detailDialog).toBeInTheDocument(); expect(desktopRow).toHaveAttribute('aria-expanded', 'true'); - const inspectorHeader = screen.getByRole('heading', { level: 5, name: '旧接入车牌' }).closest('.v2-workspace-panel-header'); - expect(inspectorHeader).toBeInTheDocument(); - expect(within(inspectorHeader!).getByText('OLDVIN')).toBeInTheDocument(); + expect(within(detailDialog).getByText('旧接入车牌 · OLDVIN')).toBeInTheDocument(); + expect(within(detailDialog).getByText('接入结论')).toBeInTheDocument(); + expect(within(detailDialog).getByText('已接来源状态正常')).toBeInTheDocument(); + expect(detailDialog.querySelector('.v2-access-inspector-header')).not.toBeInTheDocument(); fireEvent.change(screen.getByRole('textbox', { name: '车辆' }), { target: { value: 'NEWVIN' } }); fireEvent.click(screen.getByRole('button', { name: '查询' })); @@ -157,6 +162,7 @@ test('renders mobile access vehicles as selectable Semi cards', async () => { const dialog = await screen.findByRole('dialog', { name: '车辆接入详情' }); expect(within(dialog).getByRole('button', { name: '关闭车辆接入详情' })).toBeInTheDocument(); expect(dialog.querySelector('.v2-access-inspector-v3.semi-card')).toBeInTheDocument(); + expect(dialog.querySelector('.v2-access-inspector-focus.semi-card')).toBeInTheDocument(); expect(dialog.querySelector('.v2-access-inspector-summary.semi-descriptions')).toBeInTheDocument(); expect(dialog.querySelector('.v2-access-protocol-details.semi-card-group')).toBeInTheDocument(); expect(dialog.querySelectorAll('.v2-access-protocol-detail.semi-card')).toHaveLength(3); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index 1a5de412..19c10764 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -1,11 +1,11 @@ import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { FormEvent, memo, useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types'; -import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access'; +import { accessIssueSummary, accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access'; import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; import { MetricActionButton } from '../shared/MetricActionButton'; import { TablePagination } from '../shared/TablePagination'; @@ -21,7 +21,7 @@ import { useMobileLayout } from '../hooks/useMobileLayout'; import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; -const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); +const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Shanghai' }); const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' }; type Filters = typeof EMPTY_FILTERS; @@ -57,7 +57,12 @@ function compactTime(value: string) { return compactAccessTimeFormatter.format(parsed).replace(/\//g, '-'); } -function ProtocolState({ status, detailed = false, protocolLabel }: { status?: AccessProtocolStatus; detailed?: boolean; protocolLabel?: string }) { +function AccessTime({ value, compact = false }: { value?: string; compact?: boolean }) { + if (!value) return ; + return ; +} + +const ProtocolState = memo(function ProtocolState({ status, detailed = false, protocolLabel }: { status?: AccessProtocolStatus; detailed?: boolean; protocolLabel?: string }) { const state = !status?.connected ? 'missing' : status.onlineState; const label = state === 'missing' ? '无来源' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报'; const color = state === 'online' ? 'green' : state === 'offline' || state === 'unknown' ? 'orange' : 'grey'; @@ -71,8 +76,8 @@ function ProtocolState({ status, detailed = false, protocolLabel }: { status?: A > }, + { key: '最新上报', value: }, { key: '当前离线', value: formatSeconds(status?.freshnessSec) }, { key: '上报间隔', value: formatSeconds(status?.reportIntervalSec) }, { key: '数据延迟', value: {formatSeconds(status?.dataDelaySec)} } @@ -85,15 +90,24 @@ function ProtocolState({ status, detailed = false, protocolLabel }: { status?: A return
{protocolLabel ? {protocolLabel} : null} {label} - {status?.connected ? compactTime(status.latestReceivedAt) : '—'} + {status?.connected ? : '—'} {status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}
; +}); + +function ConnectionTag({ row }: { row: AccessVehicleRow }) { + const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange'; + return {connectionLabels[row.connectionState]}; } -function ConnectionState({ row }: { row: AccessVehicleRow }) { - const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange'; - return
{connectionLabels[row.connectionState]}{row.actualProtocols.length} 个真实来源
; -} +const ConnectionState = memo(function ConnectionState({ row }: { row: AccessVehicleRow }) { + const summary = accessIssueSummary(row); + return
+ + {summary} + {row.actualProtocols.length ? `${row.actualProtocols.length} 个真实来源 · ${row.actualProtocols.join(' / ')}` : '当前没有真实来源证据'} +
; +}); function ProtocolCoverage({ summary }: { summary?: AccessSummary }) { return
@@ -104,7 +118,7 @@ function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
; } -function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehicleRow[]; selectedVIN: string; onSelect: (vin: string) => void }) { +const AccessVehicleTable = memo(function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehicleRow[]; selectedVIN: string; onSelect: (vin: string) => void }) { const columns = useMemo(() => [ { title: '车辆', dataIndex: 'plate', width: 165, @@ -114,16 +128,12 @@ function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehic title: '品牌 / 车型', dataIndex: 'oem', width: 140, render: (_: string, row: AccessVehicleRow) =>
{row.oem || '品牌未维护'}{row.model || row.company || '车型未维护'}
}, - { - title: '真实来源', dataIndex: 'actualProtocols', width: 115, - render: (_: string[], row: AccessVehicleRow) =>
{row.actualProtocols.length} 个{row.actualProtocols.join(' / ') || '尚无来源'}
- }, ...PROTOCOLS.map((protocol) => ({ title: protocol, dataIndex: protocol, width: 160, render: (_: unknown, row: AccessVehicleRow) => })), { - title: '综合状态', dataIndex: 'connectionState', width: 120, + title: '差异摘要', dataIndex: 'connectionState', width: 220, render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => } ], []); @@ -143,18 +153,30 @@ function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehic onOpen: () => onSelect(row.vin) }) : ({})} />; -} +}); function VehicleInspector({ row, onClose, sheet = false }: { row: AccessVehicleRow; onClose: () => void; sheet?: boolean }) { return - } onClick={onClose} aria-label="关闭车辆接入详情" />} /> + {sheet ? null : } onClick={onClose} aria-label="关闭车辆接入详情" />} />} + +
{row.connectionState === 'healthy' ? '接入结论' : '优先核对'}{accessIssueSummary(row)}{row.actualProtocols.length ? `已发现 ${row.actualProtocols.join(' / ')} ${row.actualProtocols.length} 个真实来源` : '当前没有可用的真实来源证据'}
+ +
} + { key: '资料状态', value: row.masterDataIssues.length ? row.masterDataIssues.join(';') : '已维护' } ]} /> - {PROTOCOLS.map((protocol) => )} + {[...PROTOCOLS].sort((left, right) => { + const score = (protocol: string) => { + const status = statusByProtocol(row, protocol); + if (status?.connected && status.onlineState !== 'online') return 0; + if (status?.connected && status.delayAbnormal) return 1; + if (status?.connected) return 2; + return 3; + }; + return score(left) - score(right); + }).map((protocol) => )}
{row.expectationEvidence}查看车辆详情
; } @@ -226,7 +248,7 @@ export default function AccessPage() { title="真实来源与接入健康" description="真实来源、在线健康与资料差异一处核对" status={summary ? `${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取车辆'} - meta={数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}} + meta={数据时间 } actions={<>{editable ? : null}} /> {mobileLayout - ?
{rows.map((row) => )}
+ ?
{rows.map((row) => )}
: } {vehiclesQuery.isFetching ? : null} {!vehiclesQuery.isFetching && !rows.length ? : null} 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 362feb42..c529487a 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -212,6 +212,9 @@ describe('V2 production entry', () => { expect(corePageSources.AccessPage).not.toContain('
span, +.v2-access-connection > small { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-access-connection > span { + color: #4e5f76; + font-size: 10px; + font-weight: 650; +} + +.v2-access-connection > small { + color: #8b97a8; + font-size: 9px; +} + +.v2-access-time { + font: inherit; + font-variant-numeric: tabular-nums; +} + +.v2-access-inspector-focus.semi-card { + margin: 10px 12px 0; + overflow: hidden; + border: 1px solid #dce6f2; + border-radius: 10px; + background: linear-gradient(115deg, #f7faff 0%, #eef5ff 100%); + box-shadow: 0 7px 20px rgba(42, 72, 110, .055); +} + +.v2-access-inspector-focus.is-degraded.semi-card, +.v2-access-inspector-focus.is-incomplete.semi-card { + border-color: #f0d9b7; + background: linear-gradient(115deg, #fffaf3 0%, #fff4e6 100%); +} + +.v2-access-inspector-focus.is-offline.semi-card, +.v2-access-inspector-focus.is-not_connected.semi-card { + border-color: #efd5d2; + background: linear-gradient(115deg, #fffafa 0%, #fff0ef 100%); +} + +.v2-access-inspector-focus > .semi-card-body { + display: flex; + min-height: 82px; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px 13px !important; +} + +.v2-access-inspector-focus > .semi-card-body > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.v2-access-inspector-focus small { + color: #7c8b9f; + font-size: 9px; + font-weight: 650; +} + +.v2-access-inspector-focus strong { + overflow-wrap: anywhere; + color: #30445f; + font-size: 14px; + line-height: 1.45; +} + +.v2-access-inspector-focus span { + color: #77879b; + font-size: 10px; + line-height: 1.45; +} + +.v2-access-inspector-focus .v2-access-connection-tag.semi-tag { + flex: 0 0 auto; +} + +.v2-access-inspector-focus + .v2-access-inspector-summary.semi-descriptions { + padding-top: 8px; +} + .v2-ops-page { width: 100%; height: 100%; @@ -8737,6 +8840,10 @@ display: none; } + .v2-access-mobile-card-content > header .v2-access-connection > small { + display: none; + } + .v2-access-mobile-card-content > header .v2-access-connection-tag.semi-tag { min-height: 20px; border-radius: 5px; @@ -8752,6 +8859,11 @@ white-space: nowrap; } + .v2-access-mobile-card-content > p.is-issue { + color: #9c5b0f; + font-weight: 650; + } + .v2-access-mobile-protocols { gap: 4px; padding-top: 5px; @@ -8829,6 +8941,19 @@ scroll-snap-align: none; } + .v2-access-inspector-focus.semi-card { + margin: 8px 9px 0; + } + + .v2-access-inspector-focus > .semi-card-body { + min-height: 74px; + padding: 10px 11px !important; + } + + .v2-access-inspector-focus strong { + font-size: 13px; + } + .v2-ops-page { height: auto; min-height: 100%;