diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 6f8c8c7d..7b3d0c48 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -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": diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 0872a1f7..2e3cade0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -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) diff --git a/vehicle-data-platform/apps/web/scripts/verify-build.mjs b/vehicle-data-platform/apps/web/scripts/verify-build.mjs index ba743e6f..508ea16d 100644 --- a/vehicle-data-platform/apps/web/scripts/verify-build.mjs +++ b/vehicle-data-platform/apps/web/scripts/verify-build.mjs @@ -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`); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx index 2f3f87e1..2a0722aa 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx @@ -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(} />} />); - 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(} />); - 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(} />); + + 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 }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx index 8ae54fef..d8f68cc7 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -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('.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(); - 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
- setSyncOpen((value) => !value)}>{syncOpen ? '收起主档同步' : '批量同步主档'} : null} - /> - setFiltersCollapsed((value) => !value)} - > -
-
- } - value={keyword} - onChange={(value) => { setKeyword(value); setCandidatesOpen(true); }} - onFocus={openCandidates} - onBlur={closeCandidates} - placeholder="输入车牌 / VIN / 终端手机号" - autoComplete="off" - /> -
- - {candidatesOpen ? candidates.refetch()} - emptyText="没有匹配的授权车辆" - header="车辆候选" - meta="车牌优先 · VIN 辅助" - showProtocols - layout={mobileLayout ? 'list' : 'grid'} - onSelect={(vehicle) => openVehicle(vehicle.vin)} - /> : null} - -
- - {candidates.data ? `当前显示 ${formatZhNumber(quickVehicles.length)} 辆` : '授权范围'}} - actions={} - /> - {candidates.isFetching && !candidates.data - ?
正在读取授权车辆…
- : candidates.isError - ?
{candidates.error instanceof Error ? candidates.error.message : '授权车辆加载失败'}
- : quickVehicles.length - ?
- {quickVehicles.map((vehicle) => )} -
- : } -
- {syncOpen ? 正在加载主档同步工具…}> setSyncOpen(false)} /> : null} -
; -} - 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 ; + if (!vin) return }>; if (query.isPending) return ; if (query.isError) return
query.refetch()} />
; if (!query.data.lookupResolved) return } title="未找到车辆" description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}>; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx new file mode 100644 index 00000000..90694dcd --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx @@ -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('.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(); + 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
+ setSyncOpen((value) => !value)}>{syncOpen ? '收起主档同步' : '批量同步主档'} : null} + /> + setFiltersCollapsed((value) => !value)} + > +
+
+ } + value={keyword} + onChange={(value) => { setKeyword(value); setPage(1); setCandidatesOpen(true); }} + onFocus={openCandidates} + onBlur={closeCandidates} + placeholder="输入车牌 / VIN / 终端手机号" + autoComplete="off" + /> +
+ + {candidatesOpen ? candidates.refetch()} + emptyText="没有匹配的授权车辆" + header="车辆候选" + meta="车牌优先 · VIN 辅助" + showProtocols + layout={mobileLayout ? 'list' : 'grid'} + onSelect={(vehicle) => openVehicle(vehicle.vin)} + /> : null} + +
+ + {candidates.data ? `本页 ${formatZhNumber(pageVehicles.length)} 辆` : '授权范围'}} + actions={} + /> + {candidates.isFetching && !candidates.data + ?
正在读取授权车辆…
+ : candidates.isError + ?
{candidates.error instanceof Error ? candidates.error.message : '授权车辆加载失败'}
+ : pageVehicles.length + ? mobileLayout ?
+ {pageVehicles.map((vehicle) => )} +
: {vehicle.plate || '未绑定车牌'}{vehicle.vin} + }, + { + title: '实时状态', + dataIndex: 'online', + width: 150, + render: (_value: boolean, vehicle: VehicleCoverageRow) => {vehicle.online ? '在线' : '离线'}{vehicleLastSeenLabel(vehicle.lastSeen)} + }, + { + title: '协议来源', + dataIndex: 'protocols', + render: (protocols: string[]) => {protocols.length ? protocols.slice(0, 3).map((protocol) => {protocol}) : 暂无来源} + }, + { + title: '', + dataIndex: 'vin', + width: 104, + align: 'right', + render: (vehicleVin: string) => + } + ]} + dataSource={pageVehicles} + rowKey="vin" + pagination={false} + loading={candidates.isFetching} + empty={null} + /> + : } + {total > 0 ?
{ setPage(next); setCandidatesOpen(false); }} />
: null} + + {syncOpen ? 正在加载主档同步工具…}> setSyncOpen(false)} /> : null} + ; +} diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index 8f10cc9a..b67f3eb6 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -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(' import('./VehicleSearchWorkspace'))"); + expect(vehicleSearchSource).toContain("from '../shared/WorkspaceCommandBar'"); + expect(vehicleSearchSource).toContain(' { expect(corePageSources.VehiclePage).not.toContain('