feat: refine mobile vehicle detail actions

This commit is contained in:
lingniu
2026-07-19 00:43:37 +08:00
parent 23b632a2e0
commit 862671c083
4 changed files with 173 additions and 22 deletions

View File

@@ -0,0 +1,57 @@
import { IconChevronRight, IconMore } from '@douyinfe/semi-icons';
import { Button, SideSheet } from '@douyinfe/semi-ui';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
export type VehicleAction = {
key: string;
label: string;
icon: ReactNode;
to: string;
type: 'primary' | 'tertiary';
};
export default function VehicleActions({
actions,
mobile,
onSelect
}: {
actions: VehicleAction[];
mobile: boolean;
onSelect: (to: string) => void;
}) {
const [open, setOpen] = useState(false);
const primaryActions = actions.filter((action) => action.key === 'switch' || action.key === 'tracks');
const overflowActions = actions.filter((action) => action.key !== 'switch' && action.key !== 'tracks');
const selectAction = (to: string) => {
setOpen(false);
onSelect(to);
};
useSideSheetA11y(open, '.v2-vehicle-mobile-actions-sidesheet', 'v2-vehicle-mobile-actions', '更多车辆操作', '关闭更多车辆操作');
if (!mobile) return <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={() => onSelect(action.to)}>{action.label}</Button>)}
</div>;
return <>
<div className="v2-identity-actions" role="group" aria-label="车辆快捷操作">
{primaryActions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => selectAction(action.to)}>{action.label}</Button>)}
{overflowActions.length ? <Button className="v2-vehicle-more-trigger" theme="light" type="tertiary" icon={<IconMore />} aria-label="更多车辆操作" aria-haspopup="dialog" aria-controls="v2-vehicle-mobile-actions" aria-expanded={open} onClick={() => setOpen(true)}></Button> : null}
</div>
<SideSheet
className="v2-vehicle-mobile-actions-sidesheet"
visible={open}
placement="bottom"
height="min(48dvh, 360px)"
aria-label="更多车辆操作"
title={<div className="v2-vehicle-mobile-actions-title"><strong></strong><span></span></div>}
onCancel={() => setOpen(false)}
footer={<Button block theme="solid" type="primary" onClick={() => setOpen(false)}></Button>}
>
<div className="v2-vehicle-mobile-actions-list">
{overflowActions.map((action) => <Button key={action.key} block theme="light" type="tertiary" icon={action.icon} iconPosition="left" aria-label={action.label} onClick={() => selectAction(action.to)}>{action.label}<IconChevronRight /></Button>)}
</div>
</SideSheet>
</>;
}

View File

@@ -518,7 +518,7 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
});
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>);
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /><Route path="/history" element={<div data-testid="mobile-history-route"></div>} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('GPS 速度')).toBeInTheDocument();
expect(document.querySelector('.v2-telemetry-table')).not.toBeInTheDocument();
@@ -532,8 +532,21 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
expect(document.querySelector('.v2-telemetry-detail-protocol')).toHaveClass('semi-tag-cyan-light');
expect(screen.getByRole('button', { name: '关闭遥测字段详情' })).toBeInTheDocument();
expect(document.querySelector('.v2-vehicle-record-page')).toHaveClass('v2-vehicle-record-v3', 'is-mobile-layout');
expect(screen.getByRole('group', { name: '车辆快捷操作' }).querySelectorAll('.semi-button')).toHaveLength(5);
expect(screen.getByRole('group', { name: '车辆快捷操作' })).toHaveTextContent('切换车辆轨迹回放历史数据里程查询告警事件');
const mobileActions = screen.getByRole('group', { name: '车辆快捷操作' });
expect(mobileActions.querySelectorAll('.semi-button')).toHaveLength(3);
expect(mobileActions).toHaveTextContent('切换车辆轨迹回放更多');
expect(mobileActions).not.toHaveTextContent('历史数据里程查询告警事件');
const moreActions = screen.getByRole('button', { name: '更多车辆操作' });
expect(moreActions).toHaveAttribute('aria-haspopup', 'dialog');
expect(moreActions).toHaveAttribute('aria-controls', 'v2-vehicle-mobile-actions');
expect(moreActions).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(moreActions);
expect(await screen.findByRole('dialog', { name: '更多车辆操作' })).toBeVisible();
expect(document.querySelector('.v2-vehicle-mobile-actions-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(screen.getByRole('button', { name: '历史数据' })).toBeVisible();
expect(screen.getByRole('button', { name: '里程查询' })).toBeVisible();
expect(screen.getByRole('button', { name: '告警事件' })).toBeVisible();
expect(screen.getByRole('button', { name: '关闭更多车辆操作' })).toBeInTheDocument();
expect(document.querySelectorAll('.v2-identity-sources .semi-tag')).toHaveLength(3);
expect(document.querySelector('.v2-identity-sources')).toHaveTextContent('GB/T 32960JT/T 808宇通 MQTT');
expect(document.querySelector('.v2-identity-sources .semi-tag-blue-light')).toHaveTextContent('GB/T 32960');
@@ -544,4 +557,6 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
expect(document.querySelector('.v2-live-metric.is-speed')).toHaveTextContent('速度');
expect(document.querySelector('.v2-live-metric.is-today-mileage')).toHaveTextContent('当日里程');
expect(document.querySelector('.v2-live-metric.is-source-status')).toHaveTextContent('来源状态');
fireEvent.click(screen.getByRole('button', { name: '历史数据' }));
expect(await screen.findByTestId('mobile-history-route')).toBeInTheDocument();
});

View File

@@ -37,6 +37,7 @@ 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'));
function resetWorkspaceScroll(routeRoot?: HTMLElement | null) {
const targets = new Set<Element | null>([
@@ -492,9 +493,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
<span className="v2-identity-model"><small> / </small><strong>{[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}</strong></span>
<span className="v2-identity-source-context"><small></small><span className="v2-identity-sources">{detail.sources.length ? detail.sources.map((source) => <ProtocolTag key={source} protocol={source} compact />) : <Tag color="grey" type="light" size="small"></Tag>}</span></span>
</span>}
actions={<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>}
actions={<Suspense fallback={null}><VehicleActions actions={actions} mobile={mobileLayout} onSelect={navigate} /></Suspense>}
/>
<section className="v2-vehicle-live-section" aria-labelledby="vehicle-live-heading">
<WorkspacePanelHeader