feat: unify detail side sheets
This commit is contained in:
@@ -262,8 +262,9 @@ test('renders mobile access vehicles as selectable Semi cards', async () => {
|
||||
expect(within(dialog).getByRole('button', { name: '收起 JT808 完整证据' })).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(mocks.accessVehicles).toHaveBeenCalledTimes(vehicleRequestsBeforeEvidenceToggle);
|
||||
expect(mocks.accessSummary).toHaveBeenCalledTimes(summaryRequestsBeforeEvidenceToggle);
|
||||
expect(document.querySelector('.v2-access-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom');
|
||||
expect(document.querySelector('.v2-access-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
|
||||
expect(document.querySelector('.v2-access-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(84dvh, 740px)' });
|
||||
expect(document.querySelector('.v2-access-detail-sidesheet .v2-workspace-config-title > .semi-tag')).toHaveTextContent('部分来源异常');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '关闭车辆接入详情' }));
|
||||
expect(action).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
|
||||
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
|
||||
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
@@ -149,8 +150,11 @@ const ProtocolState = memo(function ProtocolState({
|
||||
});
|
||||
|
||||
function ConnectionTag({ row }: { row: AccessVehicleRow }) {
|
||||
const color = row.connectionState === 'healthy' ? 'green' : row.connectionState === 'not_connected' || row.connectionState === 'offline' ? 'red' : 'orange';
|
||||
return <Tag className="v2-access-connection-tag" color={color} type="light" size="small">{connectionLabels[row.connectionState]}</Tag>;
|
||||
return <Tag className="v2-access-connection-tag" color={connectionTagColor(row.connectionState)} type="light" size="small">{connectionLabels[row.connectionState]}</Tag>;
|
||||
}
|
||||
|
||||
function connectionTagColor(state: AccessVehicleRow['connectionState']) {
|
||||
return state === 'healthy' ? 'green' as const : state === 'not_connected' || state === 'offline' ? 'red' as const : 'orange' as const;
|
||||
}
|
||||
|
||||
const ConnectionState = memo(function ConnectionState({ row }: { row: AccessVehicleRow }) {
|
||||
@@ -352,7 +356,6 @@ export default function AccessPage() {
|
||||
}, [mobileLayout]);
|
||||
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
|
||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
useSideSheetA11y(Boolean(selected), '.v2-access-detail-sidesheet', 'v2-access-detail', '车辆接入详情', '关闭车辆接入详情');
|
||||
useSideSheetA11y(editable && governanceOpen, '.v2-access-governance-sidesheet', 'v2-access-governance', '接入治理配置', '关闭接入治理配置');
|
||||
useSideSheetA11y(mobileFiltersOpen, '.v2-access-mobile-filter-sidesheet', 'v2-access-mobile-filters', '接入车辆筛选', '关闭接入车辆筛选');
|
||||
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
|
||||
@@ -487,18 +490,21 @@ export default function AccessPage() {
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={mobileLayout ? undefined : limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={mobileLayout ? undefined : [{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
|
||||
</Card>
|
||||
</div>
|
||||
<SideSheet
|
||||
<WorkspaceDetailSideSheet
|
||||
className="v2-access-detail-sidesheet"
|
||||
visible={Boolean(selected)}
|
||||
aria-label="车辆接入详情"
|
||||
ariaLabel="车辆接入详情"
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 520}
|
||||
height={mobileLayout ? 'min(84dvh, 740px)' : undefined}
|
||||
title={<div className="v2-access-sheet-title"><strong>车辆接入详情</strong><span>{selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}</span></div>}
|
||||
title="车辆接入详情"
|
||||
description={selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}
|
||||
badge={selected ? connectionLabels[selected.connectionState] : undefined}
|
||||
badgeColor={selected ? connectionTagColor(selected.connectionState) : 'grey'}
|
||||
onCancel={() => setSelectedVIN('')}
|
||||
>
|
||||
{selected ? <VehicleInspector key={selected.vin} row={selected} onClose={() => setSelectedVIN('')} sheet mobile={mobileLayout} /> : null}
|
||||
</SideSheet>
|
||||
</WorkspaceDetailSideSheet>
|
||||
{editable ? <SideSheet
|
||||
className="v2-access-governance-sidesheet"
|
||||
visible={governanceOpen}
|
||||
|
||||
@@ -269,8 +269,9 @@ test('renders only selectable Semi alert cards on mobile', async () => {
|
||||
expect(action).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByRole('dialog', { name: '告警事件详情' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-alert-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom');
|
||||
expect(document.querySelector('.v2-alert-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
|
||||
expect(document.querySelector('.v2-alert-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(90dvh, 800px)' });
|
||||
expect(document.querySelector('.v2-alert-detail-sidesheet .v2-workspace-config-title > .semi-tag')).toHaveTextContent('未处理');
|
||||
expect(document.querySelector('.v2-alert-detail-sidesheet .v2-alert-inspector-heading')).not.toBeInTheDocument();
|
||||
const mobileDisposition = document.querySelector('.v2-alert-detail-sidesheet .v2-alert-disposition-card');
|
||||
const mobileProgress = document.querySelector('.v2-alert-detail-sidesheet .v2-alert-progress-card');
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ProtocolTag } from '../shared/ProtocolTag';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
|
||||
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
@@ -63,8 +64,11 @@ function SeverityTag({ severity }: Pick<AlertEvent, 'severity'>) {
|
||||
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>;
|
||||
return <Tag className={`v2-alert-status is-${status}`} color={alertStatusColor(status)} type="light" size="small">{statusLabels[status]}</Tag>;
|
||||
}
|
||||
|
||||
function alertStatusColor(status: AlertStatus) {
|
||||
return status === 'unprocessed' ? 'red' as const : status === 'processing' ? 'blue' as const : status === 'recovered' ? 'green' as const : 'grey' as const;
|
||||
}
|
||||
|
||||
function AlertTime({ value, className = '' }: { value?: string; className?: string }) {
|
||||
@@ -193,7 +197,6 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
|
||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
useSideSheetA11y(!mobileLayout && advancedOpen, '.v2-alert-filter-sidesheet', 'v2-alert-advanced-filters', '告警高级筛选', '关闭告警高级筛选');
|
||||
useSideSheetA11y(mobileFiltersOpen, '.v2-alert-mobile-filter-sidesheet', 'v2-alert-mobile-filters', '告警事件筛选', '关闭告警事件筛选');
|
||||
useSideSheetA11y(mobileLayout && Boolean(selectedID), '.v2-alert-detail-sidesheet', 'v2-alert-detail', '告警事件详情', '关闭告警详情');
|
||||
const applyDraft = () => {
|
||||
setFilters(draft);
|
||||
setOffset(0);
|
||||
@@ -337,17 +340,21 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
|
||||
<div className="v2-alert-advanced-filter">{advancedFilterFields}</div>
|
||||
</SideSheet>
|
||||
: null}
|
||||
<SideSheet
|
||||
<WorkspaceDetailSideSheet
|
||||
className="v2-alert-detail-sidesheet"
|
||||
visible={mobileLayout && Boolean(selectedID)}
|
||||
aria-label="告警事件详情"
|
||||
ariaLabel="告警事件详情"
|
||||
closeLabel="关闭告警详情"
|
||||
placement="bottom"
|
||||
height="min(90dvh, 800px)"
|
||||
title={<div className="v2-alert-sheet-title"><strong>告警详情</strong><span>{selectedEvent ? `${selectedEvent.plate || selectedEvent.vin} · ${selectedEvent.ruleName}` : '证据、状态与处置履历'}</span></div>}
|
||||
title="告警详情"
|
||||
description={selectedEvent ? `${selectedEvent.plate || selectedEvent.vin} · ${selectedEvent.ruleName}` : '证据、状态与处置履历'}
|
||||
badge={selectedEvent ? statusLabels[selectedEvent.status] : undefined}
|
||||
badgeColor={selectedEvent ? alertStatusColor(selectedEvent.status) : 'grey'}
|
||||
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>
|
||||
</WorkspaceDetailSideSheet>
|
||||
</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -267,8 +267,9 @@ test('renders only selectable Semi history cards on mobile', async () => {
|
||||
expect(action).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(action).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByRole('dialog', { name: '历史数据详情' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-history-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom');
|
||||
expect(document.querySelector('.v2-history-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-detail-sidesheet');
|
||||
expect(document.querySelector('.v2-history-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 720px)' });
|
||||
expect(document.querySelector('.v2-history-detail-sidesheet .v2-workspace-config-title > .semi-tag')).toHaveTextContent('正常');
|
||||
expect(await screen.findByText('MOBILEVIN-evidence')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭历史数据详情' }));
|
||||
expect(action).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PlatformTime } from '../shared/PlatformTime';
|
||||
import { ProtocolTag } from '../shared/ProtocolTag';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
|
||||
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
|
||||
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||||
import { WorkspaceMetricRail } from '../shared/WorkspaceMetricRail';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
@@ -356,7 +357,6 @@ export default function HistoryPage() {
|
||||
setSelectedRowRef({ scope: dataScope, id: '' });
|
||||
}, [dataScope]);
|
||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
useSideSheetA11y(mobileLayout && Boolean(selectedRow), '.v2-history-detail-sidesheet', 'v2-history-detail', '历史数据详情', '关闭历史数据详情');
|
||||
useSideSheetA11y(exportAllowed && exportJobsOpen, '.v2-history-export-sidesheet', 'v2-history-export-jobs', '历史数据导出任务', '关闭历史数据导出任务');
|
||||
useSideSheetA11y(mobileFiltersOpen, '.v2-history-filter-sidesheet', 'v2-history-filter-sheet', '历史查询范围', '关闭历史查询范围');
|
||||
|
||||
@@ -548,17 +548,20 @@ export default function HistoryPage() {
|
||||
</div>
|
||||
{!mobileLayout && selectedRow ? <aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} /></aside> : null}
|
||||
</div>
|
||||
<SideSheet
|
||||
<WorkspaceDetailSideSheet
|
||||
className="v2-history-detail-sidesheet"
|
||||
visible={mobileLayout && Boolean(selectedRow)}
|
||||
aria-label="历史数据详情"
|
||||
ariaLabel="历史数据详情"
|
||||
placement="bottom"
|
||||
height="min(82dvh, 720px)"
|
||||
title={<div className="v2-history-mobile-sheet-title"><strong>数据详情</strong><span>{selectedRow ? `${selectedRow.plate || '未绑定车牌'} · ${selectedRow.protocol}` : '来源、质量与原始证据'}</span></div>}
|
||||
title="数据详情"
|
||||
description={selectedRow ? `${selectedRow.plate || '未绑定车牌'} · ${selectedRow.protocol}` : '来源、质量与原始证据'}
|
||||
badge={selectedRow ? historyQualityLabel(selectedRow.quality) : undefined}
|
||||
badgeColor={selectedRow && isHealthyHistoryQuality(selectedRow.quality) ? 'green' : selectedRow?.quality === 'error' || selectedRow?.quality === 'critical' ? 'red' : 'orange'}
|
||||
onCancel={closeSelectedRow}
|
||||
>
|
||||
{mobileLayout && selectedRow ? <EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} showHeader={false} /> : null}
|
||||
</SideSheet>
|
||||
</WorkspaceDetailSideSheet>
|
||||
<SideSheet
|
||||
className="v2-history-export-sidesheet"
|
||||
visible={exportAllowed && exportJobsOpen}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister, hasMenu } from '../auth/session';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||||
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityColor, telemetryQualityLabel, telemetryValueTypeLabel } from '../domain/telemetry';
|
||||
import { formatZhNumber } from '../domain/formatters';
|
||||
import { formatZhNumber, vehicleOperationStatusLabels } from '../domain/formatters';
|
||||
import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols';
|
||||
import { isValidAMapCoordinate } from '../../integrations/amap';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
@@ -34,7 +34,6 @@ function availableMetric(value: number | undefined, available?: boolean) { retur
|
||||
function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; }
|
||||
function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${formatZhNumber(seconds / 3600, 1)} 小时`; }
|
||||
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 VehicleSearch = lazy(() => import('./VehicleSearchWorkspace'));
|
||||
const VehicleActions = lazy(() => import('./VehicleActions'));
|
||||
@@ -101,7 +100,7 @@ function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; edita
|
||||
<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><Select value={draft.operationStatus} onChange={(value) => setDraft({ ...draft, operationStatus: String(value) })} optionList={Object.entries(vehicleOperationStatusLabels).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>
|
||||
@@ -110,7 +109,7 @@ function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; edita
|
||||
{ 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: <Tag color={profile?.operationStatus === 'active' ? 'green' : profile?.operationStatus === 'maintenance' ? 'orange' : 'grey'} type="light" size="small">{vehicleOperationStatusLabels[profile?.operationStatus ?? 'unknown']}</Tag> },
|
||||
{ key: '接入服务商', value: fmt(profile?.accessProvider) },
|
||||
{ key: '首次接入', value: fmt(profile?.firstAccessAt) },
|
||||
{ key: '累计运行', value: durationHours(profile?.runtimeSeconds) }
|
||||
|
||||
Reference in New Issue
Block a user