feat(platform): harden telemetry pipeline and unify Semi UI workspaces

This commit is contained in:
lingniu
2026-07-18 00:26:36 +08:00
parent 65b4e4f055
commit 159c80b0ae
136 changed files with 21616 additions and 1785 deletions

View File

@@ -11,12 +11,15 @@ const mocks = vi.hoisted(() => ({
accessThresholds: vi.fn(), updateAccessThresholds: vi.fn()
}));
const auth = vi.hoisted(() => ({ role: 'admin' }));
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: auth.role } }) }));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
afterEach(() => {
cleanup();
auth.role = 'admin';
layout.mobile = false;
Object.values(mocks).forEach((mock) => mock.mockReset());
});
@@ -44,9 +47,23 @@ test('removes old access rows immediately when the vehicle filter scope changes'
? Promise.resolve({ items: [accessRow('OLDVIN', '旧接入车牌')], total: 1, limit: 50, offset: 0 })
: new Promise<Page<AccessVehicleRow>>((resolve) => { resolveNew = resolve; }));
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access?keyword=OLDVIN']}><AccessPage /></MemoryRouter></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access?keyword=OLDVIN']}><AccessPage /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('旧接入车牌')).toBeInTheDocument();
for (const className of ['v2-access-filter-card-v3', 'v2-access-kpis-card-v3', 'v2-access-table-v3']) {
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
}
expect(view.container.querySelector('.v2-access-semi-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-access-table-scroll-v3 > table')).not.toBeInTheDocument();
const desktopRow = screen.getByTestId('access-row-OLDVIN');
expect(desktopRow).toHaveAttribute('role', 'button');
expect(desktopRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.keyDown(desktopRow, { key: 'Enter' });
expect(await screen.findByRole('button', { name: '关闭车辆接入详情' })).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();
fireEvent.change(screen.getByRole('textbox', { name: '车辆' }), { target: { value: 'NEWVIN' } });
fireEvent.click(screen.getByRole('button', { name: '查询' }));
@@ -66,13 +83,23 @@ test('reports and independently retries unresolved identity and threshold failur
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><AccessPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: /接入治理/ }));
expect(await screen.findByRole('dialog', { name: '接入治理配置' })).toBeInTheDocument();
const identityError = await screen.findByText('待绑定身份服务超时');
const thresholdError = await screen.findByText('阈值配置读取失败');
fireEvent.click(within(identityError.closest('[role="alert"]')!).getByRole('button', { name: /重试/ }));
fireEvent.click(within(thresholdError.closest('[role="alert"]')!).getByRole('button', { name: /重试/ }));
expect(await screen.findByText('另有 1 条来源身份待绑定')).toBeInTheDocument();
expect(await screen.findByText('在线判定阈值 · v1')).toBeInTheDocument();
const identityTitle = await screen.findByText('来源身份待绑定');
const identityCollapse = identityTitle.closest<HTMLElement>('.semi-collapse');
expect(identityCollapse).toHaveClass('v2-access-identity-queue-v3');
expect(within(identityCollapse!).getByText('1 条').closest('.semi-tag')).toBeTruthy();
expect(within(identityCollapse!).getByText('138****0001').closest('.semi-card')).toHaveClass('v2-access-identity-card');
const thresholdTitle = await screen.findByText('在线判定阈值');
const thresholdCollapse = thresholdTitle.closest<HTMLElement>('.semi-collapse');
expect(thresholdCollapse).toHaveClass('v2-access-settings');
expect(within(thresholdCollapse!).getByText('v1').closest('.semi-tag')).toBeTruthy();
expect(screen.queryByText('待绑定身份服务超时')).not.toBeInTheDocument();
expect(screen.queryByText('阈值配置读取失败')).not.toBeInTheDocument();
expect(mocks.accessUnresolvedIdentities).toHaveBeenCalledTimes(2);
@@ -97,3 +124,26 @@ test('does not request or render admin-only thresholds for a read-only session',
expect(mocks.accessThresholds).not.toHaveBeenCalled();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
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 client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
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(action).toHaveAttribute('aria-pressed', 'false');
expect(action).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(action);
expect(action).toHaveAttribute('aria-pressed', 'true');
expect(action).toHaveAttribute('aria-expanded', 'true');
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-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,4 +1,5 @@
import { IconClose, IconDownload, IconRefresh, IconSave, IconSearch } 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
@@ -6,11 +7,18 @@ 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 { InlineError } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MetricActionButton } from '../shared/MetricActionButton';
import { PageHeader } from '../shared/PageHeader';
import { TablePagination } from '../shared/TablePagination';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { detailTriggerRow } from '../shared/detailTriggerRow';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { downloadBlob } from '../domain/download';
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 });
@@ -35,19 +43,27 @@ function compactTime(value: string) {
function ProtocolState({ status, detailed = false }: { status?: AccessProtocolStatus; detailed?: boolean }) {
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) {
return <article className={`v2-access-protocol-detail is-${state}`}>
<header><strong>{status?.protocol}</strong><span><i />{label}</span></header>
<dl>
<div><dt></dt><dd>{status?.provider || '—'}</dd></div>
<div><dt></dt><dd>{formatAccessTime(status?.firstSeenAt || '')}</dd></div>
<div><dt></dt><dd>{formatAccessTime(status?.latestReceivedAt || '')}</dd></div>
<div><dt>线</dt><dd>{formatSeconds(status?.freshnessSec)}</dd></div>
<div><dt></dt><dd>{formatSeconds(status?.reportIntervalSec)}</dd></div>
<div><dt></dt><dd className={status?.delayAbnormal ? 'is-danger' : ''}>{formatSeconds(status?.dataDelaySec)}</dd></div>
</dl>
<p>{status?.firstSeenEvidence || '当前未发现该协议来源'}</p>
</article>;
return <Card
className={`v2-access-protocol-detail is-${state}`}
title={<strong>{status?.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: formatAccessTime(status?.firstSeenAt || '') },
{ key: '最新上报', value: formatAccessTime(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>
</Card>;
}
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '当前未发现该协议来源'}>
<span><i />{label}</span>
@@ -57,7 +73,8 @@ function ProtocolState({ status, detailed = false }: { status?: AccessProtocolSt
}
function ConnectionState({ row }: { row: AccessVehicleRow }) {
return <div className={`v2-access-connection is-${row.connectionState}`}><strong>{connectionLabels[row.connectionState]}</strong><span>{row.actualProtocols.length} </span></div>;
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>;
}
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
@@ -69,23 +86,81 @@ function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
</div>;
}
function VehicleInspector({ row, onClose }: { row: AccessVehicleRow; onClose: () => void }) {
return <aside className="v2-access-inspector-v3">
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><button type="button" onClick={onClose} aria-label="关闭车辆接入详情"><IconClose /></button></header>
<section className="v2-access-inspector-summary"><div><span> / </span><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '未维护'}</strong></div><div><span></span><strong>{row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源'}</strong></div><div><span></span><strong>{row.masterDataIssues.length ? row.masterDataIssues.join('') : '已维护'}</strong></div><div><span></span><ConnectionState row={row} /></div></section>
<div className="v2-access-protocol-details">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</div>
function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehicleRow[]; selectedVIN: string; onSelect: (vin: string) => void }) {
const columns = useMemo(() => [
{
title: '车辆', dataIndex: 'plate', width: 165,
render: (_: string, row: AccessVehicleRow) => <div className="v2-access-primary-cell"><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div>
},
{
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,
render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => <ConnectionState row={row} />
}
], []);
return <Table
className="v2-access-semi-table"
columns={columns}
dataSource={rows}
rowKey="vin"
pagination={false}
empty={null}
onRow={(row) => row ? detailTriggerRow({
className: selectedVIN === row.vin ? 'is-selected' : '',
expanded: selectedVIN === row.vin,
label: `查看 ${row.plate || row.vin} 接入详情`,
testId: `access-row-${row.vin}`,
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="关闭车辆接入详情" />} />
<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} /> }
]} />
<CardGroup className="v2-access-protocol-details" type="grid" spacing={0}>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</CardGroup>
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link></footer>
</aside>;
</Card>;
}
function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) {
if (!total) return null;
return <details id="access-identity-queue" className="v2-access-identity-queue-v3"><summary><strong> {total.toLocaleString('zh-CN')} </strong><span> VIN </span></summary><div>{items.slice(0, 6).map((item) => <article key={item.id}><b>{item.identifierMasked}</b><span>{item.protocol} · {item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span><small>{item.recommendedAction}</small></article>)}</div></details>;
return <Collapse id="access-identity-queue" className="v2-access-identity-queue-v3" keepDOM>
<Collapse.Panel itemKey="unresolved-identities" header={<span className="v2-access-collapse-title"><span><strong></strong><small> VIN </small></span><Tag color="orange" type="light" size="small">{total.toLocaleString('zh-CN')} </Tag></span>}>
<div className="v2-access-identity-grid">{items.slice(0, 6).map((item) => <Card key={item.id} className="v2-access-identity-card" bodyStyle={{ padding: 0 }}>
<header><b>{item.identifierMasked}</b><Tag color="blue" type="light" size="small">{item.protocol}</Tag></header>
<span>{item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span>
<small>{item.recommendedAction}</small>
</Card>)}</div>
</Collapse.Panel>
</Collapse>;
}
function ThresholdSettings({ config, draft, editable, saving, error, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
if (!draft) return null;
return <details className="v2-access-settings"><summary>线 · v{config?.version ?? '—'}</summary><fieldset disabled={!editable}><label><span></span><input type="number" value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })} /></label><label><span>线</span><input type="number" value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <button type="button" onClick={onSave} disabled={saving}><IconSave />{saving ? '保存中' : '保存阈值'}</button> : <small></small>}</fieldset></details>;
return <Collapse className="v2-access-settings" keepDOM>
<Collapse.Panel itemKey="access-thresholds" header={<span className="v2-access-collapse-title"><span><strong>线</strong><small>线线</small></span><Tag color="blue" type="light" size="small">v{config?.version ?? '—'}</Tag></span>}>
<fieldset disabled={!editable}><label><span></span><Input suffix="秒" type="number" value={String(draft.defaultThresholdSec)} onChange={(value) => onChange({ ...draft, defaultThresholdSec: Number(value) })} /></label><label><span>线</span><Input suffix="秒" type="number" value={String(draft.longOfflineSec)} onChange={(value) => onChange({ ...draft, longOfflineSec: Number(value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><Input suffix="秒" type="number" value={String(draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec)} onChange={(value) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <Button theme="solid" icon={<IconSave />} onClick={onSave} disabled={saving}>{saving ? '保存中' : '保存阈值'}</Button> : <small></small>}</fieldset>
</Collapse.Panel>
</Collapse>;
}
function downloadRows(rows: AccessVehicleRow[]) {
@@ -100,7 +175,8 @@ export default function AccessPage() {
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const mobileLayout = useMobileLayout();
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState('');
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(() => mobileLayout ? 20 : 50); const [selectedVIN, setSelectedVIN] = useState('');
const [governanceOpen, setGovernanceOpen] = useState(false);
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>(); const queryClient = useQueryClient();
const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
const vehicleScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]);
@@ -110,7 +186,14 @@ export default function AccessPage() {
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), enabled: editable, staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]);
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
useEffect(() => {
if (!mobileLayout) return;
setLimit(20);
setOffset(0);
}, [mobileLayout]);
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
useSideSheetA11y(mobileLayout && Boolean(selected), '.v2-access-detail-sidesheet', 'v2-access-detail', '车辆接入详情', '关闭车辆接入详情');
useSideSheetA11y(editable && governanceOpen, '.v2-access-governance-sidesheet', 'v2-access-governance', '接入治理配置', '关闭接入治理配置');
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); setFiltersCollapsed(true); };
@@ -118,18 +201,81 @@ export default function AccessPage() {
const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]);
return <div className="v2-access-page v2-access-page-v3">
<header className="v2-access-heading"><div><h2></h2><p>线</p></div><div><span> {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void refresh()}><IconRefresh /></button></div></header>
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length}` : '全部主车辆'}</small></span><em>{filtersCollapsed ? '展开' : '收起'}</em></button>
<form className={`v2-access-filter-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label className="is-search"><span></span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span></span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value=""></option><option value="attention"></option><option value="healthy"></option><option value="master_data"></option><option value="degraded"></option><option value="offline">线</option><option value="not_connected"></option></select></label><label><span></span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value=""></option>{summary?.oems.map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit"></button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}></button></form>
<PageHeader
title="车辆接入管理"
description="核对车辆真实存在的数据来源、在线健康和待维护资料,不对尚未接入的业务协议作推断。"
status={summary ? `${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取车辆'}
meta={<Typography.Text type="tertiary"> {summary?.asOf ? formatAccessTime(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></>}
/>
<MobileFilterToggle summary={Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length}` : '全部主车辆'} expanded={!filtersCollapsed} onToggle={() => setFiltersCollapsed((value) => !value)} />
<Card className={`v2-access-filter-card-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}><form className="v2-access-filter-v3" onSubmit={submit}>
<label className="is-search"><span></span><Input aria-label="车辆" prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN" /></label>
<label><span id="access-connection-label"></span><Select aria-labelledby="access-connection-label" value={draft.connectionState} onChange={(value) => setDraft({ ...draft, connectionState: String(value) })} optionList={[{ value: '', label: '全部状态' }, { value: 'attention', label: '需关注' }, { value: 'healthy', label: '已接来源正常' }, { value: 'master_data', label: '资料待维护' }, { value: 'degraded', label: '部分来源异常' }, { value: 'offline', label: '已接来源离线' }, { value: 'not_connected', label: '尚无来源' }]} /></label>
<label><span id="access-protocol-label"></span><Select aria-labelledby="access-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
<label><span id="access-oem-label"></span><Select aria-labelledby="access-oem-label" value={draft.oem} onChange={(value) => setDraft({ ...draft, oem: String(value) })} optionList={[{ value: '', label: '全部品牌' }, ...(summary?.oems.map((item) => ({ value: item.name, label: item.name })) ?? [])]} /></label>
<Button className="v2-primary-button" theme="solid" htmlType="submit"></Button><Button className="v2-secondary-button" theme="light" htmlType="button" onClick={() => apply(EMPTY_FILTERS)}></Button>
</form></Card>
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
<section className="v2-access-kpis-v3">{[
['主车辆', summary?.totalVehicles ?? 0, 'all', ''], ['需关注', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['资料待维护', summary?.masterDataIncompleteVehicles ?? 0, 'incomplete', 'master_data'], ['尚无来源', summary?.neverReported ?? 0, 'never', 'not_connected']
].map(([label, value, tone, connectionState]) => <button key={String(label)} className={`is-${tone}`} type="button" onClick={() => apply({ ...criteria, connectionState: String(connectionState) })}><small>{label}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '主车辆' ? <em></em> : null}</button>)}</section>
<Card className="v2-access-kpis-card-v3" bodyStyle={{ padding: 0 }}><section className="v2-access-kpis-v3">{[
{ label: '主车辆', value: summary?.totalVehicles ?? 0, tone: 'all', connectionState: '', hint: '车辆主档' },
{ label: '需关注', value: Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), tone: 'attention', connectionState: 'attention' },
{ label: '资料待维护', value: summary?.masterDataIncompleteVehicles ?? 0, tone: 'incomplete', connectionState: 'master_data' },
{ label: '尚无来源', value: summary?.neverReported ?? 0, tone: 'never', connectionState: 'not_connected' }
].map((item) => {
const value = Number(item.value).toLocaleString('zh-CN');
return <MetricActionButton key={item.label} label={item.label} value={value} hint={item.hint} tone={item.tone} active={criteria.connectionState === item.connectionState} ariaLabel={`筛选${item.label},共 ${value}`} onClick={() => apply({ ...criteria, connectionState: item.connectionState })} />;
})}</section></Card>
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong></strong><span></span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload /></button></div></header><div className="v2-access-table-scroll-v3">{mobileLayout ? <div className="v2-access-mobile-list">{rows.map((row) => <button type="button" key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><div>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} />)}</div></button>)}</div> : <table><thead><tr><th></th><th> / </th><th></th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th></th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.actualProtocols.length} </b><span>{row.actualProtocols.join(' / ') || '尚无来源'}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></td></tr>)}</tbody></table>}{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i /></div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty"></div> : null}</div><footer><span> {page} / {totalPages} {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select aria-label="每页数量" value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
{editable && unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
{editable ? <IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} /> : null}
{editable && thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
{editable ? <ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} /> : null}
<div className={`v2-access-workspace-v3 ${selected && !mobileLayout ? 'is-inspector-open' : ''}`}>
<Card className="v2-access-table-v3" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="车辆真实接入来源"
description="缺席协议只表示“当前未发现”,不会被推断成应接缺失;时间为各来源最后接收时间"
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">
{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>
: <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={setSelectedVIN} />}
{vehiclesQuery.isFetching ? <div className="v2-access-loading" role="status"><Spin size="middle" tip="正在更新车辆接入状态…" /></div> : null}
{!vehiclesQuery.isFetching && !rows.length ? <Empty className="v2-access-empty" title="没有匹配车辆" description="调整车牌、协议或接入状态筛选后重试。" /> : null}
</div>
<footer><TablePagination page={page} totalPages={totalPages} info={`${(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
</Card>
{!mobileLayout && selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}
</div>
<SideSheet
className="v2-access-detail-sidesheet"
visible={mobileLayout && Boolean(selected)}
aria-label="车辆接入详情"
width="100%"
title={<div className="v2-access-sheet-title"><strong></strong><span>{selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}</span></div>}
onCancel={() => setSelectedVIN('')}
>
{mobileLayout && selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} sheet /> : null}
</SideSheet>
{editable ? <SideSheet
className="v2-access-governance-sidesheet"
visible={governanceOpen}
aria-label="接入治理配置"
width={560}
title={<div className="v2-access-sheet-title"><strong></strong><span>线</span></div>}
onCancel={() => setGovernanceOpen(false)}
footer={<Button theme="solid" onClick={() => setGovernanceOpen(false)}></Button>}
>
{governanceOpen ? <div className="v2-access-governance">
<Card className="v2-access-governance-summary" bodyStyle={{ padding: 0 }}>
<div><small></small><strong>{(unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN')}</strong></div>
<div><small></small><strong>v{thresholdQuery.data?.version ?? '—'}</strong></div>
<div><small></small><strong>{(summary?.totalVehicles ?? 0).toLocaleString('zh-CN')}</strong></div>
</Card>
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} />
{thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
<ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} />
</div> : null}
</SideSheet> : null}
</div>;
}

View File

@@ -1,8 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { AlertEvent, Page } from '../../api/types';
import type { AlertEvent, AlertRule, Page } from '../../api/types';
import AlertsPage from './AlertsPage';
import { ROUTER_FUTURE } from '../routing/routerConfig';
@@ -11,11 +11,14 @@ const mocks = vi.hoisted(() => ({
alertRulesV2: vi.fn(), metricCatalog: vi.fn(), alertNotificationsV2: vi.fn(), readAlertNotificationsV2: vi.fn(),
saveAlertRuleV2: vi.fn(), setAlertRuleEnabledV2: vi.fn()
}));
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: 'admin' } }) }));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
afterEach(() => {
cleanup();
layout.mobile = false;
Object.values(mocks).forEach((mock) => mock.mockReset());
});
@@ -27,6 +30,17 @@ function alertEvent(id: string, vin: string, plate: string): AlertEvent {
};
}
function alertRule(): AlertRule {
return {
id: 'speed-rule', name: '测试超速规则', description: '测试规则', severity: 'major', valueType: 'numeric',
metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 0, durationSec: 60,
recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600,
scopeProtocols: ['JT808'], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [],
notificationChannels: ['in_app'], enabled: true, version: 3,
createdBy: 'admin', updatedBy: 'admin', createdAt: '', updatedAt: ''
};
}
test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => {
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
@@ -38,9 +52,9 @@ test('preserves an unsubmitted event filter draft across alert tab URL changes',
const keyword = await screen.findByPlaceholderText('车牌 / VIN / 规则名称');
fireEvent.change(keyword, { target: { value: '保留筛选草稿' } });
fireEvent.click(screen.getByRole('button', { name: /站内通知/ }));
fireEvent.click(screen.getByRole('tab', { name: /站内通知/ }));
expect(await screen.findByText('仅站内通道具备真实送达与已读状态')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /告警事件/ }));
fireEvent.click(screen.getByRole('tab', { name: /告警事件/ }));
expect(await screen.findByPlaceholderText('车牌 / VIN / 规则名称')).toHaveValue('保留筛选草稿');
});
@@ -63,9 +77,15 @@ test('loads only event dependencies on the default alert tab', async () => {
test('loads one full notification query on a direct notifications entry', async () => {
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('仅站内通道具备真实送达与已读状态');
await screen.findByText('暂无站内通知');
expect(view.container.querySelector('.v2-alert-notifications')).toHaveClass('semi-card');
expect(screen.getByRole('heading', { level: 5, name: '站内通知' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-notification-empty.semi-empty')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-channel-card.semi-card')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-channel-descriptions.semi-descriptions')).toBeInTheDocument();
expect(mocks.alertRulesV2).not.toHaveBeenCalled();
expect(mocks.metricCatalog).not.toHaveBeenCalled();
expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1);
@@ -78,7 +98,10 @@ test('ends notification mutation feedback with a visible retryable error instead
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=notifications']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: '标为已读' }));
const markRead = await screen.findByRole('button', { name: '标为已读' });
expect(markRead.closest('.v2-alert-notification-card.semi-card')).toBeInTheDocument();
expect(markRead.closest('.v2-alert-notification-list')?.querySelector('article')).not.toBeInTheDocument();
fireEvent.click(markRead);
expect(await screen.findByText('通知状态更新超时')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '标为已读' })).toBeEnabled();
@@ -86,6 +109,7 @@ test('ends notification mutation feedback with a visible retryable error instead
test('clears old alert rows and inspector evidence when the event scope changes', async () => {
const oldEvent = alertEvent('old-event', 'OLDVIN', '旧告警车牌');
oldEvent.actions = [{ id: 1, action: 'detect', fromStatus: '', toStatus: 'unprocessed', actor: 'alert-evaluator', note: '规则首次命中', createdAt: '2026-07-16T04:00:00Z' }];
const newEvent = alertEvent('new-event', 'NEWVIN', '新告警车牌');
let resolveNew!: (value: Page<AlertEvent>) => void;
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
@@ -96,9 +120,39 @@ test('clears old alert rows and inspector evidence when the event scope changes'
mocks.alertRulesV2.mockResolvedValue([]);
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
expect((await screen.findAllByText('旧告警车牌')).length).toBeGreaterThan(0);
for (const className of ['v2-alert-filter-card', 'v2-alert-kpis-card', 'v2-alert-table-card']) {
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
}
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-workspace')).not.toHaveClass('is-inspector-open');
expect(view.container.querySelectorAll('.v2-alert-kpis .v2-metric-action')).toHaveLength(4);
expect(view.container.querySelectorAll('.v2-alert-secondary-states .semi-button')).toHaveLength(3);
expect(screen.getByRole('button', { name: '查看未读通知,共 0 条' })).toHaveClass('is-notice');
expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-table-scroll > table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-mobile-list')).not.toBeInTheDocument();
const desktopAlertRow = screen.getByTestId('alert-row-old-event');
expect(desktopAlertRow).toHaveAttribute('role', 'button');
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.keyDown(desktopAlertRow, { key: 'Enter' });
expect(await screen.findByText('old-event')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-inspector.semi-card')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-workspace')).toHaveClass('is-inspector-open');
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('事件信息').closest('.semi-card')).toHaveClass('v2-alert-detail-card');
expect(screen.getByText('证据对比').closest('.semi-card')).toHaveClass('v2-alert-evidence-card');
expect(view.container.querySelector('.v2-alert-inspector-heading')).toHaveClass('v2-workspace-panel-header');
expect(view.container.querySelectorAll('.v2-alert-descriptions.semi-descriptions')).toHaveLength(2);
expect(screen.getByLabelText('告警处理进度')).toHaveClass('semi-timeline');
expect(screen.getByText('规则首次命中')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭告警详情' }));
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(desktopAlertRow);
expect(await screen.findByText('old-event')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN / 规则名称'), { target: { value: 'NEWVIN' } });
fireEvent.click(screen.getByRole('button', { name: '查询' }));
@@ -106,10 +160,100 @@ test('clears old alert rows and inspector evidence when the event scope changes'
expect(await screen.findByText('正在更新事件…')).toBeInTheDocument();
expect(screen.queryByText('旧告警车牌')).not.toBeInTheDocument();
expect(screen.queryByText('old-event')).not.toBeInTheDocument();
expect(screen.getByText('选择告警事件')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-workspace')).not.toHaveClass('is-inspector-open');
await act(async () => resolveNew({ items: [newEvent], total: 1, limit: 20, offset: 0 }));
expect((await screen.findAllByText('新告警车牌')).length).toBeGreaterThan(0);
expect(screen.queryByText('new-event')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('alert-row-new-event'));
expect(await screen.findByText('new-event')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByText('正在更新事件…')).not.toBeInTheDocument());
});
test('renders only selectable Semi alert cards on mobile', async () => {
layout.mobile = true;
const event = alertEvent('mobile-event', 'MOBILEVIN', '粤A移动01');
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [event], total: 1, limit: 20, offset: 0 });
mocks.alertEventV2.mockResolvedValue(event);
mocks.alertRulesV2.mockResolvedValue([]);
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
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-event-table')).not.toBeInTheDocument();
expect(action).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(action);
expect(action).toHaveAttribute('aria-pressed', 'true');
expect(action).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByRole('dialog', { name: '告警事件详情' })).toBeInTheDocument();
expect(await screen.findByText('mobile-event')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭告警详情' }));
expect(screen.queryByText('mobile-event')).not.toBeInTheDocument();
expect(action).toHaveAttribute('aria-expanded', 'false');
});
test('keeps the primary alert query compact and applies advanced filters from a SideSheet', async () => {
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
await screen.findByText('当前筛选条件没有告警事件');
expect(view.container.querySelector('.v2-alert-filter-primary')).toBeInTheDocument();
expect(screen.queryByLabelText('协议')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /更多筛选/ }));
const dialog = await screen.findByRole('dialog', { name: '告警高级筛选' });
fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' }));
fireEvent.click(await screen.findByText('JT808'));
fireEvent.click(within(dialog).getByRole('button', { name: '应用筛选' }));
await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.objectContaining({ protocol: 'JT808' }), expect.anything()));
expect(screen.getByRole('button', { name: /更多筛选 · 1/ })).toHaveAttribute('aria-expanded', 'false');
});
test('creates auditable drafts from simple offline and hydrogen rule templates', async () => {
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
mocks.metricCatalog.mockResolvedValue({
metrics: [
{ key: 'freshness_sec', label: '离线时长', unit: 's', category: 'quality', valueType: 'numeric', protocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], sourceFields: {}, searchable: true, chartable: true, alertable: true },
{ key: 'hydrogen_concentration_percent', label: '最高氢浓度', unit: '%', category: 'fuel-cell', valueType: 'numeric', protocols: ['GB32960'], sourceFields: {}, searchable: true, chartable: true, alertable: true }
],
asOf: ''
});
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const ruleItem = await screen.findByRole('button', { name: /测试超速规则/ });
expect(view.container.querySelector('.v2-alert-rule-list')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-alert-rule-editor')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-alert-rule-list-heading')).toHaveClass('v2-workspace-panel-header');
expect(view.container.querySelector('.v2-alert-rule-editor-heading')).toHaveClass('v2-workspace-panel-header');
expect(ruleItem).toHaveClass('semi-button', 'v2-alert-rule-item');
await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true'));
expect(within(ruleItem).getByText('重要').closest('.semi-tag')).toBeTruthy();
expect(within(ruleItem).getByText('已启用').closest('.semi-tag')).toBeTruthy();
const offlineTemplate = await screen.findByRole('button', { name: /离线超过 10 小时/ });
expect(offlineTemplate).toHaveClass('semi-button', 'v2-alert-template-card');
expect(within(offlineTemplate).getByText('GB32960 / JT808 / YUTONG_MQTT').closest('.semi-tag')).toBeTruthy();
await waitFor(() => expect(offlineTemplate).toBeEnabled());
fireEvent.click(offlineTemplate);
expect(screen.getByDisplayValue('车辆离线超过 10 小时')).toBeInTheDocument();
expect(screen.getByDisplayValue('36000')).toBeInTheDocument();
expect(screen.getByDisplayValue('GB32960,JT808,YUTONG_MQTT')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /最高氢浓度/ }));
expect(screen.getByDisplayValue('最高氢浓度超限')).toBeInTheDocument();
expect(screen.getByPlaceholderText('请按厂家标准填写百分比')).toBeRequired();
expect(screen.getByPlaceholderText('请按厂家标准填写百分比')).toHaveValue(null);
expect(screen.getByDisplayValue('GB32960')).toBeInTheDocument();
});

View File

@@ -1,14 +1,24 @@
import { IconAlarm, IconBell, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { IconAlarm, IconBell, IconChevronRight, IconClose, IconFilter, IconPlus, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Descriptions, Empty, Input, Radio, Select, SideSheet, Spin, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, memo, useEffect, useMemo, useState } from 'react';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AlertEvent, AlertNotification, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types';
import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert';
import { InlineError } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MetricActionButton } from '../shared/MetricActionButton';
import { PageHeader } from '../shared/PageHeader';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { detailTriggerRow } from '../shared/detailTriggerRow';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister, canOperate } from '../auth/session';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
type Tab = 'events' | 'rules' | 'notifications';
type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string };
@@ -16,34 +26,124 @@ const EMPTY_FILTERS: Filters = { keyword: '', severity: '', status: '', ruleId:
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
const NUMERIC_OPERATORS = ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'between', 'outside'];
const BOOLEAN_OPERATORS = ['eq', 'neq', 'changed'];
type RuleTemplate = { id: string; title: string; summary: string; draft: AlertRuleInput };
function SeverityTag({ severity }: Pick<AlertEvent, 'severity'>) { return <span className={`v2-alert-severity is-${severity}`}><i />{severityLabels[severity]}</span>; }
function StatusTag({ status }: Pick<AlertEvent, 'status'>) { return <span className={`v2-alert-status is-${status}`}>{statusLabels[status]}</span>; }
const RULE_TEMPLATES: RuleTemplate[] = [
{
id: 'offline-10h',
title: '离线超过 10 小时',
summary: '全部协议 · 自动恢复 · 每小时最多提醒一次',
draft: {
id: '', name: '车辆离线超过 10 小时', description: '车辆任一有效来源持续 10 小时未上报时告警。',
severity: 'major', valueType: 'numeric', metric: 'freshness_sec', operator: 'gt', threshold: 36_000, thresholdHigh: 0,
durationSec: 0, recoveryOperator: 'lte', recoveryThreshold: 300, repeatIntervalSec: 3_600,
scopeProtocols: [...PROTOCOLS], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [],
notificationChannels: ['in_app'], enabled: true, version: 0
}
},
{
id: 'hydrogen-concentration',
title: '最高氢浓度',
summary: 'GB32960 · 阈值由车型或厂家标准确定',
draft: {
id: '', name: '最高氢浓度超限', description: '监控 GB32960 燃料电池系统最高氢浓度;保存前须按车型、厂家和安全规范确认百分比阈值。',
severity: 'critical', valueType: 'numeric', metric: 'hydrogen_concentration_percent', operator: 'gt', threshold: Number.NaN, thresholdHigh: 0,
durationSec: 0, recoveryOperator: '', recoveryThreshold: 0, repeatIntervalSec: 600,
scopeProtocols: ['GB32960'], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [],
notificationChannels: ['in_app'], enabled: true, version: 0
}
}
];
const AlertRows = memo(function AlertRows({ rows, selectedID, onSelect }: { rows: AlertEvent[]; selectedID: string; onSelect: (id: string) => void }) {
return <>{rows.map((event) => <tr key={event.id} className={selectedID === event.id ? 'is-selected' : ''} onClick={() => onSelect(event.id)}>
<td><input type="radio" name="alert-event" checked={selectedID === event.id} onChange={() => onSelect(event.id)} aria-label={`选择 ${event.ruleName}`} /></td>
<td><SeverityTag severity={event.severity} /></td><td><strong>{event.plate || '—'}</strong><small>{event.vin}</small></td><td>{event.ruleName}</td><td>{event.protocol || '—'}</td>
<td>{formatAlertTime(event.triggeredAt)}</td><td>{formatAlertTime(event.recoveredAt)}</td><td><StatusTag status={event.status} /></td><td>{alertValue(event)}</td><td>{thresholdText(event)}</td><td title={event.location}>{event.location || '—'}</td><td>{event.handler || '—'}</td>
</tr>)}</>;
});
function SeverityTag({ severity }: Pick<AlertEvent, 'severity'>) {
const color = severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'amber';
return <Tag className={`v2-alert-severity is-${severity}`} color={color} type="light" size="small"><i />{severityLabels[severity]}</Tag>;
}
function StatusTag({ status }: Pick<AlertEvent, 'status'>) {
const color = status === 'unprocessed' ? 'red' : status === 'processing' ? 'blue' : status === 'recovered' ? 'green' : 'grey';
return <Tag className={`v2-alert-status is-${status}`} color={color} type="light" size="small">{statusLabels[status]}</Tag>;
}
function EventInspector({ event, note, acting, actionError, editable, onNote, onAction }: { event?: AlertEvent; note: string; acting: boolean; actionError?: string; editable: boolean; onNote: (value: string) => void; onAction: (action: 'acknowledge' | 'close' | 'ignore') => void }) {
if (!event) return <aside className="v2-alert-inspector"><div className="v2-alert-side-empty"><IconAlarm /><strong></strong><span>线</span></div></aside>;
return <aside className="v2-alert-inspector"><header><div><strong>{event.ruleName}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></div></header>
<section><h3></h3><dl><div><dt> ID</dt><dd>{event.id}</dd></div><div><dt> / </dt><dd>{event.ruleId} / v{event.ruleVersion}</dd></div><div><dt> / VIN</dt><dd>{event.plate || '—'} / {event.vin}</dd></div><div><dt></dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt></dt><dd>{formatAlertTime(event.recoveredAt)}</dd></div></dl></section>
<section><h3></h3><div className="v2-alert-evidence"><div><small></small><strong>{alertValue(event)}</strong></div><b>VS</b><div><small></small><strong>{thresholdText(event)}</strong></div></div><dl><div><dt> ID</dt><dd>{event.sourceEventId || '—'}</dd></div><div><dt></dt><dd>{event.protocol || '—'}</dd></div><div><dt> / </dt><dd>{formatAlertTime(event.eventAt)} / {formatAlertTime(event.receivedAt)}</dd></div></dl></section>
<section><h3></h3><div className="v2-alert-timeline">{event.actions?.map((item) => <article key={item.id}><i /><div><strong>{actionLabels[item.action] ?? item.action}</strong><span>{item.actor} · {formatAlertTime(item.createdAt)}</span>{item.note ? <p>{item.note}</p> : null}</div></article>)}</div></section>
<section><h3></h3>{editable ? <><textarea maxLength={200} placeholder="请输入处置说明(选填)" value={note} onChange={(e) => onNote(e.target.value)} /><small className="v2-alert-note-count">{note.length}/200</small>{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}<div className="v2-alert-actions"><button className="is-primary" disabled={acting || !canAct(event.status, 'acknowledge')} onClick={() => onAction('acknowledge')}></button><button disabled={acting || !canAct(event.status, 'close')} onClick={() => onAction('close')}></button><button disabled={acting || !canAct(event.status, 'ignore')} onClick={() => onAction('ignore')}></button></div></> : <p className="v2-role-notice"></p>}</section>
function AlertEventTable({ rows, selectedID, onSelect }: { rows: AlertEvent[]; selectedID: string; onSelect: (id: string) => void }) {
const columns = useMemo(() => [
{
title: '', dataIndex: 'selection', width: 42,
render: (_: unknown, event: AlertEvent) => <Radio name="alert-event" checked={selectedID === event.id} onChange={() => onSelect(event.id)} aria-label={`选择 ${event.ruleName}`} />
},
{ title: '严重程度', dataIndex: 'severity', width: 90, render: (_: AlertEvent['severity'], event: AlertEvent) => <SeverityTag severity={event.severity} /> },
{
title: '车牌 / VIN', dataIndex: 'plate', width: 142,
render: (_: string, event: AlertEvent) => <div className="v2-alert-event-vehicle"><strong>{event.plate || '未绑定车牌'}</strong><small>{event.vin}</small></div>
},
{ title: '规则', dataIndex: 'ruleName', width: 170, render: (value: string) => <span className="v2-alert-event-rule" title={value}>{value}</span> },
{ title: '协议', dataIndex: 'protocol', width: 92, render: (value: string) => value || '—' },
{ title: '触发时间', dataIndex: 'triggeredAt', width: 132, render: (value: string) => formatAlertTime(value) },
{ title: '恢复时间', dataIndex: 'recoveredAt', width: 132, render: (value: string) => formatAlertTime(value) },
{ title: '状态', dataIndex: 'status', width: 90, render: (_: AlertEvent['status'], event: AlertEvent) => <StatusTag status={event.status} /> },
{ title: '触发值', dataIndex: 'triggerValue', width: 105, render: (_: unknown, event: AlertEvent) => alertValue(event) },
{ title: '阈值', dataIndex: 'threshold', width: 125, render: (_: unknown, event: AlertEvent) => thresholdText(event) },
{ title: '位置', dataIndex: 'location', width: 180, render: (value: string) => <span className="v2-alert-event-location" title={value}>{value || '—'}</span> },
{ title: '处理人', dataIndex: 'handler', width: 110, render: (value: string) => value || '—' }
], [onSelect, selectedID]);
return <Table
className="v2-alert-event-table"
columns={columns}
dataSource={rows}
rowKey="id"
pagination={false}
empty={null}
onRow={(event) => event ? detailTriggerRow({
className: selectedID === event.id ? 'is-selected' : '',
expanded: selectedID === event.id,
label: `查看 ${event.plate || event.vin} ${event.ruleName} 告警详情`,
testId: `alert-row-${event.id}`,
onOpen: () => onSelect(event.id)
}) : ({})}
/>;
}
function EventInspector({ event, note, acting, actionError, editable, onNote, onAction, onClose, sheet = false }: { event?: AlertEvent; note: string; acting: boolean; actionError?: string; editable: boolean; onNote: (value: string) => void; onAction: (action: 'acknowledge' | 'close' | 'ignore') => void; onClose: () => void; sheet?: boolean }) {
if (!event) return <Card className="v2-alert-inspector" bodyStyle={{ padding: 0 }}><Empty className="v2-alert-inspector-empty" image={<IconAlarm />} title="选择告警事件" description="查看触发证据、状态时间线和处置动作。" /></Card>;
return <Card className={`v2-alert-inspector${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}><WorkspacePanelHeader className="v2-alert-inspector-heading" title={event.ruleName} actions={<><span className="v2-alert-inspector-status"><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span>{sheet ? null : <Button className="v2-alert-inspector-close" theme="borderless" icon={<IconClose />} aria-label="关闭告警详情" onClick={onClose} />}</>} />
<div className="v2-alert-inspector-content">
<Card className="v2-alert-detail-card" title={<strong></strong>} headerLine bodyStyle={{ padding: 0 }}>
<Descriptions className="v2-alert-descriptions" align="left" size="small" data={[
{ key: '事件 ID', value: <code>{event.id}</code> },
{ key: '规则 / 版本', value: `${event.ruleId} / v${event.ruleVersion}` },
{ key: '车辆 / VIN', value: `${event.plate || '—'} / ${event.vin}` },
{ key: '触发时间', value: formatAlertTime(event.triggeredAt) },
{ key: '恢复时间', value: formatAlertTime(event.recoveredAt) }
]} />
</Card>
<Card className="v2-alert-detail-card v2-alert-evidence-card" title={<strong></strong>} headerLine bodyStyle={{ padding: 0 }}>
<div className="v2-alert-evidence"><Card className="v2-alert-evidence-value is-trigger"><small></small><strong>{alertValue(event)}</strong></Card><Tag color="grey" type="light" size="small">VS</Tag><Card className="v2-alert-evidence-value"><small></small><strong>{thresholdText(event)}</strong></Card></div>
<Descriptions className="v2-alert-descriptions" align="left" size="small" data={[
{ key: '来源事件 ID', value: event.sourceEventId || '—' },
{ key: '协议', value: <Tag color="blue" type="light" size="small">{event.protocol || '—'}</Tag> },
{ key: '事件 / 接收', value: `${formatAlertTime(event.eventAt)} / ${formatAlertTime(event.receivedAt)}` }
]} />
</Card>
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<strong></strong>} headerLine>
{event.actions?.length ? <Timeline className="v2-alert-timeline" aria-label="告警处理进度">{event.actions.map((item, index) => <Timeline.Item key={item.id} type={index === event.actions!.length - 1 ? 'ongoing' : 'default'} time={<span>{item.actor} · {formatAlertTime(item.createdAt)}</span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}</Timeline> : <Empty className="v2-alert-timeline-empty" title="暂无处理记录" description="事件被确认、关闭或忽略后,将在这里形成审计履历。" />}
</Card>
<Card className="v2-alert-detail-card v2-alert-disposition-card" title={<strong></strong>} headerLine>
{editable ? <><TextArea maxCount={200} autosize={{ minRows: 2, maxRows: 5 }} placeholder="请输入处置说明(选填)" value={note} onChange={onNote} />{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}<div className="v2-alert-actions"><Button theme="solid" className="is-primary" disabled={acting || !canAct(event.status, 'acknowledge')} onClick={() => onAction('acknowledge')}></Button><Button theme="light" disabled={acting || !canAct(event.status, 'close')} onClick={() => onAction('close')}></Button><Button theme="borderless" disabled={acting || !canAct(event.status, 'ignore')} onClick={() => onAction('ignore')}></Button></div></> : <p className="v2-role-notice"></p>}
</Card>
</div>
<nav className="v2-alert-links"><Link to={`/vehicles/${encodeURIComponent(event.vin)}`}></Link><Link to={`/tracks?vin=${encodeURIComponent(event.vin)}`}></Link><Link to={`/history?vin=${encodeURIComponent(event.vin)}`}></Link></nav>
</aside>;
</Card>;
}
function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, editable, onTab }: { filters: Filters; draft: Filters; setDraft: (next: Filters) => void; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) {
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selection, setSelection] = useState<{ scope: string; id: string }>(); const [note, setNote] = useState(''); const queryClient = useQueryClient();
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(20);
const [selection, setSelection] = useState<{ scope: string; id: string }>();
const [note, setNote] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches);
useEffect(() => { const media = window.matchMedia('(max-width: 680px)'); const update = () => setMobileLayout(media.matches); media.addEventListener('change', update); update(); return () => media.removeEventListener('change', update); }, []);
const [advancedOpen, setAdvancedOpen] = useState(false);
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]);
const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]);
const eventScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]);
@@ -51,18 +151,104 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
const events = useQuery<Page<AlertEvent>>({ queryKey: ['alert-events-v2', eventScope, limit, offset], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: retainPreviousPageWithinScope<Page<AlertEvent>>(eventScope), staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime });
const rows = events.data?.items ?? [];
const selectedID = selection?.scope === eventScope ? selection.id : '';
useEffect(() => { if (!mobileLayout && rows.length && !rows.some((item) => item.id === selectedID)) setSelection({ scope: eventScope, id: rows[0].id }); }, [eventScope, mobileLayout, rows, selectedID]);
const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: ({ signal }) => api.alertEventV2(selectedID, signal), enabled: Boolean(selectedID), staleTime: 3_000, gcTime: QUERY_MEMORY.summaryGcTime });
const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } });
const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); setFiltersCollapsed(true); };
const closeDetail = () => { setSelection(undefined); setNote(''); };
const selectedEvent = detail.data ?? rows.find((row) => row.id === selectedID);
useSideSheetA11y(advancedOpen, '.v2-alert-filter-sidesheet', 'v2-alert-advanced-filters', '告警高级筛选', '关闭告警高级筛选');
useSideSheetA11y(mobileLayout && Boolean(selectedID), '.v2-alert-detail-sidesheet', 'v2-alert-detail', '告警事件详情', '关闭告警详情');
const applyDraft = () => {
setFilters(draft);
setOffset(0);
setFiltersCollapsed(true);
};
const submit = (e: FormEvent) => { e.preventDefault(); applyDraft(); };
const resetFilters = () => {
setDraft(EMPTY_FILTERS);
setFilters(EMPTY_FILTERS);
setOffset(0);
setAdvancedOpen(false);
};
const quickStatus = (status: string) => { const next = { ...filters, status }; setDraft(next); setFilters(next); setOffset(0); };
const totalPages = Math.max(1, Math.ceil((events.data?.total ?? 0) / limit)); const page = Math.floor(offset / limit) + 1; const sums = summary.data;
const totalPages = Math.max(1, Math.ceil((events.data?.total ?? 0) / limit));
const page = Math.floor(offset / limit) + 1;
const sums = summary.data;
const activeFilterCount = Object.values(filters).filter(Boolean).length;
return <><button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{activeFilterCount ? `已启用 ${activeFilterCount}` : '全部告警'}</small></span><em>{filtersCollapsed ? '展开' : '收起'}</em></button><form className={`v2-alert-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label><span></span><div><IconSearch /><input value={draft.keyword} onChange={(e) => setDraft({ ...draft, keyword: e.target.value })} placeholder="车牌 / VIN / 规则名称" /></div></label><label><span></span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value })}><option value=""></option><option value="critical"></option><option value="major"></option><option value="minor"></option></select></label><label><span></span><select value={draft.status} onChange={(e) => setDraft({ ...draft, status: e.target.value })}><option value=""></option>{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label><span></span><select value={draft.ruleId} onChange={(e) => setDraft({ ...draft, ruleId: e.target.value })}><option value=""></option>{rules.map((rule) => <option key={rule.id} value={rule.id}>{rule.name}</option>)}</select></label><label><span></span><select value={draft.protocol} onChange={(e) => setDraft({ ...draft, protocol: e.target.value })}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(e) => setDraft({ ...draft, dateFrom: e.target.value })} /></label><label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(e) => setDraft({ ...draft, dateTo: e.target.value })} /></label><button className="v2-primary-button"></button><button className="v2-secondary-button" type="button" onClick={() => { setDraft(EMPTY_FILTERS); setFilters(EMPTY_FILTERS); setOffset(0); }}></button></form>
const advancedFilterCount = [filters.ruleId, filters.protocol, filters.dateFrom, filters.dateTo].filter(Boolean).length;
const inspector = <EventInspector event={selectedEvent} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} onClose={closeDetail} />;
return <>
<MobileFilterToggle summary={activeFilterCount ? `已启用 ${activeFilterCount}` : '全部告警'} expanded={!filtersCollapsed} onToggle={() => setFiltersCollapsed((value) => !value)} />
<Card className={`v2-alert-filter-card${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}>
<form className="v2-alert-filter v2-alert-filter-primary" onSubmit={submit}>
<label><span></span><Input prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN / 规则名称" /></label>
<label><span id="alert-severity-label"></span><Select aria-labelledby="alert-severity-label" value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) })} optionList={[{ value: '', label: '全部' }, { value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
<label><span id="alert-status-label"></span><Select aria-labelledby="alert-status-label" value={draft.status} onChange={(value) => setDraft({ ...draft, status: String(value) })} optionList={[{ value: '', label: '全部' }, ...Object.entries(statusLabels).map(([value, label]) => ({ value, label }))]} /></label>
<Button className="v2-alert-more-filter" theme="light" htmlType="button" icon={<IconFilter />} aria-haspopup="dialog" aria-controls="v2-alert-advanced-filters" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen(true)}>{advancedFilterCount ? ` · ${advancedFilterCount}` : ''}</Button>
<Button className="v2-primary-button" theme="solid" htmlType="submit"></Button>
<Button className="v2-secondary-button" theme="light" htmlType="button" onClick={resetFilters}></Button>
</form>
</Card>
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '告警汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
<section className="v2-alert-kpis">{[['活跃告警', sums?.active, '', ''], ['未处理', sums?.unprocessed, 'unprocessed', 'unprocessed'], ['处理中', sums?.processing, 'processing', 'processing'], ['已恢复', sums?.recovered, 'recovered', 'recovered'], ['已关闭', sums?.closed, 'closed', 'closed'], ['已忽略', sums?.ignored, 'ignored', 'ignored'], ['未读通知', unread, 'notice', 'notice']].map(([label, value, tone, status]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={() => status === 'notice' ? onTab('notifications') : quickStatus(String(status))}><small>{label as string}</small><strong>{Number(value ?? 0).toLocaleString('zh-CN')}</strong></button>)}</section>
<Card className="v2-alert-kpis-card" bodyStyle={{ padding: 0 }}>
<section className="v2-alert-kpis">{[
{ label: '活跃告警', value: sums?.active, tone: 'active', status: '' },
{ label: '未处理', value: sums?.unprocessed, tone: 'unprocessed', status: 'unprocessed' },
{ label: '处理中', value: sums?.processing, tone: 'processing', status: 'processing' },
{ label: '未读通知', value: unread, tone: 'notice', status: 'notice' }
].map((item) => {
const value = Number(item.value ?? 0).toLocaleString('zh-CN');
return <MetricActionButton key={item.label} label={item.label} value={value} tone={item.tone} active={item.status !== 'notice' && filters.status === item.status} ariaLabel={item.status === 'notice' ? `查看未读通知,共 ${value}` : `筛选${item.label},共 ${value}`} onClick={() => item.status === 'notice' ? onTab('notifications') : quickStatus(item.status)} />;
})}</section>
<nav className="v2-alert-secondary-states" aria-label="已结束告警状态">{
[
{ label: '已恢复', value: sums?.recovered, status: 'recovered' },
{ label: '已关闭', value: sums?.closed, status: 'closed' },
{ label: '已忽略', value: sums?.ignored, status: 'ignored' }
].map((item) => <Button key={item.status} theme="borderless" type="tertiary" aria-pressed={filters.status === item.status} onClick={() => quickStatus(item.status)}><span>{item.label}</span><b>{Number(item.value ?? 0).toLocaleString('zh-CN')}</b></Button>)
}</nav>
</Card>
{events.isError ? <InlineError message={events.error instanceof Error ? events.error.message : '告警事件读取失败'} onRetry={() => events.refetch()} /> : null}
<div className="v2-alert-workspace"><section className="v2-alert-table-card"><header><strong></strong><div><span> {(events.data?.total ?? 0).toLocaleString('zh-CN')} </span><button onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])}><IconRefresh /></button></div></header><div className="v2-alert-table-scroll"><table><thead><tr><th /><th></th><th> / VIN</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody><AlertRows rows={rows} selectedID={selectedID} onSelect={(id) => setSelection({ scope: eventScope, id })} /></tbody></table><div className="v2-alert-mobile-list">{rows.map((event) => <button type="button" key={event.id} className={selectedID === event.id ? 'is-selected' : ''} onClick={() => setSelection({ scope: eventScope, id: event.id })}><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></button>)}</div>{events.isFetching ? <div className="v2-alert-loading"><i /></div> : null}{!events.isFetching && !rows.length ? <div className="v2-alert-empty"></div> : null}</div><footer><span> {page} / {totalPages} </span><div><button disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option></select></div></footer></section><EventInspector event={detail.data ?? rows.find((row) => row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} /></div></>;
<div className={`v2-alert-workspace${selectedID && !mobileLayout ? ' is-inspector-open' : ''}`}>
<Card className="v2-alert-table-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader title="告警事件" meta={`${(events.data?.total ?? 0).toLocaleString('zh-CN')}`} actions={<Button theme="borderless" icon={<IconRefresh />} onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])}></Button>} />
<div className="v2-alert-table-scroll">
{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 })} />}
{events.isFetching ? <div className="v2-alert-loading" role="status"><Spin size="middle" tip="正在更新事件…" /></div> : null}
{!events.isFetching && !rows.length ? <Empty className="v2-alert-empty" title="当前筛选条件没有告警事件" description="调整车辆、严重程度、状态或时间范围后重试。" /> : null}
</div>
<footer><TablePagination page={page} totalPages={totalPages} info={`${(events.data?.total ?? 0).toLocaleString('zh-CN')}`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }]} /></footer>
</Card>
{!mobileLayout && selectedID ? inspector : null}
</div>
<SideSheet
className="v2-alert-filter-sidesheet"
visible={advancedOpen}
aria-label="告警高级筛选"
width={430}
title={<div className="v2-alert-sheet-title"><strong></strong><span></span></div>}
onCancel={() => setAdvancedOpen(false)}
footer={<div className="v2-alert-sheet-footer"><Button theme="light" onClick={() => setDraft({ ...draft, ruleId: '', protocol: '', dateFrom: '', dateTo: '' })}></Button><Button theme="solid" onClick={() => { applyDraft(); setAdvancedOpen(false); }}></Button></div>}
>
<div className="v2-alert-advanced-filter">
<label><span id="alert-rule-label"></span><Select aria-labelledby="alert-rule-label" value={draft.ruleId} onChange={(value) => setDraft({ ...draft, ruleId: String(value) })} optionList={[{ value: '', label: '全部规则' }, ...rules.map((rule) => ({ value: rule.id, label: rule.name }))]} /></label>
<label><span id="alert-protocol-label"></span><Select aria-labelledby="alert-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
<label><span></span><Input aria-label="告警起始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft({ ...draft, dateFrom: value })} /></label>
<label><span></span><Input aria-label="告警结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft({ ...draft, dateTo: value })} /></label>
</div>
</SideSheet>
<SideSheet
className="v2-alert-detail-sidesheet"
visible={mobileLayout && Boolean(selectedID)}
aria-label="告警事件详情"
width="100%"
title={<div className="v2-alert-sheet-title"><strong></strong><span>{selectedEvent ? `${selectedEvent.plate || selectedEvent.vin} · ${selectedEvent.ruleName}` : '证据、状态与处置履历'}</span></div>}
onCancel={closeDetail}
>
{mobileLayout && selectedID ? <EventInspector event={selectedEvent} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} onClose={closeDetail} sheet /> : null}
</SideSheet>
</>;
}
function emptyRule(): AlertRuleInput { return { id: '', name: '', description: '', severity: 'major', valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 100, durationSec: 60, recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600, scopeProtocols: [], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [], notificationChannels: ['in_app'], enabled: true, version: 0 }; }
@@ -88,31 +274,86 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label]));
const setList = (key: 'scopeProtocols' | 'scopeVins' | 'scopeOems' | 'scopeModels' | 'scopeCompanies', value: string) => setDraft({ ...draft, [key]: value.split(',').map((item) => item.trim()).filter(Boolean) });
const applyTemplate = (template: RuleTemplate) => {
setSelectedID('__new__');
setDraft({ ...template.draft, scopeProtocols: [...template.draft.scopeProtocols], notificationChannels: [...template.draft.notificationChannels] });
};
const severityColor = (severity: AlertRule['severity']) => severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'amber';
return <div className="v2-alert-rules">
<section className="v2-alert-rule-list"><header><strong></strong><button onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}>+ </button></header>{rules.map((rule) => <button className={selectedID === rule.id ? 'is-selected' : ''} key={rule.id} onClick={() => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }}><i className={`is-${rule.severity}`} /><span><strong>{rule.name}</strong><small>{ruleCondition(rule, catalogLabels)} · v{rule.version}</small></span><em className={rule.enabled ? 'is-enabled' : ''}>{rule.enabled ? '已启用' : '已停用'}</em></button>)}</section>
<form className="v2-alert-rule-editor" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
<header><div><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span></span></div>{draft.version ? <button type="button" disabled={toggle.isPending || save.isPending} onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'}</button> : null}</header>
<div className="v2-rule-form-grid">
<label><span></span><input required maxLength={80} value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /></label>
<label><span></span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value as AlertRuleInput['severity'] })}><option value="critical"></option><option value="major"></option><option value="minor"></option></select></label>
<label><span></span><select value={draft.valueType} onChange={(e) => { const valueType = e.target.value as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }}><option value="numeric"></option><option value="boolean"></option></select></label>
<label><span></span><select required disabled={!availableMetrics.length} value={draft.metric} onChange={(e) => setDraft({ ...draft, metric: e.target.value })}>{availableMetrics.map((metric) => <option key={metric.key} value={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</option>)}</select></label>
<label><span></span><select value={draft.operator} onChange={(e) => setDraft({ ...draft, operator: e.target.value, durationSec: e.target.value === 'changed' ? 0 : draft.durationSec })}>{operators.map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
{draft.operator === 'changed' ? <label><span></span><input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span></span><select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(e) => setDraft({ ...draft, booleanThreshold: e.target.value === 'true' })}><option value="true"></option><option value="false"></option></select></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><input type="number" step="0.1" value={draft.threshold} onChange={(e) => setDraft({ ...draft, threshold: Number(e.target.value) })} /></label>}
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span></span><input type="number" step="0.1" value={draft.thresholdHigh} onChange={(e) => setDraft({ ...draft, thresholdHigh: Number(e.target.value) })} /></label> : null}
<label><span></span><input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={draft.durationSec} onChange={(e) => setDraft({ ...draft, durationSec: Number(e.target.value) })} /></label>
<label><span></span><select value={draft.recoveryOperator} onChange={(e) => setDraft({ ...draft, recoveryOperator: e.target.value })}><option value=""></option>{['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
<label><span></span><input type="number" step="0.1" value={draft.recoveryThreshold} onChange={(e) => setDraft({ ...draft, recoveryThreshold: Number(e.target.value) })} /></label>
<label><span></span><input type="number" min="0" max="604800" value={draft.repeatIntervalSec} onChange={(e) => setDraft({ ...draft, repeatIntervalSec: Number(e.target.value) })} /></label>
<label className="is-wide"><span></span><input value={draft.scopeProtocols.join(',')} onChange={(e) => setList('scopeProtocols', e.target.value)} /></label>
<label className="is-wide"><span> VIN </span><input value={draft.scopeVins.join(',')} onChange={(e) => setList('scopeVins', e.target.value)} /></label>
<label className="is-wide"><span></span><input value={draft.scopeOems.join(',')} onChange={(e) => setList('scopeOems', e.target.value)} /></label>
<label className="is-wide"><span></span><input value={draft.scopeModels.join(',')} onChange={(e) => setList('scopeModels', e.target.value)} /></label>
<label className="is-wide"><span></span><input value={draft.scopeCompanies.join(',')} onChange={(e) => setList('scopeCompanies', e.target.value)} /></label>
<label className="is-wide"><span></span><textarea maxLength={500} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} /></label>
<Card className="v2-alert-rule-list" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-alert-rule-list-heading"
title="规则配置"
description={rules.length ? `${rules.length} 条可审计规则` : '尚未创建规则'}
actions={<Button theme="light" type="primary" icon={<IconPlus />} onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}></Button>}
/>
<div className="v2-alert-rule-items">
{rules.map((rule) => <Button
className={`v2-alert-rule-item${selectedID === rule.id ? ' is-selected' : ''}`}
key={rule.id}
theme="borderless"
type="tertiary"
aria-pressed={selectedID === rule.id}
onClick={() => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }}
>
<span className={`v2-alert-rule-dot is-${rule.severity}`} aria-hidden="true" />
<span className="v2-alert-rule-copy">
<span><strong>{rule.name}</strong><Tag size="small" color={severityColor(rule.severity)} type="light">{severityLabels[rule.severity]}</Tag></span>
<small>{ruleCondition(rule, catalogLabels)} · v{rule.version}</small>
</span>
<Tag className="v2-alert-rule-state" size="small" color={rule.enabled ? 'green' : 'grey'} type="light">{rule.enabled ? '已启用' : '已停用'}</Tag>
</Button>)}
{!rules.length ? <Empty className="v2-alert-rule-empty" title="暂无告警规则" description="从右侧模板快速创建,或新建一条自定义规则。" /> : null}
</div>
<footer><div><b></b><span> / </span></div>{mutationError ? <em>{mutationError.message}</em> : null}<button className="v2-primary-button" disabled={save.isPending || toggle.isPending}>{save.isPending ? '保存中' : '保存规则'}</button></footer>
</form>
</Card>
<Card className="v2-alert-rule-editor" bodyStyle={{ padding: 0 }}>
<form className="v2-alert-rule-editor-form" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
<WorkspacePanelHeader
className="v2-alert-rule-editor-heading"
title={draft.version ? '编辑规则' : '新建规则'}
description="数值、状态与主数据范围均纳入版本审计"
actions={draft.version ? <Button htmlType="button" theme="light" disabled={toggle.isPending || save.isPending} onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'}</Button> : null}
/>
<section className="v2-alert-rule-templates" aria-label="告警规则模板">
<div><strong></strong><span></span></div>
<nav>{RULE_TEMPLATES.map((template) => {
const available = metrics.some((metric) => metric.key === template.draft.metric && metric.alertable);
return <Button
className="v2-alert-template-card"
key={template.id}
theme="borderless"
type="tertiary"
disabled={!available}
onClick={() => applyTemplate(template)}
>
<span className="v2-alert-template-icon"><IconAlarm /></span>
<span className="v2-alert-template-copy"><b>{template.title}</b><small>{available ? template.summary : '当前字段目录尚未启用此指标'}</small></span>
<Tag size="small" color={available ? 'blue' : 'grey'} type="light">{available ? (template.draft.scopeProtocols.join(' / ') || '全部协议') : '不可用'}</Tag>
</Button>;
})}</nav>
</section>
<div className="v2-rule-form-grid">
<label><span></span><Input required maxLength={80} value={draft.name} onChange={(value) => setDraft({ ...draft, name: value })} /></label>
<label><span></span><Select value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) as AlertRuleInput['severity'] })} optionList={[{ value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
<label><span></span><Select value={draft.valueType} onChange={(value) => { const valueType = String(value) as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }} optionList={[{ value: 'numeric', label: '数值' }, { value: 'boolean', label: '布尔' }]} /></label>
<label><span></span><Select disabled={!availableMetrics.length} value={draft.metric} onChange={(value) => setDraft({ ...draft, metric: String(value) })} optionList={availableMetrics.map((metric) => ({ value: metric.key, label: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}` }))} /></label>
<label><span></span><Select value={draft.operator} onChange={(value) => { const operator = String(value); setDraft({ ...draft, operator, durationSec: operator === 'changed' ? 0 : draft.durationSec }); }} optionList={operators.map((value) => ({ value, label: operatorLabels[value] }))} /></label>
{draft.operator === 'changed' ? <label><span></span><Input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span></span><Select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(value) => setDraft({ ...draft, booleanThreshold: value === 'true' })} optionList={[{ value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><Input required type="number" step="0.01" placeholder={draft.metric === 'hydrogen_concentration_percent' ? '请按厂家标准填写百分比' : undefined} value={Number.isFinite(draft.threshold) ? String(draft.threshold) : ''} onChange={(value) => setDraft({ ...draft, threshold: value === '' ? Number.NaN : Number(value) })} /></label>}
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span></span><Input type="number" step="0.1" value={String(draft.thresholdHigh)} onChange={(value) => setDraft({ ...draft, thresholdHigh: Number(value) })} /></label> : null}
<label><span></span><Input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={String(draft.durationSec)} onChange={(value) => setDraft({ ...draft, durationSec: Number(value) })} /></label>
<label><span></span><Select value={draft.recoveryOperator} onChange={(value) => setDraft({ ...draft, recoveryOperator: String(value) })} optionList={[{ value: '', label: '未配置' }, ...['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => ({ value, label: operatorLabels[value] }))]} /></label>
<label><span></span><Input type="number" step="0.1" value={String(draft.recoveryThreshold)} onChange={(value) => setDraft({ ...draft, recoveryThreshold: Number(value) })} /></label>
<label><span></span><Input type="number" min="0" max="604800" value={String(draft.repeatIntervalSec)} onChange={(value) => setDraft({ ...draft, repeatIntervalSec: Number(value) })} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeProtocols.join(',')} onChange={(value) => setList('scopeProtocols', value)} /></label>
<label className="is-wide"><span> VIN </span><Input value={draft.scopeVins.join(',')} onChange={(value) => setList('scopeVins', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeOems.join(',')} onChange={(value) => setList('scopeOems', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeModels.join(',')} onChange={(value) => setList('scopeModels', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeCompanies.join(',')} onChange={(value) => setList('scopeCompanies', value)} /></label>
<label className="is-wide"><span></span><TextArea maxCount={500} autosize={{ minRows: 3, maxRows: 7 }} value={draft.description} onChange={(value) => setDraft({ ...draft, description: value })} /></label>
</div>
<footer><div><b></b><span> / </span></div>{mutationError ? <em>{mutationError.message}</em> : null}<Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={save.isPending || toggle.isPending}>{save.isPending ? '保存中' : '保存规则'}</Button></footer>
</form>
</Card>
</div>;
}
@@ -120,13 +361,25 @@ type NotificationsQuery = {
data?: Page<AlertNotification>;
error: Error | null;
isError: boolean;
isPending: boolean;
refetch: () => unknown;
};
function NotificationsWorkspace({ editable, notifications }: { editable: boolean; notifications: NotificationsQuery }) {
const queryClient = useQueryClient();
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
return <div className="v2-alert-notifications"><header><div><strong></strong><span></span></div>{editable ? <button disabled={read.isPending || !notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}>{read.isPending ? '正在更新' : '全部标为已读'}</button> : <span className="v2-role-badge"></span>}</header>{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button disabled={read.isPending} onClick={() => read.mutate([item.id])}>{read.isPending ? '更新中' : '标为已读'}</button> : null}</article>)}</div><footer><b></b><span>SMS / </span><span>Email / </span><span>WeCom / </span></footer></div>;
const items = notifications.data?.items ?? [];
return <Card className="v2-alert-notifications" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="站内通知" description="仅站内通道具备真实送达与已读状态" meta={`${items.length.toLocaleString('zh-CN')}`} actions={editable ? <Button theme="light" disabled={read.isPending || !items.some((item) => !item.read)} onClick={() => read.mutate(items.filter((item) => !item.read).map((item) => item.id))}>{read.isPending ? '正在更新' : '全部标为已读'}</Button> : <span className="v2-role-badge"></span>} />{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}
<div className="v2-alert-notification-list">{notifications.isPending ? <div className="v2-alert-notification-loading"><Spin size="middle" tip="正在读取站内通知" /></div> : items.length ? <CardGroup className="v2-alert-notification-cards" type="grid" spacing={0}>{items.map((item) => <Card className={`v2-alert-notification-card${item.read ? ' is-read' : ' is-unread'}`} key={item.id} title={<span className="v2-alert-notification-title"><SeverityTag severity={item.severity} /><span title={item.title}>{item.title}</span></span>} headerExtraContent={<Tag color={item.read ? 'grey' : 'orange'} type="light" size="small">{item.read ? '已读' : '未读'}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
<p className="v2-alert-notification-content" title={item.content}>{item.content}</p>
<footer><Typography.Text type="tertiary">{formatAlertTime(item.createdAt)}</Typography.Text>{editable && !item.read ? <Button theme="borderless" disabled={read.isPending} onClick={() => read.mutate([item.id])}>{read.isPending ? '更新中' : '标为已读'}</Button> : null}</footer>
</Card>)}</CardGroup> : !notifications.isError ? <Empty className="v2-alert-notification-empty" image={<IconBell />} title="暂无站内通知" description="告警触发或状态变更后,通知会在这里形成可追溯记录。" /> : null}</div>
<Card className="v2-alert-channel-card" title={<strong></strong>} headerLine bodyStyle={{ padding: 0 }}><Descriptions className="v2-alert-channel-descriptions" align="left" size="small" data={[
{ key: '短信SMS', value: <Tag color="grey" type="light" size="small"> · </Tag> },
{ key: '邮件Email', value: <Tag color="grey" type="light" size="small"> · </Tag> },
{ key: '企业微信WeCom', value: <Tag color="grey" type="light" size="small"> · </Tag> }
]} /></Card>
</Card>;
}
export default function AlertsPage() {
@@ -144,5 +397,6 @@ export default function AlertsPage() {
: notices.data?.total ?? 0;
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 }); };
return <div className="v2-alert-page"><header className="v2-alert-heading"><div><h2></h2><p></p></div></header><nav className="v2-alert-tabs"><button className={activeTab === 'events' ? 'is-active' : ''} onClick={() => setTab('events')}><IconAlarm /></button>{admin ? <button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setTab('rules')}></button> : null}<button className={activeTab === 'notifications' ? 'is-active' : ''} onClick={() => setTab('notifications')}><IconBell />{unread > 0 ? <b>{unread}</b> : null}</button></nav>{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>;
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"><PageHeader title="告警中心" description="统一监控车辆运行异常,串联触发证据、处理进度与通知状态。" status={unread ? `${unread} 条未读` : '通知已读'} statusColor={unread ? 'orange' : 'green'} meta={<Typography.Text type="tertiary">{operator ? '可处置事件' : '只读查看'}</Typography.Text>} /><SegmentedTabs className="v2-alert-tabs" variant="filled" ariaLabel="告警中心分类" value={activeTab} items={tabs} onChange={setTab} />{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>;
}

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { HistoryDataResponse, HistorySeriesResponse } from '../../api/types';
@@ -16,13 +16,16 @@ const mocks = vi.hoisted(() => ({
downloadHistoryExport: vi.fn()
}));
const auth = vi.hoisted(() => ({ role: 'admin' }));
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: auth.role } }) }));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
afterEach(() => {
cleanup();
auth.role = 'admin';
layout.mobile = false;
Object.values(mocks).forEach((mock) => mock.mockReset());
});
@@ -83,9 +86,38 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang
? Promise.resolve(historySeries('OLDVIN', '旧车牌', 'old-as-of'))
: new Promise<HistorySeriesResponse>((resolve) => { resolveNewSeries = resolve; }));
renderPage();
const view = renderPage();
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
expect(screen.queryByText('OLDVIN-evidence')).not.toBeInTheDocument();
for (const className of ['v2-history-filter-card', 'v2-history-metrics-card', 'v2-history-summary', 'v2-history-trend', 'v2-history-table-card']) {
expect(view.container.querySelector(`.${className}.semi-card`)).toBeInTheDocument();
}
expect(view.container.querySelector('.v2-history-evidence')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-workspace')).not.toHaveClass('is-inspector-open');
expect(screen.getByRole('heading', { level: 5, name: '聚合趋势' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-trend')).toHaveClass('is-collapsed');
expect(screen.getByRole('button', { name: /展开趋势/ })).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('heading', { level: 5, name: '导出任务' })).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-data-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-table-scroll > table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-mobile-list')).not.toBeInTheDocument();
const desktopHistoryRow = screen.getByTestId('history-row-OLDVIN-row');
expect(desktopHistoryRow).toHaveAttribute('role', 'button');
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.keyDown(desktopHistoryRow, { key: 'Enter' });
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByText('OLDVIN-evidence')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-evidence-descriptions.semi-descriptions')).toBeInTheDocument();
expect(view.container.querySelector('.v2-evidence-values.semi-descriptions')).toBeInTheDocument();
expect(view.container.querySelector('.v2-history-quality-tag.semi-tag')).toHaveTextContent('正常');
expect(view.container.querySelector('.v2-history-workspace')).toHaveClass('is-inspector-open');
fireEvent.click(screen.getByRole('button', { name: '关闭数据详情' }));
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'false');
expect(view.container.querySelector('.v2-history-workspace')).not.toHaveClass('is-inspector-open');
fireEvent.click(desktopHistoryRow);
expect(desktopHistoryRow).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: /展开趋势/ }));
await waitFor(() => expect(mocks.historySeries).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN多台用逗号分隔'), { target: { value: 'NEWVIN' } });
fireEvent.click(screen.getByRole('button', { name: '查询' }));
@@ -100,10 +132,54 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang
resolveNewSeries(historySeries('NEWVIN', '新车牌', 'new-as-of'));
});
expect((await screen.findAllByText('新车牌')).length).toBeGreaterThan(0);
expect(screen.queryByText('NEWVIN-evidence')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('history-row-NEWVIN-row'));
expect(await screen.findByText('NEWVIN-evidence')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
});
test('renders only selectable Semi history cards on mobile', async () => {
layout.mobile = true;
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('MOBILEVIN', '粤A移动历史', 'mobile-as-of'));
mocks.historySeries.mockResolvedValue(historySeries('MOBILEVIN', '粤A移动历史', 'mobile-as-of'));
mocks.historyExports.mockResolvedValue([]);
const view = renderPage('/history?keywords=MOBILEVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00');
const action = await screen.findByRole('button', { name: '查看 粤A移动历史 2026-07-16 04:00:00 数据详情' });
expect(action).toHaveClass('semi-button', 'v2-history-mobile-action');
expect(action.closest('.semi-card')).toHaveClass('v2-history-mobile-card');
expect(view.container.querySelector('.v2-history-data-table')).not.toBeInTheDocument();
expect(action).toHaveAttribute('aria-pressed', 'false');
expect(action).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('MOBILEVIN-evidence')).not.toBeInTheDocument();
expect((mocks.historyData.mock.calls[0]?.[0] as URLSearchParams).get('limit')).toBe('20');
expect(screen.queryByLabelText('每页数量')).not.toBeInTheDocument();
expect(screen.queryByLabelText('表格密度')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-history-side')).not.toBeInTheDocument();
expect(mocks.historyExports).not.toHaveBeenCalled();
fireEvent.click(action);
expect(action).toHaveAttribute('aria-pressed', 'true');
expect(action).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByRole('dialog', { name: '历史数据详情' })).toBeInTheDocument();
expect(await screen.findByText('MOBILEVIN-evidence')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭历史数据详情' }));
expect(action).toHaveAttribute('aria-pressed', 'false');
expect(action).toHaveAttribute('aria-expanded', 'false');
expect(document.body.style.overflow).not.toBe('hidden');
expect(screen.queryByText('MOBILEVIN-evidence')).not.toBeInTheDocument();
const exportJobsButton = screen.getByRole('button', { name: '查看导出任务' });
fireEvent.click(exportJobsButton);
expect(await screen.findByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument();
await waitFor(() => expect(mocks.historyExports).toHaveBeenCalledTimes(1));
expect(screen.getByText('暂无导出任务')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭历史数据导出任务' }));
expect(exportJobsButton).toHaveAttribute('aria-expanded', 'false');
expect(document.body.style.overflow).not.toBe('hidden');
expect(screen.queryByText('暂无导出任务')).not.toBeInTheDocument();
});
test('releases export job state immediately after leaving the history page', async () => {
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
@@ -111,6 +187,7 @@ test('releases export job state immediately after leaving the history page', asy
mocks.historyExports.mockResolvedValue([{ id: 'export-running', name: '运行中导出', status: 'running', progress: 30, format: 'csv', category: 'location', keywords: ['OLDVIN'], rowCount: 0, totalRows: 100, processedRows: 30, fileSizeBytes: 0, createdAt: '2026-07-16T04:00:00Z', updatedAt: '2026-07-16T04:00:01Z', evidence: 'test export' }]);
const { client, unmount } = renderPage();
fireEvent.click(await screen.findByRole('button', { name: '查看导出任务' }));
expect(await screen.findByText('运行中导出')).toBeInTheDocument();
expect(client.getQueryCache().find({ queryKey: ['history-exports'] })).toBeDefined();
@@ -126,20 +203,30 @@ test('opens a working column visibility panel and preserves non-hideable identit
mocks.historyData.mockResolvedValue(data);
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historyExports.mockResolvedValue([]);
renderPage();
const view = renderPage();
expect(await screen.findByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: 'SOC (%)' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '列设置' }));
const metricScroll = view.container.querySelector('.v2-history-metric-scroll');
const manageMetrics = screen.getByRole('button', { name: '管理字段' });
expect(metricScroll).toBeInTheDocument();
expect(metricScroll).toHaveAttribute('aria-label', '当前显示字段');
expect(metricScroll?.contains(screen.getByLabelText(/已选字段 速度/))).toBe(true);
expect(metricScroll?.contains(manageMetrics)).toBe(false);
expect(manageMetrics.parentElement).toHaveClass('v2-history-metrics');
const columnSettingsButton = screen.getByRole('button', { name: '列设置' });
fireEvent.click(columnSettingsButton);
expect(screen.getByRole('dialog', { name: '列显示设置' })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('搜索字段名 / 字段 key'), { target: { value: 'soc' } });
fireEvent.change(screen.getByPlaceholderText('搜索中文、状态含义或字段 key'), { target: { value: 'soc' } });
expect(screen.getByRole('checkbox', { name: /SOC/ })).toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: /速度/ })).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('搜索字段名 / 字段 key'), { target: { value: '' } });
fireEvent.change(screen.getByPlaceholderText('搜索中文、状态含义或字段 key'), { target: { value: '' } });
fireEvent.click(screen.getByRole('checkbox', { name: /速度/ }));
expect(screen.queryByRole('columnheader', { name: '速度 (km/h)' })).not.toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'VIN' })).toBeInTheDocument();
const historyTable = document.querySelector<HTMLElement>('.v2-history-table-scroll table');
expect(historyTable).toBeInTheDocument();
expect(within(historyTable!).getByRole('columnheader', { name: 'VIN' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '显示全部' }));
expect(screen.getByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
@@ -148,7 +235,8 @@ test('opens a working column visibility panel and preserves non-hideable identit
expect(screen.getByRole('columnheader', { name: '速度 (km/h)' })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: 'SOC (%)' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭列显示设置' }));
expect(screen.queryByRole('dialog', { name: '列显示设置' })).not.toBeInTheDocument();
expect(columnSettingsButton).toHaveAttribute('aria-expanded', 'false');
expect(document.body.style.overflow).not.toBe('hidden');
});
test('keeps history readable without mounting operator-only export requests for a viewer', async () => {
@@ -156,10 +244,11 @@ test('keeps history readable without mounting operator-only export requests for
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
renderPage();
const view = renderPage();
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
expect(screen.getByText('只读 · 导出需操作员权限')).toBeInTheDocument();
expect(screen.getByText('只读').closest('.semi-tag')).toHaveClass('v2-history-permission-tag');
expect(view.container.querySelector('.v2-history-toolbar-actions')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /创建导出/ })).not.toBeInTheDocument();
expect(screen.queryByText('导出任务')).not.toBeInTheDocument();
expect(mocks.historyExports).not.toHaveBeenCalled();
@@ -180,8 +269,9 @@ test('shows only the authenticated customer export workspace with owner and scop
}]);
renderPage();
fireEvent.click(await screen.findByRole('button', { name: '查看导出任务' }));
expect(await screen.findByText(/customer-a · 1 辆/)).toBeInTheDocument();
expect(screen.getByText('导出任务')).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /创建导出/ })).toBeInTheDocument();
expect(mocks.historyExports).toHaveBeenCalled();
});
@@ -199,11 +289,13 @@ test('uses selected chartable fields for trends and omits empty RAW evidence UI'
renderPage();
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
expect(mocks.historySeries).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /展开趋势/ }));
await waitFor(() => expect(mocks.historySeries).toHaveBeenCalled());
expect((mocks.historySeries.mock.calls[mocks.historySeries.mock.calls.length - 1]?.[0] as URLSearchParams).get('metrics')).toBe('speedKmh,totalMileageKm');
expect(screen.queryByText('RAW 证据')).not.toBeInTheDocument();
const mileageToggle = screen.getAllByTitle('点击取消显示').find((button) => button.textContent?.includes('总里程'));
const mileageToggle = screen.getAllByLabelText(/已选字段 .*点击取消显示/).find((button) => button.textContent?.includes('总里程'));
expect(mileageToggle).toBeDefined();
fireEvent.click(mileageToggle!);
await waitFor(() => expect((mocks.historySeries.mock.calls[mocks.historySeries.mock.calls.length - 1]?.[0] as URLSearchParams).get('metrics')).toBe('speedKmh'));

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
@@ -8,11 +9,20 @@ import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, for
import { downloadBlob } from '../domain/download';
import { InlineError } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { PageHeader } from '../shared/PageHeader';
import { TablePagination } from '../shared/TablePagination';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { detailTriggerRow } from '../shared/detailTriggerRow';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { usePlatformSession } from '../auth/AuthGate';
import { canOperate } from '../auth/session';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const DESKTOP_HISTORY_PAGE_SIZE = 50;
const MOBILE_HISTORY_PAGE_SIZE = 20;
const historyAxisTimeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
function historyQualityLabel(quality: string) {
@@ -22,20 +32,53 @@ function historyQualityLabel(quality: string) {
return quality || '未知';
}
function HistoryTrend({ response, category, loading, error, hasMetrics }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string; hasMetrics: boolean }) {
function HistoryQualityTag({ quality }: { quality: string }) {
const color = quality === 'normal' || quality === 'good' ? 'green' : quality === 'error' || quality === 'critical' ? 'red' : 'orange';
return <Tag className="v2-history-quality-tag" color={color} type="light" size="small">{historyQualityLabel(quality)}</Tag>;
}
function HistoryTrend({ response, category, loading, error, hasMetrics, expanded, onToggle }: {
response?: HistorySeriesResponse;
category: string;
loading: boolean;
error?: string;
hasMetrics: boolean;
expanded: boolean;
onToggle: () => void;
}) {
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
if (category !== 'location') return <section className="v2-history-trend"><header><strong></strong></header><div className="v2-history-chart-empty">{category === 'raw' ? '原始报文是离散证据,不生成可能误导的连续趋势;请使用明细与导出。' : '日里程按自然日展示,当前请使用明细表核对起止里程。'}</div></section>;
if (!hasMetrics) return <section className="v2-history-trend"><header><strong></strong></header><div className="v2-history-chart-empty"></div></section>;
const summary = response?.summary;
const coverage = summary?.expectedBucketCount ? Math.max(0, (summary.expectedBucketCount - summary.missingBucketCount) / summary.expectedBucketCount * 100) : 0;
return <section className="v2-history-trend"><header><strong></strong><div>{summary ? <><span>{formatSeriesGrain(summary.grainSeconds)}</span><span> {coverage.toFixed(1)}%</span><span>{summary.rawPointCount.toLocaleString('zh-CN')} </span></> : null}</div></header>
{error ? <div className="v2-history-chart-empty">{error}</div> : loading && !response ? <div className="v2-history-chart-empty"></div> : panels.length ? <div className="v2-history-trend-panels">{panels.map((panel) => <article key={panel.key}><header><strong>{panel.label}</strong><span>{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} </span></header><svg viewBox="0 0 800 116" role="img" aria-label={`${panel.label}按时间变化趋势`}>
const description = category === 'raw'
? '原始报文通过明细和导出核验证据'
: category === 'mileage'
? '日里程按自然日通过明细核对'
: '按查询时间窗汇总连续指标';
const header = <WorkspacePanelHeader
title="聚合趋势"
description={description}
meta={expanded && summary ? `${formatSeriesGrain(summary.grainSeconds)}粒度 · 覆盖 ${coverage.toFixed(1)}% · ${summary.rawPointCount.toLocaleString('zh-CN')} 原始点` : undefined}
actions={<Button
className={`v2-history-trend-toggle${expanded ? ' is-expanded' : ''}`}
theme="borderless"
type="tertiary"
size="small"
icon={<IconChevronRight />}
aria-expanded={expanded}
onClick={onToggle}
>{expanded ? '收起趋势' : '展开趋势'}</Button>}
/>;
if (!expanded) return <Card className="v2-history-trend is-collapsed" bodyStyle={{ padding: 0 }}>{header}</Card>;
if (category !== 'location') return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<Empty className="v2-history-chart-empty" title={category === 'raw' ? '原始报文不生成趋势' : '日里程按自然日展示'} description={category === 'raw' ? '离散报文请通过明细与导出核验证据。' : '请通过明细表核对每日起止里程。'} /></Card>;
if (!hasMetrics) return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<Empty className="v2-history-chart-empty" title="尚未选择趋势指标" description="选择“速度”或“总里程”后展示聚合趋势。" /></Card>;
return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}
{error ? <Empty className="v2-history-chart-empty is-error" title="趋势加载失败" description={error} /> : loading && !response ? <div className="v2-history-chart-loading"><Spin size="middle" tip="正在聚合时间序列…" /></div> : panels.length ? <div className="v2-history-trend-panels">{panels.map((panel) => <article key={panel.key}><header><strong>{panel.label}</strong><span>{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} </span></header><svg viewBox="0 0 800 116" role="img" aria-label={`${panel.label}按时间变化趋势`}>
<g className="v2-chart-grid"><line x1="54" y1="10" x2="786" y2="10" /><line x1="54" y1="53" x2="786" y2="53" /><line x1="54" y1="96" x2="786" y2="96" /></g>
<g className="v2-chart-axis"><text x="49" y="14">{panel.maximum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="49" y="100">{panel.minimum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="54" y="112">{formatAxisTime(panel.start)}</text><text x="786" y="112" textAnchor="end">{formatAxisTime(panel.end)}</text></g>
{panel.lines.flatMap((line) => line.paths.map((path, index) => <path key={`${line.key}-${index}`} d={path} fill="none" stroke={line.color} strokeWidth="2" vectorEffect="non-scaling-stroke"><title>{line.label}</title></path>))}
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <div className="v2-history-chart-empty"></div>}
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <Empty className="v2-history-chart-empty" title="当前时间窗没有趋势点" description="没有可聚合的数值点,空窗不会被人工补值。" />}
{summary ? <small className="v2-history-trend-evidence">{summary.evidence} · {summary.missingBucketCount.toLocaleString('zh-CN')} / {summary.expectedBucketCount.toLocaleString('zh-CN')} · {summary.queryDurationMs} ms</small> : null}
</section>;
</Card>;
}
export function formatAxisTime(value: string) {
@@ -53,7 +96,7 @@ function CreateExportButton({ request, disabled }: { request: HistoryExportReque
const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: api.createHistoryExport, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history-exports'] }) });
const label = mutation.isPending ? '任务排队中' : mutation.isError ? '导出失败,重试' : '创建导出';
return <button className="v2-secondary-button" type="button" disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}><IconDownload />{label}</button>;
return <Button className="v2-secondary-button" theme="light" icon={<IconDownload />} disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}>{label}</Button>;
}
function ExportDownloadButton({ id }: { id: string }) {
@@ -61,10 +104,10 @@ function ExportDownloadButton({ id }: { id: string }) {
mutationFn: () => api.downloadHistoryExport(id),
onSuccess: ({ blob, filename }) => downloadBlob(blob, filename)
});
return <button type="button" className="v2-export-download" disabled={mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '下载当前账号有权访问的导出文件'} onClick={() => mutation.mutate()}><IconDownload />{mutation.isPending ? '下载中' : mutation.isError ? '重试' : '下载'}</button>;
return <Button className="v2-export-download" theme="borderless" icon={<IconDownload />} disabled={mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '下载当前账号有权访问的导出文件'} onClick={() => mutation.mutate()}>{mutation.isPending ? '下载中' : mutation.isError ? '重试' : '下载'}</Button>;
}
function ExportJobsPanel() {
function ExportJobsPanel({ showHeader = true }: { showHeader?: boolean }) {
const query = useQuery({
queryKey: ['history-exports'],
queryFn: ({ signal }) => api.historyExports(signal),
@@ -73,17 +116,37 @@ function ExportJobsPanel() {
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const jobs = query.data ?? [];
return <section className="v2-export-jobs"><header><strong></strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}</small><small className="v2-export-owner">{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} · {job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')}${job.dateTo.replace('T', ' ')}` : job.createdAt}</small></div>{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty"></div> : !jobs.length ? <div className="v2-history-side-empty"></div> : null}</div></section>;
const statusLabel = (status: string) => status === 'queued' ? '排队中' : status === 'running' ? '执行中' : status === 'completed' ? '已完成' : '失败';
const statusColor = (status: string) => status === 'completed' ? 'green' : status === 'running' ? 'blue' : status === 'failed' ? 'red' : 'grey';
return <Card className="v2-export-jobs" bodyStyle={{ padding: 0 }}>{showHeader ? <WorkspacePanelHeader title="导出任务" description="异步生成并保留可追溯下载记录" meta={`${jobs.length.toLocaleString('zh-CN')} 个任务`} /> : null}
<div className="v2-export-job-list">{query.isPending ? <div className="v2-history-side-loading"><Spin size="middle" tip="正在读取导出任务" /></div> : query.isError ? <Empty className="v2-history-side-empty" title="导出任务加载失败" description="请稍后刷新重试。" /> : !jobs.length ? <Empty className="v2-history-side-empty" title="暂无导出任务" description="创建任务后可在这里查看进度并下载。" /> : <CardGroup type="grid" spacing={0}>{jobs.slice(0, 6).map((job) => <Card className={`v2-export-job-card is-${job.status}`} key={job.id} title={<span className="v2-export-job-name" title={job.name}>{job.name}</span>} headerExtraContent={<Tag color={statusColor(job.status)} type="light" size="small">{statusLabel(job.status)}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
<div className="v2-export-job-summary"><strong>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')}` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)}` : job.error || '任务失败'}</strong><small>{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} </small><small>{job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')}${job.dateTo.replace('T', ' ')}` : job.createdAt}</small></div>
{job.status === 'running' ? <Progress className="v2-export-job-progress" percent={job.progress} showInfo /> : null}
{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : null}
</Card>)}</CardGroup>}</div>
</Card>;
}
function EvidencePanel({ row, metrics, onClose }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void }) {
return <section className="v2-history-evidence"><header><strong></strong>{row ? <button onClick={onClose} type="button" aria-label="关闭数据详情"><IconClose /></button> : null}</header>
{row ? <><dl><div><dt></dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt></dt><dd>{row.deviceTime}</dd></div><div><dt></dt><dd>{row.serverTime}</dd></div><div><dt></dt><dd>{row.protocol}</dd></div><div><dt></dt><dd><i className={`is-${row.quality}`} />{historyQualityLabel(row.quality)}</dd></div><div className="v2-history-quality-detail"><dt></dt><dd>{row.qualityReason || '平台未返回质量说明'}</dd></div></dl><div className="v2-evidence-values"><strong></strong>{metrics.length ? metrics.slice(0, 20).map((metric) => <div key={metric.key}><span>{metric.label}<small>{metric.key}</small></span><b>{formatHistoryValue(row.values[metric.key], metric)}</b></div>) : <p></p>}</div>{row.evidenceId ? <footer><span>RAW </span><b>{row.evidenceId}</b></footer> : null}</> : <div className="v2-history-side-empty"></div>}
</section>;
function EvidencePanel({ row, metrics, onClose, showHeader = true }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void; showHeader?: boolean }) {
return <Card className="v2-history-evidence" bodyStyle={{ padding: 0 }}>{showHeader ? <WorkspacePanelHeader title="数据详情" description={row ? `${row.plate || '未绑定车牌'} · ${row.protocol}` : '选择明细后核对来源与质量'} actions={row ? <Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭数据详情" /> : null} /> : null}
{row ? <><Descriptions className="v2-history-evidence-descriptions" align="left" size="small" data={[
{ key: '车牌', value: row.plate || '—' },
{ key: 'VIN', value: row.vin },
{ key: '设备时间', value: row.deviceTime },
{ key: '服务时间', value: row.serverTime },
{ key: '数据来源', value: <Tag color="blue" type="light" size="small">{row.protocol}</Tag> },
{ key: '数据质量', value: <HistoryQualityTag quality={row.quality} /> },
{ key: '质量原因', value: row.qualityReason || '平台未返回质量说明' }
]} />
<div className="v2-evidence-section-title"><strong></strong><Tag type="light" color="grey" size="small">{metrics.length}</Tag></div>
{metrics.length ? <Descriptions className="v2-evidence-values" align="left" size="small" data={metrics.slice(0, 20).map((metric) => ({ key: <span>{metric.label}<small>{metric.key}</small></span>, value: formatHistoryValue(row.values[metric.key], metric) }))} /> : <Empty className="v2-evidence-values-empty" title="没有选中业务字段" description="通过列设置选择需要核对的字段。" />}
{row.evidenceId ? <footer><Tag color="blue" type="light" size="small">RAW </Tag><Typography.Text copyable={{ content: row.evidenceId }}>{row.evidenceId}</Typography.Text></footer> : null}</> : <Empty className="v2-history-side-empty" title="选择一条数据" description="查看来源、质量原因、业务字段与 RAW 证据。" />}
</Card>;
}
function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onReset, onClose }: {
function ColumnVisibilityPanel({ metrics, visible, visibleKeys, onToggle, onShowAll, onReset, onClose }: {
metrics: HistoryMetricDefinition[];
visible: boolean;
visibleKeys: string[];
onToggle: (key: string) => void;
onShowAll: () => void;
@@ -94,20 +157,75 @@ function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onRe
const filteredMetrics = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return metrics;
return metrics.filter((metric) => `${metric.label} ${metric.key} ${metric.unit}`.toLowerCase().includes(keyword));
return metrics.filter((metric) => `${metric.label} ${metric.key} ${metric.unit} ${metric.description ?? ''} ${metric.valueMappings?.map((item) => `${item.value} ${item.label}`).join(' ') ?? ''}`.toLowerCase().includes(keyword));
}, [metrics, search]);
const displayedMetrics = filteredMetrics.slice(0, 200);
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [onClose]);
return <aside className="v2-history-column-panel" role="dialog" aria-modal="false" aria-label="列显示设置">
<header><div><strong></strong><span></span></div><button type="button" aria-label="关闭列显示设置" onClick={onClose}><IconClose /></button></header>
<label className="v2-history-column-search"><IconSearch /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索字段名 / 字段 key" /></label>
<div>{displayedMetrics.map((metric) => <label key={metric.key}><input type="checkbox" checked={visibleKeys.includes(metric.key)} onChange={() => onToggle(metric.key)} /><span>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</span><small>{metric.key}</small></label>)}{!metrics.length ? <p></p> : search && !filteredMetrics.length ? <p></p> : filteredMetrics.length > displayedMetrics.length ? <p> 200 key </p> : null}</div>
<footer><span> {visibleKeys.length} / {metrics.length}</span><button type="button" onClick={onShowAll} disabled={!metrics.length}></button><button type="button" onClick={onReset} disabled={!metrics.length}></button></footer>
</aside>;
useSideSheetA11y(visible, '.v2-history-column-sidesheet', 'v2-history-column-settings', '列显示设置', '关闭列显示设置');
return <SideSheet
className="v2-history-column-sidesheet"
visible={visible}
aria-label="列显示设置"
width={460}
title={<div className="v2-history-column-title"><strong></strong><span></span></div>}
onCancel={onClose}
footer={<div className="v2-history-column-footer"><span> {visibleKeys.length} / {metrics.length}</span><Button theme="light" onClick={onShowAll} disabled={!metrics.length}></Button><Button theme="light" onClick={onReset} disabled={!metrics.length}></Button></div>}
>
<div className="v2-history-column-content">
<label className="v2-history-column-search"><Input prefix={<IconSearch />} value={search} onChange={setSearch} placeholder="搜索中文、状态含义或字段 key" /></label>
<div className="v2-history-column-list">{displayedMetrics.map((metric) => <label key={metric.key} title={metric.description}><Checkbox aria-label={`${metric.label}${metric.unit ? ` (${metric.unit})` : ''}`} checked={visibleKeys.includes(metric.key)} onChange={() => onToggle(metric.key)} /><span>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}{metric.valueMappings?.length ? <em>{metric.valueMappings.slice(0, 4).map((item) => `${item.value}=${item.label}`).join(' · ')}</em> : null}</span><small>{metric.key}</small></label>)}{!metrics.length ? <p></p> : search && !filteredMetrics.length ? <p></p> : filteredMetrics.length > displayedMetrics.length ? <p> 200 key </p> : null}</div>
</div>
</SideSheet>;
}
function HistoryDataTable({ rows, metrics, selectedRowID, onSelect }: { rows: HistoryDataRow[]; metrics: HistoryMetricDefinition[]; selectedRowID?: string; onSelect: (row: HistoryDataRow) => void }) {
const columns = useMemo(() => [
{
title: '', dataIndex: 'selection', width: 42,
render: (_: unknown, row: HistoryDataRow) => <Checkbox checked={selectedRowID === row.id} onChange={() => onSelect(row)} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} />
},
{ title: '设备时间', dataIndex: 'deviceTime', width: 150 },
{ title: '服务时间', dataIndex: 'serverTime', width: 150 },
{ title: '车牌', dataIndex: 'plate', width: 110, render: (value: string) => value || '—' },
{ title: 'VIN', dataIndex: 'vin', width: 160, render: (value: string) => <span className="v2-history-vin" title={value}>{value}</span> },
{ title: '协议', dataIndex: 'protocol', width: 100 },
...metrics.map((metric) => ({
title: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}`,
dataIndex: metric.key,
key: metric.key,
width: 120,
render: (_: unknown, row: HistoryDataRow) => formatHistoryValue(row.values[metric.key], metric)
})),
{
title: '质量', dataIndex: 'quality', width: 90,
render: (_: string, row: HistoryDataRow) => <span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span>
},
{
title: '质量原因', dataIndex: 'qualityReason', width: 220, className: 'v2-history-quality-reason',
render: (value: string) => <span title={value}>{value || '—'}</span>
},
{
title: '操作', dataIndex: 'action', width: 90,
render: (_: unknown, row: HistoryDataRow) => <Button size="small" theme="borderless" onClick={() => onSelect(row)}></Button>
}
], [metrics, onSelect, selectedRowID]);
const minWidth = 1112 + metrics.length * 120;
return <Table
className="v2-history-data-table"
style={{ minWidth }}
columns={columns}
dataSource={rows}
rowKey="id"
pagination={false}
empty={null}
onRow={(row) => row ? detailTriggerRow({
className: selectedRowID === row.id ? 'is-selected' : '',
expanded: selectedRowID === row.id,
label: `查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`,
testId: `history-row-${row.id}`,
onOpen: () => onSelect(row)
}) : ({})}
/>;
}
export default function HistoryPage() {
@@ -120,12 +238,20 @@ export default function HistoryPage() {
const [draft, setDraft] = useState(initial);
const [criteria, setCriteria] = useState(initial);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(50);
const mobileLayout = useMobileLayout();
const [limit, setLimit] = useState(() => mobileLayout ? MOBILE_HISTORY_PAGE_SIZE : DESKTOP_HISTORY_PAGE_SIZE);
const [visibleByCategory, setVisibleByCategory] = useState<Record<string, string[]>>({});
const [selectedRowRef, setSelectedRowRef] = useState<{ scope: string; id: string }>();
const [density, setDensity] = useState<'compact' | 'comfortable'>('compact');
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false);
const [trendExpanded, setTrendExpanded] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [exportJobsOpen, setExportJobsOpen] = useState(false);
useEffect(() => {
if (!mobileLayout) return;
setLimit(MOBILE_HISTORY_PAGE_SIZE);
setOffset(0);
}, [mobileLayout]);
const keywords = useMemo(() => parseHistoryKeywords(criteria.keywords), [criteria.keywords]);
const params = useMemo(() => {
const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, limit: String(limit), offset: String(offset) });
@@ -160,13 +286,22 @@ export default function HistoryPage() {
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria, keywords, seriesMetricKey]);
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location' && Boolean(seriesMetricKey), queryFn: ({ signal }) => api.historySeries(seriesParams, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: trendExpanded && keywords.length > 0 && criteria.category === 'location' && Boolean(seriesMetricKey), queryFn: ({ signal }) => api.historySeries(seriesParams, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const selectedRow = selectedRowRef?.scope === dataScope ? result?.rows.find((row) => row.id === selectedRowRef.id) : undefined;
useEffect(() => {
const first = result?.rows[0];
setSelectedRowRef(first ? { scope: dataScope, id: first.id } : undefined);
}, [dataScope, result]);
const scopedSelectedRowRef = selectedRowRef?.scope === dataScope ? selectedRowRef : undefined;
const selectedRow = scopedSelectedRowRef?.id === ''
? undefined
: scopedSelectedRowRef
? result?.rows.find((row) => row.id === scopedSelectedRowRef.id)
: undefined;
const selectRow = useCallback((row: HistoryDataRow) => {
setSelectedRowRef({ scope: dataScope, id: row.id });
}, [dataScope]);
const closeSelectedRow = useCallback(() => {
setSelectedRowRef({ scope: dataScope, id: '' });
}, [dataScope]);
useSideSheetA11y(mobileLayout && Boolean(selectedRow), '.v2-history-detail-sidesheet', 'v2-history-detail', '历史数据详情', '关闭历史数据详情');
useSideSheetA11y(exportAllowed && exportJobsOpen, '.v2-history-export-sidesheet', 'v2-history-export-jobs', '历史数据导出任务', '关闭历史数据导出任务');
const submit = (event: FormEvent) => {
event.preventDefault();
@@ -190,26 +325,74 @@ export default function HistoryPage() {
const setVisibleMetrics = (keys: string[]) => setVisibleByCategory((current) => ({ ...current, [criteria.category]: keys }));
const totalPages = Math.max(1, Math.ceil((result?.total ?? 0) / limit));
const page = Math.floor(offset / limit) + 1;
const summarySources = result?.summary.sources.join('、') || '—';
return <div className="v2-history-page">
<MonitorReturnBar />
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button>
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<label className="v2-history-vehicles"><span> 5 </span><div><IconSearch /><input value={draft.keywords} onChange={(event) => setDraft((value) => ({ ...value, keywords: event.target.value }))} placeholder="车牌 / VIN多台用逗号分隔" /></div></label>
<fieldset className="v2-history-range"><legend></legend><div><input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /><span></span><input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></div></fieldset>
<label><span></span><select value={draft.category} onChange={(event) => setDraft((value) => ({ ...value, category: event.target.value }))}>{(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></label>
<label><span></span><select value={draft.protocol} onChange={(event) => setDraft((value) => ({ ...value, protocol: event.target.value }))}><option value=""></option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
<button className="v2-primary-button" type="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></button><button className="v2-secondary-button" type="button" onClick={reset}></button>{exportAllowed ? <CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <span className="v2-role-badge"> · </span>}
</form>
<div className="v2-history-metrics"><strong></strong>{visibleMetrics.map((metric) => <button type="button" className="is-active" onClick={() => toggleMetric(metric.key)} key={metric.key} title="点击取消显示"><i />{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</button>)}{allMetrics.length > visibleMetrics.length ? <span> {allMetrics.length - visibleMetrics.length} </span> : !allMetrics.length ? <span></span> : null}<button type="button" className="v2-history-metrics-manage" onClick={() => setColumnSettingsOpen(true)} disabled={!allMetrics.length}><IconSetting /></button></div>
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
<div className="v2-history-workspace">
<div className="v2-history-main">
<div className="v2-history-summary"><div><small></small><strong>{result?.total.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small></small><strong>{result?.summary.vehicleCount ?? '—'}</strong></div><div><small></small><strong>{result?.summary.sources.join('') || '—'}</strong></div><div><small></small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></div>
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} hasMetrics={Boolean(seriesMetricKey)} />
<section className={`v2-history-table-card is-${density}`}><header><strong></strong><div><button type="button" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} onClick={() => setColumnSettingsOpen((value) => !value)}><IconSetting /></button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact"></option><option value="comfortable"></option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header>{columnSettingsOpen ? <ColumnVisibilityPanel metrics={allMetrics} visibleKeys={visibleKeys} onToggle={toggleMetric} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} /> : null}<div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th></th><th></th><th></th><th>VIN</th><th></th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th></th><th></th><th></th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></td><td className="v2-history-quality-reason" title={row.qualityReason}>{row.qualityReason || '—'}</td><td><button type="button" onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}></button></td></tr>)}</tbody></table><div className="v2-history-mobile-list">{result?.rows.map((row) => <button type="button" key={row.id} className={selectedRow?.id === row.id ? 'is-selected' : ''} onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></header><p><time>{row.deviceTime}</time><b>{row.protocol}</b></p><small className="v2-history-mobile-quality">{row.qualityReason || '平台未返回质量说明'}</small><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><em></em></button>)}</div>{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><i /></div> : !result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span> {page} / {totalPages} {result?.total ?? 0} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
<PageHeader
title="历史数据"
description="按车辆、时间与协议查询可追溯数据,统一核对设备时间、服务时间、字段质量和原始证据。"
status="证据可追溯"
meta={<Typography.Text type="tertiary">{keywords.length ? `${keywords.length} 辆车辆` : '等待选择车辆'}</Typography.Text>}
/>
<MobileFilterToggle title="查询条件" summary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'} expanded={!filtersCollapsed} collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} />
<Card className={`v2-history-filter-card${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}>
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<label className="v2-history-vehicles"><span> 5 </span><Input prefix={<IconSearch />} value={draft.keywords} onChange={(value) => setDraft((current) => ({ ...current, keywords: value }))} placeholder="车牌 / VIN多台用逗号分隔" /></label>
<fieldset className="v2-history-range"><legend></legend><div><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /><span></span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></div></fieldset>
<label><span id="history-category-label"></span><Select aria-labelledby="history-category-label" value={draft.category} onChange={(value) => setDraft((current) => ({ ...current, category: String(value) }))} optionList={(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => ({ value: item.key, label: item.label }))} /></label>
<label><span id="history-protocol-label"></span><Select aria-labelledby="history-protocol-label" value={draft.protocol} onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value) }))} optionList={[{ value: '', label: '全部来源' }, { value: 'GB32960', label: 'GB32960' }, { value: 'JT808', label: 'JT808' }, { value: 'YUTONG_MQTT', label: 'YUTONG_MQTT' }]} /></label>
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></Button><Button className="v2-secondary-button" theme="light" onClick={reset}></Button>{exportAllowed ? <CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}</div>
</form>
</Card>
<Card className="v2-history-metrics-card" bodyStyle={{ padding: 0 }}>
<div className="v2-history-metrics">
<strong></strong>
<div className="v2-history-metric-scroll" aria-label="当前显示字段">
{visibleMetrics.map((metric) => <Tag className="v2-history-metric-tag" color="blue" type="light" size="large" closable tabIndex={0} aria-label={`已选字段 ${metric.label}${metric.unit ? ` ${metric.unit}` : ''},点击取消显示`} onClick={() => toggleMetric(metric.key)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleMetric(metric.key); } }} onClose={(_, event) => { event.stopPropagation(); toggleMetric(metric.key); }} key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</Tag>)}
{allMetrics.length > visibleMetrics.length ? <span> {allMetrics.length - visibleMetrics.length} </span> : !allMetrics.length ? <span></span> : null}
</div>
<Button className="v2-history-metrics-manage" theme="borderless" icon={<IconSetting />} aria-label="管理字段" onClick={() => setColumnSettingsOpen(true)} disabled={!allMetrics.length}></Button>
</div>
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={() => setSelectedRowRef(undefined)} />{exportAllowed ? <ExportJobsPanel /> : null}</aside>
</Card>
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
<div className={`v2-history-workspace${selectedRow && !mobileLayout ? ' is-inspector-open' : ''}`}>
<div className={`v2-history-main${trendExpanded ? ' has-expanded-trend' : ''}`}>
<Card className="v2-history-summary" bodyStyle={{ padding: 0 }}><div><small></small><strong>{result?.total.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small></small><strong>{result?.summary.vehicleCount ?? '—'}</strong></div><div><small></small><strong className="is-source" title={summarySources}>{summarySources}</strong></div><div><small></small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></Card>
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} hasMetrics={Boolean(seriesMetricKey)} expanded={trendExpanded} onToggle={() => setTrendExpanded((value) => !value)} />
<Card className={`v2-history-table-card is-${density}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader title="数据明细" actions={<><Button theme="borderless" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} aria-controls="v2-history-column-settings" icon={<IconSetting />} onClick={() => setColumnSettingsOpen((value) => !value)}></Button>{exportAllowed ? <Button theme="borderless" aria-label="查看导出任务" aria-haspopup="dialog" aria-expanded={exportJobsOpen} aria-controls="v2-history-export-jobs" icon={<IconDownload />} onClick={() => setExportJobsOpen(true)}></Button> : null}{!mobileLayout ? <Select aria-label="表格密度" value={density} onChange={(value) => setDensity(String(value) as typeof density)} optionList={[{ value: 'compact', label: '紧凑' }, { value: 'comfortable', label: '舒适' }]} /> : null}<Button theme="borderless" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据" icon={<IconRefresh />} /></>} />
<ColumnVisibilityPanel visible={columnSettingsOpen} metrics={allMetrics} visibleKeys={visibleKeys} onToggle={toggleMetric} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} />
<div className="v2-history-table-scroll">
{mobileLayout
? <div className="v2-history-mobile-list">{result?.rows.map((row) => <Card key={row.id} className={`v2-history-mobile-card${selectedRow?.id === row.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-history-mobile-action" aria-pressed={selectedRow?.id === row.id} aria-expanded={selectedRow?.id === row.id} aria-label={`查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`} onClick={() => selectRow(row)}><span className="v2-history-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></header><p><time>{row.deviceTime}</time><b>{row.protocol}</b></p><small className="v2-history-mobile-quality">{row.qualityReason || '平台未返回质量说明'}</small><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <HistoryDataTable rows={result?.rows ?? []} metrics={visibleMetrics} selectedRowID={selectedRow?.id} onSelect={selectRow} />}
{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><Spin size="middle" tip="正在加载当前筛选范围的历史数据…" /></div> : !result?.rows.length ? <Empty className="v2-history-empty" title={keywords.length ? '当前条件没有历史记录' : '等待查询历史数据'} description={keywords.length ? '调整车辆、时间或数据来源后重试。' : '输入车牌或 VIN最多可同时查询 5 台车辆。'} /> : null}
</div>
<footer><TablePagination page={page} totalPages={totalPages} info={`${(result?.total ?? 0).toLocaleString('zh-CN')}`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={mobileLayout ? undefined : limit} onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={mobileLayout ? undefined : [{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }, { value: 100, label: '100 条/页' }]} /></footer>
</Card>
</div>
{!mobileLayout && selectedRow ? <aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} /></aside> : null}
</div>
<SideSheet
className="v2-history-detail-sidesheet"
visible={mobileLayout && Boolean(selectedRow)}
aria-label="历史数据详情"
width="100%"
title={<div className="v2-history-mobile-sheet-title"><strong></strong><span>{selectedRow ? `${selectedRow.plate || '未绑定车牌'} · ${selectedRow.protocol}` : '来源、质量与原始证据'}</span></div>}
onCancel={closeSelectedRow}
>
{mobileLayout && selectedRow ? <EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} showHeader={false} /> : null}
</SideSheet>
<SideSheet
className="v2-history-export-sidesheet"
visible={exportAllowed && exportJobsOpen}
aria-label="历史数据导出任务"
width={mobileLayout ? '100%' : 520}
title={<div className="v2-history-mobile-sheet-title"><strong></strong><span></span></div>}
onCancel={() => setExportJobsOpen(false)}
>
{exportJobsOpen ? <ExportJobsPanel showHeader={false} /> : null}
</SideSheet>
</div>;
}

View File

@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -24,6 +24,7 @@ const vehicleCardArgsSpy = vi.hoisted(() => vi.fn());
const monitorDataArgsSpy = vi.hoisted(() => vi.fn());
const qrToDataURLSpy = vi.hoisted(() => vi.fn());
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
const vehicleCardFixture = vi.hoisted(() => ({ detail: undefined as unknown }));
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
vi.mock('../map/FleetMap', () => ({
@@ -68,7 +69,7 @@ vi.mock('../hooks/useMonitorData', () => ({
},
useMonitorVehicleCard: (...args: unknown[]) => {
vehicleCardArgsSpy(...args);
return { detail: {}, activeAlerts: {}, address: {} };
return { detail: { data: vehicleCardFixture.detail }, activeAlerts: {}, address: {} };
}
}));
@@ -77,6 +78,7 @@ afterEach(() => {
monitorQueryFlags.isLoading = false;
monitorQueryFlags.isFetching = false;
monitorQueryFlags.isPlaceholderData = false;
vehicleCardFixture.detail = undefined;
vehicleCardArgsSpy.mockClear();
monitorDataArgsSpy.mockClear();
qrToDataURLSpy.mockReset();
@@ -85,6 +87,27 @@ afterEach(() => {
vi.restoreAllMocks();
});
test('never labels the latest historical mileage row as today when today has no mileage evidence', () => {
vehicleCardFixture.detail = {
sources: ['JT808'],
sourceStatus: [],
mileage: {
items: [{ vin: vehicles[1].vin, plate: vehicles[1].plate, date: '2026-07-16', startMileageKm: 2100, endMileageKm: 2234, dailyMileageKm: 134, source: 'JT808' }],
total: 1,
limit: 20,
offset: 0
}
};
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ }));
const todayMetric = screen.getByText('今日里程').parentElement;
expect(todayMetric).toHaveTextContent('今日里程—');
expect(todayMetric).not.toHaveTextContent('134');
});
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
@@ -92,13 +115,26 @@ test('starts without a selection and supports expand, collapse, reselection, and
const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ });
const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ });
expect(firstVehicle).toHaveClass('semi-button', 'v2-vehicle-row');
expect(view.container.querySelector('.v2-filterbar')).toHaveClass('semi-card');
const kpis = view.container.querySelector('.v2-kpis');
expect(kpis).toHaveClass('semi-card');
expect(Array.from(kpis?.querySelectorAll('.v2-kpi small') ?? []).map((item) => item.textContent)).toEqual([
'车辆总数', '当前在线', '当前离线', '行驶车辆', '静止车辆', '告警车辆', '今日上报', '无实时位置'
]);
expect(kpis?.querySelector('.v2-kpi.is-fleet')).toHaveClass('is-primary');
expect(kpis?.querySelector('.v2-kpi.is-online')).toHaveClass('is-primary');
expect(kpis?.querySelector('.v2-kpi.is-today')).toHaveClass('is-support');
expect(kpis?.querySelector('.v2-kpi:last-child')).toHaveClass('is-missing', 'is-support');
expect(view.container.querySelector('.v2-event-strip')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-monitor-live-tag')).toHaveClass('semi-tag');
expect(workspace).not.toHaveClass('is-detail-open');
expect(workspace).not.toHaveClass('is-detail-collapsed');
expect(firstVehicle).not.toHaveClass('is-selected');
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
expect(screen.getByText('车辆总数')).toBeInTheDocument();
expect(screen.getAllByText('无实时位置').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('无实时位置')).toBeInTheDocument();
expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效' })).toHaveTextContent('实时筛选');
expect(screen.queryByRole('button', { name: '筛选' })).not.toBeInTheDocument();
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
@@ -107,6 +143,18 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(workspace).toHaveClass('is-detail-open');
expect(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-detail')).toHaveClass('semi-card');
expect(screen.getByRole('region', { name: '粤A12345车辆详情' })).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: '车辆快捷操作' })).toBeInTheDocument();
for (const name of ['最新上报', '实时状态', '车辆信息']) {
expect(screen.getByRole('heading', { name, level: 5 })).toBeInTheDocument();
}
expect(screen.getByText('车辆最近一次有效实时数据')).toBeInTheDocument();
expect(screen.getByText('身份、车型与来源覆盖')).toBeInTheDocument();
expect(view.container.querySelector('.v2-status-text')).toHaveClass('semi-tag');
expect(screen.getByRole('button', { name: '取消选择车辆' })).toHaveClass('semi-button');
expect(screen.getByRole('button', { name: '查看总里程全部来源' })).toHaveClass('semi-button', 'v2-metric-source-link');
expect(screen.getByRole('button', { name: '查看全部位置来源' })).toHaveClass('semi-button', 'v2-detail-source-link');
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true);
expect(screen.getByText('SOC').parentElement).toHaveTextContent('SOC—');
@@ -117,6 +165,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(workspace).toHaveClass('is-detail-collapsed');
expect(firstVehicle).toHaveClass('is-selected');
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-detail-peek')).toHaveClass('semi-card');
expect(fleetMapRenderSpy).toHaveBeenCalledTimes(mapRendersAfterSelection);
expect(vehicleCardArgsSpy).toHaveBeenCalledTimes(cardCallsAfterSelection);
@@ -142,8 +191,8 @@ test('restores URL-backed monitor context and carries it into every vehicle work
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[entry]}><MonitorPage /></MemoryRouter></QueryClientProvider>);
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('粤A12345');
expect(screen.getByRole('combobox', { name: '协议' })).toHaveValue('JT808');
expect(screen.getByRole('combobox', { name: '在线状态' })).toHaveValue('online');
expect(screen.getByRole('combobox', { name: '协议' })).toHaveTextContent('JT808');
expect(screen.getByRole('combobox', { name: '在线状态' })).toHaveTextContent('在线');
expect(screen.getByRole('button', { name: '展开车辆详情' })).toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-zoom', '13');
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-initial-bounds', '113.100000,23.000000,113.400000,23.300000');
@@ -164,16 +213,35 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
const modeGroup = screen.getByRole('group', { name: '监控视图' });
const mapMode = within(modeGroup).getByRole('button', { name: /地图/ });
const listMode = within(modeGroup).getByRole('button', { name: /列表/ });
expect(mapMode).toHaveAttribute('aria-pressed', 'true');
expect(mapMode).toHaveClass('is-active');
expect(listMode).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(listMode);
expect(mapMode).toHaveAttribute('aria-pressed', 'false');
expect(listMode).toHaveAttribute('aria-pressed', 'true');
expect(listMode).toHaveClass('is-active');
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '车辆实时列表', level: 5 })).toBeInTheDocument();
expect(view.container.querySelector('.v2-monitor-table-panel')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-monitor-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-monitor-table-scroll > table')).not.toBeInTheDocument();
expect(screen.getByText('覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析')).toBeInTheDocument();
expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
expect(screen.getAllByText('42')).toHaveLength(1);
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
expect(screen.getByText('JT808')).toBeInTheDocument();
expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
expect(screen.queryByText('推荐来源')).not.toBeInTheDocument();
expect(screen.getByText('当日里程')).toBeInTheDocument();
expect(screen.getByText('协议来源')).toBeInTheDocument();
expect(screen.queryByText('最新上报')).not.toBeInTheDocument();
expect(reverseGeocode).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '解析粤A12345位置' })).toHaveClass('semi-button', 'v2-monitor-address-action');
fireEvent.click(screen.getByRole('button', { name: '解析粤A12345位置' }));
expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
expect(screen.getByText(/解析于 \d{2}:\d{2}/)).toBeInTheDocument();
@@ -218,7 +286,6 @@ test('keeps authorized vehicles without realtime coordinates in the list without
expect(await screen.findByText('粤A无定位')).toBeInTheDocument();
expect(screen.getByText('暂无实时位置')).toBeInTheDocument();
expect(screen.getByText('从未上报')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /解析粤A无定位位置/ })).not.toBeInTheDocument();
expect(reverseGeocode).not.toHaveBeenCalled();
});
@@ -260,13 +327,15 @@ test('closes the mobile entry with Escape and reports clipboard success', async
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(screen.getByRole('button', { name: /手机端/ }));
expect(await screen.findByRole('dialog', { name: '手机端全局监控' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '关闭手机端入口' })).toBeInTheDocument();
expect(await screen.findByRole('img', { name: '全局监控手机端二维码' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '复制访问地址' }));
expect(await screen.findByRole('button', { name: '已复制访问地址' })).toBeInTheDocument();
expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/\/monitor$/));
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '手机端入口' })).not.toBeInTheDocument();
fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape', keyCode: 27 });
await waitFor(() => expect(screen.queryByRole('dialog', { name: '手机端全局监控' })).not.toBeInTheDocument());
});
test('shows a retry action when on-demand QR generation fails', async () => {
@@ -301,7 +370,7 @@ test('mounts only the mobile list representation and removes its viewport listen
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards article')).toHaveLength(1));
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards .v2-monitor-mobile-card.semi-card')).toHaveLength(1));
expect(view.container.querySelector('.v2-monitor-table-scroll')).not.toBeInTheDocument();
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));

View File

@@ -1,13 +1,20 @@
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin, IconQrCode, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import {
IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin,
IconQrCode, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { Button, ButtonGroup, Card, Empty, Input, Modal, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { Page, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError } from '../shared/AsyncState';
import { TablePagination } from '../shared/TablePagination';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { formatTelemetryValue } from '../domain/telemetry';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '../routing/monitorContext';
@@ -20,21 +27,24 @@ const MOBILE_MONITOR_QUERY = '(max-width: 760px)';
function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(initialValue);
const terms = useMemo(() => parseMonitorSearchTerms(draft), [draft]);
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [onClose]);
return <div className="v2-batch-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<section className="v2-batch-search-dialog" role="dialog" aria-modal="true" aria-labelledby="batch-search-title">
<header><div><strong id="batch-search-title"></strong><span> Excel</span></div><button type="button" onClick={onClose} aria-label="关闭批量搜索"><IconClose /></button></header>
return <Modal
className="v2-batch-search-modal"
visible
centered
closeOnEsc
maskClosable
width={520}
title={<div className="v2-batch-search-title"><strong id="batch-search-title"></strong><span> Excel</span></div>}
onCancel={onClose}
footer={<div className="v2-batch-search-actions"><Button onClick={onClose}></Button><Button theme="solid" disabled={!terms.length} onClick={() => onApply(terms.join(''))}>{terms.length}</Button></div>}
>
<div className="v2-batch-search-dialog">
<label htmlFor="batch-vehicle-search"></label>
<textarea id="batch-vehicle-search" autoFocus value={draft} onChange={(event) => setDraft(event.target.value)} placeholder={'粤A12345\n粤B67890\n粤C24680'} />
<TextArea id="batch-vehicle-search" autoFocus value={draft} onChange={setDraft} autosize={{ minRows: 8, maxRows: 14 }} resize="vertical" placeholder={'粤A12345\n粤B67890\n粤C24680'} />
<div className="v2-batch-search-summary"><span> <strong>{terms.length}</strong> </span><em> {MAX_MONITOR_SEARCH_TERMS} </em></div>
<footer><button type="button" className="v2-secondary-button" onClick={onClose}></button><button type="button" className="v2-primary-button" disabled={!terms.length} onClick={() => onApply(terms.join(''))}>{terms.length}</button></footer>
</section>
</div>;
</div>
</Modal>;
}
function useMobileMonitorLayout() {
@@ -66,6 +76,10 @@ function hasRealtimeMileage(vehicle: VehicleRealtimeRow) {
return vehicle.mileageAvailable ?? hasRealtimeLocation(vehicle);
}
function hasTodayMileage(vehicle: VehicleRealtimeRow): vehicle is VehicleRealtimeRow & { todayMileageKm: number } {
return vehicle.todayMileageAvailable === true && vehicle.todayMileageKm != null;
}
function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
}
@@ -97,33 +111,37 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
const moved = Boolean(current && requested && current.key !== requested.key);
if (!current) return <span className="v2-monitor-address-empty"></span>;
if (!requested) return <button type="button" className="v2-monitor-address-action" aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}><IconMapPin /></button>;
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><i className="v2-spinner" /></span>;
if (addressQuery.isError) return <button type="button" className="v2-monitor-address-action is-error" onClick={() => void addressQuery.refetch()}></button>;
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <button type="button" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}> · </button> : null}</div>;
if (!requested) return <Button className="v2-monitor-address-action" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}></Button>;
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><Spin size="small" /></span>;
if (addressQuery.isError) return <Button className="v2-monitor-address-action is-error" size="small" theme="light" type="danger" aria-label={`${vehicle.plate || vehicle.vin}位置解析失败,重试`} onClick={() => void addressQuery.refetch()}></Button>;
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <Button className="v2-monitor-address-update" size="small" theme="borderless" type="warning" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}> · </Button> : null}</div>;
});
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
const mobile = useMobileMonitorLayout();
return <section className="v2-monitor-table-panel">
<header><div><strong></strong><span></span></div><b>{total.toLocaleString('zh-CN')} </b></header>
{!mobile ? <div className="v2-monitor-table-scroll"><table><thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.vin}>
<td className="v2-monitor-table-vehicle"><button type="button" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></button></td>
<td><span className="v2-monitor-source-badge">{row.primaryProtocol || ''}</span></td>
<td><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</td>
<td><div className="v2-monitor-mileage"><span><small></small><strong>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</strong></span><span><small></small><strong>{row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'}</strong></span></div></td>
<td>{hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable"></span>}</td>
<td><MonitorAddressCell vehicle={row} /></td>
<td><div className="v2-monitor-last-seen"><strong>{row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}</strong><span>{row.lastSeen || '—'}</span></div></td>
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}>
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin} · {row.primaryProtocol || '暂无来源'}</span></div><b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b></header>
<dl><div><dt></dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div><div><dt></dt><dd>{row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'}</dd></div><div><dt></dt><dd>{hasRealtimeLocation(row) ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : '—'}</dd></div><div><dt></dt><dd>{row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}</dd></div><div className="is-address"><dt></dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin />{hasRealtimeLocation(row) ? '地图定位' : '查看车辆'}</button></footer>
</article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
<footer><span> {page} / {totalPages} </span><div><button type="button" disabled={page <= 1} onClick={() => onPage(page - 1)}></button><button type="button" disabled={page >= totalPages} onClick={() => onPage(page + 1)}></button><select aria-label="每页车辆数" value={limit} onChange={(event) => onLimit(Number(event.target.value))}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer>
</section>;
const columns = useMemo(() => [
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className="v2-monitor-vehicle-action" theme="borderless" type="tertiary" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></Button> },
{ title: '速度', dataIndex: 'speedKmh', width: 120, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</> },
{ title: '当日里程', dataIndex: 'todayMileageKm', width: 140, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value is-today">{hasTodayMileage(row) ? formatNumber(row.todayMileageKm, 1) : '—'}</strong>{hasTodayMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '总里程', dataIndex: 'totalMileageKm', width: 170, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeMileage(row) ? formatNumber(row.totalMileageKm, 1) : ''}</strong>{hasRealtimeMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '协议来源', dataIndex: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) => <div className="v2-monitor-protocol"><Tag color={row.primaryProtocol ? 'blue' : 'grey'} type="light" size="small">{row.primaryProtocol || '未知'}</Tag>{row.protocols.length > 1 ? <small>+{row.protocols.length - 1} </small> : null}</div> },
{ title: '经纬度', dataIndex: 'longitude', width: 190, render: (_value: number, row: VehicleRealtimeRow) => hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable"></span> },
{ title: '地理位置', dataIndex: 'vin', render: (_value: string, row: VehicleRealtimeRow) => <MonitorAddressCell vehicle={row} /> }
], [onSelect]);
return <Card className="v2-monitor-table-panel" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="车辆实时列表"
description="覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析"
meta={`${total.toLocaleString('zh-CN')} 辆车辆`}
/>
{!mobile ? <div className="v2-monitor-table-scroll"><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} />{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <Card className="v2-monitor-mobile-card" key={row.vin}>
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b></header>
<dl><div><dt></dt><dd className="is-today">{hasTodayMileage(row) ? `${formatNumber(row.todayMileageKm, 1)} km` : '—'}</dd></div><div><dt></dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div><div><dt></dt><dd><span className="v2-monitor-mobile-protocol">{row.primaryProtocol || '未知'}{row.protocols.length > 1 ? ` · ${row.protocols.length}` : ''}</span></dd></div><div><dt></dt><dd>{hasRealtimeLocation(row) ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : '—'}</dd></div><div className="is-address"><dt></dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
<footer><Button className="v2-monitor-card-locate" theme="borderless" type="primary" icon={<IconMapPin />} onClick={() => onSelect(row.vin)}>{hasRealtimeLocation(row) ? '地图定位' : '查看车辆'}</Button></footer>
</Card>)}{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
<footer><TablePagination page={page} totalPages={totalPages} info={`${total.toLocaleString('zh-CN')} 辆车辆`} onPageChange={onPage} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={onLimit} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
</Card>;
}
function MobileEntry({ onClose }: { onClose: () => void }) {
@@ -132,11 +150,9 @@ function MobileEntry({ onClose }: { onClose: () => void }) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle');
const [attempt, setAttempt] = useState(0);
const url = `${window.location.origin}/monitor`;
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [onClose]);
useLayoutEffect(() => {
document.querySelector('.v2-monitor-qr-modal .semi-modal-close')?.setAttribute('aria-label', '关闭手机端入口');
}, []);
useEffect(() => {
if (copyState === 'idle') return;
const timer = window.setTimeout(() => setCopyState('idle'), 1_800);
@@ -161,17 +177,35 @@ function MobileEntry({ onClose }: { onClose: () => void }) {
setCopyState('error');
}
};
return <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section><button type="button" autoFocus onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3></h3><p>使 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : error ? <div className="v2-monitor-qr-error" role="alert"><span>{error}</span><button type="button" onClick={() => setAttempt((value) => value + 1)}></button></div> : <span className="v2-spinner" role="status" aria-label="正在生成二维码" />}<code>{url}</code><button type="button" aria-live="polite" onClick={() => void copyURL()}>{copyState === 'copied' ? '已复制访问地址' : copyState === 'error' ? '复制失败,重试' : '复制访问地址'}</button></section></div>;
return <Modal
className="v2-monitor-qr-modal"
visible
centered
width={390}
title="手机端全局监控"
closeIcon={<IconClose aria-label="关闭手机端入口" />}
closeOnEsc
maskClosable
onCancel={onClose}
footer={<Button block theme="solid" aria-live="polite" onClick={() => void copyURL()}>{copyState === 'copied' ? '已复制访问地址' : copyState === 'error' ? '复制失败,重试' : '复制访问地址'}</Button>}
>
<div className="v2-monitor-qr-content">
<span className="v2-monitor-qr-icon"><IconQrCode /></span>
<p>使 Token</p>
{qr ? <img src={qr} alt="全局监控手机端二维码" /> : error ? <div className="v2-monitor-qr-error" role="alert"><span>{error}</span><Button theme="light" type="danger" onClick={() => setAttempt((value) => value + 1)}></Button></div> : <span className="v2-spinner" role="status" aria-label="正在生成二维码" />}
<code>{url}</code>
</div>
</Modal>;
}
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
const status = vehicleStatus(vehicle);
return (
<button type="button" className={`v2-vehicle-row ${selected ? 'is-selected' : ''}`} onClick={() => onSelect(vehicle.vin)}>
<Button theme="borderless" type="tertiary" className={`v2-vehicle-row ${selected ? 'is-selected' : ''}`} onClick={() => onSelect(vehicle.vin)}>
<i className={`v2-status-dot is-${status}`} />
<span className="v2-vehicle-identity"><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin}</small></span>
<span className="v2-vehicle-motion"><strong>{formatNumber(vehicle.speedKmh, 1)} <small>km/h</small></strong><small>{statusLabel(status)}</small></span>
</button>
</Button>
);
});
@@ -192,60 +226,94 @@ function VehicleDetailCard({
const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
const detail = card.detail.data;
const activeAlerts = card.activeAlerts.data;
const telemetry = card.telemetry?.data;
const address = card.address.data;
const status = vehicleStatus(vehicle);
const dailyMileage = detail?.mileage.items[0]?.dailyMileageKm;
// The realtime row is joined to vehicle_daily_mileage with stat_date = CURDATE().
// Vehicle detail mileage is ordered by recency and may start with yesterday, so it
// must never be used as a fallback for a metric explicitly labelled "今日里程".
const dailyMileage = vehicle.todayMileageAvailable === true ? vehicle.todayMileageKm : undefined;
const latestAlert = activeAlerts?.items[0];
const mileageLabel = vehicle.primaryProtocol === 'JT808' ? 'GPS 总里程' : '仪表盘总里程';
const encodedVin = encodeURIComponent(vehicle.vin);
const accessSource = detail?.profile?.accessProvider || vehicle.locationSource;
const workflowLinks = [
['单车详情', `/vehicles/${encodedVin}`],
['轨迹回放', `/tracks?vin=${encodedVin}`],
['历史数据', `/history?vin=${encodedVin}`],
['里程查询', `/statistics?vins=${encodedVin}`]
] as const;
const gbHighlights = (telemetry?.values ?? []).filter((item) => item.protocol === 'GB32960' && [
'fuel_cell_voltage_v', 'fuel_cell_current_a', 'hydrogen_consumption_kg_per_100km',
'hydrogen_concentration_percent', 'hydrogen_pressure_mpa', 'hydrogen_temperature_c',
'engine_speed_rpm', 'total_voltage_v', 'total_current_a'
].includes(item.key)).slice(0, 9);
return (
<aside className="v2-vehicle-detail">
<div className="v2-detail-controls">
<button type="button" aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse}><IconChevronRight /></button>
<button type="button" aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear}><IconClose /></button>
</div>
<div className="v2-detail-title">
<div><strong>{vehicle.plate || '未绑定车牌'}</strong><span className={`v2-status-text is-${status}`}>{statusLabel(status)}</span></div>
<small>{vehicle.vin}</small>
</div>
<div className="v2-detail-actions">
<Link to={withMonitorReturn(`/vehicles/${encodeURIComponent(vehicle.vin)}`, monitorReturn)}></Link>
<Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}></Link>
<Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}></Link>
<Link to={withMonitorReturn(`/statistics?vins=${encodeURIComponent(vehicle.vin)}`, monitorReturn)}></Link>
</div>
<section>
<h3></h3>
<Card className="v2-vehicle-detail" bodyStyle={{ padding: 0 }}>
<div className="v2-detail-body" role="region" aria-label={`${vehicle.plate || vehicle.vin}车辆详情`}>
<div className="v2-detail-shell-header">
<div className="v2-detail-controls">
<Button size="small" theme="light" type="tertiary" icon={<IconChevronRight />} aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse} />
<Button size="small" theme="light" type="danger" icon={<IconClose />} aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear} />
</div>
<div className="v2-detail-title">
<div><strong>{vehicle.plate || '未绑定车牌'}</strong><Tag className={`v2-status-text is-${status}`} color={status === 'driving' ? 'blue' : status === 'idle' || status === 'online' ? 'green' : status === 'alert' ? 'red' : 'grey'} type="light" size="small">{statusLabel(status)}</Tag></div>
<small>{vehicle.vin}</small>
</div>
<nav className="v2-detail-actions" aria-label="车辆快捷操作">
{workflowLinks.map(([label, path]) => <Link key={label} to={withMonitorReturn(path, monitorReturn)}>{label}</Link>)}
</nav>
</div>
<section className="v2-detail-section v2-detail-report">
<WorkspacePanelHeader
variant="compact"
title="最新上报"
description="车辆最近一次有效实时数据"
meta={<Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{relativeFreshness(vehicle.lastSeen)}</Tag>}
/>
<div className="v2-detail-report-time"><small></small><strong>{vehicle.lastSeen || '暂无上报'}</strong></div>
<div className="v2-detail-report-source"><span><small></small><b>{vehicle.primaryProtocol || '未知'}</b></span><span><small></small><b>{accessSource || '待补充'}</b></span></div>
<dl className="v2-detail-list">
<div><dt>VIN</dt><dd>{vehicle.vin}</dd></div>
<div><dt></dt><dd>{vehicle.oem || '待补充'}</dd></div>
<div><dt></dt><dd>{vehicle.primaryProtocol || vehicle.protocols.join('、') || '未知'}</dd></div>
<div><dt></dt><dd>{detail?.sources.join('、') || vehicle.protocols.join('、') || '未知'}</dd></div>
<div><dt></dt><dd>{detail?.profile?.accessProvider || '待补充'}</dd></div>
<div><dt></dt><dd>{vehicle.onlineSourceCount}/{vehicle.sourceCount}</dd></div>
<div><dt></dt><dd><Button theme="borderless" type="tertiary" className="v2-detail-source-link" aria-label="查看全部位置来源" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</Button></dd></div>
<div><dt></dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
</dl>
</section>
<section>
<h3></h3>
<section className="v2-detail-section">
<WorkspacePanelHeader
variant="compact"
title="实时状态"
description={accessSource || '来源待补充'}
meta={<Tag color="blue" type="light" size="small">{vehicle.primaryProtocol || '未知协议'}</Tag>}
/>
<div className="v2-metric-grid">
<div><small></small><strong>{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}<em>{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}</em></strong></div>
<div><small>SOC</small><strong>{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}<em>{hasRealtimeSOC(vehicle) ? '%' : ''}</em></strong></div>
<div><small></small><button type="button" className="v2-metric-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></button></div>
<div><small></small><button type="button" className="v2-metric-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></button></div>
<div><small>{mileageLabel}</small><Button theme="borderless" type="tertiary" className="v2-metric-source-link" aria-label="查看总里程全部来源" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}><span>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></span><IconChevronRight /></Button></div>
<div><small></small><Button theme="borderless" type="tertiary" className="v2-metric-source-link" aria-label="查看今日里程全部来源" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}><span>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></span><IconChevronRight /></Button></div>
<div><small></small><strong>{statusLabel(status)}</strong></div>
<div><small></small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em></em></strong></div>
</div>
{gbHighlights.length ? <div className="v2-gb-highlight-grid">{gbHighlights.map((item) => <div key={`${item.key}-${item.sourceField}`} title={item.description}><small>{item.label}</small><strong>{formatTelemetryValue(item.value, item.displayValue)}<em>{item.unit}</em></strong></div>)}</div> : null}
</section>
<section>
<h3></h3>
<section className="v2-detail-section">
<WorkspacePanelHeader
variant="compact"
title="车辆信息"
description="身份、车型与来源覆盖"
meta={`${vehicle.onlineSourceCount}/${vehicle.sourceCount} 在线`}
/>
<dl className="v2-detail-list">
<div><dt></dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
<div><dt></dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
<div><dt></dt><dd><button type="button" className="v2-detail-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : ''}</button></dd></div>
<div><dt></dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
<div><dt>VIN</dt><dd>{vehicle.vin}</dd></div>
<div><dt> / </dt><dd>{[detail?.profile?.brandName, detail?.profile?.modelName].filter(Boolean).join(' / ') || vehicle.oem || '待补充'}</dd></div>
<div><dt></dt><dd>{detail?.sources.join('、') || vehicle.protocols.join('、') || '未知'}</dd></div>
<div><dt></dt><dd>{detail?.profile?.accessProvider || '待补充'}</dd></div>
<div><dt></dt><dd>{vehicle.onlineSourceCount}/{vehicle.sourceCount}</dd></div>
<div><dt></dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
</dl>
</section>
<VehicleSourceEvidencePanel vin={vehicle.vin} compact open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
</aside>
</div>
</Card>
);
}
@@ -343,13 +411,13 @@ export default function MonitorPage() {
return (
<div className="v2-monitor-page">
<section className="v2-filterbar" aria-label="车辆筛选">
<Card className="v2-filterbar" bodyStyle={{ padding: 0 }} aria-label="车辆筛选">
<div className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
<IconSearch />
<input
<Input
aria-label="搜索车辆"
prefix={<IconSearch />}
value={keyword}
onChange={(event) => { setKeyword(event.target.value); setListOffset(0); }}
onChange={(value) => { setKeyword(value); setListOffset(0); }}
onPaste={(event) => {
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
if (pastedTerms.length <= 1) return;
@@ -358,35 +426,36 @@ export default function MonitorPage() {
setListOffset(0);
}}
placeholder="车牌 / VIN可批量粘贴车牌"
suffix={<Button className="v2-search-batch-action" size="small" theme="borderless" onClick={() => setBatchSearchOpen(true)}></Button>}
/>
<button type="button" className="v2-search-batch-action" onClick={() => setBatchSearchOpen(true)}></button>
{searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length}` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
</div>
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
</select>
<select value={status} onChange={(event) => { setStatus(event.target.value); setListOffset(0); }} aria-label="在线状态">
{statuses.map((item) => <option key={item} value={item}>{item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态'}</option>)}
</select>
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}><IconRefresh /></button>
<span className="v2-sr-only" id="monitor-protocol-filter-label"></span>
<Select value={protocol} onChange={(value) => { setProtocol(String(value)); setListOffset(0); }} aria-labelledby="monitor-protocol-filter-label" optionList={protocols.map((item) => ({ value: item, label: item || '全部协议' }))} />
<span className="v2-sr-only" id="monitor-status-filter-label">线</span>
<Select value={status} onChange={(value) => { setStatus(String(value)); setListOffset(0); }} aria-labelledby="monitor-status-filter-label" optionList={statuses.map((item) => ({ value: item, label: item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态' }))} />
<Button className="v2-filter-reset" icon={<IconRefresh />} onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}></Button>
<span className="v2-filter-live-status" role="status" title="搜索和筛选条件修改后自动生效"><IconFilter /></span>
<div className="v2-monitor-mode" aria-label="监控视图"><button type="button" className={mode === 'map' ? 'is-active' : ''} onClick={() => setMode('map')}><IconMapPin /></button><button type="button" className={mode === 'list' ? 'is-active' : ''} onClick={() => { setMode('list'); setDetailOpen(false); }}><IconList /></button></div>
<button type="button" className="v2-monitor-mobile-entry" onClick={() => setMobileEntryOpen(true)}><IconQrCode /></button>
</section>
<ButtonGroup className="v2-monitor-mode" aria-label="监控视图">
<Button aria-pressed={mode === 'map'} className={mode === 'map' ? 'is-active' : ''} theme={mode === 'map' ? 'solid' : 'borderless'} type={mode === 'map' ? 'primary' : 'tertiary'} icon={<IconMapPin />} onClick={() => setMode('map')}></Button>
<Button aria-pressed={mode === 'list'} className={mode === 'list' ? 'is-active' : ''} theme={mode === 'list' ? 'solid' : 'borderless'} type={mode === 'list' ? 'primary' : 'tertiary'} icon={<IconList />} onClick={() => { setMode('list'); setDetailOpen(false); }}></Button>
</ButtonGroup>
<Button className="v2-monitor-mobile-entry" theme="light" icon={<IconQrCode />} onClick={() => setMobileEntryOpen(true)}></Button>
</Card>
{batchSearchOpen ? <BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
<section className="v2-kpis" aria-label="车辆整体统计">
<Card className="v2-kpis" bodyStyle={{ padding: 0 }} aria-label="车辆整体统计">
{[
['车辆总数', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
['无实时位置', formatNumber(summary.data?.noLocationVehicles ?? 0), 'missing'],
['当前线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
['静止车辆', formatNumber(summary.data?.idleVehicles ?? idle), 'idle'],
['告警车辆', summary.data?.alertDataAvailable ? formatNumber(summary.data.alertVehicles) : '', 'alert'],
['今日上报', formatNumber(summary.data?.frameToday ?? 0), 'today']
].map(([label, value, tone]) => <div key={label} className={`v2-kpi is-${tone}`}><small>{label}</small><strong>{value}</strong></div>)}
</section>
{ label: '车辆总数', value: formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), tone: 'fleet', priority: 'primary', unit: '辆' },
{ label: '当前在线', value: formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), tone: 'online', priority: 'primary', unit: '辆' },
{ label: '当前线', value: formatNumber(summary.data?.offlineVehicles ?? offline), tone: 'offline', priority: 'standard', unit: '辆' },
{ label: '行驶车辆', value: formatNumber(summary.data?.drivingVehicles ?? driving), tone: 'driving', priority: 'standard', unit: '辆' },
{ label: '静止车辆', value: formatNumber(summary.data?.idleVehicles ?? idle), tone: 'idle', priority: 'standard', unit: '辆' },
{ label: '告警车辆', value: summary.data?.alertDataAvailable ? formatNumber(summary.data.alertVehicles) : '—', tone: 'alert', priority: 'standard', unit: '辆' },
{ label: '今日上报', value: formatNumber(summary.data?.frameToday ?? 0), tone: 'today', priority: 'support', unit: '条' },
{ label: '无实时位置', value: formatNumber(summary.data?.noLocationVehicles ?? 0), tone: 'missing', priority: 'support', unit: '辆' }
].map(({ label, value, tone, priority, unit }) => <div key={label} className={`v2-kpi is-${tone} is-${priority}`}><small>{label}</small><strong>{value}<em>{unit}</em></strong></div>)}
</Card>
{vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆数据加载失败'} onRetry={() => vehicles.refetch()} /> : null}
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
@@ -419,23 +488,23 @@ export default function MonitorPage() {
/>
) : null}
{selected && !detailOpen ? (
<aside className="v2-detail-peek" aria-label="已收起的车辆详情">
<button type="button" aria-label="展开车辆详情" title={`展开 ${selected.plate || selected.vin} 的车辆详情`} onClick={expandDetail}>
<Card className="v2-detail-peek" bodyStyle={{ padding: 0 }}>
<Button theme="borderless" type="tertiary" aria-label="展开车辆详情" title={`展开 ${selected.plate || selected.vin} 的车辆详情`} onClick={expandDetail}>
<IconChevronLeft />
<i className={`v2-status-dot is-${vehicleStatus(selected)}`} />
<span>{selected.plate || '未绑定车牌'}</span>
</button>
</aside>
</Button>
</Card>
) : null}
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
<section className="v2-event-strip">
<Card className="v2-event-strip" bodyStyle={{ padding: 0 }}>
<strong></strong>
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
<Tag className="v2-monitor-live-tag" color="green" type="light" size="small"><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</Tag>
<span>{mode === 'map' ? `列表 ${visibleRows.length} 条 · 地图 ${map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}` : `实时列表 ${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0}`}</span>
<span className="v2-refresh-cadence"><b></b> {MONITOR_REFRESH.selected / 1000} · {MONITOR_REFRESH.fleet / 1000} · {MONITOR_REFRESH.summary / 1000} </span>
<span className="v2-refresh-cadence"><Tag color="blue" type="light" size="small"></Tag> {MONITOR_REFRESH.selected / 1000} · {MONITOR_REFRESH.fleet / 1000} · {MONITOR_REFRESH.summary / 1000} </span>
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
</section>
</Card>
{mobileEntryOpen ? <MobileEntry onClose={() => setMobileEntryOpen(false)} /> : null}
</div>
);

View File

@@ -8,9 +8,11 @@ const mocks = vi.hoisted(() => ({
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn(),
reconciliationSummary: vi.fn(), reconciliationIssues: vi.fn(), reconciliationIssue: vi.fn(), updateReconciliationIssue: vi.fn()
}));
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockReset()); });
afterEach(() => { cleanup(); layout.mobile = false; Object.values(mocks).forEach((mock) => mock.mockReset()); });
function seedSession() {
mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] });
@@ -41,7 +43,7 @@ function seedSession() {
test('renders reconciliation queue, loads evidence on demand and records review conclusion', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
linkHealth: [{ name: 'MySQL', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
@@ -57,10 +59,42 @@ test('renders reconciliation queue, loads evidence on demand and records review
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
expect(await screen.findByText('数据差异中心')).toBeInTheDocument();
fireEvent.click(await screen.findByText('多来源实时位置漂移'));
expect(screen.getByRole('tab', { name: '差异处置', selected: true })).toHaveClass('semi-button');
expect(screen.getByRole('tabpanel', { name: '差异处置' })).toBeInTheDocument();
expect(screen.queryByText('先选择一辆车')).not.toBeInTheDocument();
expect(screen.getByText('数据差异中心').closest('.semi-card')).toHaveClass('v2-reconcile-center');
expect(screen.getByText('数据差异中心').closest('.v2-workspace-panel-header')).toHaveClass('v2-reconcile-heading');
expect(document.querySelector('.v2-reconcile-kpi-card small')?.textContent).toBe('活跃差异');
expect(document.querySelectorAll('.v2-reconcile-kpi-card')).toHaveLength(4);
expect(screen.getByText('超过 24 小时').closest('.semi-card')).toHaveClass('v2-reconcile-sla');
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
const trendButton = screen.getByRole('button', { name: '30 天趋势' });
expect(trendButton).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(trendButton);
expect(screen.getByText('近 30 天趋势').closest('.semi-card')).toHaveClass('v2-reconcile-trend');
expect(screen.getByText('近 30 天趋势').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
expect(trendButton).toHaveAttribute('aria-expanded', 'true');
expect(document.querySelector('.v2-reconcile-table.semi-table-wrapper')).toBeInTheDocument();
const issueTitles = await screen.findAllByText('多来源实时位置漂移');
expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).not.toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-table-wrap > table')).not.toBeInTheDocument();
const reconcileRow = screen.getByTestId('reconcile-row-issue-1');
expect(reconcileRow).toHaveAttribute('role', 'button');
expect(reconcileRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.keyDown(reconcileRow, { key: ' ' });
expect(await screen.findByText('规则证据')).toBeInTheDocument();
expect(reconcileRow).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('规则证据').closest('.semi-card')).toHaveClass('v2-reconcile-evidence');
expect(screen.getByText('复核结论').closest('.semi-card')).toHaveClass('v2-reconcile-review');
expect(screen.getByText('处理履历').closest('.semi-card')).toHaveClass('v2-reconcile-actions');
expect(screen.getByText('规则证据').closest('.v2-workspace-panel-header')).toHaveClass('is-compact');
expect(document.querySelector('.v2-reconcile-detail-heading.v2-workspace-panel-header')).toBeInTheDocument();
expect(screen.getByText('1286')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('处置状态'), { target: { value: 'confirmed_source_a' } });
fireEvent.click(screen.getByRole('button', { name: '关闭差异详情' }));
expect(reconcileRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(issueTitles[0]);
expect(await screen.findByText('规则证据')).toBeInTheDocument();
fireEvent.click(screen.getByRole('radio', { name: '确认来源 A' }));
fireEvent.change(screen.getByLabelText('说明(必填)'), { target: { value: '来源 A 原始报文可信' } });
fireEvent.click(screen.getByRole('button', { name: '保存复核结论' }));
await waitFor(() => expect(mocks.updateReconciliationIssue).toHaveBeenCalledWith('issue-1', {
@@ -68,20 +102,65 @@ test('renders reconciliation queue, loads evidence on demand and records review
}));
});
test('reconciles service identities with bound and identity-required vehicles', async () => {
test('renders only the compact mobile reconciliation surface and defers secondary controls', async () => {
layout.mobile = true;
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
expect(await screen.findByText('多来源实时位置漂移')).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-mobile-card.semi-card')).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-table.semi-table-wrapper')).not.toBeInTheDocument();
expect(mocks.reconciliationIssues).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }), expect.any(AbortSignal));
const mobileIssueAction = screen.getByRole('button', { name: '查看 粤A00001 差异证据' });
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(mobileIssueAction);
expect(await screen.findByRole('dialog', { name: '差异证据与处置' })).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-detail-sidesheet .v2-reconcile-detail.is-sheet')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: '关闭差异详情' })).toBeInTheDocument();
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭差异详情' }));
expect(mobileIssueAction).toHaveAttribute('aria-expanded', 'false');
const toolbar = document.querySelector('.v2-reconcile-toolbar');
expect(toolbar).toHaveClass('is-mobile-collapsed');
fireEvent.click(screen.getByRole('button', { name: /修改筛选差异/ }));
expect(toolbar).not.toHaveClass('is-mobile-collapsed');
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '30 天趋势' }));
const trend = screen.getByText('近 30 天趋势').closest('.v2-reconcile-trend');
expect(trend).toBeInTheDocument();
expect(screen.getByText(/最近运行/)).toBeInTheDocument();
expect(document.querySelector('.v2-reconcile-trend-toggle')).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '收起趋势' }));
expect(screen.queryByText('近 30 天趋势')).not.toBeInTheDocument();
});
test('reconciles service identities with bound and identity-required vehicles', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [{ name: 'MySQL', status: 'ok', detail: '主库连接正常' }], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({
totalVehicles: 1024, boundVehicles: 1024, identityRequiredVehicles: 11, onlineVehicles: 234,
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: []
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: [{
protocol: 'GB32960', role: '仪表盘里程与整车状态', total: 1024, online: 234, severity: 'ok',
evidence: '实时来源覆盖稳定', action: '保持在线率监测', acceptance: '在线率与上报周期持续满足阈值'
}]
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
fireEvent.click(screen.getByRole('tab', { name: '全局健康' }));
expect(await screen.findByText('1024 / 234')).toBeInTheDocument();
for (const key of [['ops-health-v2'], ['ops-source-readiness-v2']]) {
const query = client.getQueryCache().find({ queryKey: key });
@@ -91,6 +170,22 @@ test('reconciles service identities with bound and identity-required vehicles',
}
expect(screen.getByText('服务身份 / 在线')).toBeInTheDocument();
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
expect(screen.getByText('服务身份 / 在线').closest('.semi-card')).toHaveClass('v2-ops-kpi-card');
expect(screen.getByText('运行时安全').closest('.semi-card')).toHaveClass('v2-ops-runtime');
expect(screen.getByText('协议来源就绪度').closest('.semi-card')).toHaveClass('v2-ops-sources');
expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '运行时安全', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '协议来源就绪度', level: 5 })).toBeInTheDocument();
expect(document.querySelector('.v2-ops-link-card.semi-card')).toHaveTextContent('MySQL主库连接正常');
expect(document.querySelector('.v2-ops-runtime-descriptions.semi-descriptions')).toBeInTheDocument();
expect(screen.getByText('容量检查通过').closest('.semi-empty')).toHaveClass('v2-ops-capacity-empty');
expect(document.querySelector('.v2-ops-source-list.semi-card-group')).toBeInTheDocument();
expect(screen.getByText('GB32960').closest('.semi-card')).toHaveClass('v2-ops-source-card');
expect(screen.getByText('验收标准').closest('.semi-card')).toHaveClass('v2-ops-source-card');
expect(screen.queryByText('单车多来源诊断')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
expect(screen.getByText('先选择一辆车').closest('.semi-empty')).toHaveClass('v2-source-empty');
expect(screen.getByText('单车多来源诊断').closest('.semi-card')).toHaveClass('v2-source-diagnostic');
expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument();
});
@@ -108,8 +203,10 @@ test('keeps health evidence visible when source readiness fails and retries that
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
fireEvent.click(screen.getByRole('tab', { name: '全局健康' }));
expect(await screen.findByText('来源就绪度暂时不可用')).toBeInTheDocument();
expect(screen.getByText('test-release')).toBeInTheDocument();
expect(screen.getByText('版本 test-release')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /重试/ }));
expect(await screen.findByText('1024 / 234')).toBeInTheDocument();
@@ -146,6 +243,7 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } });
const candidateButton = await waitFor(() => {
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
@@ -154,8 +252,21 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
});
fireEvent.click(candidateButton);
expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument();
expect(document.querySelectorAll('.v2-source-summary-card.semi-card')).toHaveLength(4);
expect(document.querySelectorAll('.v2-source-summary-card')[1]?.querySelector('.semi-tag')).toHaveTextContent('JT808');
expect(screen.getByText('推荐说明').closest('.semi-card')).toHaveClass('v2-source-recommendation');
expect(screen.getByText('最近策略审计').closest('.semi-card')).toHaveClass('v2-source-audit');
expect(screen.getByText('终端 133****0001')).toBeInTheDocument();
expect(screen.getByText('10s')).toBeInTheDocument();
expect(document.querySelector('.v2-source-table.semi-table-wrapper')).toBeInTheDocument();
expect(document.querySelector('.v2-source-table-wrap > table')).not.toBeInTheDocument();
expect(document.querySelector('.v2-source-mobile-list')).not.toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '来源 / 终端' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '运维策略' })).toBeInTheDocument();
expect(screen.getByText('优先级 20')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '维护策略' }));
expect(await screen.findByRole('dialog', { name: '车辆来源策略' })).toBeInTheDocument();
expect(document.querySelector('.v2-source-policy-sidesheet .semi-sidesheet-inner')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('G7 提供方'), { target: { value: 'G7s' } });
const save = screen.getByRole('button', { name: '保存策略' });
expect(save).toBeDisabled();
@@ -175,6 +286,7 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
});
test('allows provider maintenance but keeps canonical source policy read only', async () => {
layout.mobile = true;
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
@@ -202,6 +314,7 @@ test('allows provider maintenance but keeps canonical source policy read only',
mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } });
const candidateButton = await waitFor(() => {
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
@@ -210,6 +323,14 @@ test('allows provider maintenance but keeps canonical source policy read only',
});
fireEvent.click(candidateButton);
expect(await screen.findByText('协议融合快照只能维护提供方')).toBeInTheDocument();
expect(document.querySelector('.v2-source-table')).not.toBeInTheDocument();
expect(document.querySelector('.v2-source-mobile-card.semi-card')).toBeInTheDocument();
expect(document.querySelector('.v2-source-mobile-descriptions.semi-descriptions')).toBeInTheDocument();
expect(screen.getByText('优先级 100')).toBeInTheDocument();
expect(screen.queryByLabelText('JT808 优先级')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '维护提供方' }));
expect(await screen.findByRole('dialog', { name: '车辆来源策略' })).toBeInTheDocument();
expect(await screen.findByLabelText('JT808 优先级')).toBeDisabled();
expect(screen.getByLabelText('JT808 策略备注')).toBeDisabled();
expect(screen.getByRole('checkbox', { name: '启用' })).toBeDisabled();

View File

@@ -1,16 +1,30 @@
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useDeferredValue, useEffect, useState } from 'react';
import { api } from '../../api/client';
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { PageHeader } from '../shared/PageHeader';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import ReconciliationCenter from './ReconciliationCenter';
type OperationsWorkspace = 'reconciliation' | 'diagnostic' | 'health';
function statusLabel(status: string) {
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
}
function HealthTag({ status, children }: { status: string; children?: string }) {
const color = status === 'ok' ? 'green' : status === 'warning' ? 'orange' : status === 'error' ? 'red' : 'grey';
return <Tag className={`v2-ops-health-tag is-${status}`} color={color} type="light" size="small"><i />{children ?? statusLabel(status)}</Tag>;
}
function fmt(value?: string) {
if (!value) return '—';
const parsed = new Date(value.replace(' ', 'T'));
@@ -21,7 +35,7 @@ function number(value?: number, digits = 1) {
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits });
}
function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
function SourcePolicyEditor({ vin, source, diagnostic, editable, onSaved }: {
vin: string;
source: VehicleLocationSourceEvidence;
diagnostic: VehicleSourceDiagnostic;
@@ -56,28 +70,140 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
const policyEditable = source.sourceKind !== 'CANONICAL';
const policyChanged = enabled !== source.enabled || priority !== source.priority || remark.trim() !== (source.policyRemark || '');
const changed = (policyEditable && policyChanged) || providerChanged;
return <tr className={source.recommended ? 'is-recommended' : ''}>
<td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td>
<td><b>{source.protocol}</b><span>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</span></td>
<td><i className={source.online ? 'is-online' : 'is-offline'} />{source.online ? '在线' : '离线'}<span>{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''}</span></td>
<td><strong>{fmt(source.firstSeenAt)}</strong><span> {fmt(source.receivedAt || source.eventTime)}</span></td>
<td><strong>{source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}</strong><span>{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'}</span></td>
<td><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><span>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</span></td>
<td className="v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><span>{source.selectionReason || '等待选举说明'}</span></td>
<td className="v2-source-policy-cell">
<label><input type="checkbox" checked={enabled} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} /></label>
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
<input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={(event) => setProviderName(event.target.value)} placeholder="提供方,如 G7s" />
<input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={(event) => setProviderEvidence(event.target.value)} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
<input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : policyEditable ? '保存策略' : '保存提供方'}</button>
return <div className="v2-source-policy-cell">
<Checkbox checked={enabled} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setEnabled(Boolean(event.target.checked))} aria-label="启用"></Checkbox>
<Input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={String(priority)} disabled={!editable || !policyEditable || save.isPending} onChange={(value) => setPriority(Number(value))} />
<Input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={setProviderName} placeholder="提供方,如 G7s" />
<Input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={setProviderEvidence} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
<Input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || !policyEditable || save.isPending} onChange={setRemark} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
<Button theme="solid" size="small" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : policyEditable ? '保存策略' : '保存提供方'}</Button>
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
</td>
</tr>;
</div>;
}
function SourceIdentity({ source }: { source: VehicleLocationSourceEvidence }) {
return <div className="v2-source-cell v2-source-identity-cell"><span><strong>{source.sourceLabel}</strong>{source.recommended ? <Tag color="green" type="light" size="small"></Tag> : null}</span><small>{source.terminalLabel || source.sourceKind || '未维护终端'}</small></div>;
}
function SourceProtocol({ source }: { source: VehicleLocationSourceEvidence }) {
return <div className="v2-source-cell"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</small></div>;
}
function SourceHealth({ source }: { source: VehicleLocationSourceEvidence }) {
return <div className="v2-source-cell v2-source-health-cell"><span><i className={source.online ? 'is-online' : 'is-offline'} /><strong>{source.online ? '在线' : '离线'}</strong></span><small>{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''}</small></div>;
}
function SourceTiming({ source }: { source: VehicleLocationSourceEvidence }) {
return <div className="v2-source-cell"><strong>{fmt(source.firstSeenAt)}</strong><small> {fmt(source.receivedAt || source.eventTime)}</small></div>;
}
function SourceInterval({ source }: { source: VehicleLocationSourceEvidence }) {
return <div className="v2-source-cell"><strong>{source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}</strong><small>{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'}</small></div>;
}
function SourcePosition({ source }: { source: VehicleLocationSourceEvidence }) {
return <div className="v2-source-cell"><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><small>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</small></div>;
}
function SourceDecision({ source }: { source: VehicleLocationSourceEvidence }) {
return <div className="v2-source-cell v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><small>{source.selectionReason || '等待选举说明'}</small></div>;
}
function sourceRowKey(source?: VehicleLocationSourceEvidence) {
return source?.sourceRef || `${source?.protocol ?? ''}-${source?.sourceLabel ?? ''}-${source?.terminalLabel ?? ''}`;
}
function SourcePolicySummary({ source, onEdit }: {
source: VehicleLocationSourceEvidence;
onEdit: () => void;
}) {
return <div className="v2-source-policy-summary">
<span><Tag color={source.enabled ? 'green' : 'grey'} type="light" size="small">{source.enabled ? '已启用' : '已禁用'}</Tag><b> {source.priority}</b></span>
<small>{source.providerOverride || '提供方未维护'}</small>
<Button theme="light" size="small" onClick={onEdit}>{source.sourceKind === 'CANONICAL' ? '维护提供方' : '维护策略'}</Button>
</div>;
}
function SourcePolicySheet({ vin, source, diagnostic, editable, onClose, onSaved }: {
vin: string;
source?: VehicleLocationSourceEvidence;
diagnostic: VehicleSourceDiagnostic;
editable: boolean;
onClose: () => void;
onSaved: (next: VehicleSourceDiagnostic) => void;
}) {
useSideSheetA11y(Boolean(source), '.v2-source-policy-sidesheet', 'v2-source-policy-sheet', '车辆来源策略', '关闭车辆来源策略');
return <SideSheet
className="v2-source-policy-sidesheet"
visible={Boolean(source)}
width={440}
aria-label="车辆来源策略"
title={source ? <div className="v2-source-policy-sheet-title"><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.protocol} · {source.sourceKind === 'CANONICAL' ? '仅维护提供方' : '来源策略'}</span></div> : '来源策略'}
onCancel={onClose}
footer={null}
>
{source ? <div className="v2-source-policy-sheet-content"><div className="v2-source-policy-sheet-summary"><SourceHealth source={source} /><SourceDecision source={source} /></div><SourcePolicyEditor vin={vin} source={source} diagnostic={diagnostic} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => { onSaved(next); onClose(); }} /></div> : null}
</SideSheet>;
}
function SourceDiagnosticTable({ vin, sources, diagnostic, editable, onSaved }: {
vin: string;
sources: VehicleLocationSourceEvidence[];
diagnostic: VehicleSourceDiagnostic;
editable: boolean;
onSaved: (next: VehicleSourceDiagnostic) => void;
}) {
const [editingSource, setEditingSource] = useState<VehicleLocationSourceEvidence>();
const columns = [
{ title: '来源 / 终端', dataIndex: 'sourceLabel', width: 180, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceIdentity source={source} /> },
{ title: '协议', dataIndex: 'protocol', width: 120, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceProtocol source={source} /> },
{ title: '在线 / 质量', dataIndex: 'online', width: 190, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceHealth source={source} /> },
{ title: '首次 / 最近上报', dataIndex: 'firstSeenAt', width: 220, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceTiming source={source} /> },
{ title: '上报周期', dataIndex: 'reportIntervalSec', width: 145, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceInterval source={source} /> },
{ title: '位置 / 里程', dataIndex: 'longitude', width: 230, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourcePosition source={source} /> },
{ title: '选举结论', dataIndex: 'recommended', width: 220, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceDecision source={source} /> },
{ title: '运维策略', dataIndex: 'policy', width: 200, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourcePolicySummary source={source} onEdit={() => setEditingSource(source)} /> }
];
return <><div className="v2-source-table-wrap"><Table
className="v2-source-table"
columns={columns}
dataSource={sources}
rowKey={sourceRowKey}
pagination={false}
scroll={{ x: 1505 }}
empty={null}
onRow={(source) => source ? ({ className: source.recommended ? 'is-recommended' : '' }) : ({})}
/></div>
<SourcePolicySheet vin={vin} source={editingSource} diagnostic={diagnostic} editable={editable} onClose={() => setEditingSource(undefined)} onSaved={onSaved} />
</>;
}
function SourceDiagnosticCards({ vin, sources, diagnostic, editable, onSaved }: {
vin: string;
sources: VehicleLocationSourceEvidence[];
diagnostic: VehicleSourceDiagnostic;
editable: boolean;
onSaved: (next: VehicleSourceDiagnostic) => void;
}) {
const [editingSource, setEditingSource] = useState<VehicleLocationSourceEvidence>();
return <><div className="v2-source-mobile-list">{sources.map((source) => <Card className={`v2-source-mobile-card${source.recommended ? ' is-recommended' : ''}`} bodyStyle={{ padding: 0 }} key={sourceRowKey(source)}>
<header><SourceIdentity source={source} /><SourceHealth source={source} /></header>
<Descriptions className="v2-source-mobile-descriptions" align="left" size="small" data={[
{ key: '协议状态', value: <SourceProtocol source={source} /> },
{ key: '首次 / 最近上报', value: <SourceTiming source={source} /> },
{ key: '上报周期', value: <SourceInterval source={source} /> },
{ key: '位置 / 里程', value: <SourcePosition source={source} /> },
{ key: '选举结论', value: <SourceDecision source={source} /> }
]} />
<section><strong></strong><SourcePolicySummary source={source} onEdit={() => setEditingSource(source)} /></section>
</Card>)}</div>
<SourcePolicySheet vin={vin} source={editingSource} diagnostic={diagnostic} editable={editable} onClose={() => setEditingSource(undefined)} onSaved={onSaved} />
</>;
}
function SourceDiagnosticWorkspace() {
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim());
const [selected, setSelected] = useState<VehicleCoverageRow>();
@@ -109,71 +235,109 @@ function SourceDiagnosticWorkspace() {
};
const data = diagnostic.data;
const editable = session.data?.role === 'admin';
return <section className="v2-source-diagnostic">
<header>
<div><small></small><strong></strong><span> RAW访</span></div>
{selected ? <button type="button" onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}><IconRefresh /></button> : null}
</header>
const onSourceSaved = (next: VehicleSourceDiagnostic) => {
queryClient.setQueryData(['ops-source-diagnostic', next.evidence.vin], next);
void Promise.all([
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] }),
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
]);
};
return <Card className="v2-source-diagnostic" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="单车多来源诊断"
description="按需查询,不加载全量 RAW普通客户无权访问。"
actions={selected ? <Button theme="light" icon={<IconRefresh />} onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}></Button> : null}
/>
<form className="v2-source-search" onSubmit={submit}>
<label><IconSearch /><input aria-label="按车牌或 VIN 搜索诊断车辆" value={keyword} onChange={(event) => { setKeyword(event.target.value); setSelected(undefined); }} placeholder="输入车牌或 VIN支持模糊搜索" /></label>
<button type="submit" disabled={!candidates.data?.items.length}></button>
{deferredKeyword && !selected ? <div className="v2-source-candidates">
{candidates.isFetching ? <p></p> : candidates.data?.items.map((vehicle) => <button type="button" key={vehicle.vin} onMouseDown={(event) => event.preventDefault()} onClick={() => choose(vehicle)}>
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em>{vehicle.protocols.join(' / ') || '暂无来源'}</em>
</button>)}
{!candidates.isFetching && !candidates.data?.items.length ? <p></p> : null}
{(candidates.data?.total ?? 0) > 20 ? <footer><span> {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} </span><div><button type="button" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}></button><button type="button" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}></button></div></footer> : null}
</div> : null}
<Input aria-label="按车牌或 VIN 搜索诊断车辆" prefix={<IconSearch />} value={keyword} onChange={(value) => { setKeyword(value); setSelected(undefined); }} placeholder="输入车牌或 VIN支持模糊搜索" />
<Button htmlType="submit" theme="solid" disabled={!candidates.data?.items.length}></Button>
{deferredKeyword && !selected ? <VehicleCandidateList
className="v2-source-candidates"
items={candidates.data?.items ?? []}
loading={candidates.isFetching}
loadingText="正在查询车辆…"
emptyText="没有匹配的已绑定车辆"
actionLabel="诊断"
showProtocols
onSelect={choose}
footer={(candidates.data?.total ?? 0) > 20 ? <><span> {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} </span><div><Button theme="light" size="small" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}></Button><Button theme="light" size="small" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}></Button></div></> : undefined}
/> : null}
</form>
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
{!selected ? <div className="v2-source-empty"><strong></strong><span></span></div> : null}
{selected && diagnostic.isPending ? <div className="v2-source-empty"><strong></strong><span></span></div> : null}
{!selected ? <Empty className="v2-source-empty" title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null}
{selected && diagnostic.isPending ? <div className="v2-source-loading" role="status"><Spin size="large" /><strong></strong><span></span></div> : null}
{data ? <>
<div className="v2-source-summary">
<article><small></small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></article>
<article><small></small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><span>{data.evidence.recommendedLocationProtocol || '—'}</span></article>
<article><small></small><strong>{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 线</strong><span>{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}</span></article>
<article><small></small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}</span></article>
<Card className="v2-source-summary-card"><small></small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></Card>
<Card className="v2-source-summary-card"><small></small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><Tag color="blue" type="light" size="small">{data.evidence.recommendedLocationProtocol || '—'}</Tag></Card>
<Card className={`v2-source-summary-card${data.evidence.locationConflict ? ' is-warning' : ' is-ok'}`}><small></small><strong>{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 线</strong><span>{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}</span></Card>
<Card className="v2-source-summary-card"><small></small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}</span></Card>
</div>
<div className="v2-source-recommendation"><strong></strong><p>{data.recommendationReason}</p><span>{data.refreshHint}</span></div>
{!editable ? <p className="v2-source-readonly"></p> : null}
<div className="v2-source-table-wrap"><table className="v2-source-table"><thead><tr><th> / </th><th></th><th>线 / </th><th> / </th><th></th><th> / </th><th></th><th></th></tr></thead><tbody>
{data.evidence.locationSources.map((source) => <SourcePolicyRow key={source.sourceRef || `${source.protocol}-${source.sourceLabel}-${source.terminalLabel}`} vin={data.evidence.vin} source={source} diagnostic={data} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => {
queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next);
void Promise.all([
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] }),
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
]);
}} />)}
</tbody></table></div>
<section className="v2-source-audit"><header><strong></strong><span> source_key </span></header>
<Card className="v2-source-recommendation"><header><Tag color="blue" type="light" size="small"></Tag><span>{data.refreshHint}</span></header><p>{data.recommendationReason}</p></Card>
{!editable ? <div className="v2-source-readonly"><Tag color="orange" type="light" size="small"></Tag><span></span></div> : null}
{mobileLayout
? <SourceDiagnosticCards vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />
: <SourceDiagnosticTable vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />}
<Card className="v2-source-audit" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="最近策略审计" description="仅记录实际变更,原始 source_key 不对前端暴露" />
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.changeType}-${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p></p>}
</section>
</Card>
</> : null}
</section>;
</Card>;
}
export default function OperationsPage() {
const [workspace, setWorkspace] = useState<OperationsWorkspace>('reconciliation');
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
const data = health.data; const sources = readiness.data;
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
return <div className="v2-ops-page">
<header className="v2-ops-heading"><div><h2></h2><p></p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh /></button></header>
<ReconciliationCenter />
<SourceDiagnosticWorkspace />
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}
<section className="v2-ops-kpis">
<article><small></small><strong>{data?.runtime.platformRelease || '未注入'}</strong><span className={data?.runtime.dataMode === 'production' ? 'is-ok' : 'is-error'}>{data?.runtime.dataMode || 'unknown'}</span></article>
<article><small></small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article>
<article><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><span className={data?.kafkaLag === 0 ? 'is-ok' : 'is-warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</span></article>
<article><small>Redis 线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small> / 线</small><strong>{sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'}</strong><span>{sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'}</span></article>
<PageHeader
title="运维质量"
description="先定位单车多来源问题,再核对服务和协议全局健康;所有结论来自服务端证据。"
status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'}
statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'}
meta={<Typography.Text type="tertiary">{data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}</Typography.Text>}
actions={<Button theme="light" icon={<IconRefresh />} loading={health.isFetching || readiness.isFetching} onClick={refresh}></Button>}
/>
<SegmentedTabs
className="v2-ops-tabs"
variant="filled"
ariaLabel="运维质量工作区"
value={workspace}
onChange={setWorkspace}
items={[
{ key: 'reconciliation', label: '差异处置' },
{ key: 'diagnostic', label: '单车诊断' },
{ key: 'health', label: '全局健康' }
]}
/>
<section className={`v2-ops-workspace is-${workspace}`} role="tabpanel" aria-label={workspace === 'reconciliation' ? '差异处置' : workspace === 'diagnostic' ? '单车诊断' : '全局健康'}>
{workspace === 'reconciliation' ? <ReconciliationCenter /> : null}
{workspace === 'diagnostic' ? <SourceDiagnosticWorkspace /> : null}
{workspace === 'health' ? <>
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}
<section className="v2-ops-kpis">
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small></small><strong>{data?.runtime.dataMode === 'production' ? '生产数据' : '待确认'}</strong><HealthTag status={data?.runtime.dataMode === 'production' ? 'ok' : 'error'}>{data?.runtime.platformRelease ? '版本已注入' : '缺少版本'}</HealthTag></Card>
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small></small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span></span></Card>
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><HealthTag status={data?.kafkaLag === 0 ? 'ok' : 'warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</HealthTag></Card>
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small>Redis 线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span>线</span></Card>
<Card className="v2-ops-kpi-card" bodyStyle={{ padding: 0 }}><small> / 线</small><strong>{sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'}</strong><span>{sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'}</span></Card>
</section>
<div className="v2-ops-grid"><Card className="v2-ops-panel v2-ops-links" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="数据链路" meta="15 秒自动刷新" /><div className="v2-ops-link-list">{data?.linkHealth.length ? data.linkHealth.map((item) => <Card className={`v2-ops-link-card is-${item.status}`} key={item.name} bodyStyle={{ padding: 0 }}><span className="v2-ops-link-status"><i /><strong>{item.name}</strong></span><p>{item.detail || '无补充信息'}</p><HealthTag status={item.status} /></Card>) : <Empty className="v2-ops-link-empty" title={data ? '暂无链路探针' : '正在读取链路状态'} description={data ? '当前响应没有可展示的数据链路证据。' : '全局健康数据返回后将在这里更新。'} />}</div></Card>
<Card className="v2-ops-panel v2-ops-runtime" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="运行时安全" /><Descriptions className="v2-ops-runtime-descriptions" align="left" size="small" data={[
{ key: '生产数据模式', value: <HealthTag status={data?.runtime.dataMode === 'production' ? 'ok' : 'error'}>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</HealthTag> },
{ key: 'MySQL 写探针', value: <HealthTag status={data?.mysqlWritable ? 'ok' : 'error'}>{data?.mysqlWritable ? '正常' : '异常'}</HealthTag> },
{ key: 'TDengine 写探针', value: <HealthTag status={data?.tdengineWritable ? 'ok' : 'error'}>{data?.tdengineWritable ? '正常' : '异常'}</HealthTag> },
{ key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` },
{ key: '高德安全代理', value: <HealthTag status={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'ok' : 'warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</HealthTag> }
]} />{data?.capacityFindings?.length ? <div className="v2-ops-capacity-findings">{data.capacityFindings.map((item) => <Card className="v2-ops-capacity-finding" key={item}><HealthTag status="warning"></HealthTag><p>{item}</p></Card>)}</div> : <Empty className="v2-ops-capacity-empty" image={<IconTickCircle />} title="容量检查通过" description="当前没有需要处理的容量风险。" />}</Card></div>
<Card className="v2-ops-panel v2-ops-sources" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="协议来源就绪度" description="验收口径与处置建议" />{sources ? sources.sources.length ? <CardGroup className="v2-ops-source-list" type="grid" spacing={0}>{sources.sources.map((source) => <Card className={`v2-ops-source-card is-${source.severity}`} key={source.protocol} title={<span className="v2-ops-source-title"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.role}</small></span>} headerExtraContent={<HealthTag status={source.severity}>{`${source.online} / ${source.total} 在线`}</HealthTag>} headerLine>
<div className="v2-ops-source-evidence"><strong></strong><p>{source.evidence}</p></div><div className="v2-ops-source-action"><strong></strong><p>{source.action}</p></div><footer><Tag color="green" type="light" size="small"></Tag><span>{source.acceptance}</span></footer>
</Card>)}</CardGroup> : <Empty className="v2-ops-source-empty" title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : <div className="v2-ops-source-loading" role="status"><Spin size="middle" tip="正在读取协议来源就绪度" /></div>}</Card>
</> : null}
</section>
<div className="v2-ops-grid"><section className="v2-ops-links"><header><strong></strong><span>15 </span></header><div>{data?.linkHealth.map((item) => <article key={item.name}><i className={`is-${item.status}`} /><div><strong>{item.name}</strong><p>{item.detail || '无补充信息'}</p></div><span className={`is-${item.status}`}>{statusLabel(item.status)}</span></article>)}</div></section>
<section className="v2-ops-runtime"><header><strong></strong></header><dl><div><dt></dt><dd>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</dd></div><div><dt>MySQL </dt><dd className={data?.mysqlWritable ? 'is-ok' : 'is-error'}>{data?.mysqlWritable ? '正常' : '异常'}</dd></div><div><dt>TDengine </dt><dd className={data?.tdengineWritable ? 'is-ok' : 'is-error'}>{data?.tdengineWritable ? '正常' : '异常'}</dd></div><div><dt></dt><dd>{data?.runtime.requestTimeoutMs ?? '—'} ms</dd></div><div><dt></dt><dd className={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'is-ok' : 'is-warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</dd></div></dl>{data?.capacityFindings?.length ? <div className="v2-ops-findings">{data.capacityFindings.map((item) => <p key={item}>{item}</p>)}</div> : <p className="v2-ops-clear"></p>}</section></div>
<section className="v2-ops-sources"><header><strong></strong><span></span></header><div>{sources?.sources.map((source) => <article key={source.protocol}><div><i className={`is-${source.severity}`} /><strong>{source.protocol}</strong><span>{source.role}</span></div><b>{source.online} / {source.total} 线</b><p>{source.evidence}</p><p>{source.action}</p><em>{source.acceptance}</em></article>)}</div></section>
</div>;
}

View File

@@ -1,12 +1,20 @@
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, RadioGroup, Select, SideSheet, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { ReconciliationIssue } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { TablePagination } from '../shared/TablePagination';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { detailTriggerRow } from '../shared/detailTriggerRow';
import { QUERY_MEMORY } from '../queryPolicy';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const PAGE_SIZE = 50;
const DESKTOP_PAGE_SIZE = 50;
const MOBILE_PAGE_SIZE = 20;
const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']);
const reviewStatuses = [
{ value: 'pending', label: '待处理' },
@@ -31,6 +39,16 @@ function severityLabel(severity: string) {
return { critical: '严重', major: '重要', minor: '一般' }[severity] ?? severity;
}
function ReconciliationSeverityTag({ severity }: { severity: string }) {
const color = severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'grey';
return <Tag className={`v2-reconcile-severity is-${severity}`} color={color} type="light" size="small">{severityLabel(severity)}</Tag>;
}
function ReconciliationStatusTag({ status }: { status: string }) {
const color = status === 'pending' ? 'orange' : status === 'confirmed_source_a' || status === 'confirmed_source_b' ? 'blue' : status === 'fixed' || status === 'recovered' ? 'green' : 'grey';
return <Tag className={`v2-reconcile-status is-${status}`} color={color} type="light" size="small">{statusLabel(status)}</Tag>;
}
function ruleLabel(rule: string) {
return {
DUPLICATE_PLATE: '重复车牌',
@@ -59,7 +77,7 @@ function evidenceValue(value: unknown) {
return JSON.stringify(value, null, 2);
}
function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue; onClose: () => void }) {
function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: ReconciliationIssue; onClose: () => void; sheet?: boolean }) {
const queryClient = useQueryClient();
const [status, setStatus] = useState(issue.status === 'recovered' ? 'pending' : issue.status);
const [note, setNote] = useState(issue.resolutionNote ?? '');
@@ -78,11 +96,14 @@ function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue;
}
});
const requiresNote = status !== 'pending';
return <aside className="v2-reconcile-detail" aria-label="差异证据与处置">
<header>
<div><span className={`is-${issue.severity}`}>{severityLabel(issue.severity)}</span><strong>{issue.title}</strong><small>{ruleLabel(issue.ruleCode)}</small></div>
<button type="button" onClick={onClose} aria-label="关闭差异详情">×</button>
</header>
return <Card className={`v2-reconcile-detail${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }} aria-label="差异证据与处置">
<WorkspacePanelHeader
className="v2-reconcile-detail-heading"
title={issue.title}
description={ruleLabel(issue.ruleCode)}
meta={<ReconciliationSeverityTag severity={issue.severity} />}
actions={sheet ? undefined : <Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭差异详情" />}
/>
<div className="v2-reconcile-detail-body">
<section className="v2-reconcile-identity">
<div><small></small><strong>{issue.plate || '未登记车牌'}</strong><span>{issue.vin || '非单车差异'}</span></div>
@@ -90,26 +111,32 @@ function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue;
<div><small></small><strong>{fmt(issue.lastSeenAt)}</strong><span> {fmt(issue.firstSeenAt)}</span></div>
</section>
<p className="v2-reconcile-summary">{issue.summary}</p>
<section className="v2-reconcile-evidence">
<header><strong></strong><span></span></header>
<Card className="v2-reconcile-evidence" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
title="规则证据"
description="保留来源值、时间与影响对象;未知来源不自动判对错"
/>
<dl>{Object.entries(issue.evidence ?? {}).map(([key, value]) => <div key={key}><dt>{key}</dt><dd><pre>{evidenceValue(value)}</pre></dd></div>)}</dl>
</section>
<section className="v2-reconcile-review">
<header><strong></strong><span> v{issue.version}</span></header>
<label><span></span><select value={status} onChange={(event) => setStatus(event.target.value)}>{reviewStatuses.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
<label><span>{requiresNote ? '(必填)' : '(可选)'}</span><textarea value={note} maxLength={500} onChange={(event) => setNote(event.target.value)} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
<button type="button" disabled={save.isPending || (requiresNote && !note.trim())} onClick={() => save.mutate()}>{save.isPending ? '正在保存…' : '保存复核结论'}</button>
</Card>
<Card className="v2-reconcile-review" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title="复核结论" meta={`版本 v${issue.version}`} />
<label><span id="reconcile-review-status-label"></span><RadioGroup aria-labelledby="reconcile-review-status-label" type="button" buttonSize="small" value={status} options={reviewStatuses} onChange={(event) => setStatus(String(event.target.value))} /></label>
<label><span>{requiresNote ? '(必填)' : '(可选)'}</span><TextArea aria-label={`说明${requiresNote ? '(必填)' : '(可选)'}`} value={note} maxCount={500} autosize={{ minRows: 3, maxRows: 6 }} onChange={setNote} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
<Button theme="solid" disabled={save.isPending || (requiresNote && !note.trim())} loading={save.isPending} onClick={() => save.mutate()}></Button>
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
</section>
<section className="v2-reconcile-actions">
<header><strong></strong><span>{issue.actions?.length ?? 0} </span></header>
</Card>
<Card className="v2-reconcile-actions" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title="处理履历" meta={`${issue.actions?.length ?? 0}`} />
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · {fmt(action.createdAt)}</span></div></li>)}</ol> : <p></p>}
</section>
</Card>
</div>
</aside>;
</Card>;
}
export default function ReconciliationCenter() {
const mobileLayout = useMobileLayout();
const pageSize = mobileLayout ? MOBILE_PAGE_SIZE : DESKTOP_PAGE_SIZE;
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim());
const [status, setStatus] = useState('active');
@@ -117,7 +144,11 @@ export default function ReconciliationCenter() {
const [ruleCode, setRuleCode] = useState('all');
const [offset, setOffset] = useState(0);
const [selectedID, setSelectedID] = useState('');
useEffect(() => setOffset(0), [deferredKeyword, ruleCode, severity, status]);
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [trendExpanded, setTrendExpanded] = useState(false);
const closeDetail = () => setSelectedID('');
useSideSheetA11y(mobileLayout && Boolean(selectedID), '.v2-reconcile-detail-sidesheet', 'v2-reconcile-detail-sheet', '差异证据与处置', '关闭差异详情');
useEffect(() => setOffset(0), [deferredKeyword, mobileLayout, ruleCode, severity, status]);
const summary = useQuery({
queryKey: ['reconciliation-summary', 30],
@@ -126,9 +157,9 @@ export default function ReconciliationCenter() {
gcTime: QUERY_MEMORY.summaryGcTime
});
const issues = useQuery({
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, offset],
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, pageSize, offset],
queryFn: ({ signal }) => api.reconciliationIssues({
keyword: deferredKeyword, ruleCode, severity, status, limit: PAGE_SIZE, offset
keyword: deferredKeyword, ruleCode, severity, status, limit: pageSize, offset
}, signal),
staleTime: 20_000,
gcTime: QUERY_MEMORY.highVolumeGcTime
@@ -149,60 +180,142 @@ export default function ReconciliationCenter() {
const data = summary.data;
const page = issues.data;
const activeCount = page?.items.filter((item) => activeStatuses.has(item.status)).length ?? 0;
const currentPage = Math.floor(offset / pageSize) + 1;
const totalPages = Math.max(1, Math.ceil((page?.total ?? 0) / pageSize));
const issueRows = page?.items ?? [];
const activeFilterCount = Number(Boolean(deferredKeyword)) + Number(severity !== 'all') + Number(ruleCode !== 'all');
const filterSummary = `${status === 'active' ? '活跃差异' : statusLabel(status)}${activeFilterCount ? ` · 另 ${activeFilterCount}` : ''}`;
const selectedIssue = detail.data ?? issueRows.find((item) => item.id === selectedID);
const detailPanel = selectedID && detail.isPending
? <Card className={`v2-reconcile-detail${mobileLayout ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}><div className="v2-reconcile-detail-loading" role="status"><Spin size="middle" tip="正在读取差异证据…" /></div></Card>
: selectedID && detail.isError
? <Card className={`v2-reconcile-detail${mobileLayout ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></Card>
: detail.data
? <ReconciliationDetail issue={detail.data} onClose={closeDetail} sheet={mobileLayout} />
: null;
const columns = [
{ title: '等级', dataIndex: 'severity', width: 76, render: (value: string) => <ReconciliationSeverityTag severity={value} /> },
{ title: '差异 / 规则', dataIndex: 'title', width: 230, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{item.title}</strong><small>{ruleLabel(item.ruleCode)} · {item.occurrenceCount.toLocaleString('zh-CN')} </small></span> },
{ title: '车辆', dataIndex: 'plate', width: 176, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{item.plate || '非单车差异'}</strong><small>{item.vin || '平台级口径'}</small></span> },
{ title: '来源', dataIndex: 'protocolA', width: 150, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><small>{item.category}</small></span> },
{ title: '最近发现', dataIndex: 'lastSeenAt', width: 190, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{fmt(item.lastSeenAt)}</strong><small> {fmt(item.firstSeenAt)}</small></span> },
{ title: '状态', dataIndex: 'status', width: 112, render: (value: string) => <ReconciliationStatusTag status={value} /> }
];
return <section className="v2-reconcile-center">
<header className="v2-reconcile-heading">
<div><small></small><strong></strong><span></span></div>
<button type="button" onClick={refresh} disabled={summary.isFetching || issues.isFetching}><IconRefresh /></button>
</header>
return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-reconcile-heading"
title="数据差异中心"
description="自动发现、相同问题去重、恢复后闭环;复核人员只确认证据,不凭空修改原始数据。"
meta="每日自动对账"
actions={<>
<Button
className="v2-reconcile-trend-open"
theme="borderless"
type="tertiary"
icon={trendExpanded ? <IconChevronUp /> : <IconChevronDown />}
aria-label="30 天趋势"
aria-expanded={trendExpanded}
aria-controls="v2-reconcile-trend"
onClick={() => setTrendExpanded((value) => !value)}
>30 </Button>
<Button theme="light" icon={<IconRefresh />} loading={summary.isFetching || issues.isFetching} onClick={refresh}></Button>
</>}
/>
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
<div className="v2-reconcile-kpis">
<article className="is-active"><small></small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article className={data?.overSla ? 'is-overdue' : ''}><small> 24 </small><strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<div className="v2-reconcile-overview">
<div className="v2-reconcile-kpis">
<Card className="v2-reconcile-kpi-card is-active" bodyStyle={{ padding: 0 }}><small></small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span></span></Card>
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small></small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span></span></Card>
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small></small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span></span></Card>
<Card className="v2-reconcile-kpi-card" bodyStyle={{ padding: 0 }}><small></small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span></span></Card>
</div>
<Card className={`v2-reconcile-sla${data?.overSla ? ' is-overdue' : ''}`} bodyStyle={{ padding: 0 }}>
<span><Tag color={data?.overSla ? 'red' : 'green'} type="light" size="small">SLA</Tag><small> 24 </small></span>
<strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong>
<p>{data?.overSla ? '优先复核高等级差异' : '当前没有超时差异'}</p>
</Card>
</div>
<div className="v2-reconcile-layout">
<div className={`v2-reconcile-layout${trendExpanded ? ' is-trend-open' : ''}`}>
<div className="v2-reconcile-main">
<div className="v2-reconcile-toolbar">
<label className="v2-reconcile-search"><IconSearch /><input aria-label="搜索差异车辆或规则" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌、VIN、标题或说明" /></label>
<select aria-label="筛选差异状态" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="active"></option><option value="pending"></option><option value="confirmed_source_a"> A</option><option value="confirmed_source_b"> B</option><option value="recovered"></option><option value="fixed"></option><option value="no_action"></option><option value="all"></option>
</select>
<select aria-label="筛选严重程度" value={severity} onChange={(event) => setSeverity(event.target.value)}>
<option value="all"></option><option value="critical"></option><option value="major"></option><option value="minor"></option>
</select>
<select aria-label="筛选差异规则" value={ruleCode} onChange={(event) => setRuleCode(event.target.value)}>
<option value="all"></option>{data?.byRule.map((item) => <option key={item.name} value={item.name}>{ruleLabel(item.name)} · {item.count}</option>)}
</select>
<MobileFilterToggle
title="筛选差异"
summary={filterSummary}
expanded={!filtersCollapsed}
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
/>
<div className={`v2-reconcile-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`}>
<Input className="v2-reconcile-search" prefix={<IconSearch />} aria-label="搜索差异车辆或规则" value={keyword} onChange={setKeyword} placeholder="车牌、VIN、标题或说明" />
<span className="v2-sr-only" id="reconcile-status-filter-label"></span>
<Select aria-labelledby="reconcile-status-filter-label" value={status} onChange={(value) => setStatus(String(value))} optionList={[
{ value: 'active', label: '活跃差异' }, { value: 'pending', label: '待处理' },
{ value: 'confirmed_source_a', label: '确认来源 A' }, { value: 'confirmed_source_b', label: '确认来源 B' },
{ value: 'recovered', label: '已恢复' }, { value: 'fixed', label: '已修复' },
{ value: 'no_action', label: '无需处理' }, { value: 'all', label: '全部状态' }
]} />
<span className="v2-sr-only" id="reconcile-severity-filter-label"></span>
<Select aria-labelledby="reconcile-severity-filter-label" value={severity} onChange={(value) => setSeverity(String(value))} optionList={[
{ value: 'all', label: '全部等级' }, { value: 'critical', label: '严重' },
{ value: 'major', label: '重要' }, { value: 'minor', label: '一般' }
]} />
<span className="v2-sr-only" id="reconcile-rule-filter-label"></span>
<Select aria-labelledby="reconcile-rule-filter-label" value={ruleCode} onChange={(value) => setRuleCode(String(value))} optionList={[
{ value: 'all', label: '全部规则' },
...(data?.byRule.map((item) => ({ value: item.name, label: `${ruleLabel(item.name)} · ${item.count}` })) ?? [])
]} />
</div>
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
<div className="v2-reconcile-table-wrap">
<table className="v2-reconcile-table"><thead><tr><th></th><th> / </th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{page?.items.map((item) => <tr key={item.id} role="button" tabIndex={0} className={selectedID === item.id ? 'is-selected' : ''} onClick={() => setSelectedID(item.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedID(item.id); }}>
<td><span className={`v2-reconcile-severity is-${item.severity}`}>{severityLabel(item.severity)}</span></td>
<td><strong>{item.title}</strong><span>{ruleLabel(item.ruleCode)} · {item.occurrenceCount.toLocaleString('zh-CN')} </span></td>
<td><strong>{item.plate || '非单车差异'}</strong><span>{item.vin || '平台级口径'}</span></td>
<td><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><span>{item.category}</span></td>
<td><strong>{fmt(item.lastSeenAt)}</strong><span> {fmt(item.firstSeenAt)}</span></td>
<td><span className={`v2-reconcile-status is-${item.status}`}>{statusLabel(item.status)}</span></td>
</tr>)}</tbody>
</table>
{issues.isPending ? <p className="v2-reconcile-empty"></p> : null}
{!issues.isPending && !page?.items.length ? <p className="v2-reconcile-empty"></p> : null}
{!mobileLayout ? <Table className="v2-reconcile-table" columns={columns} dataSource={issueRows} rowKey="id" pagination={false} scroll={{ x: 934 }} onRow={(item) => item ? detailTriggerRow({
className: selectedID === item.id ? 'is-selected' : '',
expanded: selectedID === item.id,
label: `查看 ${item.plate || item.title} 差异证据`,
testId: `reconcile-row-${item.id}`,
onOpen: () => setSelectedID(item.id)
}) : ({})} /> : <div className="v2-reconcile-mobile-list">{issueRows.map((item) => <Card key={item.id} className={`v2-reconcile-mobile-card${selectedID === item.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}>
<Button theme="borderless" type="tertiary" className="v2-reconcile-mobile-action" aria-pressed={selectedID === item.id} aria-expanded={selectedID === item.id} aria-label={`查看 ${item.plate || item.title} 差异证据`} onClick={() => setSelectedID(item.id)}>
<span className="v2-reconcile-mobile-content"><header><span><ReconciliationSeverityTag severity={item.severity} /><ReconciliationStatusTag status={item.status} /></span><small>{fmt(item.lastSeenAt)}</small></header><strong>{item.title}</strong><p>{ruleLabel(item.ruleCode)} · {item.occurrenceCount.toLocaleString('zh-CN')} </p><dl><div><dt></dt><dd>{item.plate || '非单车差异'}</dd></div><div><dt></dt><dd>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</dd></div></dl><footer><IconChevronRight /></footer></span>
</Button>
</Card>)}</div>}
{issues.isPending ? <div className="v2-reconcile-loading" role="status"><Spin size="middle" tip="正在读取差异队列…" /></div> : null}
{!issues.isPending && !issueRows.length ? <Empty className="v2-reconcile-empty" title="当前筛选条件没有差异" description="调整状态、等级、规则或搜索条件后重试。" /> : null}
</div>
<footer className="v2-reconcile-pagination"><span> {(page?.total ?? 0).toLocaleString('zh-CN')} · {activeCount} </span><div><button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}></button><button type="button" disabled={offset + PAGE_SIZE >= (page?.total ?? 0)} onClick={() => setOffset(offset + PAGE_SIZE)}></button></div></footer>
<footer className="v2-reconcile-pagination"><TablePagination page={currentPage} totalPages={totalPages} info={`${(page?.total ?? 0).toLocaleString('zh-CN')} 条 · 本页 ${activeCount} 条活跃`} onPageChange={(next) => setOffset((next - 1) * pageSize)} /></footer>
</div>
<aside className="v2-reconcile-trend">
<header><strong> 30 </strong><span> {fmt(data?.lastRunAt)}</span></header>
<div>{data?.trend.slice(-14).map((item) => <article key={item.date}><time>{item.date.slice(5)}</time><div title={`存量 ${item.active},新增 ${item.new},恢复 ${item.recovered}`}><i className="is-active" style={{ width: `${Math.max(2, item.active / maxTrend * 100)}%` }} /><i className="is-new" style={{ width: `${Math.max(0, item.new / maxTrend * 100)}%` }} /><i className="is-recovered" style={{ width: `${Math.max(0, item.recovered / maxTrend * 100)}%` }} /></div><b>{item.active}</b></article>)}</div>
{trendExpanded ? <div id="v2-reconcile-trend" className="v2-reconcile-trend-slot"><Card className="v2-reconcile-trend" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
title="近 30 天趋势"
meta={`最近运行 ${fmt(data?.lastRunAt)}`}
actions={<Button
className="v2-reconcile-trend-toggle"
theme="borderless"
type="tertiary"
size="small"
icon={<IconChevronUp />}
aria-label="收起趋势"
aria-expanded
aria-controls="v2-reconcile-trend"
onClick={() => setTrendExpanded(false)}
></Button>}
/>
<><div>{data?.trend.slice(-14).map((item) => <article key={item.date}><time>{item.date.slice(5)}</time><div title={`存量 ${item.active},新增 ${item.new},恢复 ${item.recovered}`}><i className="is-active" style={{ width: `${Math.max(2, item.active / maxTrend * 100)}%` }} /><i className="is-new" style={{ width: `${Math.max(0, item.new / maxTrend * 100)}%` }} /><i className="is-recovered" style={{ width: `${Math.max(0, item.recovered / maxTrend * 100)}%` }} /></div><b>{item.active}</b></article>)}</div>
{!data?.trend.length ? <p>完成首次每日检测后显示趋势。</p> : null}
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer>
</aside>
{selectedID && detail.isPending ? <aside className="v2-reconcile-detail"><div className="v2-reconcile-empty">正在读取证据…</div></aside> : null}
{selectedID && detail.isError ? <aside className="v2-reconcile-detail"><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></aside> : null}
{detail.data ? <ReconciliationDetail issue={detail.data} onClose={() => setSelectedID('')} /> : null}
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer></>
</Card></div> : null}
{!mobileLayout ? detailPanel : null}
</div>
</section>;
</Card>
<SideSheet
className="v2-reconcile-detail-sidesheet"
visible={mobileLayout && Boolean(selectedID)}
width="100%"
aria-label="差异证据与处置"
title={<div className="v2-reconcile-sheet-title"><strong>差异证据与处置</strong><span>{selectedIssue ? `${selectedIssue.plate || '平台级差异'} · ${ruleLabel(selectedIssue.ruleCode)}` : '加载规则证据与处置履历'}</span></div>}
onCancel={closeDetail}
>
{mobileLayout ? detailPanel : null}
</SideSheet>
</>;
}

View File

@@ -52,7 +52,17 @@ test('renders only the desktop matrix with dates as columns and a period total',
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
expect(screen.getByText('已绑定主车辆')).toBeInTheDocument();
expect(screen.getByText('已选择 1 辆')).toBeInTheDocument();
expect(screen.getByText('已绑定主车辆').closest('.semi-card')).toHaveClass('v2-mileage-summary-card');
expect(screen.getByText('车辆每日里程').closest('.semi-card')).toHaveClass('v2-mileage-results');
expect(view.container.querySelector('.v2-mileage-query-panel.semi-card')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-page > .v2-page-heading')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-summary.semi-card')).toHaveAttribute('aria-label', '里程查询统计信息');
expect(view.container.querySelector('.v2-mileage-summary-secondary.semi-card-group')).toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-mileage-summary-card.semi-card')).toHaveLength(4);
expect(view.container.querySelector('.v2-mileage-summary-card.is-primary .v2-mileage-summary-value')).toHaveTextContent('193.3 km');
expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
@@ -127,7 +137,10 @@ test('removes the previous mileage scope while a new date range is loading', asy
expect(screen.queryByText('193.3 km')).not.toBeInTheDocument();
expect(screen.queryByText('104.6 km')).not.toBeInTheDocument();
expect(screen.queryByText('88.7 km')).not.toBeInTheDocument();
expect(screen.getAllByText('2026-07-10 至 2026-07-16').length).toBeGreaterThan(0);
const end = new Date();
const start = new Date(Date.now() - 6 * 86_400_000);
const localDate = (value: Date) => new Date(value.getTime() - value.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
expect(screen.getAllByText(`${localDate(start)}${localDate(end)}`).length).toBeGreaterThan(0);
});
test('uses one responsive mileage matrix without viewport listeners', async () => {
@@ -137,26 +150,67 @@ test('uses one responsive mileage matrix without viewport listeners', async () =
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
await waitFor(() => expect(view.container.querySelectorAll('.v2-mileage-table tbody tr')).toHaveLength(1));
expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(2);
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveTextContent('车牌');
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveTextContent('区间总里程');
expect(view.container.querySelector('.v2-mileage-table th.is-vin')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table th.is-plate')).toHaveClass('is-plate');
expect(view.container.querySelector('.v2-mileage-table th.is-total')).toHaveClass('is-total');
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
});
test('routes vertical wheel input from the mileage workspace to the table without hijacking buttons', async () => {
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
const page = view.container.querySelector<HTMLElement>('.v2-mileage-page');
expect(page).toBeInTheDocument();
await waitFor(() => expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument());
const scroller = view.container.querySelector<HTMLElement>('.v2-mileage-table-wrap');
expect(scroller).toBeInTheDocument();
Object.defineProperties(scroller!, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 900 },
scrollTop: { configurable: true, value: 0, writable: true }
});
fireEvent.wheel(page!, { deltaY: 180, deltaX: 0 });
expect(scroller!.scrollTop).toBe(180);
fireEvent.wheel(screen.getByRole('button', { name: '刷新里程数据' }), { deltaY: 180, deltaX: 0 });
expect(scroller!.scrollTop).toBe(180);
fireEvent.wheel(page!, { deltaY: 120, deltaX: 240 });
expect(scroller!.scrollTop).toBe(180);
});
test('supports searching and selecting license plates before querying exact VINs', async () => {
prepareData();
mocks.vehicles.mockResolvedValue({
items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808' }
],
total: 2,
limit: 12,
offset: 0
});
renderPage();
const search = screen.getByRole('textbox', { name: '搜索车牌' });
fireEvent.focus(search);
fireEvent.change(search, { target: { value: '粤A12' } });
expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: /粤A12345/ }));
const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 GB32960 JT808 选择' });
expect(screen.getAllByRole('option')).toHaveLength(1);
fireEvent.click(option);
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => {
const calls = mocks.mileageStatistics.mock.calls;
expect(calls[calls.length - 1]?.[0].get('vins')).toBe('LTEST000000000001');
});
expect(screen.getByTitle('LTEST000000000001')).toHaveTextContent('粤A12345');
expect(document.querySelector('.v2-mileage-chip')).toHaveClass('semi-tag');
expect(document.querySelector('.v2-mileage-chip')).toHaveTextContent('粤A12345');
});
test('shows and recovers from a failed license plate candidate query', async () => {
@@ -179,8 +233,12 @@ test('lets users disable mileage sources and persists the source priority', asyn
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
expect(document.querySelectorAll('.v2-mileage-source-card.semi-card')).toHaveLength(3);
expect(screen.getByText('优先级 1').closest('.semi-tag')).toHaveClass('v2-mileage-source-priority');
expect(screen.getByText('GPS 里程')).toBeInTheDocument();
expect(screen.getByText('GPS 里程').closest('.semi-tag')).toBeInTheDocument();
expect(screen.getAllByText('仪表盘里程')).toHaveLength(2);
expect(screen.getByRole('button', { name: /数据源.*GB32960 优先.*3\/3/ })).toHaveAttribute('title', '当前优先:国标 GB32960');
fireEvent.click(screen.getByRole('switch', { name: '禁用 国标 GB32960' }));
fireEvent.click(screen.getByRole('button', { name: '上移 宇通 MQTT' }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
@@ -206,14 +264,16 @@ test('paginates all unique vehicles when no license plate is selected', async ()
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(screen.getByText('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(document.querySelector('.v2-table-pagination-current')).toHaveTextContent('1/2');
expect(screen.getByText('档案口径 · 1 辆有里程')).toBeInTheDocument();
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
expect(screen.getByText('第 2 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(screen.getByText('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(document.querySelector('.v2-table-pagination-current')).toHaveTextContent('2/2');
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
});

View File

@@ -1,6 +1,7 @@
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Empty, Input, SideSheet, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { FormEvent, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
@@ -8,8 +9,14 @@ import { createMileageExportStream, type MileageExportStream } from '../domain/m
import { formatZhNumber } from '../domain/formatters';
import { InlineError } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const DAY = 86_400_000;
const DETAIL_LIMIT = 10_000;
@@ -106,6 +113,8 @@ function initialCriteria(searchParams: URLSearchParams): Criteria {
function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onChange: (sources: MileageSourceOption[]) => void }) {
const [open, setOpen] = useState(false);
const enabled = value.filter((source) => source.enabled);
const primarySource = enabled[0];
useSideSheetA11y(open, '.v2-mileage-source-sidesheet', 'v2-mileage-source-strategy', '数据源策略配置', '关闭数据源策略');
const update = (sources: MileageSourceOption[]) => {
onChange(sources);
try { window.localStorage.setItem(SOURCE_STORAGE_KEY, JSON.stringify(sources)); } catch { /* preference persistence is optional */ }
@@ -124,19 +133,25 @@ function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onC
};
return <div className="v2-mileage-source-strategy">
<button type="button" className="v2-mileage-source-trigger" aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen((current) => !current)}>
<IconSetting /><span></span><b>{enabled.length}/3</b>
</button>
{open ? <section className="v2-mileage-source-popover" role="dialog" aria-label="数据源策略配置">
<header><div><strong></strong><span></span></div><button type="button" aria-label="关闭数据源策略" onClick={() => setOpen(false)}><IconClose /></button></header>
<div className="v2-mileage-source-list">{value.map((source, index) => <article key={source.protocol} className={source.enabled ? '' : 'is-disabled'}>
<button type="button" className={`v2-mileage-source-switch${source.enabled ? ' is-on' : ''}`} role="switch" aria-checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onClick={() => toggle(source.protocol)}><i /></button>
<div><strong>{source.label}</strong><small><b>{source.mileageType}</b><code>{source.protocol}</code></small></div>
<em>{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</em>
<p><button type="button" aria-label={`上移 ${source.label}`} disabled={index === 0} onClick={() => move(index, -1)}><IconArrowUp /></button><button type="button" aria-label={`下移 ${source.label}`} disabled={index === value.length - 1} onClick={() => move(index, 1)}><IconArrowDown /></button></p>
</article>)}</div>
<footer><span>使</span><button type="button" onClick={() => setOpen(false)}></button></footer>
</section> : null}
<Button className="v2-mileage-source-trigger" theme="light" icon={<IconSetting />} aria-haspopup="dialog" aria-expanded={open} aria-controls="v2-mileage-source-strategy" title={`当前优先:${primarySource?.label ?? '未配置'}`} onClick={() => setOpen((current) => !current)}>
<span></span><em>{primarySource?.protocol ?? '未配置'} </em><b>{enabled.length}/3</b>
</Button>
<SideSheet
className="v2-mileage-source-sidesheet"
visible={open}
aria-label="数据源策略"
width={430}
title={<div className="v2-mileage-source-title"><strong></strong><span></span></div>}
onCancel={() => setOpen(false)}
footer={<div className="v2-mileage-source-footer"><span>使</span><Button theme="solid" onClick={() => setOpen(false)}></Button></div>}
>
<div className="v2-mileage-source-list">{value.map((source, index) => <Card key={source.protocol} className={`v2-mileage-source-card${source.enabled ? '' : ' is-disabled'}`} bodyStyle={{ padding: 0 }}>
<Switch className="v2-mileage-source-switch" checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onChange={() => toggle(source.protocol)} />
<div className="v2-mileage-source-copy"><strong>{source.label}</strong><small><Tag color="blue" type="light" size="small">{source.mileageType}</Tag><code>{source.protocol}</code></small></div>
<Tag className="v2-mileage-source-priority" color={source.enabled ? 'blue' : 'grey'} type="light" size="small">{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</Tag>
<div className="v2-mileage-source-order"><Button theme="borderless" aria-label={`上移 ${source.label}`} icon={<IconArrowUp />} disabled={index === 0} onClick={() => move(index, -1)} /><Button theme="borderless" aria-label={`下移 ${source.label}`} icon={<IconArrowDown />} disabled={index === value.length - 1} onClick={() => move(index, 1)} /></div>
</Card>)}</div>
</SideSheet>
</div>;
}
@@ -168,7 +183,7 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
gcTime: QUERY_MEMORY.optionGcTime
});
const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]);
const options = (candidates.data?.items ?? []).filter((vehicle, index, rows) => rows.findIndex((item) => item.vin === vehicle.vin) === index);
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const add = (vehicle: VehicleRow) => {
if (selected.has(vehicle.vin) || value.length >= MAX_SELECTED_VEHICLES) return;
@@ -181,20 +196,35 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
<div className={`v2-mileage-multiselect${open ? ' is-open' : ''}`}>
<IconSearch />
<div className="v2-mileage-selection">
{value.map((vehicle) => <button key={vehicle.vin} type="button" className="v2-mileage-chip" title={vehicle.vin} onClick={() => onChange(value.filter((item) => item.vin !== vehicle.vin))}>
<span>{vehicle.plate || vehicle.vin}</span><IconClose />
</button>)}
<input value={search} onFocus={openPicker} onBlur={closePicker} onChange={(event) => { setSearch(event.target.value); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
{value.map((vehicle) => <Tag
key={vehicle.vin}
className="v2-mileage-chip"
color="blue"
type="light"
closable
onClose={(_, event) => {
event.stopPropagation();
onChange(value.filter((item) => item.vin !== vehicle.vin));
}}
>
{vehicle.plate || vehicle.vin}
</Tag>)}
<Input borderless value={search} onFocus={openPicker} onBlur={closePicker} onChange={(next) => { setSearch(next); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
</div>
{open ? <div className="v2-mileage-options" role="listbox">
<header><span></span><em>{value.length}/{MAX_SELECTED_VEHICLES} </em></header>
{candidates.isLoading ? <p></p> : null}
{!candidates.isLoading && candidates.isError ? <div className="v2-vehicle-option-error" role="alert"><span>{candidates.error instanceof Error ? candidates.error.message : '车牌候选加载失败'}</span><button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => void candidates.refetch()}></button></div> : null}
{!candidates.isLoading && options.map((vehicle) => <button type="button" role="option" aria-selected={selected.has(vehicle.vin)} key={vehicle.vin} disabled={selected.has(vehicle.vin)} onMouseDown={(event) => event.preventDefault()} onClick={() => add(vehicle)}>
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span>{selected.has(vehicle.vin) ? <em></em> : null}
</button>)}
{!candidates.isLoading && !candidates.isError && !options.length ? <p></p> : null}
</div> : null}
{open ? <VehicleCandidateList
className="v2-mileage-options"
items={options}
loading={candidates.isLoading}
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车牌候选加载失败') : undefined}
onRetry={() => candidates.refetch()}
emptyText="没有匹配的车牌"
selectedVins={selected}
disableSelected
header="车牌候选"
meta={`${value.length}/${MAX_SELECTED_VEHICLES} 已选`}
showProtocols
onSelect={add}
/> : null}
</div>
<small> {MAX_SELECTED_VEHICLES} </small>
</label>;
@@ -216,10 +246,10 @@ function SummaryRail({ data, criteria, fleetTotal, loading }: { data?: MileageSt
];
const primary = items[2];
const secondary = [items[0], items[1], items[3]];
return <section className="v2-mileage-summary" aria-label="里程查询统计信息">
<article className="is-primary"><small>{primary[0]}</small><strong>{primary[1]}</strong><span>{primary[2]}</span></article>
<div className="v2-mileage-summary-secondary">{secondary.map(([label, value, note]) => <article key={label}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</div>
</section>;
return <Card className="v2-mileage-summary" aria-label="里程查询统计信息" bodyStyle={{ padding: 0 }}>
<Card className="v2-mileage-summary-card is-primary" bodyStyle={{ padding: 0 }} aria-label={`${primary[0]}${primary[1]}${primary[2]}`}><small>{primary[0]}</small><strong className="v2-mileage-summary-value">{primary[1]}</strong><span>{primary[2]}</span></Card>
<CardGroup className="v2-mileage-summary-secondary" type="grid" spacing={0}>{secondary.map(([label, value, note]) => <Card className="v2-mileage-summary-card" bodyStyle={{ padding: 0 }} aria-label={`${label}${value}${note}`} key={label}><small>{label}</small><strong>{value}</strong><span>{note}</span></Card>)}</CardGroup>
</Card>;
}
type VehicleMileageMatrix = VehicleOption & { days: Map<string, number>; sources: Map<string, string>; totalMileageKm: number };
@@ -240,20 +270,35 @@ function dateLabel(date: string) {
return `${Number(month)}/${Number(day)}`;
}
function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: string[] }) {
function MileageTable({ rows, dates, scrollRef }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement> }) {
let maxDailyMileage = 1;
for (const row of rows) {
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
}
return <div className="v2-mileage-table-wrap">
<table className="v2-mileage-table">
<thead><tr><th className="is-sticky is-plate"></th><th className="is-sticky is-vin">VIN</th>{dates.map((date) => <th key={date} className="is-number is-date" title={date}>{dateLabel(date)}</th>)}<th className="is-number is-total"></th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.vin}><td className="is-sticky is-plate"><strong>{row.plate || '未绑定'}</strong></td><td className="is-sticky is-vin"><code>{row.vin}</code></td>{dates.map((date) => {
const mileage = row.days.get(date);
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return <td key={date} className={`is-number${mileage != null ? ' is-daily' : ' is-empty'}`} title={mileage != null ? `来源:${row.sources.get(date) || '—'}` : undefined} style={intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined}>{mileage != null ? `${formatKm(mileage)} km` : '—'}</td>;
})}<td className="is-number is-period is-total">{formatKm(row.totalMileageKm)} km</td></tr>)}</tbody>
</table>
const columns = [
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
...dates.map((date) => ({
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: 96,
onHeaderCell: () => ({ title: date }),
onCell: (row?: VehicleMileageMatrix) => {
const mileage = row?.days.get(date);
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return {
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
title: mileage != null ? `来源:${row?.sources.get(date) || '—'}` : undefined,
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
};
},
render: (_value: unknown, row: VehicleMileageMatrix) => {
const mileage = row.days.get(date);
return mileage != null ? `${formatKm(mileage)} km` : '—';
}
})),
{ title: '区间总里程', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: 128, onCell: () => ({ className: 'is-number is-period is-total' }), render: (value: number) => `${formatKm(value)} km` }
];
const tableWidth = 120 + dates.length * 96 + 128;
return <div className="v2-mileage-table-wrap" ref={scrollRef}>
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
</div>;
}
@@ -269,6 +314,8 @@ export default function StatisticsPage() {
const [validationError, setValidationError] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const exportControllerRef = useRef<AbortController | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
@@ -277,6 +324,23 @@ export default function StatisticsPage() {
exportControllerRef.current?.abort();
};
}, []);
useEffect(() => {
const page = pageRef.current;
if (!page) return;
const redirectWheelToTable = (event: WheelEvent) => {
if (window.matchMedia('(max-width: 680px)').matches || event.ctrlKey || event.shiftKey || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
if (event.target instanceof Element && event.target.closest('input,button,select,textarea,[role="dialog"],[role="listbox"]')) return;
const scroller = tableScrollRef.current;
if (!scroller) return;
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroller.scrollTop + event.deltaY));
if (nextScrollTop === scroller.scrollTop) return;
scroller.scrollTop = nextScrollTop;
event.preventDefault();
};
page.addEventListener('wheel', redirectWheelToTable, { passive: false });
return () => page.removeEventListener('wheel', redirectWheelToTable);
}, []);
const hasVehicles = criteria.vehicles.length > 0;
const criteriaError = mileageDateRangeError(criteria);
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
@@ -420,32 +484,38 @@ export default function StatisticsPage() {
exportControllerRef.current.abort();
};
return <div className="v2-mileage-page">
return <div className="v2-mileage-page" ref={pageRef}>
<MonitorReturnBar />
<section className="v2-mileage-query-panel">
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button>
<Card className="v2-mileage-query-panel" bodyStyle={{ padding: 0 }}>
<MobileFilterToggle title="查询条件" summary={criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`} expanded={!filtersCollapsed} collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} />
<form className={`v2-mileage-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
<label><span></span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
<button className="v2-primary-button" type="submit"></button>
<label><span></span><Input aria-label="开始日期" type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /></label>
<label><span></span><Input aria-label="结束日期" type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></label>
<Button className="v2-primary-button" theme="solid" htmlType="submit"></Button>
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
<div className="v2-mileage-ranges"><span></span><button className={isSameRange(draft, todayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(todayRange)}></button><button className={isSameRange(draft, yesterdayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(yesterdayRange)}></button><button className={isSameRange(draft, sevenDayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(sevenDayRange)}> 7 </button><button className={isSameRange(draft, thirtyDayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(thirtyDayRange)}> 30 </button><button className={isSameRange(draft, ninetyDayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(ninetyDayRange)}> 90 </button></div>
<div className="v2-mileage-ranges"><span></span><Button theme={isSameRange(draft, todayRange) ? 'solid' : 'light'} onClick={() => setRange(todayRange)}></Button><Button theme={isSameRange(draft, yesterdayRange) ? 'solid' : 'light'} onClick={() => setRange(yesterdayRange)}></Button><Button theme={isSameRange(draft, sevenDayRange) ? 'solid' : 'light'} onClick={() => setRange(sevenDayRange)}> 7 </Button><Button theme={isSameRange(draft, thirtyDayRange) ? 'solid' : 'light'} onClick={() => setRange(thirtyDayRange)}> 30 </Button><Button theme={isSameRange(draft, ninetyDayRange) ? 'solid' : 'light'} onClick={() => setRange(ninetyDayRange)}> 90 </Button></div>
</form>
{validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
</section>
</Card>
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
<section className="v2-mileage-results">
<header><div><strong></strong><span>{criteria.dateFrom} {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles}`} · {dates.length} </em><button className="is-refresh" type="button" aria-label="刷新里程数据" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新'}</button><button type="button" aria-label={isExporting ? '取消导出' : '导出 Excel'} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles || Boolean(criteriaError)}>{isExporting ? <IconClose /> : <IconDownload />}{isExporting ? '取消导出' : '导出 Excel'}</button></div></header>
<Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="车辆每日里程"
description={`${criteria.dateFrom}${criteria.dateTo}`}
meta={`${hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles}`} · ${dates.length} 个自然日`}
actionsClassName="v2-mileage-result-actions"
actions={<><Button className="is-refresh" theme="borderless" aria-label="刷新里程数据" icon={<IconRefresh />} onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}>{refreshing ? '更新中' : '刷新'}</Button><Button theme="light" aria-label={isExporting ? '取消导出' : '导出 Excel'} icon={isExporting ? <IconClose /> : <IconDownload />} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles || Boolean(criteriaError)}>{isExporting ? '取消导出' : '导出 Excel'}</Button></>}
/>
{exportProgress ? <div className="v2-mileage-export-progress" role="progressbar" aria-label={exportProgress.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={exportPercent}>
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
<i className={exportPercent == null ? 'is-indeterminate' : ''}><b style={exportPercent == null ? undefined : { width: `${exportPercent}%` }} /></i>
</div> : null}
{resultsLoading ? <div className="v2-mileage-loading" role="status" aria-live="polite"><span className="v2-spinner" /><div><strong></strong><small></small></div></div> : <MileageTable rows={matrixRows} dates={dates} />}
{!resultsLoading && !displayVehicles.length ? <div className="v2-mileage-empty"></div> : null}
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `${page} / ${totalPages} 页 · ${totalVehicles} 辆 · 每页 ${PAGE_SIZE}`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}></button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}></button></div> : null}</footer>
</section>
{resultsLoading ? <div className="v2-mileage-loading" role="status" aria-live="polite"><Spin size="middle" tip="正在查询里程" /><small></small></div> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} /> : null}
{!resultsLoading && !displayVehicles.length ? <Empty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
<footer>{!hasVehicles && totalVehicles ? <TablePagination page={page} totalPages={totalPages} info={`${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE}${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={fleetVehicles.isFetching} onPageChange={setPage} /> : <span className="v2-table-pagination-info"> {totalVehicles.toLocaleString('zh-CN')} {exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>
</Card>
<footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 · </span></footer>
</div>;
}

View File

@@ -10,6 +10,42 @@ import { ROUTER_FUTURE } from '../routing/routerConfig';
const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../map/TrackMap', () => ({ TrackMap: ({ activeIndex, followDurationMs, points }: { activeIndex: number; followDurationMs: number; points: unknown[] }) => <div data-testid="track-map" data-active-index={activeIndex} data-follow-duration={followDurationMs} data-point-count={points.length} /> }));
vi.mock('@douyinfe/semi-ui', async (importOriginal) => {
const actual = await importOriginal<typeof import('@douyinfe/semi-ui')>();
return {
...actual,
Slider: ({ value, onChange, min = 0, max = 100, disabled, ...props }: {
value?: number;
onChange?: (value: number) => void;
min?: number;
max?: number;
disabled?: boolean;
'aria-label'?: string;
}) => <input
type="range"
aria-label={props['aria-label']}
min={min}
max={max}
value={value ?? min}
disabled={disabled}
onChange={(event) => onChange?.(Number(event.target.value))}
/>,
Select: ({ value, onChange, optionList = [], ...props }: {
value?: string | number;
onChange?: (value: string) => void;
optionList?: Array<{ value: string | number; label: React.ReactNode }>;
'aria-label'?: string;
'aria-labelledby'?: string;
}) => <select
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
value={String(value ?? '')}
onChange={(event) => onChange?.(event.target.value)}
>
{optionList.map((option) => <option key={String(option.value)} value={String(option.value)}>{option.label}</option>)}
</select>
};
});
const track = {
vin: 'LTEST000000000001', plate: '粤A12345', total: 3, truncated: false, sampled: false, asOf: '2026-07-15T08:00:00Z',
@@ -51,10 +87,12 @@ test('preserves the exact monitor return after querying another track range', as
viewport: { zoom: 14, bounds: '' }, listOffset: 0, listLimit: 50, hasViewport: true
});
const initialEntry = withMonitorReturn('/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59', monitorPath);
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><TrackPage /></MemoryRouter></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><TrackPage /></MemoryRouter></QueryClientProvider>);
expect(view.container.querySelector('.v2-track-page')).toHaveClass('has-monitor-return');
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
fireEvent.change(screen.getByLabelText('数据来源'), { target: { value: 'JT808' } });
fireEvent.click(screen.getByRole('button', { name: '修改条件' }));
fireEvent.change(screen.getByRole('combobox', { name: '数据来源' }), { target: { value: 'JT808' } });
fireEvent.click(screen.getByRole('button', { name: /查询轨迹/ }));
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
@@ -62,10 +100,14 @@ test('preserves the exact monitor return after querying another track range', as
test('keeps committed track criteria authoritative to same-route navigation and browser history', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackNavigationHarness /></MemoryRouter></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackNavigationHarness /></MemoryRouter></QueryClientProvider>);
expect(view.container.querySelector('.v2-track-page')).not.toHaveClass('has-monitor-return');
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '3');
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
expect(screen.getByText(/07-15 00:00/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '修改条件' }));
expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001');
fireEvent.click(screen.getByRole('button', { name: '清空轨迹路由' }));
@@ -76,7 +118,8 @@ test('keeps committed track criteria authoritative to same-route navigation and
fireEvent.click(screen.getByRole('button', { name: '后退轨迹路由' }));
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '3');
expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001');
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '修改条件' })).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(screen.getByRole('button', { name: '前进轨迹路由' }));
expect(await screen.findByText('先选择车辆,再开始轨迹回放')).toBeInTheDocument();
@@ -98,33 +141,86 @@ test('distinguishes a failed vehicle lookup from an empty result and retries in
expect(mocks.vehicles).toHaveBeenCalledTimes(2);
});
test('merges duplicate vehicle sources and keeps the active date shortcut visible', async () => {
mocks.vehicles.mockResolvedValue({
items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808' },
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'GB32960' }
],
total: 2,
limit: 10,
offset: 0
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks']}><TrackPage /></MemoryRouter></QueryClientProvider>);
expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: '昨天' }));
expect(screen.getByRole('button', { name: '昨天' })).toHaveAttribute('aria-pressed', 'true');
const search = screen.getByRole('textbox', { name: '搜索轨迹车辆' });
fireEvent.focus(search);
fireEvent.change(search, { target: { value: '粤A12345' } });
const option = await screen.findByRole('option', { name: '粤A12345 LTEST000000000001 JT808 GB32960 选择' });
expect(document.querySelectorAll('.v2-track-vehicle-options [role="option"]')).toHaveLength(1);
expect(option).toHaveTextContent('粤A12345LTEST000000000001JT808GB32960选择');
});
test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
expect(view.container.querySelector('.v2-track-query-card.semi-card')).toHaveClass('is-collapsed');
expect(view.container.querySelector('.v2-track-rail-result.semi-card')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '修改条件' })).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-track-current-card.semi-card')).toBeInTheDocument();
expect(view.container.querySelector('.v2-track-playback-dock.semi-card')).toBeInTheDocument();
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0');
expect(screen.getByText('停留 00:05:00 · 1 个点')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /停留 00:05:00/ })).toHaveClass('semi-button', 'v2-track-list-action');
expect(screen.getByRole('button', { name: /停留 00:05:00/ }).closest('.semi-list-item')).toHaveClass('v2-track-evidence-item');
expect(view.container.querySelector('.v2-track-evidence-list.semi-list')).toBeInTheDocument();
expect(screen.getByRole('slider', { name: '轨迹播放进度' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '暂停轨迹播放' })).not.toBeInTheDocument();
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1));
expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: /事件点/ }));
fireEvent.click(screen.getByRole('button', { name: /急加速/ }));
fireEvent.click(screen.getByRole('tab', { name: /事件点/ }));
const acceleration = screen.getByRole('button', { name: /急加速/ });
expect(acceleration).toHaveClass('semi-button', 'v2-track-list-action');
expect(acceleration.closest('.semi-list-item')).toHaveClass('v2-track-evidence-item');
fireEvent.click(acceleration);
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '1');
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(2));
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(1));
fireEvent.click(screen.getByRole('tab', { name: '概览' }));
expect(view.container.querySelectorAll('.v2-track-overview-card.semi-card')).toHaveLength(3);
expect(view.container.querySelector('.v2-track-overview-descriptions.semi-descriptions')).toBeInTheDocument();
expect(view.container.querySelector('.v2-track-source-list.semi-list')).toBeInTheDocument();
expect(view.container.querySelectorAll('.v2-track-source-item.semi-list-item')).toHaveLength(1);
expect(screen.getByText('完整点集').closest('.semi-tag')).toBeInTheDocument();
expect(screen.getByText('通过').closest('.semi-tag')).toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox', { name: '速度' }), { target: { value: '4' } });
fireEvent.click(screen.getByRole('button', { name: '开始轨迹播放' }));
expect(screen.getByTestId('track-map')).toHaveAttribute('data-follow-duration', '65');
fireEvent.click(screen.getByRole('button', { name: '暂停轨迹播放' }));
expect(screen.getByTestId('track-map')).toHaveAttribute('data-follow-duration', '180');
fireEvent.click(screen.getByRole('button', { name: '收起查询面板' }));
expect(screen.getByRole('button', { name: /查询与明细/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /查询与明细/ }));
expect(screen.getByRole('button', { name: '收起查询面板' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '修改条件' }));
expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001');
expect(screen.getByRole('button', { name: '收起条件' })).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '收起条件' }));
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '隐藏查询与明细' }));
expect(screen.getByRole('button', { name: '展开查询与明细' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '展开查询与明细' }));
expect(screen.getByRole('button', { name: '隐藏查询与明细' })).toBeInTheDocument();
await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1));
expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600');
@@ -135,6 +231,24 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(0));
});
test('uses a Semi bottom SideSheet for mobile track criteria and evidence', async () => {
Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: '展开查询与明细' }));
const sheet = await waitFor(() => {
const element = document.querySelector('.v2-track-detail-sidesheet .semi-sidesheet-inner');
expect(element).toHaveAttribute('aria-label', '轨迹查询与明细');
return element;
});
expect(sheet).toHaveClass('semi-sidesheet-inner');
expect(document.querySelector('.v2-track-detail-sidesheet .v2-track-rail')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '关闭轨迹查询与明细' })).toBeInTheDocument();
});
test('coalesces rapid track scrubbing into one address lookup for the final point', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
@@ -143,6 +257,7 @@ test('coalesces rapid track scrubbing into one address lookup for the final poin
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1));
const progress = screen.getByRole('slider', { name: '轨迹播放进度' });
expect(progress).toHaveAttribute('max', '2');
fireEvent.change(progress, { target: { value: '1' } });
fireEvent.change(progress, { target: { value: '2' } });

View File

@@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import {
IconBox, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { Button, Card, Descriptions, Empty, Input, List, Select, SideSheet, Slider, Tag } from '@douyinfe/semi-ui';
import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
@@ -11,13 +12,20 @@ import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEvent
import { TrackMap } from '../map/TrackMap';
import { InlineError } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { QUERY_MEMORY } from '../queryPolicy';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const speedOptions = [0.5, 1, 2, 4] as const;
type PlaybackSpeed = (typeof speedOptions)[number];
type PanelTab = 'stops' | 'events' | 'overview';
type Draft = { keyword: string; dateFrom: string; dateTo: string; protocol: string };
type TrackPreset = 'today' | 'yesterday' | 'three-days';
const EMPTY_INDEXES: readonly number[] = [];
const numberFormatters = new Map<number, Intl.NumberFormat>();
const timeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -76,6 +84,22 @@ function defaultTrackWindow() {
return trackWindow();
}
function trackPreset(draft: Pick<Draft, 'dateFrom' | 'dateTo'>, now = new Date()): TrackPreset | undefined {
const today = localDateTime(now).slice(0, 10);
const yesterdayDate = new Date(now);
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = localDateTime(yesterdayDate).slice(0, 10);
const threeDayStartDate = new Date(now);
threeDayStartDate.setDate(threeDayStartDate.getDate() - 2);
const threeDayStart = localDateTime(threeDayStartDate).slice(0, 10);
const from = draft.dateFrom.slice(0, 10);
const to = draft.dateTo.slice(0, 10);
if (from === today && to === today) return 'today';
if (from === yesterday && to === yesterday) return 'yesterday';
if (from === threeDayStart && to === today) return 'three-days';
return undefined;
}
function eventTone(type: string) {
if (type === 'start') return 'start';
if (type === 'end' || type === 'braking' || type === 'gap') return 'end';
@@ -103,93 +127,140 @@ function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange:
return next;
}, [debounced]);
const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: ({ signal }) => api.vehicles(params, signal), enabled: open, staleTime: 30_000, gcTime: QUERY_MEMORY.optionGcTime });
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
<IconSearch />
<input
aria-label="搜索轨迹车辆" autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
<Input
aria-label="搜索轨迹车辆" prefix={<IconSearch />} autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
value={value} onFocus={openPicker} onBlur={closePicker}
onChange={(event) => { onChange(event.target.value); setOpen(true); }}
onChange={(next) => { onChange(next); setOpen(true); }}
/>
{value ? <button type="button" aria-label="清空车辆" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')}><IconClose /></button> : null}
{open ? <div className="v2-track-vehicle-options" role="listbox">
<header><span></span><em> VIN</em></header>
{candidates.isFetching ? <p><span className="v2-spinner" /></p> : null}
{!candidates.isFetching && candidates.isError ? <div className="v2-vehicle-option-error" role="alert"><span>{candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败'}</span><button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => void candidates.refetch()}></button></div> : null}
{!candidates.isFetching && (candidates.data?.items ?? []).map((vehicle) => <button
type="button" role="option" aria-selected={false} key={vehicle.vin}
onMouseDown={(event) => event.preventDefault()} onClick={() => { onSelect(vehicle); setOpen(false); }}
><strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em></em></button>)}
{!candidates.isFetching && !candidates.isError && !(candidates.data?.items.length) ? <p></p> : null}
</div> : null}
{value ? <Button className="v2-track-vehicle-clear" theme="borderless" aria-label="清空车辆" icon={<IconClose />} onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')} /> : null}
{open ? <VehicleCandidateList
className="v2-track-vehicle-options"
items={options}
loading={candidates.isFetching}
loadingText="正在搜索车辆"
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
onRetry={() => candidates.refetch()}
header="车辆候选"
meta="车牌优先 · 合并来源"
showProtocols
onSelect={(vehicle) => { onSelect(vehicle); setOpen(false); }}
/> : null}
</div>;
}
function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
return <div className="v2-track-overview-panel">
<section><header><strong></strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><dl>
<div><dt></dt><dd>{dateTime(track.summary.startTime)}</dd></div>
<div><dt></dt><dd>{dateTime(track.summary.endTime)}</dd></div>
<div><dt></dt><dd>{number(track.summary.distanceKm)} km</dd></div>
<div><dt> / </dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div>
<div><dt> / </dt><dd>{number(track.summary.averageSpeedKmh)} / {number(track.summary.maximumSpeedKmh)} km/h</dd></div>
<div><dt> / </dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div>
</dl></section>
<section><header><strong></strong><span>{track.coverage.totalPoints.toLocaleString('zh-CN')} </span></header><div className="v2-track-source-list">{track.sources.map((source) => <article key={source.protocol}><strong>{source.protocol}</strong><span>{source.pointCount.toLocaleString('zh-CN')} </span><small>{time(source.startTime)}{time(source.endTime)}</small></article>)}</div></section>
<section className={`v2-track-quality-card is-${track.quality.status}`}><header><strong></strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><p>{track.quality.evidence}</p><small> {track.quality.invalidCoordinatePoints} · {track.quality.duplicatePoints} · {track.quality.driftPoints} · {track.quality.largeGapCount}</small></section>
<Card className="v2-track-overview-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title="行程概览" meta={<Tag color={track.sampled ? 'orange' : 'green'} type="light" size="small">{track.sampled ? '地图已抽稀' : '完整点集'}</Tag>} />
<div className="v2-track-overview-card-body"><Descriptions className="v2-track-overview-descriptions" align="left" size="small" data={[
{ key: '开始时间', value: dateTime(track.summary.startTime) },
{ key: '结束时间', value: dateTime(track.summary.endTime) },
{ key: '行驶里程', value: `${number(track.summary.distanceKm)} km` },
{ key: '行驶 / 停车', value: `${formatDuration(track.summary.movingSeconds)} / ${formatDuration(track.summary.stoppedSeconds)}` },
{ key: '平均 / 最高速度', value: `${number(track.summary.averageSpeedKmh)} / ${number(track.summary.maximumSpeedKmh)} km/h` },
{ key: '停车 / 分段', value: `${track.summary.stopCount} / ${track.summary.segmentCount}` }
]} /></div>
</Card>
<Card className="v2-track-overview-card v2-track-source-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title="数据来源" meta={<Tag color="blue" type="light" size="small">{track.coverage.totalPoints.toLocaleString('zh-CN')} </Tag>} />
<div className="v2-track-overview-card-body"><List className="v2-track-source-list">{track.sources.map((source) => <List.Item className="v2-track-source-item" key={source.protocol}><Tag color="blue" type="light" size="small">{source.protocol}</Tag><strong>{source.pointCount.toLocaleString('zh-CN')} </strong><small>{time(source.startTime)}{time(source.endTime)}</small></List.Item>)}</List></div>
</Card>
<Card className={`v2-track-overview-card v2-track-quality-card is-${track.quality.status}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title="轨迹质量" meta={<Tag color={track.quality.status === 'good' ? 'green' : 'orange'} type="light" size="small">{track.quality.status === 'good' ? '通过' : '需关注'}</Tag>} />
<div className="v2-track-overview-card-body"><p>{track.quality.evidence}</p><small> {track.quality.invalidCoordinatePoints} · {track.quality.duplicatePoints} · {track.quality.driftPoints} · {track.quality.largeGapCount}</small></div>
</Card>
</div>;
}
const TrackRail = memo(function TrackRail({ draft, track, activeStopIndexes, activeEventIndexes, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: {
const TrackRail = memo(function TrackRail({ draft, track, loading, activeStopIndexes, activeEventIndexes, tab, queryCollapsed, onDraft, onSubmit, onTab, onSelectIndex, onToggleQuery, onCollapse }: {
draft: Draft;
track?: TrackPlaybackResponse;
loading: boolean;
activeStopIndexes: readonly number[];
activeEventIndexes: readonly number[];
tab: PanelTab;
queryCollapsed: boolean;
onDraft: (draft: Draft) => void;
onSubmit: (event: FormEvent) => void;
onTab: (tab: PanelTab) => void;
onSelectIndex: (index: number) => void;
onToggleQuery: () => void;
onCollapse: () => void;
}) {
const activePreset = trackPreset(draft);
const choosePreset = (daysBack: number) => onDraft({ ...draft, ...trackWindow(daysBack, daysBack > 0) });
return <aside className="v2-track-rail">
<form className="v2-track-query" onSubmit={onSubmit}>
<header><div><strong></strong><span> 7 </span></div><button type="button" aria-label="收起查询面板" onClick={onCollapse}><IconChevronLeft /></button></header>
<label><span></span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
<div className="v2-track-presets"><button type="button" onClick={() => choosePreset(0)}></button><button type="button" onClick={() => choosePreset(1)}></button><button type="button" onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}> 3 </button></div>
<div className="v2-track-date-grid"><label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => onDraft({ ...draft, dateFrom: event.target.value })} /></label><label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => onDraft({ ...draft, dateTo: event.target.value })} /></label></div>
<label><span></span><select value={draft.protocol} onChange={(event) => onDraft({ ...draft, protocol: event.target.value })}><option value=""></option><option value="GB32960">GB32960 · </option><option value="JT808">JT808 · GPS </option><option value="YUTONG_MQTT">YUTONG · </option></select></label>
<button className="v2-track-query-button" type="submit" disabled={!draft.keyword.trim()}><IconSearch /></button>
</form>
<Card className={`v2-track-query-card${queryCollapsed ? ' is-collapsed' : ''}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-track-query-header"
title="轨迹查询"
description={queryCollapsed ? '查询条件已应用' : '最长支持连续 7 天'}
actions={<>
<Button
className="v2-track-query-toggle"
theme="borderless"
type="tertiary"
size="small"
icon={<IconChevronRight />}
aria-label={queryCollapsed ? '修改条件' : '收起条件'}
aria-expanded={!queryCollapsed}
onClick={onToggleQuery}
>{queryCollapsed ? '修改条件' : '收起条件'}</Button>
<Button className="v2-track-rail-hide" theme="borderless" aria-label="隐藏查询与明细" icon={<IconChevronLeft />} onClick={onCollapse}><span className="v2-track-rail-hide-label"></span></Button>
</>}
/>
{queryCollapsed ? <div className="v2-track-query-summary">
<span><strong>{draft.keyword || '尚未选择车辆'}</strong><small>{draft.dateFrom.replace('T', ' ').slice(5)} {draft.dateTo.replace('T', ' ').slice(5)}</small></span>
<Tag color={draft.protocol ? 'blue' : 'grey'} type="light" size="small">{draft.protocol || '自动来源'}</Tag>
</div> : <form className="v2-track-query" onSubmit={onSubmit}>
<label><span></span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
<div className="v2-track-presets">
<Button theme={activePreset === 'today' ? 'solid' : 'light'} aria-pressed={activePreset === 'today'} onClick={() => choosePreset(0)}></Button>
<Button theme={activePreset === 'yesterday' ? 'solid' : 'light'} aria-pressed={activePreset === 'yesterday'} onClick={() => choosePreset(1)}></Button>
<Button theme={activePreset === 'three-days' ? 'solid' : 'light'} aria-pressed={activePreset === 'three-days'} onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}> 3 </Button>
</div>
<div className="v2-track-date-grid"><label><span></span><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => onDraft({ ...draft, dateFrom: value })} /></label><label><span></span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => onDraft({ ...draft, dateTo: value })} /></label></div>
<label><span id="track-source-label"></span><Select aria-labelledby="track-source-label" value={draft.protocol} onChange={(value) => onDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '自动选择最佳来源' }, { value: 'GB32960', label: 'GB32960 · 仪表盘里程' }, { value: 'JT808', label: 'JT808 · GPS 里程' }, { value: 'YUTONG_MQTT', label: 'YUTONG · 仪表盘里程' }]} /></label>
<Button className="v2-track-query-button" theme="solid" htmlType="submit" icon={<IconSearch />} loading={loading} disabled={!draft.keyword.trim()}></Button>
</form>}
</Card>
<div className="v2-track-rail-result">
{track ? <div className="v2-track-rail-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>{track.vin}</small></div><em>{number(track.summary.distanceKm)} km</em></div> : null}
<nav aria-label="轨迹明细分类"><button type="button" className={tab === 'stops' ? 'is-active' : ''} onClick={() => onTab('stops')}> <b>{track?.stops.length ?? 0}</b></button><button type="button" className={tab === 'events' ? 'is-active' : ''} onClick={() => onTab('events')}> <b>{track?.events.length ?? 0}</b></button><button type="button" className={tab === 'overview' ? 'is-active' : ''} onClick={() => onTab('overview')}></button></nav>
<Card className="v2-track-rail-result" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
className="v2-track-result-header"
title={track ? track.plate || track.vin : '轨迹明细'}
description={track ? track.vin : '停留、事件与行程证据'}
meta={track ? `${number(track.summary.distanceKm)} km` : undefined}
/>
<SegmentedTabs className="v2-track-rail-tabs" ariaLabel="轨迹明细分类" value={tab} onChange={onTab} items={[{ key: 'stops', label: '停留点', count: track?.stops.length ?? 0 }, { key: 'events', label: '事件点', count: track?.events.length ?? 0 }, { key: 'overview', label: '概览' }]} />
<div className="v2-track-rail-scroll">
{!track ? <div className="v2-track-rail-empty"><IconMapPin /><strong></strong><p></p></div> : null}
{track && tab === 'stops' ? <div className="v2-track-stop-list">{track.stops.map((stop, index) => <button type="button" className={activeStopIndexes.includes(index) ? 'is-active' : ''} key={`${stop.startTime}-${index}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small> {formatDuration(stop.durationSeconds)} · {stop.pointCount} </small></span><em>{time(stop.endTime)}</em></button>)}{!track.stops.length ? <p className="v2-track-list-empty"> 3 </p> : null}</div> : null}
{track && tab === 'events' ? <div className="v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <button type="button" className={activeEventIndexes.includes(index) ? 'is-active' : ''} key={`${event.type}-${event.time}-${index}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>; })}</div> : null}
{!track ? <Empty className="v2-track-rail-empty" image={<IconMapPin />} title="选择车辆后查询轨迹" description="停留点、轨迹事件与行程证据会在这里统一呈现。" /> : null}
{track && tab === 'stops' ? <List className="v2-track-evidence-list v2-track-stop-list">{track.stops.map((stop, index) => <List.Item className="v2-track-evidence-item" key={`${stop.startTime}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeStopIndexes.includes(index) ? ' is-active' : ''}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small> {formatDuration(stop.durationSeconds)} · {stop.pointCount} </small></span><em>{time(stop.endTime)}</em></Button></List.Item>)}{!track.stops.length ? <Empty className="v2-track-list-empty" image={<IconMapPin />} title="当前没有停留点" description="时间窗内没有超过 3 分钟的连续停留。" /> : null}</List> : null}
{track && tab === 'events' ? <List className="v2-track-evidence-list v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <List.Item className="v2-track-evidence-item" key={`${event.type}-${event.time}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeEventIndexes.includes(index) ? ' is-active' : ''}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></Button></List.Item>; })}{!track.events.length ? <Empty className="v2-track-list-empty" image={<IconMapPin />} title="当前没有事件点" description="时间窗内没有识别到启停、急加速或异常间隔。" /> : null}</List> : null}
{track && tab === 'overview' ? <OverviewPanel track={track} /> : null}
</div>
</div>
</Card>
</aside>;
});
const SegmentRail = memo(function SegmentRail({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
const SegmentRail = memo(function SegmentRail({ track }: { track: TrackPlaybackResponse }) {
const segments = track.segments.slice(0, 160);
const total = Math.max(1, segments.reduce((sum, segment) => sum + Math.max(1, segment.durationSeconds), 0));
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{segments.map((segment) => <button
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{segments.map((segment) => <span
aria-label={`${segment.title} ${time(segment.startTime)}${time(segment.endTime)}`}
className={`is-${segment.type}`} key={`${segment.index}-${segment.startTime}`}
onClick={() => onSelectIndex(segment.sampledStartIndex)} style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
type="button"
/>)}</div>;
});
export default function TrackPage() {
const [searchParams, setSearchParams] = useSearchParams();
const mobileLayout = useMobileLayout();
const monitorReturn = monitorReturnFromParams(searchParams);
const fallback = useMemo(defaultTrackWindow, []);
const routeKey = searchParams.toString();
@@ -210,8 +281,10 @@ export default function TrackPage() {
const [showStops, setShowStops] = useState(true);
const [panelTab, setPanelTab] = useState<PanelTab>('stops');
const [railCollapsed, setRailCollapsed] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 700px)').matches);
const [queryCollapsed, setQueryCollapsed] = useState(() => Boolean(criteria.keyword));
const animationRef = useRef<number>();
const lastFrameRef = useRef(0);
useSideSheetA11y(mobileLayout && !railCollapsed, '.v2-track-detail-sidesheet', 'v2-track-detail-sheet', '轨迹查询与明细', '关闭轨迹查询与明细');
const params = useMemo(() => {
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1600' });
@@ -231,7 +304,10 @@ export default function TrackPage() {
const currentAddressPoint = useMemo(() => trackAddressCoordinate(current?.longitude, current?.latitude), [current?.latitude, current?.longitude]);
const [addressPoint, setAddressPoint] = useState<TrackAddressCoordinate>();
useEffect(() => { setDraft(criteria); }, [criteria]);
useEffect(() => {
setDraft(criteria);
setQueryCollapsed(Boolean(criteria.keyword));
}, [criteria]);
useEffect(() => {
if (playing || !currentAddressPoint) return;
const timer = window.setTimeout(() => setAddressPoint(currentAddressPoint), TRACK_ADDRESS_SETTLE_MS);
@@ -276,6 +352,7 @@ export default function TrackPage() {
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
if (next.dateTo) url.set('dateTo', next.dateTo);
if (next.protocol) url.set('protocol', next.protocol);
setQueryCollapsed(true);
setSearchParams(preserveMonitorReturn(url, monitorReturn), { replace: true });
}, [draft, monitorReturn, setSearchParams]);
const selectIndex = useCallback((index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); }, [points.length]);
@@ -287,51 +364,61 @@ export default function TrackPage() {
setPlaying((value) => !value);
};
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
const trackRail = <TrackRail draft={draft} track={track} loading={query.isFetching} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} queryCollapsed={queryCollapsed} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onToggleQuery={() => setQueryCollapsed((value) => !value)} onCollapse={collapseRail} />;
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}`}>
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
<MonitorReturnBar />
<TrackRail draft={draft} track={track} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={collapseRail} />
{mobileLayout ? <SideSheet
className="v2-track-detail-sidesheet"
visible={!railCollapsed}
placement="bottom"
height="min(82dvh, 720px)"
aria-label="轨迹查询与明细"
title={<div className="v2-track-detail-sheet-title"><strong></strong><span>{track ? `${track.plate || track.vin} · ${number(track.summary.distanceKm)} km` : '选择车辆和时间范围'}</span></div>}
footer={null}
onCancel={collapseRail}
>{trackRail}</SideSheet> : trackRail}
<section className="v2-track-stage">
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} followDurationMs={playing ? Math.max(40, trackPlaybackInterval(playbackSpeed) - 10) : 180} onSelectIndex={selectIndex} onFollowChange={setFollow} />
{railCollapsed ? <Button className="v2-track-rail-expand" theme="light" aria-label="展开查询与明细" icon={<IconList />} onClick={() => setRailCollapsed(false)}></Button> : null}
<div className="v2-track-stage-tools">
{railCollapsed ? <button type="button" aria-label="展开查询与明细" onClick={() => setRailCollapsed(false)}><IconList /><span></span></button> : null}
<button type="button" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} disabled={!points.length} onClick={() => setFollow((value) => !value)}><IconMapPin /><span>{follow ? '跟随车辆' : '自由浏览'}</span></button>
<button type="button" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>{showStops ? <IconEyeOpened /> : <IconEyeClosed />}<span></span></button>
<button type="button" aria-label="导出轨迹 CSV" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /><span></span></button>
<Button theme={follow ? 'solid' : 'light'} aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} icon={<IconMapPin />} disabled={!points.length} onClick={() => setFollow((value) => !value)}>{follow ? '跟随车辆' : '自由浏览'}</Button>
<Button theme={showStops ? 'solid' : 'light'} aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} icon={showStops ? <IconEyeOpened /> : <IconEyeClosed />} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}></Button>
<Button theme="light" aria-label="导出轨迹 CSV" icon={<IconDownload />} disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}></Button>
</div>
{track?.points.length ? <>
<div className={`v2-track-coverage-float${track.coverage.complete ? '' : ' is-warning'}`}><i />
<span><strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>{track.coverage.evidence}</span>
<span><strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong><small title={track.coverage.evidence}>{track.coverage.evidence}</small></span>
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} {track.coverage.returnedPoints.toLocaleString('zh-CN')} </em>
</div>
<article className="v2-track-current-card">
<header><div><strong>{track.plate || track.vin}</strong><span>{dateTime(current?.deviceTime)}</span></div><b>{number(progress, 0)}%</b></header>
<Card className="v2-track-current-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title={track.plate || track.vin} description={dateTime(current?.deviceTime)} meta={`${number(progress, 0)}%`} />
<div><span><small></small><strong>{number(current?.speedKmh ?? 0)}<em> km/h</em></strong></span><span><small></small><strong>{direction(current?.directionDeg)}</strong></span><span><small>SOC</small><strong>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}</strong></span></div>
<p title={addressSettling ? undefined : addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressSettling ? '位置已变化,等待地址解析…' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
</article>
</> : <div className="v2-track-empty-state"><IconMapPin /><strong></strong><p></p><button type="button" onClick={() => setRailCollapsed(false)}><IconSearch /></button></div>}
</Card>
</> : <Card className="v2-track-empty-state" bodyStyle={{ padding: 0 }}><Empty image={<IconMapPin />} title="先选择车辆,再开始轨迹回放" description="地图会显示完整路径、停留点、事件点和播放位置。"><Button theme="solid" icon={<IconSearch />} onClick={() => setRailCollapsed(false)}></Button></Empty></Card>}
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" /></div> : null}
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}
<footer className="v2-track-playback-dock">
<Card className="v2-track-playback-dock" bodyStyle={{ padding: 0 }}>
<div className="v2-track-dock-summary"><strong>{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</strong><span>{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}</span></div>
<div className="v2-track-dock-progress">
{track ? <SegmentRail track={track} onSelectIndex={selectIndex} /> : <div className="v2-track-segment-placeholder" />}
<input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={boundedIndex} onChange={(event) => selectIndex(Number(event.target.value))} disabled={!points.length} style={{ '--track-progress': `${progress}%` } as React.CSSProperties} />
<div><time>{time(current?.deviceTime)}</time><span> {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
{track ? <SegmentRail track={track} /> : <div className="v2-track-segment-placeholder" />}
<Slider key={`track-progress-${points.length}`} className="v2-track-progress-slider" aria-label="轨迹播放进度" min={0} max={Math.max(0, points.length - 1)} step={1} value={boundedIndex} onChange={(value) => selectIndex(Number(value))} disabled={!points.length} showBoundary={false} tipFormatter={(value) => `${Number(value) + 1} / ${points.length} 个数据点`} />
<div className="v2-track-progress-meta"><time>{time(current?.deviceTime)}</time><span> {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
</div>
<div className="v2-track-dock-controls">
<button type="button" aria-label="上一个轨迹点" onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex}><IconChevronLeft /></button>
<button type="button" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} onClick={togglePlayback} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button>
<button type="button" aria-label="下一个轨迹点" onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1}><IconChevronRight /></button>
<label><span></span><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as PlaybackSpeed)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></label>
<button type="button" aria-label="回到起点" onClick={() => selectIndex(0)} disabled={!boundedIndex}><IconRefresh /></button>
<Button theme="borderless" aria-label="上一个轨迹点" icon={<IconChevronLeft />} onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex} />
<Button theme="solid" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} icon={playing ? <IconPause /> : <IconPlay />} onClick={togglePlayback} disabled={points.length < 2} />
<Button theme="borderless" aria-label="下一个轨迹点" icon={<IconChevronRight />} onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1} />
<label><span id="track-playback-speed-label"></span><Select aria-labelledby="track-playback-speed-label" value={playbackSpeed} onChange={(value) => setPlaybackSpeed(Number(value) as PlaybackSpeed)} optionList={speedOptions.map((speed) => ({ value: speed, label: `${speed}×` }))} /></label>
<Button theme="borderless" aria-label="回到起点" icon={<IconRefresh />} onClick={() => selectIndex(0)} disabled={!boundedIndex} />
</div>
<div className="v2-track-dock-metrics"><span><small></small><strong>{number(current?.totalMileageKm ?? 0)} km</strong></span><span><small></small><strong>{current?.protocol || '—'}</strong></span><span><small></small><strong>{alarm(current?.alarmFlag)}</strong></span></div>
</footer>
</Card>
</section>
</div>;
}

View File

@@ -9,11 +9,14 @@ const mocks = vi.hoisted(() => ({
createCustomerUser: vi.fn(),
updateCustomerUser: vi.fn()
}));
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
afterEach(() => {
cleanup();
layout.mobile = false;
Object.values(mocks).forEach((mock) => mock.mockReset());
});
@@ -38,19 +41,129 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
mocks.vehicleCoverage.mockResolvedValue({ items: [duplicatedVehicle, duplicatedVehicle], total: 1, limit: 20, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: /华东客户/ }));
expect((await screen.findByRole('button', { name: '移除 粤A11111' })).closest('article')).toHaveTextContent('粤A11111VIN001');
const customer = await screen.findByRole('button', { name: /选择客户 华东客户/ });
expect(view.container.querySelector('.v2-user-list.semi-card')).toBeInTheDocument();
expect(view.container.querySelector('.v2-customer-list.semi-list')).toBeInTheDocument();
expect(customer).toHaveClass('semi-button', 'v2-user-list-item');
expect(customer.closest('.semi-list-item')).toHaveClass('v2-user-list-row');
expect(customer).toHaveAttribute('aria-pressed', 'false');
expect(customer).toHaveAttribute('aria-expanded', 'false');
expect(view.container.querySelector('.v2-user-editor.semi-card')).not.toBeInTheDocument();
fireEvent.click(customer);
await waitFor(() => expect(customer).toHaveAttribute('aria-expanded', 'true'));
expect(view.container.querySelector('.v2-user-editor.semi-card')).toBeInTheDocument();
expect(view.container.querySelector('.v2-user-editor-tabs.semi-tabs')).toBeInTheDocument();
expect(screen.getAllByRole('tab')).toHaveLength(3);
expect(view.container.querySelector('.v2-user-vehicle-section.semi-card')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /菜单权限/ }));
expect(screen.getByText('客户开放菜单').closest('.semi-tag')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ }));
expect(document.querySelector('.v2-user-vehicle-section .v2-user-section-title .semi-tag')).toHaveTextContent('1 辆');
const granted = await screen.findByRole('button', { name: '移除 粤A11111' });
expect(granted).toHaveClass('semi-button');
expect(granted.closest('.v2-assigned-vehicle-card')).toHaveClass('semi-card');
expect(granted.closest('.v2-assigned-vehicle-card')).toHaveTextContent('粤A11111VIN001');
expect(screen.getByRole('button', { name: '调整 粤A11111 有效期' })).toHaveAttribute('aria-haspopup', 'dialog');
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
expect(await screen.findByLabelText('粤A11111 启用时间')).toHaveValue('2026-06-05T00:00');
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(2);
expect(document.querySelector('.v2-user-grant-sidesheet .semi-sidesheet-inner')).toHaveAttribute('aria-label', '车辆授权有效期');
fireEvent.click(screen.getByRole('button', { name: '关闭车辆授权有效期' }));
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
expect(document.querySelector('.v2-user-avatar')).toHaveClass('semi-avatar');
expect(document.querySelector('.v2-user-status-tag')).toHaveClass('semi-tag');
expect(document.querySelector('.v2-grant-history')).toHaveClass('semi-collapse');
expect(document.querySelector('.v2-grant-history-title .semi-tag')).toHaveTextContent('1 条');
fireEvent.change(screen.getByRole('textbox', { name: '按车牌或 VIN 搜索' }), { target: { value: '粤A22222' } });
await waitFor(() => expect(mocks.vehicleCoverage).toHaveBeenCalled());
const candidates = await screen.findAllByRole('button', { name: /粤A22222.*VIN002.*选择/ });
const candidates = await screen.findAllByRole('option', { name: /粤A22222.*VIN002.*选择/ });
expect(candidates).toHaveLength(1);
expect((mocks.vehicleCoverage.mock.calls[0][0] as URLSearchParams).get('keyword')).toBe('粤A22222');
fireEvent.click(candidates[0]);
expect((await screen.findByRole('button', { name: '移除 粤A22222' })).closest('article')).toHaveTextContent('粤A22222VIN002');
expect((await screen.findByRole('button', { name: '移除 粤A22222' })).closest('.v2-assigned-vehicle-card')).toHaveTextContent('粤A22222VIN002');
});
test('pages and filters large vehicle grants without nesting a vehicle-list scrollbar', async () => {
const vehicles = Array.from({ length: 12 }, (_, index) => ({
vin: `VIN${String(index + 1).padStart(3, '0')}`,
plate: `粤A${String(index + 1).padStart(5, '0')}`,
validFrom: '2026-06-05T00:00:00+08:00',
sourceSystem: 'manual',
grantedBy: '平台管理员'
}));
mocks.adminUsers.mockResolvedValue([{
id: 7,
username: 'customer-east',
displayName: '华东客户',
userType: 'customer',
status: 'enabled',
customerRef: '',
tenantRef: '',
authProvider: 'local',
menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'],
vehicleVins: vehicles.map((vehicle) => vehicle.vin),
vehicles,
grantHistory: [],
createdAt: '2026-07-16T00:00:00Z',
updatedAt: '2026-07-16T00:00:00Z'
}]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(10);
expect(screen.getByText('第 110 条,共 12 辆')).toBeInTheDocument();
expect(view.container.querySelector('.v2-assigned-pagination')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect(await screen.findByRole('button', { name: '移除 粤A00012' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(2);
fireEvent.change(screen.getByRole('textbox', { name: '筛选已授权车辆' }), { target: { value: '粤A00012' } });
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(1);
expect(screen.getByText('1 条匹配结果')).toBeInTheDocument();
expect(screen.getByText('第 11 条,共 1 辆')).toBeInTheDocument();
});
test('keeps the customer directory visible on mobile and opens details on demand', async () => {
layout.mobile = true;
mocks.adminUsers.mockResolvedValue([{
id: 7, username: 'customer-east', displayName: '华东客户', userType: 'customer', status: 'enabled',
customerRef: '', tenantRef: '', authProvider: 'local', menuKeys: ['monitor'], vehicleVins: [],
vehicles: [], grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
}]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
expect(await screen.findByText('客户权限目录')).toBeInTheDocument();
const customer = await screen.findByRole('button', { name: /选择客户 华东客户/ });
expect(view.container.querySelector('.v2-user-mobile-picker')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-customer-list.semi-list')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '搜索客户账号' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-user-editor')).not.toBeInTheDocument();
fireEvent.click(customer);
expect(await screen.findByRole('button', { name: '关闭账号详情' })).toBeInTheDocument();
expect(customer).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭账号详情' }));
expect(view.container.querySelector('.v2-user-editor')).not.toBeInTheDocument();
expect(customer).toHaveAttribute('aria-expanded', 'false');
expect(screen.getAllByRole('button', { name: '新建客户账号' })).toHaveLength(1);
});
test('uses a standard Semi empty state when no customer account exists', async () => {
mocks.adminUsers.mockResolvedValue([]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
expect(await screen.findByText('还没有客户账号')).toBeInTheDocument();
expect(view.container.querySelector('.v2-user-list-empty.semi-empty')).toBeInTheDocument();
expect(view.container.querySelector('.v2-user-editor')).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: '创建第一个客户账号' })).toHaveLength(1);
});
test('submits per-vehicle authorization interval instead of only a VIN list', async () => {
@@ -63,8 +176,11 @@ test('submits per-vehicle authorization interval instead of only a VIN list', as
mocks.updateCustomerUser.mockResolvedValue({ id: 7 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
fireEvent.click(await screen.findByRole('button', { name: /华东客户/ }));
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
fireEvent.change(await screen.findByLabelText('粤A11111 启用时间'), { target: { value: '2026-06-05T00:00' } });
fireEvent.click(screen.getByRole('button', { name: '完成' }));
fireEvent.click(screen.getByRole('button', { name: '保存权限' }));
await waitFor(() => expect(mocks.updateCustomerUser).toHaveBeenCalled());
expect(mocks.updateCustomerUser.mock.calls[0][1]).toMatchObject({

View File

@@ -1,7 +1,16 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconChevronRight, IconClose, IconDelete, IconPlus, IconSearch } from '@douyinfe/semi-icons';
import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, SideSheet, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import { PageHeader } from '../shared/PageHeader';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
const customerMenus = [
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
@@ -21,6 +30,8 @@ type Draft = {
vehicleGrants: CustomerVehicleGrantInput[];
};
type EditorSection = 'identity' | 'menus' | 'vehicles';
const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
});
@@ -48,30 +59,42 @@ function formatTime(value?: string) {
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function uniqueVehicles<T extends { vin: string; plate: string }>(vehicles: T[]) {
const byVIN = new Map<string, T>();
for (const vehicle of vehicles) {
const vin = vehicle.vin.trim().toUpperCase();
const current = byVIN.get(vin);
if (!vin || (current?.plate && !vehicle.plate)) continue;
byVIN.set(vin, vehicle);
}
return [...byVIN.values()];
function formatGrantTime(value?: string) {
if (!value) return '未设置';
return value.replace('T', ' ').slice(0, 16);
}
export default function UsersPage() {
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 });
const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]);
const [customerKeyword, setCustomerKeyword] = useState('');
const visibleCustomers = useMemo(() => {
const keyword = customerKeyword.trim().toLocaleLowerCase('zh-CN');
if (!keyword) return customers;
return customers.filter((user) => [user.displayName, user.username, user.customerRef, user.tenantRef]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword)));
}, [customerKeyword, customers]);
const [selectedID, setSelectedID] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
const [activeSection, setActiveSection] = useState<EditorSection>('identity');
const selected = useMemo(() => customers.find((user) => user.id === selectedID), [customers, selectedID]);
const [draft, setDraft] = useState<Draft>(() => draftFromUser());
const [vehicleKeyword, setVehicleKeyword] = useState('');
const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim());
const [assignedKeyword, setAssignedKeyword] = useState('');
const [assignedPage, setAssignedPage] = useState(1);
const [bulkVINs, setBulkVINs] = useState('');
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
const [feedback, setFeedback] = useState('');
const [editingGrantVIN, setEditingGrantVIN] = useState('');
const enabledCustomers = useMemo(() => customers.filter((user) => user.status === 'enabled').length, [customers]);
const grantedVehicles = useMemo(() => customers.reduce((total, user) => total + user.vehicles.length, 0), [customers]);
const editingGrant = useMemo(() => draft.vehicleGrants.find((grant) => grant.vin === editingGrantVIN), [draft.vehicleGrants, editingGrantVIN]);
const editingGrantPlate = editingGrant ? vehicleLabels[editingGrant.vin] : '';
const grantWindowInvalid = Boolean(editingGrant && (!editingGrant.validFrom || (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom)));
useSideSheetA11y(Boolean(editingGrant), '.v2-user-grant-sidesheet', 'v2-user-grant-window', '车辆授权有效期', '关闭车辆授权有效期');
useEffect(() => {
if (!creating && selected) {
@@ -87,7 +110,20 @@ export default function UsersPage() {
staleTime: 30_000
});
const assigned = useMemo(() => new Set(draft.vehicleGrants.map((grant) => grant.vin)), [draft.vehicleGrants]);
const candidateVehicles = useMemo(() => uniqueVehicles(candidates.data?.items ?? []), [candidates.data?.items]);
const candidateVehicles = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const assignedPageSize = mobileLayout ? 4 : 10;
const filteredAssignedGrants = useMemo(() => {
const keyword = assignedKeyword.trim().toLocaleLowerCase('zh-CN');
if (!keyword) return draft.vehicleGrants;
return draft.vehicleGrants.filter((grant) => [grant.vin, vehicleLabels[grant.vin]]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword)));
}, [assignedKeyword, draft.vehicleGrants, vehicleLabels]);
const assignedTotalPages = Math.max(1, Math.ceil(filteredAssignedGrants.length / assignedPageSize));
const safeAssignedPage = Math.min(assignedPage, assignedTotalPages);
const visibleAssignedGrants = useMemo(() => {
const offset = (safeAssignedPage - 1) * assignedPageSize;
return filteredAssignedGrants.slice(offset, offset + assignedPageSize);
}, [assignedPageSize, filteredAssignedGrants, safeAssignedPage]);
useEffect(() => {
if (candidateVehicles.length === 0) return;
setVehicleLabels((current) => {
@@ -121,26 +157,53 @@ export default function UsersPage() {
const startCreate = () => {
setCreating(true);
setSelectedID(null);
setActiveSection('identity');
setDraft(draftFromUser());
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setVehicleLabels({});
setFeedback('');
setEditingGrantVIN('');
};
const closeEditor = () => {
setCreating(false);
setSelectedID(null);
setDraft(draftFromUser());
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setVehicleLabels({});
setFeedback('');
setEditingGrantVIN('');
};
const selectCustomer = (user: AdminUser) => {
setCreating(false);
setSelectedID(user.id);
setActiveSection('vehicles');
setDraft(draftFromUser(user));
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setFeedback('');
setEditingGrantVIN('');
};
const toggleVIN = (vin: string) => {
if (!assigned.has(vin)) {
setAssignedKeyword('');
setAssignedPage(1);
}
setDraft((value) => ({
...value,
vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin)
? value.vehicleGrants.filter((grant) => grant.vin !== vin)
: [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin))
}));
if (editingGrantVIN === vin) setEditingGrantVIN('');
};
const toggleVIN = (vin: string) => setDraft((value) => ({
...value,
vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin)
? value.vehicleGrants.filter((grant) => grant.vin !== vin)
: [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin))
}));
const updateGrant = (vin: string, patch: Partial<CustomerVehicleGrantInput>) => setDraft((value) => ({
...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant)
}));
@@ -151,47 +214,174 @@ export default function UsersPage() {
const added = next.filter((vin) => !existing.has(vin)).map((vin) => ({ vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }));
return { ...value, vehicleGrants: [...value.vehicleGrants, ...added].sort((left, right) => left.vin.localeCompare(right.vin)) };
});
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
};
const submit = (event: FormEvent) => { event.preventDefault(); setFeedback(''); save.mutate(); };
const submit = (event: FormEvent) => {
event.preventDefault();
setFeedback('');
if (!draft.displayName.trim() || (creating && (!draft.username.trim() || !draft.password))) {
setActiveSection('identity');
setFeedback('请先完善登录身份中的必填信息');
return;
}
const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom));
if (invalidGrant) {
setActiveSection('vehicles');
setEditingGrantVIN(invalidGrant.vin);
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
return;
}
save.mutate();
};
return <div className="v2-user-admin">
<header className="v2-user-admin-heading">
<div><h2></h2><p> 30 </p></div>
<button type="button" onClick={startCreate}></button>
</header>
<div className="v2-user-admin-grid">
<aside className="v2-user-list">
<div className="v2-user-list-summary"><strong>{customers.length}</strong><span></span></div>
{users.isPending ? <p className="v2-user-empty"></p> : customers.length === 0 ? <p className="v2-user-empty"></p> : customers.map((user) => <button key={user.id} type="button" className={selectedID === user.id && !creating ? 'is-active' : ''} onClick={() => selectCustomer(user)}>
<span className="v2-user-avatar">{user.displayName.slice(0, 1)}</span>
<span><b>{user.displayName}</b><small>@{user.username} · {user.vehicles.length} </small></span>
<i className={user.status === 'enabled' ? 'is-enabled' : ''}>{user.status === 'enabled' ? '启用' : '停用'}</i>
</button>)}
</aside>
<main className="v2-user-editor">
{!creating && !selected ? <div className="v2-user-editor-empty"><strong></strong><p></p><button type="button" onClick={startCreate}></button></div> : <form onSubmit={submit}>
<div className="v2-user-editor-title"><div><h3>{creating ? '创建客户账号' : selected?.displayName}</h3><p>{creating ? '设置登录身份和最小必要权限' : `最近登录:${formatTime(selected?.lastLoginAt)}`}</p></div><label className="v2-user-status"><input type="checkbox" checked={draft.status === 'enabled'} onChange={(event) => setDraft((value) => ({ ...value, status: event.target.checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label></div>
<section><h4></h4><div className="v2-user-fields">
<label><span></span><input required disabled={!creating} value={draft.username} onChange={(event) => setDraft((value) => ({ ...value, username: event.target.value }))} placeholder="例如 customer-huadong" /></label>
<label><span></span><input required value={draft.displayName} onChange={(event) => setDraft((value) => ({ ...value, displayName: event.target.value }))} placeholder="显示在平台右上角" /></label>
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><input required={creating} type="password" autoComplete="new-password" value={draft.password} onChange={(event) => setDraft((value) => ({ ...value, password: event.target.value }))} placeholder="至少 10 位,包含三类字符" /></label>
<label><span></span><input value={draft.customerRef} onChange={(event) => setDraft((value) => ({ ...value, customerRef: event.target.value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
</div></section>
<section><h4> <small></small></h4><div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><input type="checkbox" checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div></section>
<section><div className="v2-vehicle-permission-heading"><h4> <small> {draft.vehicleGrants.length} </small></h4>{draft.vehicleGrants.length ? <button type="button" onClick={() => setDraft((value) => ({ ...value, vehicleGrants: [] }))}></button> : null}</div>
<div className="v2-vehicle-permission-tools"><label><span> VIN </span><input value={vehicleKeyword} onChange={(event) => setVehicleKeyword(event.target.value)} placeholder="输入后显示候选车辆" /></label><label><span> VIN</span><span className="v2-bulk-vin"><input value={bulkVINs} onChange={(event) => setBulkVINs(event.target.value)} placeholder="空格、逗号或换行分隔" /><button type="button" disabled={!bulkVINs.trim()} onClick={addBulkVINs}></button></span></label></div>
{deferredVehicleKeyword ? <div className="v2-vehicle-candidates">{candidates.isPending ? <p></p> : candidateVehicles.length === 0 ? <p></p> : candidateVehicles.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={vehicle.vin} onClick={() => toggleVIN(vehicle.vin)}><span><b>{vehicle.plate || '未登记车牌'}</b><small>{vehicle.vin}</small></span><i>{assigned.has(vehicle.vin) ? '已分配' : '选择'}</i></button>)}</div> : null}
{draft.vehicleGrants.length ? <div className="v2-assigned-vins">{draft.vehicleGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return <article key={grant.vin}>
<header><span><b>{plate || '未登记车牌'}</b><small>{grant.vin}</small></span><button type="button" aria-label={`移除 ${plate || grant.vin}`} onClick={() => toggleVIN(grant.vin)}>×</button></header>
<div><label><span></span><input aria-label={`${plate || grant.vin} 启用时间`} type="datetime-local" required value={grant.validFrom} onChange={(event) => updateGrant(grant.vin, { validFrom: event.target.value })} /></label><label><span></span><input aria-label={`${plate || grant.vin} 停用时间`} type="datetime-local" min={grant.validFrom} value={grant.validTo} onChange={(event) => updateGrant(grant.vin, { validTo: event.target.value })} /></label></div>
</article>; })}</div> : <p className="v2-user-empty"></p>}
{!creating && selected?.grantHistory?.length ? <details className="v2-grant-history"><summary> · {selected.grantHistory.length} </summary><div>{selected.grantHistory.map((item) => <article key={item.id}><header><strong>{item.plate || '未登记车牌'}</strong><span>{item.vin}</span></header><p>{formatTime(item.validFrom)} {item.validTo ? formatTime(item.validTo) : '持续有效'}</p><small>{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}</small></article>)}</div></details> : null}
</section>
<PageHeader
title="客户账号与数据权限"
description="客户只会看到已分配的菜单和车辆,权限变更最多在 30 秒内对现有会话生效。"
status={`${customers.length} 个客户账号`}
meta={<Typography.Text type="tertiary"></Typography.Text>}
actions={<Button theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}></Button>}
/>
<div className={`v2-user-admin-grid${creating || selected ? ' is-editor-open' : ''}`}>
<Card className="v2-user-list" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-user-directory-header"
title="客户权限目录"
description="按客户查看账号状态、菜单和车辆授权范围"
meta={<Tag color="blue" type="light" size="small">{customers.length} </Tag>}
/>
<div className="v2-user-directory-body">
<div className="v2-user-directory-metrics" aria-label="客户账号概览">
<span className="is-primary"><small></small><strong>{enabledCustomers}</strong></span>
<span><small></small><strong>{customers.length - enabledCustomers}</strong></span>
<span><small></small><strong>{grantedVehicles}</strong></span>
</div>
{users.isPending ? <div className="v2-user-list-loading" role="status"><Spin size="middle" tip="正在加载客户账号" /></div> : customers.length === 0 ? <Empty className="v2-user-list-empty" title="还没有客户账号" description="创建客户账号后,可在这里配置菜单和车辆范围。"><Button theme="solid" onClick={startCreate}></Button></Empty> : <>
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={setCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
<List
className="v2-customer-list"
dataSource={visibleCustomers}
emptyContent={<Empty className="v2-user-filter-empty" title="没有匹配账号" description="请更换名称或账号关键词。" />}
renderItem={(user) => <List.Item key={user.id} className={`v2-user-list-row${selectedID === user.id && !creating ? ' is-active' : ''}`}>
<Button
className="v2-user-list-item"
theme="borderless"
type="tertiary"
aria-label={`选择客户 ${user.displayName},账号 ${user.username}${user.vehicles.length} 辆授权车`}
aria-pressed={selectedID === user.id && !creating}
aria-expanded={selectedID === user.id && !creating}
onClick={() => selectCustomer(user)}
>
<Avatar className="v2-user-avatar" color={user.status === 'enabled' ? 'light-blue' : 'grey'} shape="square" size="small">{user.displayName.slice(0, 1)}</Avatar>
<span className="v2-user-list-identity"><b>{user.displayName}</b><small>@{user.username}</small></span>
<span className="v2-user-list-facts"><small></small><b>{user.menuKeys.length} </b></span>
<span className="v2-user-list-facts"><small></small><b>{user.vehicles.length} </b></span>
<span className="v2-user-list-facts is-login"><small></small><b>{formatTime(user.lastLoginAt)}</b></span>
<span className="v2-user-list-trailing"><Tag className={`v2-user-status-tag is-${user.status}`} color={user.status === 'enabled' ? 'green' : 'grey'} type="light" size="small">{user.status === 'enabled' ? '启用' : '停用'}</Tag><IconChevronRight /></span>
</Button>
</List.Item>}
/>
</>}
</div>
</Card>
{creating || selected ? <Card className="v2-user-editor" bodyStyle={{ padding: 0 }} aria-label="客户账号详情">
<form onSubmit={submit}>
<div className="v2-user-editor-title"><div><h3>{creating ? '创建客户账号' : selected?.displayName}</h3><p>{creating ? '设置登录身份和最小必要权限' : `@${selected?.username} · ${draft.menuKeys.length} 个菜单 · ${draft.vehicleGrants.length} 辆车 · 最近登录 ${formatTime(selected?.lastLoginAt)}`}</p></div><label className="v2-user-status"><Switch aria-label={draft.status === 'enabled' ? '账号启用' : '账号停用'} checked={draft.status === 'enabled'} onChange={(checked) => setDraft((value) => ({ ...value, status: checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label><Button className="v2-user-editor-close" theme="borderless" type="tertiary" icon={<IconClose />} aria-label="关闭账号详情" onClick={closeEditor} /></div>
<Tabs className="v2-user-editor-tabs" activeKey={activeSection} onChange={(key) => setActiveSection(String(key) as EditorSection)}>
<Tabs.TabPane tab="登录身份" itemKey="identity">
<Card className="v2-user-editor-section v2-user-identity-section" title="登录身份" headerLine>
<div className="v2-user-fields">
<label><span></span><Input required disabled={!creating} value={draft.username} onChange={(value) => setDraft((current) => ({ ...current, username: value }))} placeholder="例如 customer-huadong" /></label>
<label><span></span><Input required value={draft.displayName} onChange={(value) => setDraft((current) => ({ ...current, displayName: value }))} placeholder="显示在平台右上角" /></label>
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><Input required={creating} mode="password" autoComplete="new-password" value={draft.password} onChange={(value) => setDraft((current) => ({ ...current, password: value }))} placeholder="至少 10 位,包含三类字符" /></label>
<label><span></span><Input value={draft.customerRef} onChange={(value) => setDraft((current) => ({ ...current, customerRef: value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
</div>
</Card>
</Tabs.TabPane>
<Tabs.TabPane tab={<span className="v2-user-tab-label"><Tag color="blue" type="light" size="small">{draft.menuKeys.length}</Tag></span>} itemKey="menus">
<Card className="v2-user-editor-section v2-user-menu-section" title={<span className="v2-user-section-title"> <Tag color="blue" type="light" size="small"></Tag></span>} headerLine>
<div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><Checkbox checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div>
</Card>
</Tabs.TabPane>
<Tabs.TabPane tab={<span className="v2-user-tab-label"><Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length}</Tag></span>} itemKey="vehicles">
<Card className="v2-user-editor-section v2-user-vehicle-section" title={<span className="v2-user-section-title"> <Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length} </Tag></span>} headerLine headerExtraContent={draft.vehicleGrants.length ? <Button theme="borderless" type="tertiary" size="small" onClick={() => setDraft((value) => ({ ...value, vehicleGrants: [] }))}></Button> : null}>
<div className="v2-vehicle-permission-tools"><label><span> VIN </span><Input aria-label="按车牌或 VIN 搜索" value={vehicleKeyword} onChange={setVehicleKeyword} placeholder="输入后显示候选车辆" /></label><label><span> VIN</span><span className="v2-bulk-vin"><Input value={bulkVINs} onChange={setBulkVINs} placeholder="空格、逗号或换行分隔" /><Button theme="light" disabled={!bulkVINs.trim()} onClick={addBulkVINs}></Button></span></label></div>
{deferredVehicleKeyword ? <VehicleCandidateList
className="v2-vehicle-candidates"
layout="grid"
items={candidateVehicles}
loading={candidates.isPending}
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
onRetry={() => candidates.refetch()}
selectedVins={assigned}
selectedLabel="已分配"
onSelect={(vehicle) => toggleVIN(vehicle.vin)}
/> : null}
{draft.vehicleGrants.length ? <>
<div className="v2-assigned-toolbar">
<span><strong></strong><small>{assignedKeyword ? `${filteredAssignedGrants.length} 条匹配结果` : `${draft.vehicleGrants.length} 辆车拥有当前访问权限`}</small></span>
<Input
aria-label="筛选已授权车辆"
prefix={<IconSearch />}
showClear
value={assignedKeyword}
onChange={(value) => {
setAssignedKeyword(value);
setAssignedPage(1);
}}
placeholder="筛选已授权车牌或 VIN"
/>
</div>
{visibleAssignedGrants.length ? <div className="v2-assigned-vins">{visibleAssignedGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return <Card key={grant.vin} className="v2-assigned-vehicle-card" bodyStyle={{ padding: 0 }}>
<header><span><b>{plate || '未登记车牌'}</b><small>{grant.vin}</small></span><span className="v2-assigned-vehicle-actions"><Button theme="light" type="tertiary" size="small" aria-haspopup="dialog" aria-controls="v2-user-grant-window" aria-label={`调整 ${plate || grant.vin} 有效期`} onClick={() => setEditingGrantVIN(grant.vin)}></Button><Button theme="borderless" type="tertiary" size="small" aria-label={`移除 ${plate || grant.vin}`} icon={<IconDelete />} onClick={() => toggleVIN(grant.vin)} /></span></header>
<div className="v2-assigned-vehicle-interval"><span><small></small><strong>{formatGrantTime(grant.validFrom)}</strong></span><i aria-hidden="true"></i><span><small></small><strong>{grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}</strong></span></div>
</Card>; })}</div> : <Empty className="v2-user-assigned-filter-empty" title="没有匹配的授权车辆" description="请更换车牌或 VIN 关键词。" />}
<div className="v2-assigned-pagination">
<TablePagination
page={safeAssignedPage}
totalPages={assignedTotalPages}
info={<> {(safeAssignedPage - 1) * assignedPageSize + (filteredAssignedGrants.length ? 1 : 0)}{Math.min(safeAssignedPage * assignedPageSize, filteredAssignedGrants.length)} {filteredAssignedGrants.length} </>}
onPageChange={setAssignedPage}
/>
</div>
</> : <Empty className="v2-user-vehicle-empty" title="尚未分配车辆" description="客户将无法看到任何车辆数据。" />}
{!creating && selected?.grantHistory?.length ? <Collapse className="v2-grant-history">
<Collapse.Panel itemKey="grant-history" header={<span className="v2-grant-history-title"> <Tag color="blue" type="light" size="small">{selected.grantHistory.length} </Tag></span>}>
<div className="v2-grant-history-list">{selected.grantHistory.map((item) => <Card key={item.id} className="v2-grant-history-card" bodyStyle={{ padding: 0 }}>
<header><span><strong>{item.plate || '未登记车牌'}</strong><small>{item.vin}</small></span><Tag color={item.validTo ? 'grey' : 'green'} type="light" size="small">{item.validTo ? '已结束' : '有效中'}</Tag></header>
<p>{formatTime(item.validFrom)} <i></i> {item.validTo ? formatTime(item.validTo) : '持续有效'}</p>
<small>{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}</small>
</Card>)}</div>
</Collapse.Panel>
</Collapse> : null}
</Card>
</Tabs.TabPane>
</Tabs>
{feedback ? <p className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} role="status">{feedback}</p> : null}
<footer><button type="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</button></footer>
</form>}
</main>
<footer><Button theme="solid" htmlType="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</Button></footer>
</form>
</Card> : null}
</div>
<SideSheet
className="v2-user-grant-sidesheet"
visible={Boolean(editingGrant)}
width={420}
aria-label="车辆授权有效期"
title={<div className="v2-user-grant-sheet-title"><strong></strong><span>{editingGrant ? `${editingGrantPlate || '未登记车牌'} · ${editingGrant.vin}` : '车辆授权时间窗口'}</span></div>}
onCancel={() => setEditingGrantVIN('')}
footer={<div className="v2-user-grant-sheet-footer"><Typography.Text type="tertiary"></Typography.Text><Button theme="solid" disabled={grantWindowInvalid} onClick={() => setEditingGrantVIN('')}></Button></div>}
>
{editingGrant ? <div className="v2-user-grant-form">
<Card className="v2-user-grant-summary" bodyStyle={{ padding: 0 }}>
<span><small></small><strong>{editingGrantPlate || '未登记车牌'}</strong></span>
<span><small>VIN</small><strong>{editingGrant.vin}</strong></span>
</Card>
<label><span></span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 启用时间`} type="datetime-local" required value={editingGrant.validFrom} onChange={(value) => updateGrant(editingGrant.vin, { validFrom: value })} /></label>
<label><span></span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 停用时间`} type="datetime-local" min={editingGrant.validFrom} value={editingGrant.validTo} onChange={(value) => updateGrant(editingGrant.vin, { validTo: value })} /></label>
{grantWindowInvalid ? <p role="alert"></p> : <p></p>}
</div> : null}
</SideSheet>
</div>;
}

View File

@@ -9,6 +9,7 @@ import { ROUTER_FUTURE } from '../routing/routerConfig';
import VehiclePage from './VehiclePage';
const fleetMapVehicles = vi.hoisted(() => vi.fn());
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../map/FleetMap', () => ({
FleetMap: ({ vehicles }: { vehicles: VehicleRealtimeRow[] }) => {
@@ -20,6 +21,7 @@ vi.mock('../map/FleetMap', () => ({
vi.mock('../auth/AuthGate', () => ({
usePlatformSession: () => ({ session: { name: 'test-viewer', role: 'viewer', authMode: 'enforce' } })
}));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
const initialRealtime = {
vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocols: ['JT808'], sourceStatus: [],
@@ -44,6 +46,7 @@ const detail = {
afterEach(() => {
cleanup();
layout.mobile = false;
vi.restoreAllMocks();
fleetMapVehicles.mockReset();
});
@@ -62,7 +65,7 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
await waitFor(() => expect(realtimeSpy).toHaveBeenCalledTimes(1));
const realtimeQuery = client.getQueryCache().find({ queryKey: ['vehicle-detail-realtime', initialRealtime.vin] });
@@ -74,12 +77,106 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
await waitFor(() => expect(fleetMapVehicles).toHaveBeenLastCalledWith([
expect.objectContaining({ longitude: 113.28, latitude: 23.15, reportIntervalMs: 30_000 })
]));
expect(view.container.querySelector('.v2-identity-band')).toHaveClass('semi-card');
expect(screen.getByRole('heading', { name: '最新上报', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '实时位置', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '最近事件', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '实时遥测', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '车辆主档', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: '单车详情导航' })).toBeInTheDocument();
const telemetryPanel = view.container.querySelector<HTMLElement>('#vehicle-telemetry-panel')!;
const scrollIntoView = vi.fn();
Object.defineProperty(telemetryPanel, 'scrollIntoView', { configurable: true, value: scrollIntoView });
fireEvent.click(screen.getByRole('button', { name: '跳转到实时遥测' }));
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
expect(telemetryPanel).toHaveFocus();
expect(view.container.querySelector('.v2-record-descriptions.semi-descriptions')).toBeInTheDocument();
expect(screen.getByText('待维护').closest('.semi-tag')).toBeInTheDocument();
expect(sourceEvidence).not.toHaveBeenCalled();
fireEvent.click(screen.getByTitle('展开全部位置来源'));
const locationSource = screen.getByRole('button', { name: '查看全部位置来源' });
expect(locationSource).toHaveClass('semi-button', 'v2-map-source-link');
expect(locationSource.closest('.semi-card')).toHaveClass('v2-single-map-card');
const totalMileageSource = screen.getByRole('button', { name: '查看总里程全部来源' });
expect(totalMileageSource).toHaveClass('semi-button', 'v2-live-source-link');
expect(totalMileageSource.closest('.semi-card')).toHaveClass('v2-live-overview');
fireEvent.click(locationSource);
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
});
test('keeps the monitor return on the vehicle page and its nested investigation links', async () => {
test('uses the shared Semi empty recovery surface when a vehicle cannot be resolved', async () => {
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, lookupResolved: false });
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [], total: 0, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles/UNKNOWN']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('未找到车辆')).toBeInTheDocument();
expect(view.container.querySelector('.v2-not-found.semi-card .semi-empty')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /重新查询/ })).toHaveClass('semi-button-primary');
});
test('shows deduplicated Semi vehicle candidates and opens the selected VIN', async () => {
const workspace = document.createElement('main');
workspace.className = 'v2-content';
workspace.scrollTop = 320;
const scrollTo = vi.fn();
Object.defineProperty(workspace, 'scrollTo', { configurable: true, value: scrollTo });
document.body.appendChild(workspace);
const candidate = {
vin: initialRealtime.vin,
plate: initialRealtime.plate,
phone: '13800000000',
oem: '测试品牌',
protocol: 'JT808',
online: true,
lastSeen: initialRealtime.lastSeen,
locationText: '广东省广州市',
bindingScore: 100
};
const vehicles = vi.spyOn(api, 'vehicles').mockResolvedValue({
items: [candidate, { ...candidate }],
total: 2,
limit: 8,
offset: 0
});
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
const capabilitySummary = screen.getByLabelText('可查询的数据范围');
expect(capabilitySummary).toHaveTextContent('统一身份');
expect(capabilitySummary).toHaveTextContent('实时状态');
expect(capabilitySummary).toHaveTextContent('来源证据');
const input = screen.getByRole('textbox', { name: '搜索车辆' });
expect(input).toHaveAttribute('aria-expanded', 'false');
expect(input).toHaveAttribute('aria-controls', 'v2-vehicle-search-options');
expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument();
expect(vehicles).not.toHaveBeenCalled();
fireEvent.focus(input);
expect(input).toHaveAttribute('aria-expanded', 'true');
fireEvent.change(input, { target: { value: initialRealtime.plate } });
await waitFor(() => expect(vehicles).toHaveBeenCalled());
const option = await screen.findByRole('option', { name: `${initialRealtime.plate} ${initialRealtime.vin} JT808 选择` });
expect(view.container.querySelector('#v2-vehicle-search-options.v2-vehicle-candidate-list')).toBeInTheDocument();
expect(view.container.querySelector('#v2-vehicle-search-options')).toHaveAttribute('aria-busy', 'false');
expect(screen.getAllByRole('option')).toHaveLength(1);
fireEvent.mouseDown(option);
fireEvent.click(option);
expect(await screen.findByText(initialRealtime.vin)).toBeInTheDocument();
expect(workspace.scrollTop).toBe(0);
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
expect(screen.getByRole('group', { name: '车辆快捷操作' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '切换车辆' })).toHaveClass('semi-button', 'semi-button-primary');
workspace.remove();
});
test('keeps the monitor return on the vehicle page and its nested investigation actions', async () => {
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
@@ -94,21 +191,28 @@ test('keeps the monitor return on the vehicle page and its nested investigation
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
const trackLink = screen.getByRole('link', { name: /轨迹回放/ });
const historyLink = screen.getByRole('link', { name: /历史数据/ });
const mileageLink = screen.getByRole('link', { name: /里程查询/ });
expect(new URL(trackLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
expect(new URL(historyLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
expect(new URL(mileageLink.getAttribute('href')!, 'http://localhost').searchParams.get('monitorReturn')).toBe(monitorPath);
const actionGroup = screen.getByRole('group', { name: '车辆快捷操作' });
expect(actionGroup).toHaveTextContent('切换车辆');
expect(actionGroup).toHaveTextContent('轨迹回放');
expect(actionGroup).toHaveTextContent('历史数据');
expect(actionGroup).toHaveTextContent('里程查询');
expect(actionGroup).toHaveTextContent('告警事件');
expect(withMonitorReturn(`/tracks?vin=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`);
expect(withMonitorReturn(`/history?vin=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`);
expect(withMonitorReturn(`/statistics?vins=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`);
const liveOverview = screen.getByText('最新上报').closest('section');
const archive = screen.getByText('车辆主档').closest('section');
const liveOverview = screen.getByText('最新上报').closest('.semi-card');
const archive = screen.getByRole('heading', { name: '车辆主档', level: 5 }).closest('.semi-card');
expect(liveOverview).not.toBeNull();
expect(archive).not.toBeNull();
expect(liveOverview).toHaveClass('v2-record-card', 'v2-live-overview');
expect(archive).toHaveClass('v2-record-card', 'v2-archive-card');
expect(liveOverview!.compareDocumentPosition(archive!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(addressSpy).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '解析当前位置' }));
const addressAction = screen.getByRole('button', { name: '解析当前位置' });
expect(addressAction).toHaveClass('semi-button', 'v2-current-address-action');
fireEvent.click(addressAction);
expect(await screen.findByText('广东省广州市测试道路')).toBeInTheDocument();
expect(addressSpy).toHaveBeenCalledTimes(1);
});
@@ -125,17 +229,89 @@ test('shows unavailable live fields as dashes instead of fabricated zeroes', asy
todayMileageAvailable: false,
todayMileageKm: 0
};
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, realtimeSummary: unavailable });
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({
...detail,
realtimeSummary: unavailable,
mileage: {
items: [{ vin: unavailable.vin, plate: unavailable.plate, date: '2026-07-15', startMileageKm: 900, endMileageKm: 988.8, dailyMileageKm: 88.8, source: 'JT808' }],
total: 1,
limit: 20,
offset: 0
}
});
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [unavailable], total: 1, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
const liveOverview = (await screen.findByText('最新上报')).closest('section')!;
const liveOverview = (await screen.findByText('最新上报')).closest('.semi-card')!;
for (const label of ['速度', 'SOC', '总里程', '当日里程']) {
const metric = Array.from(liveOverview.querySelectorAll('.v2-live-grid > div')).find((item) => item.querySelector('small')?.textContent === label);
expect(metric).toHaveTextContent('—');
expect(metric).not.toHaveTextContent('88.8');
expect(metric).not.toHaveTextContent(/^0/);
}
});
test('keeps protocol telemetry independent and explains mileage semantics', async () => {
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, sources: ['GB32960', 'JT808'] });
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [{ ...initialRealtime, protocols: ['GB32960', 'JT808'] }], total: 1, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({
vin: initialRealtime.vin,
categories: [{ key: 'vehicle', label: '整车数据', count: 2 }],
values: [
{
key: 'total_mileage_km', sourceField: 'gb32960.vehicle.total_mileage_km', label: '仪表盘总里程', unit: 'km',
category: 'vehicle', valueType: 'numeric', value: 1200, protocol: 'GB32960', frameId: 'gb', deviceTime: '', serverTime: '2026-07-16 10:00:00',
quality: 'good', qualityReason: '正常', freshnessSeconds: 1
},
{
key: 'total_mileage_km', sourceField: 'jt808.location.total_mileage_km', label: 'GPS 总里程', unit: 'km',
category: 'vehicle', valueType: 'numeric', value: 1180, protocol: 'JT808', frameId: 'jt', deviceTime: '', serverTime: '2026-07-16 10:00:00',
quality: 'good', qualityReason: '正常', freshnessSeconds: 1
}
],
asOf: '2026-07-16T10:00:00+08:00', staleAfterSeconds: 300, scannedFrames: 2, evidence: 'test'
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole('tab', { name: /GB\/T 32960/, selected: true })).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-table.semi-table-wrapper')).toBeInTheDocument();
expect(document.querySelectorAll('.v2-telemetry-table [role="columnheader"]')).toHaveLength(5);
expect(document.querySelector('.v2-telemetry-mobile-list')).not.toBeInTheDocument();
expect(document.querySelector('.v2-event-list.semi-list')).toBeInTheDocument();
expect(document.querySelector('.v2-event-empty.semi-empty')).toBeInTheDocument();
expect(screen.getByText('仪表盘总里程')).toBeInTheDocument();
expect(screen.queryByText('GPS 总里程')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /JT\/T 808/ }));
expect(screen.getByRole('tab', { name: /JT\/T 808/, selected: true })).toBeInTheDocument();
expect(screen.getByText('GPS 总里程')).toBeInTheDocument();
expect(screen.queryByText('仪表盘总里程')).not.toBeInTheDocument();
});
test('uses compact Semi telemetry evidence cards on mobile', async () => {
layout.mobile = true;
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({
vin: initialRealtime.vin,
categories: [{ key: 'vehicle', label: '整车数据', count: 1 }],
values: [{
key: 'speed_kmh', sourceField: 'jt808.location.speed_kmh', label: 'GPS 速度', unit: 'km/h',
category: 'vehicle', valueType: 'numeric', value: 36, protocol: 'JT808', frameId: 'jt-mobile', deviceTime: '2026-07-16 10:00:00',
serverTime: '2026-07-16 10:00:01', quality: 'good', qualityReason: '正常', freshnessSeconds: 1
}],
asOf: '2026-07-16T10:00:01+08:00', staleAfterSeconds: 300, scannedFrames: 1, evidence: 'test'
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('GPS 速度')).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-table')).not.toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-mobile-list.semi-list')).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-mobile-item.semi-list-item')).toHaveTextContent('36km/h正常');
});

View File

@@ -1,24 +1,29 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import {
IconAlarm, IconArrowRight, IconBox, IconCalendar, IconClock, IconCopy,
IconMapPin, IconSearch, IconTickCircle
IconChevronRight, IconMapPin, IconSearch, IconTickCircle
} from '@douyinfe/semi-icons';
import { FormEvent, useMemo, useState } from 'react';
import { Button, Card, Descriptions, Empty, Input, List, Select, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult, VehicleRealtimeRow } from '../../api/types';
import type { LatestTelemetryResponse, LatestTelemetryValue, QualityIssueRow, VehicleDetail, VehicleRealtimeRow } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister, hasMenu } from '../auth/session';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry';
import { formatZhNumber } from '../domain/formatters';
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
import { isValidAMapCoordinate } from '../../integrations/amap';
import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
function fmt(value?: string) { return value?.trim() || '—'; }
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; }
@@ -28,108 +33,157 @@ function durationHours(seconds?: number | null) { return seconds == null ? '—'
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;
const SINGLE_VEHICLE_REFRESH_MS = 10_000;
const VehicleProfileSyncPanel = lazy(() => import('./VehicleProfileSyncPanel'));
function ProfileSyncPanel({ onClose }: { onClose: () => void }) {
const [sourceSystem, setSourceSystem] = useState('');
const [sourceVersion, setSourceVersion] = useState('');
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>('preserve');
const [items, setItems] = useState<VehicleProfileSyncItem[]>([]);
const [fileName, setFileName] = useState('');
const [parseError, setParseError] = useState('');
const sync = useMutation<VehicleProfileSyncResult, Error, boolean>({
mutationFn: (dryRun) => api.syncVehicleProfiles({ sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items })
});
const readFile = async (file?: File) => {
sync.reset(); setItems([]); setFileName(file?.name ?? ''); setParseError('');
if (!file) return;
try { setItems(parseVehicleProfileSyncCSV(await file.text())); } catch (error) { setParseError(error instanceof Error ? error.message : 'CSV 解析失败'); }
};
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !sync.isPending;
const issues = sync.data?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle').slice(0, 20) ?? [];
const applied = sync.data && !sync.data.dryRun;
return <section className="v2-profile-sync-panel" aria-label="车辆主档批量同步">
<header><div><strong></strong><p>CSV 500 </p></div><button type="button" onClick={onClose}></button></header>
<div className="v2-profile-sync-fields">
<label><span></span><input value={sourceSystem} onChange={(event) => { setSourceSystem(event.target.value); sync.reset(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
<label><span></span><input value={sourceVersion} onChange={(event) => { setSourceVersion(event.target.value); sync.reset(); }} placeholder="例如 snapshot-20260714-01" maxLength={128} /></label>
<label><span></span><select value={conflictPolicy} onChange={(event) => { setConflictPolicy(event.target.value as 'preserve' | 'overwrite'); sync.reset(); }}><option value="preserve"></option><option value="overwrite"></option></select></label>
<label className="is-file"><span>CSV </span><input type="file" accept=".csv,text/csv" onChange={(event) => { void readFile(event.target.files?.[0]); }} /></label>
</div>
<p className="v2-profile-sync-format"><code>{vehicleProfileSyncCSVHeader}</code></p>
{fileName ? <p className="v2-profile-sync-file">{fileName} · {items.length} </p> : null}
{parseError ? <p className="v2-profile-sync-error">{parseError}</p> : null}
{sync.isError ? <p className="v2-profile-sync-error">{sync.error.message}</p> : null}
{sync.data ? <div className="v2-profile-sync-result">
<div><span><strong>{sync.data.received}</strong></span><span><strong>{sync.data.created}</strong></span><span><strong>{sync.data.updated}</strong></span><span><strong>{sync.data.unchanged}</strong></span><span><strong>{sync.data.conflicted}</strong></span><span><strong>{sync.data.missing}</strong></span></div>
{issues.length ? <ul>{issues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul> : <p></p>}
</div> : null}
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning"></p> : null}
<footer><button type="button" onClick={() => sync.mutate(true)} disabled={!ready}>{sync.isPending ? '处理中…' : '预演同步'}</button><button className="is-primary" type="button" onClick={() => sync.mutate(false)} disabled={!ready || !sync.data?.dryRun}>{applied ? '已完成写入' : '确认写入'}</button></footer>
</section>;
function resetWorkspaceScroll() {
const content = document.querySelector<HTMLElement>('.v2-content');
if (!content) return;
content.scrollTop = 0;
content.scrollLeft = 0;
content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
}
function VehicleSearch() {
const navigate = useNavigate();
const { session } = usePlatformSession();
const mobileLayout = useMobileLayout();
const [keyword, setKeyword] = useState('');
const [syncOpen, setSyncOpen] = useState(false);
const [candidatesOpen, setCandidatesOpen] = useState(false);
const closeTimerRef = useRef<number>();
const deferredKeyword = useDeferredValue(keyword.trim());
const candidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: '8', offset: '0' });
if (deferredKeyword) params.set('keyword', deferredKeyword);
return params;
}, [deferredKeyword]);
const candidates = useQuery({
queryKey: ['vehicle-search-options', candidateParams.toString()],
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
enabled: candidatesOpen,
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
const openCandidates = () => {
window.clearTimeout(closeTimerRef.current);
setCandidatesOpen(true);
};
const closeCandidates = () => {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = window.setTimeout(() => setCandidatesOpen(false), 140);
};
const openVehicle = (value: string) => {
const normalized = value.trim();
if (!normalized) return;
setCandidatesOpen(false);
resetWorkspaceScroll();
navigate(`/vehicles/${encodeURIComponent(normalized)}`);
window.queueMicrotask(resetWorkspaceScroll);
window.requestAnimationFrame(resetWorkspaceScroll);
window.setTimeout(resetWorkspaceScroll, 120);
};
const submit = (event: FormEvent) => {
event.preventDefault();
const value = keyword.trim();
if (value) navigate(`/vehicles/${encodeURIComponent(value)}`);
openVehicle(keyword);
};
return <section className={`v2-vehicle-search-page ${syncOpen ? 'has-sync-panel' : ''}`}>
<div className="v2-vehicle-search-card">
<span className="v2-search-hero-icon"><IconBox size="extra-large" /></span>
<h2></h2>
<p>VIN </p>
<form onSubmit={submit}>
<IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="输入车牌 / VIN / 终端手机号" autoFocus />
<button type="submit"> <IconArrowRight /></button>
return <section className={`v2-vehicle-search-page${syncOpen ? ' has-sync-panel' : ''}${candidatesOpen ? ' has-candidates' : ''}`}>
<Card className="v2-vehicle-search-card">
<div className="v2-vehicle-search-intro">
<span className="v2-search-hero-icon"><IconBox size="extra-large" /></span>
<div>
<Typography.Title heading={2}></Typography.Title>
<Typography.Text type="secondary">VIN </Typography.Text>
</div>
</div>
<form className="v2-vehicle-search-form" onSubmit={submit}>
<div className={`v2-vehicle-search-picker${candidatesOpen ? ' is-open' : ''}`}>
<Input
aria-label="搜索车辆"
aria-controls="v2-vehicle-search-options"
aria-expanded={candidatesOpen}
prefix={<IconSearch />}
value={keyword}
onChange={(value) => { setKeyword(value); setCandidatesOpen(true); }}
onFocus={openCandidates}
onBlur={closeCandidates}
placeholder="输入车牌 / VIN / 终端手机号"
autoComplete="off"
/>
</div>
<Button theme="solid" htmlType="submit" icon={<IconArrowRight />} iconPosition="right"></Button>
{candidatesOpen ? <VehicleCandidateList
id="v2-vehicle-search-options"
className="v2-vehicle-search-options"
items={options}
loading={candidates.isFetching}
loadingText="正在搜索授权车辆"
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
onRetry={() => candidates.refetch()}
emptyText="没有匹配的授权车辆"
header="车辆候选"
meta="车牌优先 · VIN 辅助"
showProtocols
layout={mobileLayout ? 'list' : 'grid'}
onSelect={(vehicle) => openVehicle(vehicle.vin)}
/> : null}
</form>
{canAdminister(session) ? <button className="v2-profile-sync-open" type="button" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</button> : null}
</div>
{syncOpen ? <ProfileSyncPanel onClose={() => setSyncOpen(false)} /> : null}
<div className="v2-vehicle-search-capabilities" aria-label="可查询的数据范围">
<span><IconBox /><b></b><small>VIN </small></span>
<span><IconClock /><b></b><small></small></span>
<span><IconTickCircle /><b></b><small></small></span>
</div>
{canAdminister(session) ? <Button className="v2-profile-sync-open" theme="borderless" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</Button> : null}
</Card>
{syncOpen ? <Suspense fallback={<Card className="v2-profile-sync-panel v2-profile-sync-loading" bodyStyle={{ padding: 0 }}><span role="status"><span className="v2-spinner" /></span></Card>}><VehicleProfileSyncPanel onClose={() => setSyncOpen(false)} /></Suspense> : null}
</section>;
}
function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
const profile = detail.profile;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState({ modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
const [draft, setDraft] = useState({ brandName: '', modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
const save = useMutation({
mutationFn: () => api.updateVehicleProfile(detail.vin, {
modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
brandName: draft.brandName.trim(), modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
operationStatus: draft.operationStatus as NonNullable<typeof profile>['operationStatus'], accessProvider: draft.accessProvider.trim(), firstAccessAt: draft.firstAccessAt,
runtimeSeconds: draft.runtimeHours.trim() === '' ? null : Math.round(Number(draft.runtimeHours) * 3600), version: profile?.version ?? 0
}),
onSuccess: () => { setEditing(false); onUpdated(); }
});
const startEditing = () => {
setDraft({ modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
setDraft({ brandName: profile?.brandName ?? '', modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
save.reset(); setEditing(true);
};
const submit = (event: FormEvent) => { event.preventDefault(); if (draft.runtimeHours === '' || Number.isFinite(Number(draft.runtimeHours))) save.mutate(); };
return <section className="v2-record-card v2-archive-card">
<header><strong></strong><span className="v2-profile-heading"> {profile?.completeness ?? 0}%{editable && !editing ? <button type="button" onClick={startEditing}></button> : null}</span></header>
return <Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="车辆主档"
description="身份、车型与运营属性"
meta={`完整度 ${profile?.completeness ?? 0}%`}
actions={editable && !editing ? <Button theme="borderless" size="small" onClick={startEditing}></Button> : null}
/>
{editing ? <form className="v2-profile-form" onSubmit={submit}>
<label><span></span><input maxLength={128} value={draft.modelName} onChange={(event) => setDraft({ ...draft, modelName: event.target.value })} /></label>
<label><span></span><input maxLength={64} value={draft.vehicleType} onChange={(event) => setDraft({ ...draft, vehicleType: event.target.value })} /></label>
<label><span></span><input maxLength={128} value={draft.companyName} onChange={(event) => setDraft({ ...draft, companyName: event.target.value })} /></label>
<label><span></span><select value={draft.operationStatus} onChange={(event) => setDraft({ ...draft, operationStatus: event.target.value })}>{Object.entries(operationStatusLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label>
<label><span></span><input maxLength={128} value={draft.accessProvider} onChange={(event) => setDraft({ ...draft, accessProvider: event.target.value })} /></label>
<label><span></span><input type="datetime-local" value={draft.firstAccessAt} onChange={(event) => setDraft({ ...draft, firstAccessAt: event.target.value })} /></label>
<label><span></span><input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(event) => setDraft({ ...draft, runtimeHours: event.target.value })} /></label>
{save.isError ? <p>{save.error.message}</p> : null}<footer><button type="button" onClick={() => setEditing(false)}></button><button className="is-primary" type="submit" disabled={save.isPending}>{save.isPending ? '保存中' : '保存档案'}</button></footer>
</form> : <><dl className="v2-record-list">
<div><dt> / </dt><dd>{[profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—'}</dd></div>
<div><dt></dt><dd>{fmt(profile?.companyName)}</dd></div>
<div><dt></dt><dd>{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</dd></div>
<div><dt></dt><dd>{fmt(profile?.accessProvider)}</dd></div>
<div><dt></dt><dd>{fmt(profile?.firstAccessAt)}</dd></div>
<div><dt></dt><dd>{durationHours(profile?.runtimeSeconds)}</dd></div>
</dl><p className="v2-record-note"> {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
</section>;
<label><span></span><Input maxLength={128} value={draft.brandName} onChange={(value) => setDraft({ ...draft, brandName: value })} /></label>
<label><span></span><Input maxLength={128} value={draft.modelName} onChange={(value) => setDraft({ ...draft, modelName: value })} /></label>
<label><span></span><Input maxLength={64} value={draft.vehicleType} onChange={(value) => setDraft({ ...draft, vehicleType: value })} /></label>
<label><span></span><Input maxLength={128} value={draft.companyName} onChange={(value) => setDraft({ ...draft, companyName: value })} /></label>
<label><span></span><Select value={draft.operationStatus} onChange={(value) => setDraft({ ...draft, operationStatus: String(value) })} optionList={Object.entries(operationStatusLabels).map(([value, label]) => ({ value, label }))} /></label>
<label><span></span><Input maxLength={128} value={draft.accessProvider} onChange={(value) => setDraft({ ...draft, accessProvider: value })} /></label>
<label><span></span><Input aria-label="首次接入" type="datetime-local" value={draft.firstAccessAt} onChange={(value) => setDraft({ ...draft, firstAccessAt: value })} /></label>
<label><span></span><Input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(value) => setDraft({ ...draft, runtimeHours: value })} /></label>
{save.isError ? <p>{save.error.message}</p> : null}<footer><Button theme="light" onClick={() => setEditing(false)}></Button><Button theme="solid" htmlType="submit" loading={save.isPending}></Button></footer>
</form> : <><Descriptions className="v2-record-descriptions" align="left" size="small" data={[
{ key: '车辆品牌', value: fmt(profile?.brandName) },
{ key: '车型 / 类型', value: [profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—' },
{ key: '所属企业', value: fmt(profile?.companyName) },
{ key: '运营状态', value: <Tag color={profile?.operationStatus === 'active' ? 'green' : profile?.operationStatus === 'maintenance' ? 'orange' : 'grey'} type="light" size="small">{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</Tag> },
{ key: '接入服务商', value: fmt(profile?.accessProvider) },
{ key: '首次接入', value: fmt(profile?.firstAccessAt) },
{ key: '累计运行', value: durationHours(profile?.runtimeSeconds) }
]} /><p className="v2-record-note"> {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
</Card>;
}
function Events({ detail }: { detail: VehicleDetail }) {
@@ -137,42 +191,119 @@ function Events({ detail }: { detail: VehicleDetail }) {
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${item.protocol} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
].slice(0, 5);
return <section className="v2-record-card v2-events-card">
<header><strong></strong><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}></Link></header>
<div className="v2-event-list">{events.length ? events.map((event, index) => <div className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time>{fmt(event.time)}</time>
</div>) : <div className="v2-empty-compact"></div>}</div>
</section>;
return <Card className="v2-record-card v2-events-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="最近事件"
description="质量提醒与协议来源状态"
meta={`${events.length}`}
actions={<Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><Button theme="borderless" type="tertiary" size="small"></Button></Link>}
/>
<List
className="v2-event-list"
dataSource={events}
split={false}
emptyContent={<Empty className="v2-event-empty" title="暂无可用事件证据" description="车辆产生质量提醒或来源状态变化后会显示在这里。" />}
renderItem={(event, index) => <List.Item className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time>{fmt(event.time)}</time>
</List.Item>}
/>
</Card>;
}
function TelemetryFieldCell({ item }: { item: LatestTelemetryValue }) {
return <div className="v2-telemetry-field"><strong title={item.description}>{item.label}</strong><small title={item.sourceField}>{item.sourceField}</small></div>;
}
function TelemetryValueCell({ item }: { item: LatestTelemetryValue }) {
return <div className="v2-telemetry-value"><strong>{formatTelemetryValue(item.value, item.displayValue)}</strong>{item.unit ? <span>{item.unit}</span> : null}</div>;
}
function TelemetryQualityCell({ item }: { item: LatestTelemetryValue }) {
const color = item.quality === 'good' ? 'green' : item.quality === 'stale' ? 'orange' : 'red';
return <div className="v2-telemetry-quality"><Tag color={color} type="light" size="small">{telemetryQualityLabel(item.quality)}</Tag><small title={item.qualityReason}>{item.qualityReason || '未提供质量说明'} · {formatZhNumber(item.freshnessSeconds, 0)}s</small></div>;
}
function TelemetryTimeCell({ item }: { item: LatestTelemetryValue }) {
return <div className="v2-telemetry-time"><span><small></small>{formatTelemetryTime(item.deviceTime)}</span><span><small></small>{formatTelemetryTime(item.serverTime)}</span></div>;
}
function TelemetrySourceCell({ item }: { item: LatestTelemetryValue }) {
return <div className="v2-telemetry-source"><Tag color="blue" type="light" size="small">{item.protocol}</Tag><small title={item.sourceEndpoint}>{item.sourceEndpoint || '协议默认来源'}</small></div>;
}
function telemetryRowKey(item?: LatestTelemetryValue) {
return `${item?.protocol ?? ''}-${item?.category ?? ''}-${item?.sourceField ?? ''}-${item?.frameId ?? ''}`;
}
function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryResponse; pending: boolean; error?: string }) {
const mobileLayout = useMobileLayout();
const [selectedProtocol, setSelectedProtocol] = useState('');
const [selectedCategory, setSelectedCategory] = useState('vehicle');
const indexed = useMemo(() => {
const valuesByCategory = new Map<string, LatestTelemetryResponse['values']>();
const valuesByProtocolCategory = new Map<string, LatestTelemetryResponse['values']>();
const sources = new Map<string, { protocol: string; endpoint?: string }>();
const protocols: string[] = [];
for (const value of data?.values ?? []) {
const values = valuesByCategory.get(value.category);
if (values) values.push(value); else valuesByCategory.set(value.category, [value]);
if (!protocols.includes(value.protocol)) protocols.push(value.protocol);
const categoryKey = `${value.protocol}\u0000${value.category}`;
const values = valuesByProtocolCategory.get(categoryKey);
if (values) values.push(value); else valuesByProtocolCategory.set(categoryKey, [value]);
const sourceKey = `${value.protocol}\u0000${value.sourceEndpoint ?? ''}`;
if (!sources.has(sourceKey)) sources.set(sourceKey, { protocol: value.protocol, endpoint: value.sourceEndpoint });
}
return { valuesByCategory, sources: [...sources.values()] };
const order = ['GB32960', 'JT808', 'YUTONG_MQTT'];
protocols.sort((left, right) => order.indexOf(left) - order.indexOf(right));
return { valuesByProtocolCategory, protocols, sources: [...sources.values()] };
}, [data]);
const categories = data?.categories ?? [];
const activeCategory = indexed.valuesByCategory.has(selectedCategory) ? selectedCategory : categories[0]?.key ?? '';
const visibleMetrics = indexed.valuesByCategory.get(activeCategory) ?? [];
return <section className="v2-record-card v2-telemetry-card">
<nav>{categories.map((item) => <button className={activeCategory === item.key ? 'is-active' : ''} onClick={() => setSelectedCategory(item.key)} type="button" key={item.key}>{item.label}<span>{item.count}</span></button>)}</nav>
<div className="v2-telemetry-list">
{pending ? <div className="v2-empty-compact"></div> : error ? <div className="v2-empty-compact is-error">{error}</div> : visibleMetrics.length ? visibleMetrics.map((item) => <div key={item.key}>
<span>{item.label}<small title={item.sourceField}>{item.sourceField} · {item.protocol}{item.sourceEndpoint ? ` · ${item.sourceEndpoint}` : ''}</small></span>
<strong>{formatTelemetryValue(item.value)} <em>{item.unit}</em></strong>
<time title={`设备时间 ${item.deviceTime || '缺失'};接收时间 ${item.serverTime || '缺失'}${item.qualityReason};帧 ${item.frameId}`}><i className={`is-${item.quality}`}>{telemetryQualityLabel(item.quality)}</i>{formatTelemetryTime(item.deviceTime || item.serverTime)}</time>
</div>) : <div className="v2-empty-compact"> {data?.scannedFrames ?? 0} </div>}
const activeProtocol = indexed.protocols.includes(selectedProtocol) ? selectedProtocol : indexed.protocols[0] ?? '';
const categoryLabels = new Map((data?.categories ?? []).map((category) => [category.key, category.label]));
const categories = [...new Set((data?.values ?? []).filter((value) => value.protocol === activeProtocol).map((value) => value.category))]
.map((key) => ({ key, label: categoryLabels.get(key) ?? key, count: indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${key}`)?.length ?? 0 }));
const activeCategory = indexed.valuesByProtocolCategory.has(`${activeProtocol}\u0000${selectedCategory}`) ? selectedCategory : categories[0]?.key ?? '';
const visibleMetrics = indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${activeCategory}`) ?? [];
const protocolLabel = (protocol: string) => protocol === 'GB32960' ? 'GB/T 32960' : protocol === 'JT808' ? 'JT/T 808' : protocol === 'YUTONG_MQTT' ? '宇通 MQTT' : protocol;
const columns = [
{ title: '字段 / 协议映射', dataIndex: 'label', width: 260, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryFieldCell item={item} /> },
{ title: '当前值', dataIndex: 'value', width: 150, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryValueCell item={item} /> },
{ title: '质量 / 新鲜度', dataIndex: 'quality', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryQualityCell item={item} /> },
{ title: '设备 / 接收时间', dataIndex: 'deviceTime', width: 230, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryTimeCell item={item} /> },
{ title: '数据来源', dataIndex: 'protocol', width: 180, render: (_: unknown, item: LatestTelemetryValue) => <TelemetrySourceCell item={item} /> }
];
const state = pending
? <div className="v2-telemetry-state" role="status"><strong></strong><span></span></div>
: error
? <div className="v2-telemetry-state is-error" role="alert"><strong></strong><span>{error}</span></div>
: visibleMetrics.length === 0
? <Empty className="v2-telemetry-empty" title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
: mobileLayout
? <List className="v2-telemetry-mobile-list" dataSource={visibleMetrics} split={false} renderItem={(item) => <List.Item className="v2-telemetry-mobile-item" key={telemetryRowKey(item)}>
<header><TelemetryFieldCell item={item} /><TelemetryValueCell item={item} /></header>
<div><TelemetryQualityCell item={item} /><TelemetryTimeCell item={item} /></div>
<TelemetrySourceCell item={item} />
</List.Item>} />
: <div className="v2-telemetry-table-wrap"><Table
className="v2-telemetry-table"
columns={columns}
dataSource={visibleMetrics}
rowKey={telemetryRowKey}
pagination={false}
scroll={{ x: 990 }}
empty={null}
/></div>;
return <Card className="v2-record-card v2-telemetry-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="实时遥测"
description="按协议与字段分类查看当前值、质量和来源"
meta={`${data?.values.length ?? 0}`}
/>
<div className="v2-telemetry-tabs">
<SegmentedTabs className="v2-telemetry-protocols" variant="filled" ariaLabel="数据协议" value={activeProtocol} onChange={(protocol) => { setSelectedProtocol(protocol); setSelectedCategory('vehicle'); }} items={indexed.protocols.map((protocol) => ({ key: protocol, label: protocolLabel(protocol), count: (data?.values ?? []).filter((value) => value.protocol === protocol).length }))} />
<SegmentedTabs className="v2-telemetry-categories" ariaLabel="字段分类" value={activeCategory} onChange={setSelectedCategory} items={categories} />
</div>
{state}
<footer><b></b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer>
</section>;
</Card>;
}
function CurrentVehicleAddress({ vehicle, fallback }: { vehicle?: VehicleRealtimeRow; fallback?: string }) {
@@ -201,58 +332,107 @@ function CurrentVehicleAddress({ vehicle, fallback }: { vehicle?: VehicleRealtim
<span className="v2-current-address"><small></small>{!coordinate
? <strong></strong>
: !requestedKey
? <button type="button" onClick={() => setRequestedKey(coordinateKey)}></button>
? <Button className="v2-current-address-action" theme="light" type="primary" size="small" aria-label="解析当前位置" icon={<IconMapPin />} onClick={() => setRequestedKey(coordinateKey)}></Button>
: address.isFetching && !address.data
? <strong></strong>
: address.isError
? <button type="button" className="is-error" onClick={() => void address.refetch()}></button>
? <Button className="v2-current-address-action is-error" theme="light" type="danger" size="small" aria-label="地址解析失败,重试" onClick={() => void address.refetch()}></Button>
: <strong title={address.data?.formattedAddress}>{address.data?.formattedAddress || fallback || '暂无地址结果'}</strong>}
{moved ? <button type="button" onClick={() => setRequestedKey(coordinateKey)}> · </button> : null}
{moved ? <Button className="v2-current-address-action" theme="borderless" type="primary" size="small" aria-label="车辆已移动,更新地址" icon={<IconMapPin />} onClick={() => setRequestedKey(coordinateKey)}> · </Button> : null}
</span>
</div>;
}
const vehicleSectionIDs = {
location: 'vehicle-location-panel',
events: 'vehicle-events-panel',
telemetry: 'vehicle-telemetry-panel',
archive: 'vehicle-archive-panel'
} as const;
type VehicleSection = keyof typeof vehicleSectionIDs;
function VehicleRecordNavigation() {
const jumpToSection = (section: VehicleSection) => {
const target = document.getElementById(vehicleSectionIDs[section]);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.focus({ preventScroll: true });
};
return <Card className="v2-record-card v2-vehicle-record-nav" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
title="详情导航"
description="快速定位当前车辆的数据区域"
actions={<nav className="v2-vehicle-section-nav" aria-label="单车详情导航">
<Button size="small" theme="borderless" type="tertiary" icon={<IconMapPin />} aria-label="跳转到实时位置" onClick={() => jumpToSection('location')}></Button>
<Button size="small" theme="borderless" type="tertiary" icon={<IconAlarm />} aria-label="跳转到最近事件" onClick={() => jumpToSection('events')}></Button>
<Button size="small" theme="borderless" type="tertiary" icon={<IconClock />} aria-label="跳转到实时遥测" onClick={() => jumpToSection('telemetry')}></Button>
<Button size="small" theme="borderless" type="tertiary" icon={<IconBox />} aria-label="跳转到车辆主档" onClick={() => jumpToSection('archive')}></Button>
</nav>}
/>
</Card>;
}
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, monitorReturn, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; monitorReturn: string; onUpdated: () => void }) {
const { session } = usePlatformSession();
const navigate = useNavigate();
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
const realtime = liveRealtime ?? detail.realtimeSummary;
const identity = detail.identity;
const hasLocation = Boolean(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude));
const mapVehicles = hasLocation && realtime ? [realtime] : [];
const lastMileage = detail.mileage.items[0];
const actions = [
{ key: 'switch', label: '切换车辆', icon: <IconSearch />, to: '/vehicles', type: 'primary' as const },
...(hasMenu(session, 'tracks') ? [{ key: 'tracks', label: '轨迹回放', icon: <IconMapPin />, to: withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
...(hasMenu(session, 'history') ? [{ key: 'history', label: '历史数据', icon: <IconCalendar />, to: withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
...(hasMenu(session, 'statistics') ? [{ key: 'statistics', label: '里程查询', icon: <IconClock />, to: withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
...(hasMenu(session, 'alerts') ? [{ key: 'alerts', label: '告警事件', icon: <IconAlarm />, to: `/alerts?vin=${encodeURIComponent(detail.vin)}`, type: 'tertiary' as const }] : [])
];
return <div className="v2-vehicle-record-page">
<MonitorReturnBar />
<section className="v2-identity-band">
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><span className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`}><i />{realtime?.online ? '在线' : '离线'}</span><small>VIN</small><b>{detail.vin}</b><button type="button" title="复制 VIN" onClick={() => navigator.clipboard?.writeText(detail.vin)}><IconCopy /></button></div>
<div className="v2-identity-meta"><div><small></small><strong>{fmt(identity?.oem || realtime?.oem)}</strong></div><div><small></small><p>{detail.sources.length ? detail.sources.map((source) => <span key={source}>{source}</span>) : <span></span>}</p></div></div>
<div className="v2-identity-actions">
{hasMenu(session, 'tracks') ? <Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconMapPin /></Link> : null}
{hasMenu(session, 'history') ? <Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconCalendar /></Link> : null}
{hasMenu(session, 'statistics') ? <Link to={withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconClock /></Link> : null}
{hasMenu(session, 'alerts') ? <Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm /></Link> : null}
<Card className="v2-identity-band" bodyStyle={{ padding: 0 }}>
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><Tag className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`} color={realtime?.online ? 'green' : 'grey'} size="small">{realtime?.online ? '在线' : '离线'}</Tag><small>VIN</small><b>{detail.vin}</b><Button theme="borderless" aria-label="复制 VIN" title="复制 VIN" icon={<IconCopy />} onClick={() => navigator.clipboard?.writeText(detail.vin)} /></div>
<div className="v2-identity-meta"><div><small> / </small><strong>{[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}</strong></div><div><small></small><p>{detail.sources.length ? detail.sources.map((source) => <span key={source}>{source}</span>) : <span></span>}</p></div></div>
<div className="v2-identity-actions" role="group" aria-label="车辆快捷操作">
{actions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => navigate(action.to)}>{action.label}</Button>)}
</div>
</section>
</Card>
<section className="v2-record-card v2-live-card v2-live-overview">
<header><strong></strong><span><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock />{fmt(realtime?.lastSeen || identity?.lastSeen)}</span></header>
<Card className="v2-record-card v2-live-card v2-live-overview" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="最新上报"
meta={<span className="v2-live-report-meta"><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock />{fmt(realtime?.lastSeen || identity?.lastSeen)}</span>}
/>
<div className="v2-live-grid">
<div><small></small><strong>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}<em>km/h</em></strong></div>
<div><small>SOC</small><strong>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}<em>%</em></strong></div>
<div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em>km</em></button></div>
<div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{availableMetric(realtime?.todayMileageKm ?? lastMileage?.dailyMileageKm, realtime?.todayMileageAvailable)}<em>km</em></button></div>
<div><small></small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看总里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em>km</em></span><IconChevronRight /></Button></div>
<div><small></small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看当日里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.todayMileageKm, realtime?.todayMileageAvailable)}<em>km</em></span><IconChevronRight /></Button></div>
<div><small></small><strong className="is-text">{fmt(realtime?.primaryProtocol)}<em>{realtime?.locationSource ? ` · ${realtime.locationSource}` : ''}</em></strong></div>
<div><small></small><strong>{realtime?.onlineSourceCount ?? 0}<em> 线 / {detail.sourceStatus.length} </em></strong></div>
</div>
<CurrentVehicleAddress vehicle={realtime} fallback={identity?.locationText} />
</section>
</Card>
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
<VehicleRecordNavigation />
<div className="v2-record-grid">
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} /><footer><button type="button" className="v2-map-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}><IconMapPin />{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</button><time>{fmt(realtime?.lastSeen)}</time></footer></section>
<Events detail={detail} />
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
<div id={vehicleSectionIDs.location} tabIndex={-1} className="v2-record-section-anchor v2-record-location-anchor">
<Card className="v2-single-map-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="实时位置"
description="定位点按车辆上报周期平滑移动"
meta={realtime?.online ? '实时跟随' : '保留最后位置'}
/>
<FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} />
<footer><Button theme="borderless" type="tertiary" className="v2-map-source-link" title="展开全部位置来源" aria-label="查看全部位置来源" icon={<IconMapPin />} onClick={() => setSourceEvidenceOpen(true)}>{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</Button><time>{fmt(realtime?.lastSeen)}</time></footer>
</Card>
</div>
<div id={vehicleSectionIDs.events} tabIndex={-1} className="v2-record-section-anchor"><Events detail={detail} /></div>
<div id={vehicleSectionIDs.telemetry} tabIndex={-1} className="v2-record-section-anchor"><TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} /></div>
<div id={vehicleSectionIDs.archive} tabIndex={-1} className="v2-record-section-anchor"><Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} /></div>
</div>
</div>;
}
@@ -273,9 +453,25 @@ export default function VehiclePage() {
...LIVE_QUERY_POLICY
});
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY });
useEffect(() => {
resetWorkspaceScroll();
let trailingFrame = 0;
const frame = window.requestAnimationFrame(() => {
resetWorkspaceScroll();
trailingFrame = window.requestAnimationFrame(resetWorkspaceScroll);
});
const shortTimer = window.setTimeout(resetWorkspaceScroll, 120);
const settledTimer = window.setTimeout(resetWorkspaceScroll, 360);
return () => {
window.cancelAnimationFrame(frame);
window.cancelAnimationFrame(trailingFrame);
window.clearTimeout(shortTimer);
window.clearTimeout(settledTimer);
};
}, [vin, resolvedVin]);
if (!vin) return <VehicleSearch />;
if (query.isPending) return <PageLoading />;
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
if (!query.data.lookupResolved) return <section className="v2-not-found"><IconSearch size="extra-large" /><h2></h2><p>{vin}VIN </p><Link to="/vehicles"></Link></section>;
if (!query.data.lookupResolved) return <Card className="v2-not-found" bodyStyle={{ padding: 0 }}><Empty image={<IconSearch size="extra-large" />} title="未找到车辆" description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}><Link to="/vehicles"><Button theme="solid" icon={<IconSearch />}></Button></Link></Empty></Card>;
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} monitorReturn={monitorReturn} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
}

View File

@@ -0,0 +1,46 @@
import { useMutation } from '@tanstack/react-query';
import { Button, Card, Input, Select, Upload } from '@douyinfe/semi-ui';
import { useState } from 'react';
import { api } from '../../api/client';
import type { VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
export default function VehicleProfileSyncPanel({ onClose }: { onClose: () => void }) {
const [sourceSystem, setSourceSystem] = useState('');
const [sourceVersion, setSourceVersion] = useState('');
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>('preserve');
const [items, setItems] = useState<VehicleProfileSyncItem[]>([]);
const [fileName, setFileName] = useState('');
const [parseError, setParseError] = useState('');
const sync = useMutation<VehicleProfileSyncResult, Error, boolean>({
mutationFn: (dryRun) => api.syncVehicleProfiles({ sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items })
});
const readFile = async (file?: File) => {
sync.reset(); setItems([]); setFileName(file?.name ?? ''); setParseError('');
if (!file) return;
try { setItems(parseVehicleProfileSyncCSV(await file.text())); } catch (error) { setParseError(error instanceof Error ? error.message : 'CSV 解析失败'); }
};
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !sync.isPending;
const issues = sync.data?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle').slice(0, 20) ?? [];
const applied = sync.data && !sync.data.dryRun;
return <Card className="v2-profile-sync-panel" aria-label="车辆主档批量同步">
<header><div><strong></strong><p>CSV 500 </p></div><Button theme="borderless" type="tertiary" onClick={onClose}></Button></header>
<div className="v2-profile-sync-fields">
<label><span></span><Input value={sourceSystem} onChange={(value) => { setSourceSystem(value); sync.reset(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
<label><span></span><Input value={sourceVersion} onChange={(value) => { setSourceVersion(value); sync.reset(); }} placeholder="例如 snapshot-20260714-01" maxLength={128} /></label>
<label><span></span><Select value={conflictPolicy} onChange={(value) => { setConflictPolicy(String(value) as 'preserve' | 'overwrite'); sync.reset(); }} optionList={[{ value: 'preserve', label: '保护现有来源' }, { value: 'overwrite', label: '显式覆盖现有来源' }]} /></label>
<label className="is-file"><span>CSV </span><Upload action="" accept=".csv,text/csv" limit={1} uploadTrigger="custom" showUploadList={false} onFileChange={(files) => { void readFile(files[0]); }}><Button theme="light"> CSV </Button></Upload></label>
</div>
<p className="v2-profile-sync-format"><code>{vehicleProfileSyncCSVHeader}</code></p>
{fileName ? <p className="v2-profile-sync-file">{fileName} · {items.length} </p> : null}
{parseError ? <p className="v2-profile-sync-error">{parseError}</p> : null}
{sync.isError ? <p className="v2-profile-sync-error">{sync.error.message}</p> : null}
{sync.data ? <div className="v2-profile-sync-result">
<div><span><strong>{sync.data.received}</strong></span><span><strong>{sync.data.created}</strong></span><span><strong>{sync.data.updated}</strong></span><span><strong>{sync.data.unchanged}</strong></span><span><strong>{sync.data.conflicted}</strong></span><span><strong>{sync.data.missing}</strong></span></div>
{issues.length ? <ul>{issues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul> : <p></p>}
</div> : null}
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning"></p> : null}
<footer><Button theme="light" onClick={() => sync.mutate(true)} disabled={!ready} loading={sync.isPending}></Button><Button theme="solid" onClick={() => sync.mutate(false)} disabled={!ready || !sync.data?.dryRun}>{applied ? '已完成写入' : '确认写入'}</Button></footer>
</Card>;
}