refine Semi UI alert and access workspaces

This commit is contained in:
lingniu
2026-07-18 04:26:37 +08:00
parent cff2e07a57
commit 866dfdaf5f
5 changed files with 375 additions and 36 deletions

View File

@@ -132,11 +132,16 @@ test('renders mobile access vehicles as selectable Semi cards', async () => {
prepareBaseData();
mocks.accessVehicles.mockResolvedValue({ items: [accessRow('VIN001', '粤A00001')], total: 1, limit: 50, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><AccessPage /></MemoryRouter></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><AccessPage /></MemoryRouter></QueryClientProvider>);
const action = await screen.findByRole('button', { name: '查看 粤A00001 接入详情' });
expect(action).toHaveClass('semi-button', 'v2-access-mobile-action');
expect(action.closest('.semi-card')).toHaveClass('v2-access-mobile-card');
expect(view.container.querySelector('.v2-access-table-scroll-v3.is-mobile-scroll')).toHaveAttribute('tabindex', '0');
expect(view.container.querySelector('.v2-access-table-scroll-v3.is-mobile-scroll')).toHaveAttribute('aria-label', '车辆接入列表,可上下滚动');
expect(within(action).getByText('GB32960')).toBeInTheDocument();
expect(within(action).getByText('JT808')).toBeInTheDocument();
expect(within(action).getByText('YUTONG_MQTT')).toBeInTheDocument();
expect(action).toHaveAttribute('aria-pressed', 'false');
expect(action).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(action);

View File

@@ -40,7 +40,7 @@ function compactTime(value: string) {
return compactAccessTimeFormatter.format(parsed).replace(/\//g, '-');
}
function ProtocolState({ status, detailed = false }: { status?: AccessProtocolStatus; detailed?: boolean }) {
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';
@@ -66,6 +66,7 @@ function ProtocolState({ status, detailed = false }: { status?: AccessProtocolSt
</Card>;
}
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>
<small>{status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}</small>
@@ -246,9 +247,13 @@ export default function AccessPage() {
actionsClassName="v2-access-table-actions"
actions={<><ProtocolCoverage summary={summary} /><Button theme="light" icon={<IconDownload />} onClick={() => downloadRows(rows)} disabled={!rows.length}></Button></>}
/>
<div className="v2-access-table-scroll-v3">
<div
className={`v2-access-table-scroll-v3${mobileLayout ? ' is-mobile-scroll' : ''}`}
tabIndex={mobileLayout ? 0 : undefined}
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} 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>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><span className="v2-access-mobile-protocols">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} protocolLabel={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}

View File

@@ -188,6 +188,8 @@ test('renders only selectable Semi alert cards on mobile', async () => {
const action = await screen.findByRole('button', { name: '查看 粤A移动01 粤A移动01速度告警 告警详情' });
expect(action).toHaveClass('semi-button', 'v2-alert-mobile-action');
expect(action.closest('.semi-card')).toHaveClass('v2-alert-mobile-card');
expect(view.container.querySelector('.v2-alert-table-scroll.is-mobile-scroll')).toHaveAttribute('tabindex', '0');
expect(view.container.querySelector('.v2-alert-table-scroll.is-mobile-scroll')).toHaveAttribute('aria-label', '告警事件列表,可上下滚动');
expect(view.container.querySelector('.v2-alert-event-table')).not.toBeInTheDocument();
expect(action).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(action);

View File

@@ -223,7 +223,11 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
meta={`${(events.data?.total ?? 0).toLocaleString('zh-CN')}`}
actions={<Button theme="borderless" icon={<IconRefresh />} loading={events.isFetching} onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])}></Button>}
/>
<div className="v2-alert-table-scroll">
<div
className={`v2-alert-table-scroll${mobileLayout ? ' is-mobile-scroll' : ''}`}
tabIndex={mobileLayout ? 0 : undefined}
aria-label={mobileLayout ? '告警事件列表,可上下滚动' : undefined}
>
{mobileLayout
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 告警详情`} onClick={() => setSelection({ scope: eventScope, id: event.id })}><span className="v2-alert-mobile-card-content"><header><strong>{event.plate || '未绑定车牌'}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></header><p>{event.ruleName}</p><dl><div><dt></dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt></dt><dd>{event.protocol || '—'}</dd></div><div><dt></dt><dd>{alertValue(event)}</dd></div><div><dt></dt><dd>{thresholdText(event)}</dd></div></dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <AlertEventTable rows={rows} selectedID={selectedID} onSelect={(id) => setSelection({ scope: eventScope, id })} />}
@@ -410,5 +414,5 @@ export default function AlertsPage() {
const setTab = (next: Tab) => { setTabState(next); const copy = new URLSearchParams(params); copy.set('tab', next); setParams(copy, { replace: true }); };
const setFilters = (next: Filters) => { setFilterState(next); const copy = new URLSearchParams(); if (tab !== 'events') copy.set('tab', tab); Object.entries(next).forEach(([key, value]) => { if (value) copy.set(key, value); }); setParams(copy, { replace: true }); };
const tabs = [{ key: 'events' as const, label: '告警事件', icon: <IconAlarm /> }, ...(admin ? [{ key: 'rules' as const, label: '规则配置' }] : []), { key: 'notifications' as const, label: '站内通知', icon: <IconBell />, count: unread || undefined }];
return <div className="v2-alert-page"><div className="v2-alert-navigation"><SegmentedTabs className="v2-alert-tabs" variant="filled" ariaLabel="告警中心分类" value={activeTab} items={tabs} onChange={setTab} /><div className="v2-alert-navigation-meta"><Typography.Text type="tertiary">{operator ? '可处置事件' : '只读查看'}</Typography.Text><Tag color={unread ? 'orange' : 'green'} type="light" size="small">{unread ? `${unread.toLocaleString('zh-CN')} 条未读` : '通知已读'}</Tag></div></div>{activeTab !== 'notifications' && rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} draft={eventDraft} setDraft={setEventDraft} setFilters={setFilters} rules={rules.data ?? []} unread={unread} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} notifications={notifications} />}</div>;
return <div className={`v2-alert-page is-${activeTab}`}><div className="v2-alert-navigation"><SegmentedTabs className="v2-alert-tabs" variant="filled" ariaLabel="告警中心分类" value={activeTab} items={tabs} onChange={setTab} /><div className="v2-alert-navigation-meta"><Typography.Text type="tertiary">{operator ? '可处置事件' : '只读查看'}</Typography.Text><Tag color={unread ? 'orange' : 'green'} type="light" size="small">{unread ? `${unread.toLocaleString('zh-CN')} 条未读` : '通知已读'}</Tag></div></div>{activeTab !== 'notifications' && rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} draft={eventDraft} setDraft={setEventDraft} setFilters={setFilters} rules={rules.data ?? []} unread={unread} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} notifications={notifications} />}</div>;
}