polish access difference workspace
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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', () => {
|
||||
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 }]);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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('在线');
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { AccessProtocolThreshold, AccessThresholdConfig, AccessVehicleRow } from '../../api/types';
|
||||
|
||||
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> = {
|
||||
@@ -29,6 +36,25 @@ export function formatAccessTime(value: string) {
|
||||
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) {
|
||||
return config.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? config.defaultThresholdSec;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -212,6 +212,9 @@ describe('V2 production entry', () => {
|
||||
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).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-protocol-descriptions"');
|
||||
expect(corePageSources.AccessPage).toContain('<CardGroup className="v2-access-protocol-details"');
|
||||
|
||||
@@ -8522,6 +8522,109 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -8737,6 +8840,10 @@
|
||||
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 {
|
||||
min-height: 20px;
|
||||
border-radius: 5px;
|
||||
@@ -8752,6 +8859,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-access-mobile-card-content > p.is-issue {
|
||||
color: #9c5b0f;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.v2-access-mobile-protocols {
|
||||
gap: 4px;
|
||||
padding-top: 5px;
|
||||
@@ -8829,6 +8941,19 @@
|
||||
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 {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user