refine Semi UI access evidence inspector
This commit is contained in:
@@ -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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><AccessPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
@@ -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: '关闭车辆接入详情' }));
|
||||
|
||||
@@ -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 <time className="v2-access-time" dateTime={value} title={`${formatAccessTime(value)} · 上海时间`}>{compact ? compactTime(value) : formatAccessTime(value)}</time>;
|
||||
}
|
||||
|
||||
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 <Card
|
||||
className={`v2-access-protocol-detail is-${state}`}
|
||||
title={<strong>{status?.protocol}</strong>}
|
||||
className={`v2-access-protocol-detail is-${state}${expanded ? ' is-expanded' : ''}`}
|
||||
title={<strong>{protocol}</strong>}
|
||||
headerExtraContent={<Tag className={`v2-access-protocol-tag is-${state}`} color={color} type="light" size="small"><i />{label}</Tag>}
|
||||
headerLine
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Descriptions className="v2-access-protocol-descriptions" align="left" size="small" data={[
|
||||
{ key: '接入厂家', value: status?.provider || '—' },
|
||||
{ key: '首次接入', value: <AccessTime value={status?.firstSeenAt} /> },
|
||||
{ key: '最新上报', value: <AccessTime value={status?.latestReceivedAt} /> },
|
||||
{ key: '当前离线', value: formatSeconds(status?.freshnessSec) },
|
||||
{ key: '上报间隔', value: formatSeconds(status?.reportIntervalSec) },
|
||||
{ key: '数据延迟', value: <Typography.Text type={status?.delayAbnormal ? 'danger' : 'primary'}>{formatSeconds(status?.dataDelaySec)}</Typography.Text> }
|
||||
]} />
|
||||
<p className="v2-access-protocol-evidence" title={status?.firstSeenEvidence || '当前未发现该协议来源'}>
|
||||
{status?.firstSeenEvidence || '当前未发现该协议来源'}
|
||||
</p>
|
||||
{status?.connected ? <>
|
||||
<div className="v2-access-protocol-glance" role="group" aria-label={`${protocol} 关键状态`}>
|
||||
<span><small>接入厂家</small><strong title={status.provider || '接入方未维护'}>{status.provider || '接入方未维护'}</strong></span>
|
||||
<span><small>最新上报</small><strong><AccessTime value={status.latestReceivedAt} compact /></strong></span>
|
||||
<span><small>当前离线</small><strong>{formatSeconds(status.freshnessSec)}</strong></span>
|
||||
<span className={status.delayAbnormal ? 'is-danger' : ''}><small>数据延迟</small><strong>{formatSeconds(status.dataDelaySec)}</strong></span>
|
||||
</div>
|
||||
<Button
|
||||
className={`v2-access-protocol-toggle${expanded ? ' is-expanded' : ''}`}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
icon={<IconChevronDown />}
|
||||
iconPosition="right"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={evidenceId}
|
||||
aria-label={`${expanded ? '收起' : '查看'} ${protocol} 完整证据`}
|
||||
onClick={() => onToggle?.(protocol)}
|
||||
>
|
||||
{expanded ? '收起完整证据' : protocolNeedsAttention(status) ? '查看异常证据' : '查看完整证据'}
|
||||
</Button>
|
||||
{expanded ? <div id={evidenceId} className="v2-access-protocol-expanded" role="region" aria-label={`${protocol} 完整接入证据`}>
|
||||
<Descriptions className="v2-access-protocol-descriptions" align="left" size="small" data={[
|
||||
{ key: '首次接入', value: <AccessTime value={status.firstSeenAt} /> },
|
||||
{ key: '上报间隔', value: formatSeconds(status.reportIntervalSec) }
|
||||
]} />
|
||||
<p className="v2-access-protocol-evidence" title={evidence}>{evidence}</p>
|
||||
</div> : null}
|
||||
</> : <div className="v2-access-protocol-missing">
|
||||
<span>当前未发现真实接入来源</span>
|
||||
<small>无需展开,待收到该协议报文后自动补充证据。</small>
|
||||
</div>}
|
||||
</Card>;
|
||||
}
|
||||
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '当前未发现该协议来源'}>
|
||||
@@ -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 <Card className={`v2-access-inspector-v3${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
{sheet ? null : <WorkspacePanelHeader className="v2-access-inspector-header" title={row.plate || '未绑定车牌'} description={row.vin} actions={<Button theme="borderless" icon={<IconClose />} onClick={onClose} aria-label="关闭车辆接入详情" />} />}
|
||||
<Card className={`v2-access-inspector-focus is-${row.connectionState}`} bodyStyle={{ padding: 0 }}>
|
||||
<div><small>{row.connectionState === 'healthy' ? '接入结论' : '优先核对'}</small><strong>{accessIssueSummary(row)}</strong><span>{row.actualProtocols.length ? `已发现 ${row.actualProtocols.join(' / ')} ${row.actualProtocols.length} 个真实来源` : '当前没有可用的真实来源证据'}</span></div>
|
||||
<ConnectionTag row={row} />
|
||||
</Card>
|
||||
<Descriptions className="v2-access-inspector-summary" align="left" size="small" data={[
|
||||
{mobile ? <div className="v2-access-inspector-summary-grid" role="group" aria-label="车辆接入概览">
|
||||
<span><small>品牌 / 车型</small><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '未维护'}</strong></span>
|
||||
<span><small>真实来源</small><strong>{row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源'}</strong></span>
|
||||
<span><small>资料状态</small><strong>{row.masterDataIssues.length ? row.masterDataIssues.join(';') : '已维护'}</strong></span>
|
||||
</div> : <Descriptions className="v2-access-inspector-summary" align="left" size="small" data={[
|
||||
{ key: '品牌 / 车型', value: [row.oem, row.model].filter(Boolean).join(' / ') || '未维护' },
|
||||
{ key: '真实接入来源', value: row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源' },
|
||||
{ key: '资料状态', value: row.masterDataIssues.length ? row.masterDataIssues.join(';') : '已维护' }
|
||||
]} />
|
||||
<CardGroup className="v2-access-protocol-details" type="grid" spacing={0}>{[...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) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</CardGroup>
|
||||
]} />}
|
||||
<div className="v2-access-protocol-heading">
|
||||
<span><strong>协议证据</strong><small>异常优先展示,按需展开完整证据</small></span>
|
||||
<Tag color={row.actualProtocols.length ? 'blue' : 'grey'} type="light" size="small">{row.actualProtocols.length} / {PROTOCOLS.length} 已接入</Tag>
|
||||
</div>
|
||||
<CardGroup className="v2-access-protocol-details" type="grid" spacing={0}>{protocols.map((protocol) => <ProtocolState
|
||||
key={protocol}
|
||||
status={statusByProtocol(row, protocol)}
|
||||
detailed
|
||||
expanded={expandedProtocol === protocol}
|
||||
onToggle={toggleProtocol}
|
||||
/>)}</CardGroup>
|
||||
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}>查看车辆详情</Link></footer>
|
||||
</Card>;
|
||||
}
|
||||
@@ -349,7 +406,7 @@ export default function AccessPage() {
|
||||
title={<div className="v2-access-sheet-title"><strong>车辆接入详情</strong><span>{selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}</span></div>}
|
||||
onCancel={() => setSelectedVIN('')}
|
||||
>
|
||||
{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} sheet /> : null}
|
||||
{selected ? <VehicleInspector key={selected.vin} row={selected} onClose={() => setSelectedVIN('')} sheet mobile={mobileLayout} /> : null}
|
||||
</SideSheet>
|
||||
{editable ? <SideSheet
|
||||
className="v2-access-governance-sidesheet"
|
||||
|
||||
Reference in New Issue
Block a user