From f7b523453c07aad1a2d9dd6cc03315140f0163e3 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 16:50:20 +0800 Subject: [PATCH] refine Semi UI access evidence inspector --- .../apps/web/src/v2/pages/AccessPage.test.tsx | 24 ++- .../apps/web/src/v2/pages/AccessPage.tsx | 117 ++++++++++---- .../apps/web/src/v2/styles/v2.css | 150 +++++++++++++++++- 3 files changed, 254 insertions(+), 37 deletions(-) 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 30ddcf44..8bdd08c7 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 @@ -157,7 +157,11 @@ test('does not request or render admin-only thresholds for a read-only session', test('renders mobile access vehicles as selectable Semi cards', async () => { layout.mobile = true; prepareBaseData(); - mocks.accessVehicles.mockResolvedValue({ items: [accessRow('VIN001', '粤A00001')], total: 1, limit: 50, offset: 0 }); + const delayedRow = accessRow('VIN001', '粤A00001'); + delayedRow.connectionState = 'degraded'; + delayedRow.protocolStatuses[0].delayAbnormal = true; + delayedRow.protocolStatuses[0].dataDelaySec = 128; + mocks.accessVehicles.mockResolvedValue({ items: [delayedRow], total: 1, limit: 50, offset: 0 }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = render(); @@ -191,9 +195,25 @@ test('renders mobile access vehicles as selectable Semi cards', async () => { 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-inspector-summary-grid')).toHaveAttribute('aria-label', '车辆接入概览'); + expect(within(dialog).getByText('协议证据')).toBeInTheDocument(); + expect(within(dialog).getByText('1 / 3 已接入').closest('.semi-tag')).toBeInTheDocument(); expect(dialog.querySelector('.v2-access-protocol-details.semi-card-group')).toBeInTheDocument(); expect(dialog.querySelectorAll('.v2-access-protocol-detail.semi-card')).toHaveLength(3); + const evidenceToggle = within(dialog).getByRole('button', { name: '收起 JT808 完整证据' }); + expect(evidenceToggle).toHaveAttribute('aria-expanded', 'true'); + expect(within(dialog).getByRole('region', { name: 'JT808 完整接入证据' })).toBeInTheDocument(); + expect(within(dialog).getByText('test first seen')).toBeInTheDocument(); + expect(within(dialog).getAllByText('当前未发现真实接入来源')).toHaveLength(2); + const vehicleRequestsBeforeEvidenceToggle = mocks.accessVehicles.mock.calls.length; + const summaryRequestsBeforeEvidenceToggle = mocks.accessSummary.mock.calls.length; + fireEvent.click(evidenceToggle); + expect(within(dialog).getByRole('button', { name: '查看 JT808 完整证据' })).toHaveAttribute('aria-expanded', 'false'); + expect(within(dialog).queryByRole('region', { name: 'JT808 完整接入证据' })).not.toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: '查看 JT808 完整证据' })); + expect(within(dialog).getByRole('button', { name: '收起 JT808 完整证据' })).toHaveAttribute('aria-expanded', 'true'); + expect(mocks.accessVehicles).toHaveBeenCalledTimes(vehicleRequestsBeforeEvidenceToggle); + expect(mocks.accessSummary).toHaveBeenCalledTimes(summaryRequestsBeforeEvidenceToggle); expect(document.querySelector('.v2-access-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-access-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(84dvh, 740px)' }); fireEvent.click(within(dialog).getByRole('button', { name: '关闭车辆接入详情' })); 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 6027f5fa..8eb538bf 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -1,7 +1,7 @@ -import { IconChevronRight, IconClose, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons'; +import { IconChevronDown, IconChevronRight, IconClose, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Empty, Input, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { FormEvent, KeyboardEvent, memo, useEffect, useMemo, useState } from 'react'; +import { FormEvent, KeyboardEvent, memo, useCallback, 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'; @@ -62,29 +62,75 @@ function AccessTime({ value, compact = false }: { value?: string; compact?: bool return ; } -const ProtocolState = memo(function ProtocolState({ status, detailed = false, protocolLabel }: { status?: AccessProtocolStatus; detailed?: boolean; protocolLabel?: string }) { +function protocolNeedsAttention(status?: AccessProtocolStatus) { + return Boolean(status?.connected && (status.onlineState !== 'online' || status.delayAbnormal)); +} + +function protocolPriority(status?: AccessProtocolStatus) { + if (status?.connected && status.onlineState !== 'online') return 0; + if (status?.connected && status.delayAbnormal) return 1; + if (status?.connected) return 2; + return 3; +} + +const ProtocolState = memo(function ProtocolState({ + status, + detailed = false, + protocolLabel, + expanded = false, + onToggle +}: { + status?: AccessProtocolStatus; + detailed?: boolean; + protocolLabel?: string; + expanded?: boolean; + onToggle?: (protocol: string) => void; +}) { 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'; if (detailed) { + const protocol = status?.protocol || '未知协议'; + const evidenceId = `access-protocol-${protocol.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-evidence`; + const evidence = status?.firstSeenEvidence || '当前未发现该协议来源'; return {status?.protocol}} + className={`v2-access-protocol-detail is-${state}${expanded ? ' is-expanded' : ''}`} + title={{protocol}} headerExtraContent={{label}} headerLine bodyStyle={{ padding: 0 }} > - }, - { key: '最新上报', value: }, - { key: '当前离线', value: formatSeconds(status?.freshnessSec) }, - { key: '上报间隔', value: formatSeconds(status?.reportIntervalSec) }, - { key: '数据延迟', value: {formatSeconds(status?.dataDelaySec)} } - ]} /> -

- {status?.firstSeenEvidence || '当前未发现该协议来源'} -

+ {status?.connected ? <> +
+ 接入厂家{status.provider || '接入方未维护'} + 最新上报 + 当前离线{formatSeconds(status.freshnessSec)} + 数据延迟{formatSeconds(status.dataDelaySec)} +
+ + {expanded ?
+ }, + { key: '上报间隔', value: formatSeconds(status.reportIntervalSec) } + ]} /> +

{evidence}

+
: null} + :
+ 当前未发现真实接入来源 + 无需展开,待收到该协议报文后自动补充证据。 +
}
; } return
@@ -157,28 +203,39 @@ const AccessVehicleTable = memo(function AccessVehicleTable({ rows, selectedVIN, />; }); -function VehicleInspector({ row, onClose, sheet = false }: { row: AccessVehicleRow; onClose: () => void; sheet?: boolean }) { +function VehicleInspector({ row, onClose, sheet = false, mobile = false }: { row: AccessVehicleRow; onClose: () => void; sheet?: boolean; mobile?: boolean }) { + const protocols = useMemo(() => [...PROTOCOLS].sort((left, right) => protocolPriority(statusByProtocol(row, left)) - protocolPriority(statusByProtocol(row, right))), [row]); + const [expandedProtocol, setExpandedProtocol] = useState(() => protocols.find((protocol) => protocolNeedsAttention(statusByProtocol(row, protocol))) || ''); + const toggleProtocol = useCallback((protocol: string) => { + setExpandedProtocol((current) => current === protocol ? '' : protocol); + }, []); + return {sheet ? null : } onClick={onClose} aria-label="关闭车辆接入详情" />} />}
{row.connectionState === 'healthy' ? '接入结论' : '优先核对'}{accessIssueSummary(row)}{row.actualProtocols.length ? `已发现 ${row.actualProtocols.join(' / ')} ${row.actualProtocols.length} 个真实来源` : '当前没有可用的真实来源证据'}
- + 品牌 / 车型{[row.oem, row.model].filter(Boolean).join(' / ') || '未维护'} + 真实来源{row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源'} + 资料状态{row.masterDataIssues.length ? row.masterDataIssues.join(';') : '已维护'} +
: - {[...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.actualProtocols.length} / {PROTOCOLS.length} 已接入 +
+ {protocols.map((protocol) => )}
{row.expectationEvidence}查看车辆详情
; } @@ -349,7 +406,7 @@ export default function AccessPage() { title={
车辆接入详情{selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}
} onCancel={() => setSelectedVIN('')} > - {selected ? setSelectedVIN('')} sheet /> : null} + {selected ? setSelectedVIN('')} sheet mobile={mobileLayout} /> : null} {editable ? span { + display: grid; + min-width: 0; + align-content: center; + gap: 4px; + padding: 3px 9px; +} +.v2-access-inspector-summary-grid > span:first-child { padding-left: 0; } +.v2-access-inspector-summary-grid > span + span { border-left: 1px solid #e8edf3; } +.v2-access-inspector-summary-grid small { + color: #8592a3; + font-size: 9px; + line-height: 1.2; +} +.v2-access-inspector-summary-grid strong { + overflow: hidden; + color: #3c4e65; + font-size: 10px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} +.v2-access-protocol-heading { + display: flex; + min-height: 45px; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid #e6ebf2; + background: #f7f9fc; + padding: 8px 13px 7px; +} +.v2-access-protocol-heading > span { + display: grid; + min-width: 0; + gap: 2px; +} +.v2-access-protocol-heading strong { + color: #34475e; + font-size: 12px; +} +.v2-access-protocol-heading small { + overflow: hidden; + color: #8794a5; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} +.v2-access-protocol-heading .semi-tag { + min-height: 22px; + flex: 0 0 auto; + border-radius: 999px; + font-size: 9px; +} .v2-access-protocol-details.semi-card-group { display: grid; grid-template-columns: 1fr; @@ -6995,6 +7056,56 @@ button, a { -webkit-tap-highlight-color: transparent; } border: 1px solid currentColor; background: transparent; } +.v2-access-protocol-glance { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + background: #edf1f5; +} +.v2-access-protocol-glance > span { + display: grid; + min-width: 0; + min-height: 45px; + align-content: center; + gap: 3px; + background: #fff; + padding: 6px 11px; +} +.v2-access-protocol-glance small { + color: #8793a4; + font-size: 9px; + line-height: 1.2; +} +.v2-access-protocol-glance strong, +.v2-access-protocol-glance .v2-access-time { + overflow: hidden; + color: #405269; + font-size: 10px; + font-weight: 650; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} +.v2-access-protocol-glance .is-danger strong { color: var(--v2-red); } +.v2-access-protocol-toggle.semi-button { + width: 100%; + min-height: 38px; + justify-content: space-between; + border-top: 1px solid #e9eef4; + border-radius: 0; + color: #54708e; + padding: 0 11px; + font-size: 10px; + font-weight: 650; +} +.v2-access-protocol-toggle .semi-icon { + transition: transform .18s ease; +} +.v2-access-protocol-toggle.is-expanded .semi-icon { transform: rotate(180deg); } +.v2-access-protocol-expanded { + border-top: 1px solid #e9eef4; + background: #fbfcfe; +} .v2-access-protocol-descriptions.semi-descriptions { padding: 7px 11px 3px; } .v2-access-protocol-descriptions .semi-descriptions-item { min-height: 31px; @@ -7032,6 +7143,24 @@ button, a { -webkit-tap-highlight-color: transparent; } -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.v2-access-protocol-missing { + display: grid; + min-height: 62px; + align-content: center; + gap: 4px; + background: #fbfcfd; + padding: 9px 11px; +} +.v2-access-protocol-missing span { + color: #627188; + font-size: 10px; + font-weight: 650; +} +.v2-access-protocol-missing small { + color: #909bab; + font-size: 9px; + line-height: 1.4; +} .v2-access-loading { position: absolute; z-index: 4; @@ -7066,11 +7195,14 @@ button, a { -webkit-tap-highlight-color: transparent; } } @media (max-width: 680px) { - .v2-access-inspector-summary.semi-descriptions { padding: 8px 12px 6px; } - .v2-access-inspector-summary .semi-descriptions-item { min-height: 44px; } - .v2-access-inspector-summary .semi-descriptions-item-th { width: 80px; } - .v2-access-inspector-summary .semi-descriptions-key, - .v2-access-inspector-summary .semi-descriptions-value { font-size: 11px; } + .v2-access-inspector-summary-grid { min-height: 68px; } + .v2-access-inspector-summary-grid > span { padding-inline: 8px; } + .v2-access-inspector-summary-grid small { font-size: 10px; } + .v2-access-inspector-summary-grid strong { font-size: 11px; } + .v2-access-protocol-heading { min-height: 44px; padding-inline: 12px; } + .v2-access-protocol-heading strong { font-size: 13px; } + .v2-access-protocol-heading small, + .v2-access-protocol-heading .semi-tag { font-size: 10px; } .v2-access-protocol-details.semi-card-group { display: flex; overflow-x: auto; @@ -7086,6 +7218,14 @@ button, a { -webkit-tap-highlight-color: transparent; } } .v2-access-protocol-detail > .semi-card-header { min-height: 44px; } .v2-access-protocol-detail > .semi-card-header .semi-card-header-wrapper-title strong { font-size: 13px; } + .v2-access-protocol-glance > span { min-height: 43px; padding-inline: 10px; } + .v2-access-protocol-glance small, + .v2-access-protocol-glance strong, + .v2-access-protocol-glance .v2-access-time, + .v2-access-protocol-missing span { font-size: 11px; } + .v2-access-protocol-toggle.semi-button { min-height: 42px; font-size: 11px; } + .v2-access-protocol-missing { min-height: 66px; } + .v2-access-protocol-missing small { font-size: 10px; } .v2-access-protocol-descriptions .semi-descriptions-item { min-height: 34px; } .v2-access-protocol-descriptions .semi-descriptions-key, .v2-access-protocol-descriptions .semi-descriptions-value,