refine Semi UI vehicle directory

This commit is contained in:
lingniu
2026-07-18 06:47:39 +08:00
parent 4bca0482c1
commit 4b3bb3b3dd
8 changed files with 475 additions and 184 deletions

View File

@@ -73,9 +73,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
limit := parseSQLPageSize(query.Get("limit"), 20)
offset := parsePositive(query.Get("offset"), 0)
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{}
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
having := []string{}
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
@@ -146,7 +145,6 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
if len(having) > 0 {
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
}
vehicleSetSQL := `SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''`
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
@@ -174,9 +172,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{}
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
having := []string{}
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
@@ -245,7 +242,6 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
if len(having) > 0 {
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
}
vehicleSetSQL := `SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''`
groupSQL := `SELECT v.vin, ` +
`COUNT(DISTINCT s.protocol) AS source_count, ` +
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
@@ -279,6 +275,28 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
}
}
func buildVehicleCoverageSetSQL(scope string) (string, []any) {
scope = strings.TrimSpace(scope)
if scope == "" {
return `SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''`, nil
}
vins := splitCSV(scope)
if len(vins) == 0 || (len(vins) == 1 && vins[0] == "__NO_VEHICLE_SCOPE__") {
return `SELECT CAST(NULL AS CHAR(64)) AS vin WHERE 1 = 0`, nil
}
selects := make([]string, len(vins))
args := make([]any, len(vins))
for index, vin := range vins {
if index == 0 {
selects[index] = `SELECT ? AS vin`
} else {
selects[index] = `SELECT ?`
}
args[index] = vin
}
return `SELECT DISTINCT scoped.vin FROM (` + strings.Join(selects, ` UNION ALL `) + `) scoped`, args
}
func archiveMissingFieldPredicate(field string) string {
switch strings.TrimSpace(field) {
case "plate":

View File

@@ -105,6 +105,40 @@ func TestBuildVehicleCoverageSQLIncludesNoDataVehicles(t *testing.T) {
}
}
func TestBuildVehicleCoverageSQLUsesAuthorizedVINsAsPopulation(t *testing.T) {
query := url.Values{"scopeVins": {"VIN001,VIN002"}, "limit": {"10"}, "offset": {"20"}}
built := buildVehicleCoverageSQL(query)
for _, want := range []string{
"SELECT DISTINCT scoped.vin",
"SELECT ? AS vin UNION ALL SELECT ?",
"LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin",
"LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin",
} {
if !strings.Contains(built.Text, want) {
t.Fatalf("authorized vehicle coverage SQL missing %q: %s", want, built.Text)
}
}
if strings.Contains(built.Text, "FROM (SELECT vin FROM vehicle_identity_binding") {
t.Fatalf("authorized vehicle coverage must not drop granted VINs without identity bindings: %s", built.Text)
}
if !slices.Equal(built.Args, []any{"VIN001", "VIN002", 10, 20}) {
t.Fatalf("args = %#v", built.Args)
}
if !slices.Equal(built.CountArgs, []any{"VIN001", "VIN002"}) {
t.Fatalf("count args = %#v", built.CountArgs)
}
}
func TestBuildVehicleCoverageSQLFailsClosedForEmptyAuthorizedScope(t *testing.T) {
built := buildVehicleCoverageSQL(url.Values{"scopeVins": {"__NO_VEHICLE_SCOPE__"}})
if !strings.Contains(built.Text, "SELECT CAST(NULL AS CHAR(64)) AS vin WHERE 1 = 0") {
t.Fatalf("empty authorized scope must use an empty vehicle population: %s", built.Text)
}
if len(built.Args) != 2 || built.Args[0] != 20 || built.Args[1] != 0 || len(built.CountArgs) != 0 {
t.Fatalf("args = %#v count args = %#v", built.Args, built.CountArgs)
}
}
func TestBuildVehicleCoverageSQLFiltersMissingProtocol(t *testing.T) {
query := url.Values{"missingProtocol": {"YUTONG_MQTT"}, "limit": {"8"}}
built := buildVehicleCoverageSQL(query)

View File

@@ -7,6 +7,7 @@ const excelAssets = assets.filter((name) => /^exceljs(?:\.min)?-[\w-]+\.js$/.tes
const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.js$/.test(name));
const monitorAssets = assets.filter((name) => /^MonitorPage-[\w-]+\.js$/.test(name));
const vehicleAssets = assets.filter((name) => /^VehiclePage-[\w-]+\.js$/.test(name));
const vehicleSearchAssets = assets.filter((name) => /^VehicleSearchWorkspace-[\w-]+\.js$/.test(name));
const profileSyncAssets = assets.filter((name) => /^VehicleProfileSyncPanel-[\w-]+\.js$/.test(name));
const runtimeConfig = readFileSync(resolve(process.cwd(), 'dist/app-config.js'), 'utf8');
const safeRuntimeTemplate = readFileSync(resolve(process.cwd(), 'public/app-config.example.js'), 'utf8');
@@ -23,8 +24,8 @@ if (mileageWorkers.length !== 1) {
if (monitorAssets.length !== 1) {
throw new Error(`production build must contain exactly one monitor page asset, found ${monitorAssets.length}: ${monitorAssets.join(', ') || 'none'}`);
}
if (vehicleAssets.length !== 1 || profileSyncAssets.length !== 1) {
throw new Error(`vehicle profile sync must remain one deferred route asset: vehicle=${vehicleAssets.join(', ') || 'none'} sync=${profileSyncAssets.join(', ') || 'none'}`);
if (vehicleAssets.length !== 1 || vehicleSearchAssets.length !== 1 || profileSyncAssets.length !== 1) {
throw new Error(`vehicle workspaces must remain deferred route assets: vehicle=${vehicleAssets.join(', ') || 'none'} search=${vehicleSearchAssets.join(', ') || 'none'} sync=${profileSyncAssets.join(', ') || 'none'}`);
}
if (runtimeConfig !== safeRuntimeTemplate) {
throw new Error('production dist/app-config.js must match the credential-free runtime template');
@@ -78,8 +79,9 @@ if (vehicleBytes > 30_000) {
}
const profileSyncBytes = statSync(resolve(assetsDirectory, profileSyncAssets[0])).size;
const vehicleSource = readFileSync(resolve(assetsDirectory, vehicleAssets[0]), 'utf8');
if (!vehicleSource.includes(profileSyncAssets[0])) {
throw new Error('vehicle profile sync asset must be loaded only through the vehicle page dynamic import');
const vehicleSearchSource = readFileSync(resolve(assetsDirectory, vehicleSearchAssets[0]), 'utf8');
if (!vehicleSource.includes(vehicleSearchAssets[0]) || !vehicleSearchSource.includes(profileSyncAssets[0])) {
throw new Error('vehicle directory and profile sync assets must remain a two-level deferred import from the vehicle page');
}
const qrBytes = statSync(resolve(assetsDirectory, qrAssets[0])).size;
process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} monitor_bytes=${monitorBytes} vehicle_bytes=${vehicleBytes} profile_sync_deferred=1 profile_sync_bytes=${profileSyncBytes} qr_deferred=1 qr_bytes=${qrBytes} runtime_config_sanitized=1 boot_recovery=1\n`);

View File

@@ -135,13 +135,18 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
plate: initialRealtime.plate,
phone: '13800000000',
oem: '测试品牌',
protocol: 'JT808',
protocols: ['JT808'],
missingProtocols: [],
sourceStatus: [],
sourceCount: 1,
onlineSourceCount: 1,
online: true,
lastSeen: initialRealtime.lastSeen,
locationText: '广东省广州市',
bindingScore: 100
bindingScore: 100,
bindingStatus: 'bound'
};
const vehicles = vi.spyOn(api, 'vehicles').mockResolvedValue({
const vehicles = vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({
items: [candidate, { ...candidate }],
total: 2,
limit: 12,
@@ -154,7 +159,7 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
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>);
expect(screen.getByRole('heading', { name: '车辆快速定位', level: 5 })).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: '车辆快速定位', level: 5 })).toBeInTheDocument();
expect(screen.getByLabelText('查车操作')).toHaveClass('v2-workspace-command-bar', 'v2-vehicle-command-bar');
expect(screen.getByRole('heading', { name: '车辆定位', level: 5 }).closest('.semi-card')).toHaveClass('v2-vehicle-query-panel', 'v2-workspace-filter-panel');
const input = screen.getByRole('textbox', { name: '搜索车辆' });
@@ -164,15 +169,16 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
await waitFor(() => expect(vehicles).toHaveBeenCalledTimes(1));
const initialQuery = vehicles.mock.calls[0]?.[0];
expect(initialQuery).toBeDefined();
expect(initialQuery!.get('limit')).toBe('12');
expect(screen.getByRole('heading', { name: '最近上报车辆', level: 5 })).toBeInTheDocument();
expect(await screen.findByText('当前显示 1 辆')).toBeInTheDocument();
expect(screen.getAllByRole('listitem', { name: `打开 ${initialRealtime.plate} 车辆档案` })).toHaveLength(1);
expect(initialQuery!.get('limit')).toBe('10');
expect(screen.getByRole('heading', { name: '授权车辆目录', level: 5 })).toBeInTheDocument();
expect(await screen.findByText('本页 1 辆')).toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument();
expect(screen.getByRole('button', { name: `打开 ${initialRealtime.vin} 车辆档案` })).toBeInTheDocument();
fireEvent.focus(input);
expect(input).toHaveAttribute('aria-expanded', 'true');
fireEvent.change(input, { target: { value: initialRealtime.plate } });
await waitFor(() => expect(vehicles).toHaveBeenCalledTimes(2));
expect(screen.getByRole('heading', { name: '匹配车辆快捷入口', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
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');
@@ -196,19 +202,25 @@ test('defaults to a compact mobile vehicle directory and keeps eight recent vehi
plate: `粤A${String(index).padStart(5, '0')}`,
phone: '',
oem: '测试品牌',
protocol: index % 2 ? 'JT808' : 'GB32960',
protocols: [index % 2 ? 'JT808' : 'GB32960'],
missingProtocols: [],
sourceStatus: [],
sourceCount: 1,
onlineSourceCount: index < 6 ? 1 : 0,
online: index < 6,
lastSeen: `2026-07-16 10:00:${String(index).padStart(2, '0')}`,
locationText: '广东省广州市',
bindingScore: 100
bindingScore: 100,
bindingStatus: 'bound'
}));
vi.spyOn(api, 'vehicles').mockResolvedValue({ items, total: 10, limit: 12, offset: 0 });
vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({ items, total: 10, limit: 8, offset: 0 });
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 />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('当前显示 8 辆')).toBeInTheDocument();
expect(await screen.findByText('本页 8 辆')).toBeInTheDocument();
expect(screen.getAllByRole('listitem', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-query-panel')).toHaveClass('is-mobile-collapsed');
const toggle = screen.getByRole('button', { name: '修改车辆定位10 辆授权车辆' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
@@ -217,6 +229,50 @@ test('defaults to a compact mobile vehicle directory and keeps eight recent vehi
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toBeVisible();
});
test('paginates the complete desktop authorized vehicle directory', async () => {
const firstPage = Array.from({ length: 10 }, (_, index) => ({
vin: `LTEST${String(index).padStart(12, '0')}`,
plate: `粤A${String(index).padStart(5, '0')}`,
phone: '',
oem: '测试品牌',
protocols: [index % 2 ? 'JT808' : 'GB32960'],
missingProtocols: [],
sourceStatus: [],
sourceCount: 1,
onlineSourceCount: index < 6 ? 1 : 0,
online: index < 6,
lastSeen: `2026-07-16 10:00:${String(index).padStart(2, '0')}`,
locationText: '广东省广州市',
bindingScore: 100,
bindingStatus: 'bound'
}));
const lastVehicle = { ...firstPage[0], vin: 'LTEST000000000010', plate: '粤A00010' };
const vehicles = vi.spyOn(api, 'vehicleCoverage').mockImplementation((params) => {
const query = params ?? new URLSearchParams();
return Promise.resolve({
items: query.get('offset') === '10' ? [lastVehicle] : firstPage,
total: 11,
limit: 10,
offset: Number(query.get('offset') ?? 0)
});
});
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 />} /></Routes></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('粤A00000')).toBeInTheDocument();
expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument();
expect(screen.getByText('共 11 辆 · 本页 10 辆')).toBeInTheDocument();
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('1/2');
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('offset')).toBe('10'));
expect(await screen.findByText('粤A00010')).toBeInTheDocument();
expect(screen.getByText('共 11 辆 · 本页 1 辆')).toBeInTheDocument();
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('2/2');
});
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 });

View File

@@ -1,10 +1,10 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import {
IconAlarm, IconArrowRight, IconBox, IconCalendar, IconClock, IconCopy,
IconChevronRight, IconMapPin, IconRefresh, IconSearch, IconTickCircle
IconAlarm, IconBox, IconCalendar, IconClock, IconCopy,
IconChevronRight, IconMapPin, IconSearch, IconTickCircle
} from '@douyinfe/semi-icons';
import { Button, Card, Descriptions, Empty, Input, List, Select, Table, Tag } from '@douyinfe/semi-ui';
import { FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { FormEvent, lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { LatestTelemetryResponse, LatestTelemetryValue, QualityIssueRow, VehicleDetail, VehicleRealtimeRow } from '../../api/types';
@@ -18,11 +18,7 @@ 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 { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
@@ -35,7 +31,7 @@ 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'));
const VehicleSearch = lazy(() => import('./VehicleSearchWorkspace'));
function resetWorkspaceScroll() {
const content = document.querySelector<HTMLElement>('.v2-content');
@@ -45,159 +41,12 @@ function resetWorkspaceScroll() {
content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
}
function vehicleLastSeenLabel(value?: string) {
if (!value) return '暂无上报时间';
const normalized = value.replace('T', ' ');
return normalized.length >= 16 ? `${normalized.slice(5, 16)} 更新` : normalized;
}
function eventTimeLabel(value?: string) {
if (!value) return '—';
const normalized = value.replace('T', ' ');
return normalized.length >= 16 ? normalized.slice(5, 16) : normalized;
}
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 [filtersCollapsed, setFiltersCollapsed] = useState(mobileLayout);
const closeTimerRef = useRef<number>();
const deferredKeyword = useDeferredValue(keyword.trim());
const candidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: '12', 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),
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const quickVehicles = useMemo(() => options.slice(0, mobileLayout ? 8 : 12), [mobileLayout, options]);
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();
openVehicle(keyword);
};
const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围';
return <section className={`v2-vehicle-search-page${syncOpen ? ' has-sync-panel' : ''}${candidatesOpen ? ' has-candidates' : ''}`}>
<WorkspaceCommandBar
className="v2-vehicle-command-bar"
ariaLabel="查车操作"
title="车辆快速定位"
description="车牌、VIN 或手机号快速查车"
status={candidates.isPending ? '正在读取授权范围' : vehicleScope}
statusColor={candidates.isError ? 'red' : 'blue'}
actions={canAdminister(session) ? <Button theme="light" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起主档同步' : '批量同步主档'}</Button> : null}
/>
<WorkspaceFilterPanel
className="v2-vehicle-query-panel"
title="车辆定位"
description="车牌优先VIN 辅助"
mobileSummary={deferredKeyword ? `正在查找 ${deferredKeyword}` : candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : '正在读取授权范围'}
expanded={!filtersCollapsed}
status={deferredKeyword ? candidates.isFetching ? '正在查找' : `${options.length} 辆匹配` : vehicleScope}
statusColor={deferredKeyword ? 'blue' : 'grey'}
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form className={`v2-vehicle-search-form${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} 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>
</WorkspaceFilterPanel>
<Card className="v2-vehicle-recent-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title={deferredKeyword ? '匹配车辆快捷入口' : '最近上报车辆'}
description={deferredKeyword ? '按当前搜索条件展示,可直接进入车辆数字档案' : '按照最新上报时间排列,无需搜索即可继续查看'}
meta={<Tag color={deferredKeyword ? 'blue' : 'grey'} type="light" size="small">{candidates.data ? `当前显示 ${formatZhNumber(quickVehicles.length)}` : '授权范围'}</Tag>}
actions={<Button theme="borderless" type="tertiary" size="small" icon={<IconRefresh />} loading={candidates.isFetching} onClick={() => candidates.refetch()}></Button>}
/>
{candidates.isFetching && !candidates.data
? <div className="v2-vehicle-recent-state" role="status"><span className="v2-spinner" /><span></span></div>
: candidates.isError
? <div className="v2-vehicle-recent-state is-error" role="alert"><span>{candidates.error instanceof Error ? candidates.error.message : '授权车辆加载失败'}</span><Button size="small" theme="light" onClick={() => candidates.refetch()}></Button></div>
: quickVehicles.length
? <div className="v2-vehicle-recent-grid" role="list" aria-label={deferredKeyword ? '匹配车辆' : '最近上报车辆'}>
{quickVehicles.map((vehicle) => <Button
key={vehicle.vin}
className="v2-vehicle-recent-item"
theme="borderless"
type="tertiary"
role="listitem"
aria-label={`打开 ${vehicle.plate || '未绑定车牌'} 车辆档案`}
onClick={() => openVehicle(vehicle.vin)}
>
<span className="v2-vehicle-recent-identity">
<strong>{vehicle.plate || '未绑定车牌'}</strong>
<small>{vehicle.vin}</small>
</span>
<span className="v2-vehicle-recent-status">
<Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{vehicle.online ? '在线' : '离线'}</Tag>
<small>{vehicleLastSeenLabel(vehicle.lastSeen)}</small>
</span>
<span className="v2-vehicle-recent-protocols">{vehicle.protocols.slice(0, 2).map((protocol) => <Tag key={protocol} color="blue" type="light" size="small">{protocol}</Tag>)}</span>
<IconChevronRight className="v2-vehicle-recent-arrow" />
</Button>)}
</div>
: <Empty className="v2-vehicle-recent-empty" title={deferredKeyword ? '没有匹配车辆' : '暂无授权车辆'} description={deferredKeyword ? '调整车牌、VIN 或手机号后重试。' : '管理员分配车辆权限后会显示在这里。'} />}
</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);
@@ -539,7 +388,7 @@ export default function VehiclePage() {
window.clearTimeout(settledTimer);
};
}, [vin, resolvedVin]);
if (!vin) return <VehicleSearch />;
if (!vin) return <Suspense fallback={<PageLoading />}><VehicleSearch /></Suspense>;
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 <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>;

View File

@@ -0,0 +1,214 @@
import { useQuery } from '@tanstack/react-query';
import { IconArrowRight, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, Table, Tag } from '@douyinfe/semi-ui';
import { type FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api/client';
import type { VehicleCoverageRow } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { formatZhNumber } from '../domain/formatters';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { QUERY_MEMORY } from '../queryPolicy';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
const VehicleProfileSyncPanel = lazy(() => import('./VehicleProfileSyncPanel'));
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 vehicleLastSeenLabel(value?: string) {
if (!value) return '暂无上报时间';
const normalized = value.replace('T', ' ');
return normalized.length >= 16 ? `${normalized.slice(5, 16)} 更新` : normalized;
}
export default function VehicleSearchWorkspace() {
const navigate = useNavigate();
const { session } = usePlatformSession();
const mobileLayout = useMobileLayout();
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [syncOpen, setSyncOpen] = useState(false);
const [candidatesOpen, setCandidatesOpen] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(mobileLayout);
const closeTimerRef = useRef<number>();
const deferredKeyword = useDeferredValue(keyword.trim());
const pageSize = mobileLayout ? 8 : 10;
const candidateParams = useMemo(() => {
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
if (deferredKeyword) params.set('keyword', deferredKeyword);
return params;
}, [deferredKeyword, page, pageSize]);
const candidates = useQuery({
queryKey: ['vehicle-directory', candidateParams.toString()],
queryFn: ({ signal }) => api.vehicleCoverage(candidateParams, signal),
placeholderData: (previous) => previous,
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const pageVehicles = useMemo(() => options.slice(0, pageSize), [options, pageSize]);
const total = candidates.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
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();
openVehicle(keyword);
};
const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围';
return <section className={`v2-vehicle-search-page${syncOpen ? ' has-sync-panel' : ''}${candidatesOpen ? ' has-candidates' : ''}`}>
<WorkspaceCommandBar
className="v2-vehicle-command-bar"
ariaLabel="查车操作"
title="车辆快速定位"
description="车牌、VIN 或手机号快速查车"
status={candidates.isPending ? '正在读取授权范围' : vehicleScope}
statusColor={candidates.isError ? 'red' : 'blue'}
actions={canAdminister(session) ? <Button theme="light" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起主档同步' : '批量同步主档'}</Button> : null}
/>
<WorkspaceFilterPanel
className="v2-vehicle-query-panel"
title="车辆定位"
description="车牌优先VIN 辅助"
mobileSummary={deferredKeyword ? `正在查找 ${deferredKeyword}` : candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : '正在读取授权范围'}
expanded={!filtersCollapsed}
status={deferredKeyword ? candidates.isFetching ? '正在查找' : `${options.length} 辆匹配` : vehicleScope}
statusColor={deferredKeyword ? 'blue' : 'grey'}
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form className={`v2-vehicle-search-form${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} 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); setPage(1); 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>
</WorkspaceFilterPanel>
<Card className="v2-vehicle-recent-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title={deferredKeyword ? '匹配车辆' : '授权车辆目录'}
description={deferredKeyword ? '按当前搜索条件展示,可直接进入车辆数字档案' : '按照最新上报时间排列,分页浏览全部授权车辆'}
meta={<Tag color={deferredKeyword ? 'blue' : 'grey'} type="light" size="small">{candidates.data ? `本页 ${formatZhNumber(pageVehicles.length)}` : '授权范围'}</Tag>}
actions={<Button theme="borderless" type="tertiary" size="small" icon={<IconRefresh />} loading={candidates.isFetching} onClick={() => candidates.refetch()}></Button>}
/>
{candidates.isFetching && !candidates.data
? <div className="v2-vehicle-recent-state" role="status"><span className="v2-spinner" /><span></span></div>
: candidates.isError
? <div className="v2-vehicle-recent-state is-error" role="alert"><span>{candidates.error instanceof Error ? candidates.error.message : '授权车辆加载失败'}</span><Button size="small" theme="light" onClick={() => candidates.refetch()}></Button></div>
: pageVehicles.length
? mobileLayout ? <div className="v2-vehicle-recent-grid" role="list" aria-label={deferredKeyword ? '匹配车辆' : '授权车辆目录'}>
{pageVehicles.map((vehicle) => <Button
key={vehicle.vin}
className="v2-vehicle-recent-item"
theme="borderless"
type="tertiary"
role="listitem"
aria-label={`打开 ${vehicle.plate || '未绑定车牌'} 车辆档案`}
onClick={() => openVehicle(vehicle.vin)}
>
<span className="v2-vehicle-recent-identity">
<strong>{vehicle.plate || '未绑定车牌'}</strong>
<small>{vehicle.vin}</small>
</span>
<span className="v2-vehicle-recent-status">
<Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{vehicle.online ? '在线' : '离线'}</Tag>
<small>{vehicleLastSeenLabel(vehicle.lastSeen)}</small>
</span>
<span className="v2-vehicle-recent-protocols">{vehicle.protocols.slice(0, 2).map((protocol) => <Tag key={protocol} color="blue" type="light" size="small">{protocol}</Tag>)}</span>
<IconChevronRight className="v2-vehicle-recent-arrow" />
</Button>)}
</div> : <Table
className="v2-vehicle-directory-table"
columns={[
{
title: '车辆',
dataIndex: 'plate',
width: 220,
render: (_value: string, vehicle: VehicleCoverageRow) => <span className="v2-vehicle-directory-identity"><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin}</small></span>
},
{
title: '实时状态',
dataIndex: 'online',
width: 150,
render: (_value: boolean, vehicle: VehicleCoverageRow) => <span className="v2-vehicle-directory-status"><Tag color={vehicle.online ? 'green' : 'grey'} type="light" size="small">{vehicle.online ? '在线' : '离线'}</Tag><small>{vehicleLastSeenLabel(vehicle.lastSeen)}</small></span>
},
{
title: '协议来源',
dataIndex: 'protocols',
render: (protocols: string[]) => <span className="v2-vehicle-directory-protocols">{protocols.length ? protocols.slice(0, 3).map((protocol) => <Tag key={protocol} color="blue" type="light" size="small">{protocol}</Tag>) : <span></span>}</span>
},
{
title: '',
dataIndex: 'vin',
width: 104,
align: 'right',
render: (vehicleVin: string) => <Button className="v2-vehicle-directory-open" theme="borderless" type="primary" size="small" icon={<IconChevronRight />} iconPosition="right" aria-label={`打开 ${vehicleVin} 车辆档案`} onClick={() => openVehicle(vehicleVin)}></Button>
}
]}
dataSource={pageVehicles}
rowKey="vin"
pagination={false}
loading={candidates.isFetching}
empty={null}
/>
: <Empty className="v2-vehicle-recent-empty" title={deferredKeyword ? '没有匹配车辆' : '暂无授权车辆'} description={deferredKeyword ? '调整车牌、VIN 或手机号后重试。' : '管理员分配车辆权限后会显示在这里。'} />}
{total > 0 ? <footer className="v2-vehicle-directory-pagination"><TablePagination page={page} totalPages={totalPages} info={`${total.toLocaleString('zh-CN')} 辆 · 本页 ${pageVehicles.length.toLocaleString('zh-CN')}`} disabled={candidates.isFetching} onPageChange={(next) => { setPage(next); setCandidatesOpen(false); }} /></footer> : 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>;
}

View File

@@ -11,6 +11,7 @@ const corePageSources = Object.fromEntries(['MonitorPage', 'VehiclePage', 'Track
name,
readFileSync(resolve(process.cwd(), `src/v2/pages/${name}.tsx`), 'utf8')
]));
const vehicleSearchSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleSearchWorkspace.tsx'), 'utf8');
const reconciliationSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/ReconciliationCenter.tsx'), 'utf8');
const profileSyncPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleProfileSyncPanel.tsx'), 'utf8');
const sourceEvidenceSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleSourceEvidencePanel.tsx'), 'utf8');
@@ -59,10 +60,13 @@ describe('V2 production entry', () => {
expect(corePageSources[name]).not.toContain("from '../shared/PageHeader'");
expect(corePageSources[name]).not.toContain('<PageHeader');
}
for (const name of ['VehiclePage', 'AccessPage', 'OperationsPage', 'UsersPage']) {
for (const name of ['AccessPage', 'OperationsPage', 'UsersPage']) {
expect(corePageSources[name]).toContain("from '../shared/WorkspaceCommandBar'");
expect(corePageSources[name]).toContain('<WorkspaceCommandBar');
}
expect(corePageSources.VehiclePage).toContain("lazy(() => import('./VehicleSearchWorkspace'))");
expect(vehicleSearchSource).toContain("from '../shared/WorkspaceCommandBar'");
expect(vehicleSearchSource).toContain('<WorkspaceCommandBar');
expect(corePageSources.VehiclePage).toContain('<Select');
expect(corePageSources.VehiclePage).toContain('<Card className="v2-identity-band v2-record-card v2-live-card v2-live-overview v2-vehicle-command-card"');
expect(corePageSources.VehiclePage).toContain('className="v2-live-grid" role="list" aria-label="车辆实时指标"');
@@ -89,13 +93,13 @@ describe('V2 production entry', () => {
expect(corePageSources.VehiclePage).not.toContain('<nav className="v2-telemetry-protocols"');
expect(corePageSources.VehiclePage).not.toContain('<input');
expect(corePageSources.VehiclePage).not.toContain('<button');
expect(corePageSources.VehiclePage).toContain("lazy(() => import('./VehicleProfileSyncPanel'))");
expect(corePageSources.VehiclePage).toContain('<Card className="v2-profile-sync-panel v2-profile-sync-loading"');
expect(vehicleSearchSource).toContain("lazy(() => import('./VehicleProfileSyncPanel'))");
expect(vehicleSearchSource).toContain('<Card className="v2-profile-sync-panel v2-profile-sync-loading"');
expect(profileSyncPanelSource).toContain('<Upload');
expect(profileSyncPanelSource).toContain('<Card className="v2-profile-sync-panel"');
expect(profileSyncPanelSource).not.toContain('<input');
expect(profileSyncPanelSource).not.toContain('<section className="v2-profile-sync-panel"');
expect(corePageSources.VehiclePage).toContain('v2-profile-sync-panel');
expect(vehicleSearchSource).toContain('v2-profile-sync-panel');
expect(corePageSources.MonitorPage).toContain('<Card className="v2-vehicle-detail"');
expect(corePageSources.MonitorPage).toContain('<Card className="v2-detail-peek"');
expect(corePageSources.MonitorPage).toContain('<Card className="v2-event-strip"');
@@ -453,10 +457,12 @@ describe('V2 production entry', () => {
expect(workspaceStyles).toContain('bottom: 110px;');
expect(workspaceFilterPanelSource).toContain("from './MobileFilterToggle'");
expect(workspaceFilterPanelSource).toContain('<MobileFilterToggle');
for (const name of ['VehiclePage', 'HistoryPage', 'AlertsPage', 'AccessPage', 'StatisticsPage', 'UsersPage', 'OperationsPage']) {
for (const name of ['HistoryPage', 'AlertsPage', 'AccessPage', 'StatisticsPage', 'UsersPage', 'OperationsPage']) {
expect(corePageSources[name]).toContain("from '../shared/WorkspaceFilterPanel'");
expect(corePageSources[name]).toContain('<WorkspaceFilterPanel');
}
expect(vehicleSearchSource).toContain("from '../shared/WorkspaceFilterPanel'");
expect(vehicleSearchSource).toContain('<WorkspaceFilterPanel');
expect(tablePaginationSource).toContain('<Pagination');
});

View File

@@ -2043,6 +2043,105 @@
min-height: 78px;
}
.v2-vehicle-directory-table.semi-table-wrapper {
width: 100%;
overflow-x: auto;
}
.v2-vehicle-directory-table .semi-table {
min-width: 640px;
}
.v2-vehicle-directory-table .semi-table-thead > .semi-table-row > .semi-table-row-head {
height: 38px;
border-bottom-color: #e7edf4;
background: #f7f9fc;
padding-block: 7px;
color: #708096;
font-size: 11px;
font-weight: 650;
}
.v2-vehicle-directory-table .semi-table-tbody > .semi-table-row {
cursor: default;
transition: background-color .15s ease;
}
.v2-vehicle-directory-table .semi-table-tbody > .semi-table-row:hover {
background: #f7faff;
}
.v2-vehicle-directory-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
height: 48px;
border-bottom-color: #edf1f6;
padding-block: 6px;
}
.v2-vehicle-directory-identity {
display: grid;
min-width: 0;
gap: 2px;
}
.v2-vehicle-directory-identity strong {
overflow: hidden;
color: #1b2a3e;
font-size: 14px;
font-weight: 720;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-vehicle-directory-identity small,
.v2-vehicle-directory-status small {
overflow: hidden;
color: #7f8da1;
font-size: 10px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-vehicle-directory-status {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.v2-vehicle-directory-status .semi-tag,
.v2-vehicle-directory-protocols .semi-tag {
flex: 0 0 auto;
margin: 0;
}
.v2-vehicle-directory-protocols {
display: flex;
min-width: 0;
align-items: center;
gap: 5px;
color: #8491a3;
font-size: 11px;
}
.v2-vehicle-directory-open.semi-button {
min-width: 82px;
height: 30px;
border-radius: 7px;
font-size: 11px;
}
.v2-vehicle-recent-card > .semi-card-body > .v2-vehicle-directory-pagination {
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid #e7edf4;
background: #fbfcfe;
padding: 7px 12px;
}
@media (max-width: 1100px) {
.v2-vehicle-recent-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -2172,6 +2271,19 @@
grid-template-columns: 1fr;
}
.v2-vehicle-recent-card > .semi-card-body > .v2-vehicle-directory-pagination {
min-height: 52px;
padding-inline: 10px;
}
.v2-vehicle-directory-pagination .v2-table-pagination-info {
font-size: 10px;
}
.v2-vehicle-directory-pagination .v2-table-pagination-controls {
margin-left: auto;
}
.v2-track-detail-sidesheet .semi-sidesheet-inner {
overflow: hidden;
border-radius: 18px 18px 0 0;