polish access difference workspace

This commit is contained in:
lingniu
2026-07-18 11:40:35 +08:00
parent 0802b1989a
commit 09e16e8d26
6 changed files with 230 additions and 30 deletions

View File

@@ -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<HTMLElement>('.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<HTMLElement>('.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);

View File

@@ -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 <span className="v2-access-time"></span>;
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 }) {
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
>
<Descriptions className="v2-access-protocol-descriptions" align="left" size="small" data={[
{ key: '接入厂家', value: status?.provider || '—' },
{ key: '首次接入', value: formatAccessTime(status?.firstSeenAt || '') },
{ key: '最新上报', value: formatAccessTime(status?.latestReceivedAt || '') },
{ 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> }
@@ -85,15 +90,24 @@ function ProtocolState({ status, detailed = false, protocolLabel }: { status?: A
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '当前未发现该协议来源'}>
{protocolLabel ? <b className="v2-access-protocol-label">{protocolLabel}</b> : null}
<span><i />{label}</span>
<strong>{status?.connected ? compactTime(status.latestReceivedAt) : '—'}</strong>
<strong>{status?.connected ? <AccessTime value={status.latestReceivedAt} compact /> : '—'}</strong>
<small>{status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}</small>
</div>;
});
function ConnectionTag({ row }: { row: AccessVehicleRow }) {
const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange';
return <Tag className="v2-access-connection-tag" color={color} type="light" size="small">{connectionLabels[row.connectionState]}</Tag>;
}
function ConnectionState({ row }: { row: AccessVehicleRow }) {
const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange';
return <div className={`v2-access-connection is-${row.connectionState}`}><Tag className="v2-access-connection-tag" color={color} type="light" size="small">{connectionLabels[row.connectionState]}</Tag><span>{row.actualProtocols.length} </span></div>;
}
const ConnectionState = memo(function ConnectionState({ row }: { row: AccessVehicleRow }) {
const summary = accessIssueSummary(row);
return <div className={`v2-access-connection is-${row.connectionState}`}>
<ConnectionTag row={row} />
<span title={summary}>{summary}</span>
<small>{row.actualProtocols.length ? `${row.actualProtocols.length} 个真实来源 · ${row.actualProtocols.join(' / ')}` : '当前没有真实来源证据'}</small>
</div>;
});
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
return <div className="v2-access-protocol-coverage" aria-label="真实协议来源概览">
@@ -104,7 +118,7 @@ function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
</div>;
}
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) => <div className="v2-access-primary-cell"><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></div>
},
{
title: '真实来源', dataIndex: 'actualProtocols', width: 115,
render: (_: string[], row: AccessVehicleRow) => <div className="v2-access-source-cell"><b>{row.actualProtocols.length} </b><span>{row.actualProtocols.join(' / ') || '尚无来源'}</span></div>
},
...PROTOCOLS.map((protocol) => ({
title: protocol, dataIndex: protocol, width: 160,
render: (_: unknown, row: AccessVehicleRow) => <ProtocolState status={statusByProtocol(row, protocol)} />
})),
{
title: '综合状态', dataIndex: 'connectionState', width: 120,
title: '差异摘要', dataIndex: 'connectionState', width: 220,
render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => <ConnectionState row={row} />
}
], []);
@@ -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 <Card className={`v2-access-inspector-v3${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader className="v2-access-inspector-header" title={row.plate || '未绑定车牌'} description={row.vin} actions={sheet ? undefined : <Button theme="borderless" icon={<IconClose />} onClick={onClose} aria-label="关闭车辆接入详情" />} />
{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={[
{ 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('') : '已维护' },
{ key: '综合状态', value: <ConnectionState row={row} /> }
{ key: '资料状态', value: row.masterDataIssues.length ? row.masterDataIssues.join('') : '已维护' }
]} />
<CardGroup className="v2-access-protocol-details" type="grid" spacing={0}>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</CardGroup>
<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>
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link></footer>
</Card>;
}
@@ -226,7 +248,7 @@ export default function AccessPage() {
title="真实来源与接入健康"
description="真实来源、在线健康与资料差异一处核对"
status={summary ? `${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取车辆'}
meta={<Typography.Text type="tertiary"> {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</Typography.Text>}
meta={<Typography.Text type="tertiary"> <AccessTime value={summary?.asOf} /></Typography.Text>}
actions={<>{editable ? <Button theme="light" icon={<IconSetting />} aria-haspopup="dialog" aria-controls="v2-access-governance" aria-expanded={governanceOpen} onClick={() => setGovernanceOpen(true)}>{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}<Button theme="light" icon={<IconRefresh />} onClick={() => void refresh()}></Button></>}
/>
<WorkspaceFilterPanel
@@ -276,7 +298,7 @@ export default function AccessPage() {
aria-label={mobileLayout ? '车辆接入列表,可上下滚动' : undefined}
>
{mobileLayout
? <div className="v2-access-mobile-list">{rows.map((row) => <Card key={row.vin} className={`v2-access-mobile-card${selected?.vin === row.vin ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" aria-pressed={selected?.vin === row.vin} aria-expanded={selected?.vin === row.vin} aria-label={`查看 ${row.plate || row.vin} 接入详情`} className="v2-access-mobile-action" onClick={() => setSelectedVIN(row.vin)}><span className="v2-access-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><span className="v2-access-mobile-protocols">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} protocolLabel={compactProtocolLabel(protocol)} status={statusByProtocol(row, protocol)} />)}</span><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
? <div className="v2-access-mobile-list">{rows.map((row) => <Card key={row.vin} className={`v2-access-mobile-card${selected?.vin === row.vin ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" aria-pressed={selected?.vin === row.vin} aria-expanded={selected?.vin === row.vin} aria-label={`查看 ${row.plate || row.vin} 接入详情`} className="v2-access-mobile-action" onClick={() => setSelectedVIN(row.vin)}><span className="v2-access-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><ConnectionState row={row} /></header><p className={row.connectionState === 'healthy' ? '' : 'is-issue'} title={row.connectionState === 'healthy' ? undefined : accessIssueSummary(row)}>{row.connectionState === 'healthy' ? `${row.oem || '品牌未维护'} · ${row.model || row.company || '车型未维护'}` : `${accessIssueSummary(row)} · ${row.oem || '品牌未维护'} ${row.model || row.company || '车型未维护'}`}</p><span className="v2-access-mobile-protocols">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} protocolLabel={compactProtocolLabel(protocol)} status={statusByProtocol(row, protocol)} />)}</span><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={setSelectedVIN} />}
{vehiclesQuery.isFetching ? <PanelLoading className="v2-access-loading" title="正在更新车辆接入状态…" description="当前列表返回后会自动替换。" compact={Boolean(rows.length)} /> : null}
{!vehiclesQuery.isFetching && !rows.length ? <PanelEmpty className="v2-access-empty" title="没有匹配车辆" description="调整车牌、协议或接入状态筛选后重试。" /> : null}