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

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('access domain helpers', () => {
it('formats duration without hiding sign or long offline windows', () => { 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 }]); 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', () => { 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: '尚未接入业务应接口径' }]); 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('在线'); expect(csv).toContain('在线');

View File

@@ -1,7 +1,14 @@
import type { AccessProtocolThreshold, AccessThresholdConfig, AccessVehicleRow } from '../../api/types'; import type { AccessProtocolThreshold, AccessThresholdConfig, AccessVehicleRow } from '../../api/types';
const accessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { 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<AccessVehicleRow['onlineState'], string> = { export const accessStateLabels: Record<AccessVehicleRow['onlineState'], string> = {
@@ -29,6 +36,25 @@ export function formatAccessTime(value: string) {
return accessTimeFormatter.format(parsed).replace(/\//g, '-'); 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) { export function thresholdForProtocol(config: AccessThresholdConfig, protocol: string) {
return config.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? config.defaultThresholdSec; return config.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? config.defaultThresholdSec;
} }

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(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
} }
expect(screen.getByRole('navigation', { name: '接入差异筛选' })).toHaveClass('v2-access-status-tabs'); 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'); const filterActions = view.container.querySelector<HTMLElement>('.v2-access-filter-actions');
expect(filterActions).toBeInTheDocument(); expect(filterActions).toBeInTheDocument();
expect(within(filterActions!).getByRole('button', { name: '查询' })).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'); expect(desktopRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.keyDown(desktopRow, { key: 'Enter' }); fireEvent.keyDown(desktopRow, { key: 'Enter' });
expect(await screen.findByRole('button', { name: '关闭车辆接入详情' })).toBeInTheDocument(); 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'); expect(desktopRow).toHaveAttribute('aria-expanded', 'true');
const inspectorHeader = screen.getByRole('heading', { level: 5, name: '旧接入车牌' }).closest<HTMLElement>('.v2-workspace-panel-header'); expect(within(detailDialog).getByText('旧接入车牌 · OLDVIN')).toBeInTheDocument();
expect(inspectorHeader).toBeInTheDocument(); expect(within(detailDialog).getByText('接入结论')).toBeInTheDocument();
expect(within(inspectorHeader!).getByText('OLDVIN')).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.change(screen.getByRole('textbox', { name: '车辆' }), { target: { value: 'NEWVIN' } });
fireEvent.click(screen.getByRole('button', { name: '查询' })); 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: '车辆接入详情' }); const dialog = await screen.findByRole('dialog', { name: '车辆接入详情' });
expect(within(dialog).getByRole('button', { name: '关闭车辆接入详情' })).toBeInTheDocument(); expect(within(dialog).getByRole('button', { name: '关闭车辆接入详情' })).toBeInTheDocument();
expect(dialog.querySelector('.v2-access-inspector-v3.semi-card')).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.semi-descriptions')).toBeInTheDocument();
expect(dialog.querySelector('.v2-access-protocol-details.semi-card-group')).toBeInTheDocument(); expect(dialog.querySelector('.v2-access-protocol-details.semi-card-group')).toBeInTheDocument();
expect(dialog.querySelectorAll('.v2-access-protocol-detail.semi-card')).toHaveLength(3); 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 { 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 { 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 { 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 { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types'; 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 { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MetricActionButton } from '../shared/MetricActionButton'; import { MetricActionButton } from '../shared/MetricActionButton';
import { TablePagination } from '../shared/TablePagination'; import { TablePagination } from '../shared/TablePagination';
@@ -21,7 +21,7 @@ import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; 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: '' }; const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
type Filters = typeof EMPTY_FILTERS; type Filters = typeof EMPTY_FILTERS;
@@ -57,7 +57,12 @@ function compactTime(value: string) {
return compactAccessTimeFormatter.format(parsed).replace(/\//g, '-'); 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 state = !status?.connected ? 'missing' : status.onlineState;
const label = state === 'missing' ? '无来源' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报'; const label = state === 'missing' ? '无来源' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报';
const color = state === 'online' ? 'green' : state === 'offline' || state === 'unknown' ? 'orange' : 'grey'; 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={[ <Descriptions className="v2-access-protocol-descriptions" align="left" size="small" data={[
{ key: '接入厂家', value: status?.provider || '—' }, { key: '接入厂家', value: status?.provider || '—' },
{ key: '首次接入', value: formatAccessTime(status?.firstSeenAt || '') }, { key: '首次接入', value: <AccessTime value={status?.firstSeenAt} /> },
{ key: '最新上报', value: formatAccessTime(status?.latestReceivedAt || '') }, { key: '最新上报', value: <AccessTime value={status?.latestReceivedAt} /> },
{ key: '当前离线', value: formatSeconds(status?.freshnessSec) }, { key: '当前离线', value: formatSeconds(status?.freshnessSec) },
{ key: '上报间隔', value: formatSeconds(status?.reportIntervalSec) }, { key: '上报间隔', value: formatSeconds(status?.reportIntervalSec) },
{ key: '数据延迟', value: <Typography.Text type={status?.delayAbnormal ? 'danger' : 'primary'}>{formatSeconds(status?.dataDelaySec)}</Typography.Text> } { 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)}` : '当前未发现该协议来源'}> 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} {protocolLabel ? <b className="v2-access-protocol-label">{protocolLabel}</b> : null}
<span><i />{label}</span> <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> <small>{status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}</small>
</div>; </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 ConnectionState = memo(function ConnectionState({ row }: { row: AccessVehicleRow }) {
const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange'; const summary = accessIssueSummary(row);
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>; 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 }) { function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
return <div className="v2-access-protocol-coverage" aria-label="真实协议来源概览"> return <div className="v2-access-protocol-coverage" aria-label="真实协议来源概览">
@@ -104,7 +118,7 @@ function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
</div>; </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(() => [ const columns = useMemo(() => [
{ {
title: '车辆', dataIndex: 'plate', width: 165, title: '车辆', dataIndex: 'plate', width: 165,
@@ -114,16 +128,12 @@ function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehic
title: '品牌 / 车型', dataIndex: 'oem', width: 140, 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> 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) => ({ ...PROTOCOLS.map((protocol) => ({
title: protocol, dataIndex: protocol, width: 160, title: protocol, dataIndex: protocol, width: 160,
render: (_: unknown, row: AccessVehicleRow) => <ProtocolState status={statusByProtocol(row, protocol)} /> 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} /> render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => <ConnectionState row={row} />
} }
], []); ], []);
@@ -143,18 +153,30 @@ function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehic
onOpen: () => onSelect(row.vin) onOpen: () => onSelect(row.vin)
}) : ({})} }) : ({})}
/>; />;
} });
function VehicleInspector({ row, onClose, sheet = false }: { row: AccessVehicleRow; onClose: () => void; sheet?: boolean }) { 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 }}> 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={[ <Descriptions className="v2-access-inspector-summary" align="left" size="small" data={[
{ key: '品牌 / 车型', value: [row.oem, row.model].filter(Boolean).join(' / ') || '未维护' }, { key: '品牌 / 车型', value: [row.oem, row.model].filter(Boolean).join(' / ') || '未维护' },
{ key: '真实接入来源', value: row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源' }, { key: '真实接入来源', value: row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源' },
{ key: '资料状态', value: row.masterDataIssues.length ? row.masterDataIssues.join('') : '已维护' }, { key: '资料状态', value: row.masterDataIssues.length ? row.masterDataIssues.join('') : '已维护' }
{ key: '综合状态', value: <ConnectionState row={row} /> }
]} /> ]} />
<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> <footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link></footer>
</Card>; </Card>;
} }
@@ -226,7 +248,7 @@ export default function AccessPage() {
title="真实来源与接入健康" title="真实来源与接入健康"
description="真实来源、在线健康与资料差异一处核对" description="真实来源、在线健康与资料差异一处核对"
status={summary ? `${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取车辆'} 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></>} 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 <WorkspaceFilterPanel
@@ -276,7 +298,7 @@ export default function AccessPage() {
aria-label={mobileLayout ? '车辆接入列表,可上下滚动' : undefined} aria-label={mobileLayout ? '车辆接入列表,可上下滚动' : undefined}
> >
{mobileLayout {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} />} : <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={setSelectedVIN} />}
{vehiclesQuery.isFetching ? <PanelLoading className="v2-access-loading" title="正在更新车辆接入状态…" description="当前列表返回后会自动替换。" compact={Boolean(rows.length)} /> : null} {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} {!vehiclesQuery.isFetching && !rows.length ? <PanelEmpty className="v2-access-empty" title="没有匹配车辆" description="调整车牌、协议或接入状态筛选后重试。" /> : null}

View File

@@ -212,6 +212,9 @@ describe('V2 production entry', () => {
expect(corePageSources.AccessPage).not.toContain('<section className="v2-access-table-v3"'); expect(corePageSources.AccessPage).not.toContain('<section className="v2-access-table-v3"');
expect(corePageSources.AccessPage).not.toContain('<aside className="v2-access-inspector-v3"'); expect(corePageSources.AccessPage).not.toContain('<aside className="v2-access-inspector-v3"');
expect(corePageSources.AccessPage).toContain('<Card key={row.vin} className={`v2-access-mobile-card'); expect(corePageSources.AccessPage).toContain('<Card key={row.vin} className={`v2-access-mobile-card');
expect(corePageSources.AccessPage).toContain('<Card className={`v2-access-inspector-focus');
expect(corePageSources.AccessPage).toContain("title: '差异摘要'");
expect(corePageSources.AccessPage).not.toContain("title: '真实来源'");
expect(corePageSources.AccessPage).toContain('<Descriptions className="v2-access-inspector-summary"'); expect(corePageSources.AccessPage).toContain('<Descriptions className="v2-access-inspector-summary"');
expect(corePageSources.AccessPage).toContain('<Descriptions className="v2-access-protocol-descriptions"'); expect(corePageSources.AccessPage).toContain('<Descriptions className="v2-access-protocol-descriptions"');
expect(corePageSources.AccessPage).toContain('<CardGroup className="v2-access-protocol-details"'); expect(corePageSources.AccessPage).toContain('<CardGroup className="v2-access-protocol-details"');

View File

@@ -8522,6 +8522,109 @@
padding-block: 6px; padding-block: 6px;
} }
.v2-access-table-scroll-v3 table {
min-width: 1005px;
}
.v2-access-table-scroll-v3 th:nth-child(1) { width: 165px; }
.v2-access-table-scroll-v3 th:nth-child(2) { width: 140px; }
.v2-access-table-scroll-v3 th:nth-child(3),
.v2-access-table-scroll-v3 th:nth-child(4),
.v2-access-table-scroll-v3 th:nth-child(5) { width: 160px; }
.v2-access-table-scroll-v3 th:nth-child(6) { width: 220px; }
.v2-access-connection {
min-width: 0;
gap: 3px;
}
.v2-access-connection > 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 { .v2-ops-page {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -8737,6 +8840,10 @@
display: none; 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 { .v2-access-mobile-card-content > header .v2-access-connection-tag.semi-tag {
min-height: 20px; min-height: 20px;
border-radius: 5px; border-radius: 5px;
@@ -8752,6 +8859,11 @@
white-space: nowrap; white-space: nowrap;
} }
.v2-access-mobile-card-content > p.is-issue {
color: #9c5b0f;
font-weight: 650;
}
.v2-access-mobile-protocols { .v2-access-mobile-protocols {
gap: 4px; gap: 4px;
padding-top: 5px; padding-top: 5px;
@@ -8829,6 +8941,19 @@
scroll-snap-align: none; 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 { .v2-ops-page {
height: auto; height: auto;
min-height: 100%; min-height: 100%;