refine Semi UI vehicle directory
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user