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)}
+
+ }
+ iconPosition="right"
+ aria-expanded={expanded}
+ aria-controls={evidenceId}
+ aria-label={`${expanded ? '收起' : '查看'} ${protocol} 完整证据`}
+ onClick={() => onToggle?.(protocol)}
+ >
+ {expanded ? '收起完整证据' : protocolNeedsAttention(status) ? '查看异常证据' : '查看完整证据'}
+
+ {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) => )}
;
}
@@ -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,