diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index b2ffae35..27a62cee 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -181,7 +181,7 @@ export function AppShell() { - + setHelpOpen(false)} /> {passwordOpen ? setPasswordOpen(false)} onChanged={logout} /> : null} 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 6861d399..bd893cba 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 @@ -172,7 +172,11 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as expect(initialQuery!.get('limit')).toBe('10'); expect(screen.getByRole('heading', { name: '授权车辆目录', level: 5 })).toBeInTheDocument(); expect(await screen.findByText('本页 1 辆')).toBeInTheDocument(); + expect(screen.getByText('本页在线 1')).toBeInTheDocument(); + expect(screen.getByText('多源车辆 0')).toBeInTheDocument(); expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument(); + expect(view.container.querySelector('.v2-vehicle-directory-profile')).toHaveTextContent('测试品牌13800000000'); + expect(view.container.querySelector('.v2-vehicle-discovery-shell')).toContainElement(screen.getByLabelText('查车操作')); expect(screen.getByRole('button', { name: `打开 ${initialRealtime.vin} 车辆档案` })).toBeInTheDocument(); fireEvent.focus(input); expect(input).toHaveAttribute('aria-expanded', 'true'); 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 2b48a69f..7c2b5ff0 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -4,7 +4,7 @@ import { IconChevronRight, IconMapPin, IconSearch, IconTickCircle } from '@douyinfe/semi-icons'; import { Button, Card, Descriptions, Empty, Input, List, Select, SideSheet, Table, Tag } from '@douyinfe/semi-ui'; -import { FormEvent, lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { FormEvent, lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, 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'; @@ -35,11 +35,28 @@ const SINGLE_VEHICLE_REFRESH_MS = 10_000; const VehicleSearch = lazy(() => import('./VehicleSearchWorkspace')); 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' }); + const targets = [ + document.scrollingElement, + document.documentElement, + document.body, + document.querySelector('.v2-shell'), + document.querySelector('.v2-main'), + document.querySelector('.v2-content') + ]; + for (const target of targets) { + if (!(target instanceof HTMLElement)) continue; + target.scrollTop = 0; + target.scrollLeft = 0; + target.scrollTo?.({ top: 0, left: 0, behavior: 'auto' }); + } +} + +function revealVehicleIdentity(root: HTMLElement | null) { + resetWorkspaceScroll(); + if (!root) return; + root.scrollTop = 0; + root.scrollLeft = 0; + root.scrollIntoView?.({ behavior: 'auto', block: 'start', inline: 'nearest' }); } function eventTimeLabel(value?: string) { @@ -394,6 +411,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele const { session } = usePlatformSession(); const navigate = useNavigate(); const mobileLayout = useMobileLayout(); + const recordRootRef = useRef(null); const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false); const realtime = liveRealtime ?? detail.realtimeSummary; const identity = detail.identity; @@ -406,7 +424,33 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele ...(hasMenu(session, 'statistics') ? [{ key: 'statistics', label: '里程查询', icon: , to: withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []), ...(hasMenu(session, 'alerts') ? [{ key: 'alerts', label: '告警事件', icon: , to: `/alerts?vin=${encodeURIComponent(detail.vin)}`, type: 'tertiary' as const }] : []) ]; - return
+ useLayoutEffect(() => { + const root = recordRootRef.current; + if (!root) return; + let cancelled = false; + const timers: number[] = []; + const revealIdentity = () => { + if (!cancelled) revealVehicleIdentity(root); + }; + const cancelEntryReset = () => { + cancelled = true; + for (const timer of timers) window.clearTimeout(timer); + }; + const interactionEvents = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const; + for (const eventName of interactionEvents) window.addEventListener(eventName, cancelEntryReset, { capture: true, passive: true }); + revealIdentity(); + const frame = window.requestAnimationFrame(revealIdentity); + // The map and live evidence arrive independently. Keep the identity band + // anchored only during initial settlement, then immediately yield to input. + for (const delay of [80, 180, 360, 720, 1200, 1800, 2400]) timers.push(window.setTimeout(revealIdentity, delay)); + return () => { + cancelled = true; + window.cancelAnimationFrame(frame); + for (const timer of timers) window.clearTimeout(timer); + for (const eventName of interactionEvents) window.removeEventListener(eventName, cancelEntryReset, true); + }; + }, [detail.vin]); + return
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx index 41d126ee..99f0d604 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx @@ -20,11 +20,20 @@ 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' }); + const targets = [ + document.scrollingElement, + document.documentElement, + document.body, + document.querySelector('.v2-shell'), + document.querySelector('.v2-main'), + document.querySelector('.v2-content') + ]; + for (const target of targets) { + if (!(target instanceof HTMLElement)) continue; + target.scrollTop = 0; + target.scrollLeft = 0; + target.scrollTo?.({ top: 0, left: 0, behavior: 'auto' }); + } } function vehicleLastSeenLabel(value?: string) { @@ -61,6 +70,8 @@ export default function VehicleSearchWorkspace() { const pageVehicles = useMemo(() => options.slice(0, pageSize), [options, pageSize]); const total = candidates.data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const onlineOnPage = useMemo(() => pageVehicles.filter((vehicle) => vehicle.online).length, [pageVehicles]); + const multiSourceOnPage = useMemo(() => pageVehicles.filter((vehicle) => vehicle.protocols.length > 1).length, [pageVehicles]); const administrator = canAdminister(session); useEffect(() => () => window.clearTimeout(closeTimerRef.current), []); useEffect(() => { @@ -79,6 +90,7 @@ export default function VehicleSearchWorkspace() { const normalized = value.trim(); if (!normalized) return; setCandidatesOpen(false); + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); resetWorkspaceScroll(); navigate(`/vehicles/${encodeURIComponent(normalized)}`); window.queueMicrotask(resetWorkspaceScroll); @@ -91,64 +103,70 @@ export default function VehicleSearchWorkspace() { }; const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围'; return
- {!mobileLayout || administrator ? : undefined} onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起同步' : mobileLayout ? '主档同步' : '批量同步主档'} : null} - /> : 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} - -
+
+ {!mobileLayout || administrator ? : undefined} onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起同步' : mobileLayout ? '主档同步' : '批量同步主档'} : null} + /> : 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)} 辆` : '授权范围'}} + meta={ + {candidates.data ? `本页 ${formatZhNumber(pageVehicles.length)} 辆` : '授权范围'} + {candidates.data ? 本页在线 {formatZhNumber(onlineOnPage)} : null} + {candidates.data ? 多源车辆 {formatZhNumber(multiSourceOnPage)} : null} + } actions={} /> {candidates.isFetching && !candidates.data @@ -186,10 +204,16 @@ export default function VehicleSearchWorkspace() { width: 220, render: (_value: string, vehicle: VehicleCoverageRow) => {vehicle.plate || '未绑定车牌'}{vehicle.vin} }, + { + title: '车辆信息', + dataIndex: 'oem', + width: 260, + render: (_value: string, vehicle: VehicleCoverageRow) => {vehicle.oem || '品牌待维护'}{vehicle.phone || '暂无终端手机号'} + }, { title: '实时状态', dataIndex: 'online', - width: 150, + width: 180, render: (_value: boolean, vehicle: VehicleCoverageRow) => {vehicle.online ? '在线' : '离线'}{vehicleLastSeenLabel(vehicle.lastSeen)} }, { @@ -200,13 +224,22 @@ export default function VehicleSearchWorkspace() { { title: '', dataIndex: 'vin', - width: 104, + width: 116, align: 'right', - render: (vehicleVin: string) => + render: (vehicleVin: string) => } ]} dataSource={pageVehicles} rowKey="vin" + onRow={(vehicle) => vehicle ? ({ + tabIndex: 0, + onClick: () => openVehicle(vehicle.vin), + onKeyDown: (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + openVehicle(vehicle.vin); + } + }) : ({})} pagination={false} loading={candidates.isFetching} empty={null} diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css index 09bbc347..2bcc2546 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -1983,10 +1983,11 @@ align-content: start; place-items: start center; gap: 12px; - overflow: auto; + overflow: visible; padding: 16px var(--v2-page-gutter) 28px; } +.v2-vehicle-discovery-shell, .v2-vehicle-search-page > .v2-page-heading, .v2-vehicle-search-page > .v2-mobile-filter-toggle, .v2-vehicle-search-page > .v2-vehicle-query-panel, @@ -1996,6 +1997,32 @@ width: min(1280px, 100%); } +.v2-vehicle-discovery-shell { + display: grid; + overflow: visible; + gap: 0; + border: 1px solid #dce4ee; + border-radius: 14px; + background: #fff; + box-shadow: 0 9px 28px rgba(31, 53, 80, .055); +} + +.v2-vehicle-discovery-shell > .v2-workspace-command-bar { + width: 100%; + min-height: 50px; + border: 0; + border-bottom: 1px solid #e6ecf3; + border-radius: 14px 14px 0 0; + box-shadow: none; +} + +.v2-vehicle-discovery-shell > .v2-vehicle-query-panel.semi-card { + width: 100%; + border: 0; + border-radius: 0 0 14px 14px; + box-shadow: none; +} + .v2-vehicle-query-panel.v2-workspace-filter-panel.semi-card { overflow: visible; } @@ -2062,27 +2089,35 @@ } .v2-vehicle-directory-table .semi-table-tbody > .semi-table-row { - cursor: default; + cursor: pointer; transition: background-color .15s ease; } -.v2-vehicle-directory-table .semi-table-tbody > .semi-table-row:hover { +.v2-vehicle-directory-table .semi-table-tbody > .semi-table-row:hover, +.v2-vehicle-directory-table .semi-table-tbody > .semi-table-row:focus-visible { background: #f7faff; } +.v2-vehicle-directory-table .semi-table-tbody > .semi-table-row:focus-visible { + outline: 2px solid rgba(18, 104, 243, .28); + outline-offset: -2px; +} + .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 { +.v2-vehicle-directory-identity, +.v2-vehicle-directory-profile { display: grid; min-width: 0; gap: 2px; } -.v2-vehicle-directory-identity strong { +.v2-vehicle-directory-identity strong, +.v2-vehicle-directory-profile strong { overflow: hidden; color: #1b2a3e; font-size: 14px; @@ -2091,7 +2126,14 @@ white-space: nowrap; } +.v2-vehicle-directory-profile strong { + color: #40536a; + font-size: 11px; + font-weight: 650; +} + .v2-vehicle-directory-identity small, +.v2-vehicle-directory-profile small, .v2-vehicle-directory-status small { overflow: hidden; color: #7f8da1; @@ -2101,6 +2143,16 @@ white-space: nowrap; } +.v2-vehicle-directory-summary { + display: flex; + align-items: center; + gap: 5px; +} + +.v2-vehicle-directory-summary > .semi-tag { + margin: 0; +} + .v2-vehicle-directory-status { display: flex; min-width: 0; @@ -2148,6 +2200,11 @@ } .v2-vehicle-record-page { + overflow: visible !important; + overflow-anchor: none; +} + +.v2-content { overflow-anchor: none; } @@ -2236,9 +2293,14 @@ @media (max-width: 700px) { .v2-vehicle-search-page { gap: 8px; + overflow: visible; padding: 9px 8px 92px; } + .v2-vehicle-discovery-shell { + display: contents; + } + .v2-vehicle-command-bar.is-mobile-admin { min-height: 50px; border-color: #dce6f2; @@ -2309,6 +2371,10 @@ margin-left: auto; } + .v2-vehicle-directory-summary > .is-secondary { + display: none; + } + .v2-track-detail-sidesheet .semi-sidesheet-inner { overflow: hidden; border-radius: 18px 18px 0 0;