feat: unify detail side sheets
This commit is contained in:
@@ -12,6 +12,14 @@ const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', {
|
||||
|
||||
export type DateTimeSourceZone = 'preserve' | 'utc';
|
||||
|
||||
export const vehicleOperationStatusLabels = {
|
||||
unknown: '待维护',
|
||||
active: '运营中',
|
||||
inactive: '停运',
|
||||
maintenance: '维保中',
|
||||
retired: '已退役'
|
||||
} as const;
|
||||
|
||||
export function formatZhNumber(value: number, maximumFractionDigits = 0) {
|
||||
let formatter = zhNumberFormatters.get(maximumFractionDigits);
|
||||
if (!formatter) {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -22,6 +22,7 @@ const passwordInputSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/P
|
||||
const workspaceFilterPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceFilterPanel.tsx'), 'utf8');
|
||||
const workspaceMetricRailSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceMetricRail.tsx'), 'utf8');
|
||||
const workspaceSideSheetSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceSideSheet.tsx'), 'utf8');
|
||||
const workspaceDetailSideSheetSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceDetailSideSheet.tsx'), 'utf8');
|
||||
const segmentedTabsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/SegmentedTabs.tsx'), 'utf8');
|
||||
const metricActionSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MetricActionButton.tsx'), 'utf8');
|
||||
const tablePaginationSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/TablePagination.tsx'), 'utf8');
|
||||
@@ -123,16 +124,21 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.MonitorPage).toContain('<Empty className="v2-monitor-table-empty"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Spin size="small" tip="正在更新车辆实时数据…"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<table><thead><tr><th>车辆</th>');
|
||||
expect(sourceEvidenceSource).toContain("import { Button, Card, Empty, Input, SideSheet, Spin, Tag } from '@douyinfe/semi-ui'");
|
||||
expect(sourceEvidenceSource).toContain("import { Button, Card, Empty, Input, Spin, Tag } from '@douyinfe/semi-ui'");
|
||||
expect(sourceEvidenceSource).toContain('<Card className={`v2-source-evidence');
|
||||
expect(sourceEvidenceSource).toContain('<Card className={`v2-source-evidence-card');
|
||||
expect(sourceEvidenceSource).toContain('<SideSheet');
|
||||
expect(sourceEvidenceSource).toContain('<WorkspaceDetailSideSheet');
|
||||
expect(sourceEvidenceSource).toContain('className="v2-source-evidence-sidesheet"');
|
||||
expect(sourceEvidenceSource).toContain('<Tag className="is-recommended"');
|
||||
expect(sourceEvidenceSource).toContain('<Spin size="small"');
|
||||
expect(sourceEvidenceSource).toContain('<Empty className="v2-source-evidence-empty"');
|
||||
expect(sourceEvidenceSource).not.toContain('<article className={`v2-source-evidence-card');
|
||||
expect(sourceEvidenceSource).not.toContain('<section className={`v2-source-evidence');
|
||||
expect(workspaceDetailSideSheetSource).toContain('variant="detail"');
|
||||
for (const name of ['HistoryPage', 'AlertsPage', 'AccessPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/WorkspaceDetailSideSheet'");
|
||||
expect(corePageSources[name]).toContain('<WorkspaceDetailSideSheet');
|
||||
}
|
||||
expect(corePageSources.HistoryPage).toContain('<Input');
|
||||
expect(corePageSources.HistoryPage).toContain('<Select');
|
||||
expect(corePageSources.HistoryPage).toContain('<Checkbox');
|
||||
@@ -178,7 +184,7 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.AlertsPage).toContain('<WorkspaceCommandBar');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-navigation v2-alert-command-bar"');
|
||||
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-filter-sidesheet"');
|
||||
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-detail-sidesheet"');
|
||||
expect(corePageSources.AlertsPage).toContain('<WorkspaceDetailSideSheet\n className="v2-alert-detail-sidesheet"');
|
||||
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-rule-editor-sidesheet"');
|
||||
expect(corePageSources.AlertsPage).toContain('className={`v2-alert-event-table');
|
||||
expect(corePageSources.AlertsPage).toContain('detailTriggerRow({');
|
||||
|
||||
@@ -77,7 +77,8 @@ test('opens source evidence in a Semi bottom SideSheet on mobile without expandi
|
||||
expect(view.container.querySelector('.v2-source-evidence.is-mobile-sheet')).toHaveClass('is-open');
|
||||
expect(view.container.querySelector('.v2-source-evidence > .semi-card-body > .v2-source-evidence-body')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('北斗平台')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-source-evidence-sidesheet')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-source-evidence-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet');
|
||||
expect(document.querySelector('.v2-source-evidence-sidesheet .v2-workspace-config-title > .semi-tag')).toHaveTextContent('3 个来源');
|
||||
expect(document.querySelectorAll('.v2-source-evidence-sidesheet .v2-source-evidence-card')).toHaveLength(3);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭来源证据' }));
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button, Card, Empty, Input, SideSheet, Spin, Tag } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Empty, Input, Spin, Tag } from '@douyinfe/semi-ui';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleLocationSourceEvidence, VehicleMileageSourceEvidence } from '../../api/types';
|
||||
import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { WorkspaceDetailSideSheet } from './WorkspaceDetailSideSheet';
|
||||
|
||||
function today() {
|
||||
const now = new Date();
|
||||
@@ -110,8 +110,6 @@ export function VehicleSourceEvidencePanel({
|
||||
if (sourceCount <= 2) return '当前车辆来源较少,仅展示实际存在的证据';
|
||||
return `已读取 ${query.data.locationSources.length} 个位置来源、${query.data.mileageSources.length} 个里程来源`;
|
||||
}, [query.data, sourceCount]);
|
||||
useSideSheetA11y(mobileLayout && expanded, '.v2-source-evidence-sidesheet', 'v2-source-evidence-sheet', '位置与里程来源证据', '关闭来源证据');
|
||||
|
||||
const evidenceBody = <div className="v2-source-evidence-body">
|
||||
<div className="v2-source-evidence-toolbar">
|
||||
<label><span>里程日期</span><Input aria-label="里程日期" type="date" value={date} max={today()} onChange={setDate} /></label>
|
||||
@@ -140,17 +138,20 @@ export function VehicleSourceEvidencePanel({
|
||||
</header>
|
||||
{expanded && !mobileLayout ? evidenceBody : null}
|
||||
</Card>
|
||||
{mobileLayout ? <SideSheet
|
||||
{mobileLayout ? <WorkspaceDetailSideSheet
|
||||
className="v2-source-evidence-sidesheet"
|
||||
visible={expanded}
|
||||
placement="bottom"
|
||||
height="min(88dvh, 780px)"
|
||||
aria-label="位置与里程来源证据"
|
||||
title={<div className="v2-source-evidence-sheet-title"><strong>位置与里程来源</strong><span>{description}</span></div>}
|
||||
footer={null}
|
||||
ariaLabel="位置与里程来源证据"
|
||||
closeLabel="关闭来源证据"
|
||||
title="位置与里程来源"
|
||||
description={description}
|
||||
badge={sourceCount ? `${sourceCount} 个来源` : '按需读取'}
|
||||
badgeColor={sourceCount ? 'blue' : 'grey'}
|
||||
onCancel={() => setExpanded(false)}
|
||||
>
|
||||
<div className="v2-source-evidence-sheet-body">{evidenceBody}</div>
|
||||
</SideSheet> : null}
|
||||
</WorkspaceDetailSideSheet> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { WorkspaceDetailSideSheet } from './WorkspaceDetailSideSheet';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders one accessible Semi detail sheet with shared identity and status', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<WorkspaceDetailSideSheet
|
||||
className="test-detail-sheet"
|
||||
visible
|
||||
ariaLabel="车辆证据详情"
|
||||
closeLabel="关闭证据详情"
|
||||
title="车辆详情"
|
||||
description="粤A00001 · VIN-001"
|
||||
badge="状态正常"
|
||||
badgeColor="green"
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<p>详情内容</p>
|
||||
</WorkspaceDetailSideSheet>);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '车辆证据详情' });
|
||||
expect(dialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'test-detail-sheet');
|
||||
expect(screen.getByText('粤A00001 · VIN-001')).toBeInTheDocument();
|
||||
expect(screen.getByText('状态正常').closest('.semi-tag')).toHaveClass('semi-tag-green-light');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭证据详情' }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
WorkspaceSideSheet,
|
||||
type WorkspaceSideSheetBadgeColor
|
||||
} from './WorkspaceSideSheet';
|
||||
|
||||
type WorkspaceDetailSideSheetProps = {
|
||||
className?: string;
|
||||
visible: boolean;
|
||||
ariaLabel: string;
|
||||
closeLabel?: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
badge?: ReactNode;
|
||||
badgeColor?: WorkspaceSideSheetBadgeColor;
|
||||
placement?: 'left' | 'right' | 'top' | 'bottom';
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
onCancel: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function WorkspaceDetailSideSheet({
|
||||
className,
|
||||
visible,
|
||||
ariaLabel,
|
||||
closeLabel,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
badgeColor,
|
||||
placement,
|
||||
width,
|
||||
height,
|
||||
onCancel,
|
||||
children
|
||||
}: WorkspaceDetailSideSheetProps) {
|
||||
return <WorkspaceSideSheet
|
||||
className={className}
|
||||
variant="detail"
|
||||
visible={visible}
|
||||
ariaLabel={ariaLabel}
|
||||
closeLabel={closeLabel}
|
||||
title={title}
|
||||
description={description}
|
||||
badge={badge}
|
||||
badgeColor={badgeColor}
|
||||
placement={placement}
|
||||
width={width}
|
||||
height={height}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
{children}
|
||||
</WorkspaceSideSheet>;
|
||||
}
|
||||
@@ -17,15 +17,18 @@ export type WorkspaceSideSheetSummaryItem = {
|
||||
tone?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
|
||||
};
|
||||
|
||||
type WorkspaceSideSheetProps = {
|
||||
export type WorkspaceSideSheetBadgeColor = 'blue' | 'green' | 'orange' | 'red' | 'grey';
|
||||
|
||||
export type WorkspaceSideSheetProps = {
|
||||
className?: string;
|
||||
variant?: 'config' | 'detail';
|
||||
visible: boolean;
|
||||
ariaLabel: string;
|
||||
closeLabel?: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
badge?: ReactNode;
|
||||
badgeColor?: 'blue' | 'green' | 'orange' | 'red' | 'grey';
|
||||
badgeColor?: WorkspaceSideSheetBadgeColor;
|
||||
placement?: 'left' | 'right' | 'top' | 'bottom';
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
@@ -39,6 +42,7 @@ type WorkspaceSideSheetProps = {
|
||||
|
||||
export function WorkspaceSideSheet({
|
||||
className = '',
|
||||
variant = 'config',
|
||||
visible,
|
||||
ariaLabel,
|
||||
closeLabel = `关闭${ariaLabel}`,
|
||||
@@ -57,7 +61,7 @@ export function WorkspaceSideSheet({
|
||||
children
|
||||
}: WorkspaceSideSheetProps) {
|
||||
const sheetId = useId();
|
||||
const sheetClassName = ['v2-workspace-config-sidesheet', className].filter(Boolean).join(' ');
|
||||
const sheetClassName = [`v2-workspace-${variant}-sidesheet`, className].filter(Boolean).join(' ');
|
||||
const hasFooter = Boolean(footerNote || secondaryActions.length || primaryAction);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -20806,6 +20806,72 @@
|
||||
box-shadow: 0 7px 18px rgba(18, 104, 243, .18);
|
||||
}
|
||||
|
||||
/*
|
||||
* Shared Semi UI detail SideSheet: preserve each page's evidence content while
|
||||
* keeping object identity, status, surface depth and scrolling consistent.
|
||||
*/
|
||||
.v2-workspace-detail-sidesheet .semi-sidesheet-inner {
|
||||
overflow: hidden;
|
||||
border-left: 1px solid #d9e3ef;
|
||||
background: #f4f7fb;
|
||||
box-shadow: -22px 0 64px rgba(24, 43, 68, .16);
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .semi-sidesheet-header {
|
||||
min-height: 78px;
|
||||
border-bottom: 1px solid #dfe7f0;
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, rgba(18, 104, 243, .075), transparent 36%),
|
||||
linear-gradient(180deg, #fff 0%, #fbfcfe 100%);
|
||||
padding: 13px 18px;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .semi-sidesheet-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .v2-workspace-config-title strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .semi-sidesheet-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .semi-sidesheet-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #f4f7fb;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .v2-workspace-config-content {
|
||||
padding: 9px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .v2-workspace-config-content > .semi-card {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.v2-alert-detail-sidesheet .v2-workspace-config-content,
|
||||
.v2-source-evidence-sidesheet .v2-workspace-config-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v2-history-detail-sidesheet .v2-workspace-config-content,
|
||||
.v2-access-detail-sidesheet .v2-workspace-config-content {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.v2-source-evidence-sidesheet .v2-source-evidence-sheet-body > .v2-source-evidence-body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.v2-history-column-sidesheet .v2-workspace-config-content {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@@ -20872,6 +20938,49 @@
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.v2-workspace-detail-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
border: 1px solid #d8e2ed;
|
||||
border-bottom: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
box-shadow: 0 -18px 48px rgba(23, 43, 68, .17);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header {
|
||||
min-height: 70px;
|
||||
padding: 10px 12px 10px 14px;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .v2-workspace-config-title {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .v2-workspace-config-title strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .v2-workspace-config-title small {
|
||||
max-width: 250px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .v2-workspace-config-title > .semi-tag {
|
||||
min-height: 23px;
|
||||
padding-inline: 8px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.v2-workspace-detail-sidesheet .v2-workspace-config-content {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.v2-alert-detail-sidesheet .v2-workspace-config-content,
|
||||
.v2-source-evidence-sidesheet .v2-workspace-config-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v2-workspace-config-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner {
|
||||
border: 1px solid #d8e2ed;
|
||||
border-bottom: 0;
|
||||
|
||||
Reference in New Issue
Block a user