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

View File

@@ -16539,48 +16539,128 @@
.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-meta strong {
margin-top: 3px;
font-size: 10px;
font-size: 11px;
line-height: 1.35;
}
.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-sources {
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
gap: 4px;
margin-top: 4px;
}
.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-sources .semi-tag {
min-height: 18px;
padding-inline: 4px;
font-size: 7px;
min-height: 20px;
padding-inline: 6px;
font-size: 9px;
}
.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-actions {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 3px;
overflow: hidden;
padding: 5px 6px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
overflow: visible;
padding: 7px 8px 8px;
}
.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-actions > .semi-button {
min-height: 48px;
padding: 2px 1px;
min-height: 40px;
border-radius: 8px;
padding: 4px 7px;
}
.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-actions > .semi-button .semi-button-content {
min-width: 0;
flex-direction: column;
gap: 2px;
flex-direction: row;
justify-content: center;
gap: 5px;
}
.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-actions > .semi-button .semi-button-content-left {
margin-right: 0;
font-size: 14px;
font-size: 15px;
}
.v2-vehicle-record-v3 .v2-vehicle-command-header.v2-vehicle-record-command .v2-identity-actions > .semi-button .semi-button-content-right {
max-width: 100%;
margin-left: 0;
font-size: 8px;
font-size: 11px;
font-weight: 600;
}
.v2-vehicle-mobile-actions-sidesheet .semi-sidesheet-inner {
width: 100vw !important;
max-width: 100%;
overflow: hidden;
border-radius: 18px 18px 0 0;
background: #f4f7fb;
box-shadow: 0 -20px 56px rgba(25, 45, 72, .2);
padding-bottom: env(safe-area-inset-bottom);
}
.v2-vehicle-mobile-actions-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header {
min-height: 68px;
border-bottom: 1px solid #dfe7f0;
background: rgba(255, 255, 255, .98);
padding: 11px 14px;
}
.v2-vehicle-mobile-actions-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body {
min-height: 0;
background: #f4f7fb;
padding: 10px 12px;
}
.v2-vehicle-mobile-actions-sidesheet.semi-sidesheet-bottom .semi-sidesheet-footer {
border-top: 1px solid #dfe7f0;
background: rgba(255, 255, 255, .98);
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
}
.v2-vehicle-mobile-actions-title {
display: grid;
gap: 3px;
}
.v2-vehicle-mobile-actions-title > strong {
color: #253a52;
font-size: 15px;
line-height: 1.3;
}
.v2-vehicle-mobile-actions-title > span {
color: #8090a3;
font-size: 10px;
line-height: 1.4;
}
.v2-vehicle-mobile-actions-list {
display: grid;
gap: 7px;
}
.v2-vehicle-mobile-actions-list > .semi-button {
min-height: 48px;
justify-content: flex-start;
border: 1px solid #e1e8f1;
border-radius: 10px;
background: #fff;
color: #2b425d;
padding-inline: 14px;
font-size: 13px;
font-weight: 600;
}
.v2-vehicle-mobile-actions-list > .semi-button .semi-button-content {
width: 100%;
justify-content: flex-start;
}
.v2-vehicle-mobile-actions-list > .semi-button .semi-button-content-right {
display: flex;
min-width: 0;
flex: 1 1 auto;
align-items: center;
justify-content: space-between;
}
.v2-vehicle-record-v3 .v2-vehicle-live-section > .v2-workspace-panel-header {