diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
index 28662e24..9151bf8f 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
@@ -1,10 +1,10 @@
import {
- IconChevronLeft, IconClose, IconFilter, IconList, IconMapPin,
+ IconChevronLeft, IconFilter, IconList, IconMapPin,
IconQrCode, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
-import { Button, Card, Empty, Input, Modal, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
+import { Button, Card, Empty, Input, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
-import { lazy, memo, Suspense, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { lazy, memo, Suspense, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { Page, VehicleRealtimeRow } from '../../api/types';
@@ -14,6 +14,7 @@ import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { ProtocolTag } from '../shared/ProtocolTag';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
+import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { formatNumber, statusLabel, vehicleStatus } from '../domain/monitor';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, type MonitorViewport } from '../hooks/useMonitorData';
@@ -29,27 +30,41 @@ const MONITOR_VIEW_ITEMS = [
] as const;
const VehicleDetailCard = lazy(() => import('./MonitorVehicleDetailCard'));
-function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
+function BatchVehicleSearchDialog({ initialValue, mobile, onApply, onClose }: { initialValue: string; mobile: boolean; onApply: (value: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(initialValue);
const terms = useMemo(() => parseMonitorSearchTerms(draft), [draft]);
- return 批量搜索车辆从 Excel、文本或聊天记录中直接粘贴车牌}
+ ariaLabel="批量搜索车辆"
+ closeLabel="关闭批量搜索车辆"
+ dialogId="v2-monitor-batch-search"
+ placement={mobile ? 'bottom' : 'right'}
+ width={mobile ? undefined : 520}
+ height={mobile ? 'min(86dvh, 700px)' : undefined}
+ title="批量搜索车辆"
+ description="从 Excel、文本或聊天记录中直接粘贴车牌"
+ icon={}
+ badge={`${terms.length} 辆`}
+ badgeColor={terms.length ? 'blue' : 'grey'}
+ summaryItems={[
+ { label: '已识别', value: terms.length.toLocaleString('zh-CN'), detail: '可直接应用到监控筛选', tone: terms.length ? 'primary' : 'neutral' },
+ { label: '重复处理', value: '自动去重', detail: '相同车牌只保留一次', tone: 'success' },
+ { label: '单次上限', value: `${MAX_MONITOR_SEARCH_TERMS} 辆`, detail: '超出部分不会进入查询' }
+ ]}
+ footerNote="支持换行、空格、逗号或分号分隔。"
+ secondaryActions={[{ label: '取消', onClick: onClose }]}
+ primaryAction={{ label: `应用搜索(${terms.length})`, ariaLabel: `应用搜索(${terms.length})`, disabled: !terms.length, icon: , onClick: () => onApply(terms.join(',')) }}
onCancel={onClose}
- footer={}
>
-
已识别 {terms.length} 辆,重复项已自动去除最多 {MAX_MONITOR_SEARCH_TERMS} 辆
+
已识别 {terms.length} 辆,重复项已自动去除最多 {MAX_MONITOR_SEARCH_TERMS} 辆
- ;
+ ;
}
type AddressCoordinate = { longitude: number; latitude: number; key: string };
@@ -135,15 +150,12 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo
;
}
-function MobileEntry({ onClose }: { onClose: () => void }) {
+function MobileEntry({ mobile, onClose }: { mobile: boolean; onClose: () => void }) {
const [qr, setQr] = useState('');
const [error, setError] = useState('');
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle');
const [attempt, setAttempt] = useState(0);
const url = `${window.location.origin}/monitor`;
- useLayoutEffect(() => {
- document.querySelector('.v2-monitor-qr-modal .semi-modal-close')?.setAttribute('aria-label', '关闭手机端入口');
- }, []);
useEffect(() => {
if (copyState === 'idle') return;
const timer = window.setTimeout(() => setCopyState('idle'), 1_800);
@@ -168,25 +180,40 @@ function MobileEntry({ onClose }: { onClose: () => void }) {
setCopyState('error');
}
};
- return }
- closeOnEsc
- maskClosable
+ description="扫码后使用现有账号进入全局监控"
+ icon={}
+ badge="账号鉴权"
+ badgeColor="blue"
+ summaryItems={[
+ { label: '入口页面', value: '全局监控', detail: '移动端响应式布局', tone: 'primary' },
+ { label: '凭证安全', value: '不含 Token', detail: '扫码后仍需账号鉴权', tone: 'success' },
+ { label: '二维码状态', value: qrState, detail: error ? '可在下方重新生成' : '仅在打开入口时生成', tone: error ? 'danger' : qr ? 'success' : 'neutral' }
+ ]}
+ footerNote={url}
+ secondaryActions={[{ label: '关闭', onClick: onClose }]}
+ primaryAction={{ label: copyLabel, loading: !qr && !error, disabled: !qr && !error, onClick: () => void copyURL() }}
onCancel={onClose}
- footer={}
>
-
-
扫码后使用现有账号鉴权,不在二维码中保存 Token
+
扫码后使用现有账号鉴权,不在二维码中保存 Token。
{qr ?

: error ?
{error}
:
}
{url}
+
{copyLabel}
- ;
+ ;
}
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
@@ -380,7 +407,7 @@ export default function MonitorPage() {
} aria-label="打开手机端入口" onClick={() => setMobileEntryOpen(true)}>手机端
- {batchSearchOpen ? setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
+ {batchSearchOpen ? setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
{[
@@ -450,7 +477,7 @@ export default function MonitorPage() {
- {mobileEntryOpen ? setMobileEntryOpen(false)} /> : null}
+ {mobileEntryOpen ? setMobileEntryOpen(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 705cd075..3184df98 100644
--- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
+++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
@@ -454,8 +454,10 @@ describe('V2 production entry', () => {
expect(workspaceMetricRailSource).toContain('
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 a70e2159..e4032702 100644
--- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
+++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
@@ -220,6 +220,164 @@
}
}
+/*
+ * Global monitor action workbenches.
+ * Batch vehicle input and the mobile handoff now share the same Semi UI
+ * identity, summary, scrolling and footer hierarchy as the rest of the app.
+ */
+.v2-monitor-batch-sidesheet .v2-workspace-config-content,
+.v2-monitor-qr-sidesheet .v2-workspace-config-content {
+ padding: 12px;
+}
+
+.v2-monitor-batch-sidesheet .v2-batch-search-dialog {
+ display: grid;
+ width: 100%;
+ align-content: start;
+ gap: 10px;
+ border: 1px solid #dce5ef;
+ border-radius: 12px;
+ background: #fff;
+ padding: 14px;
+ box-shadow: 0 7px 22px rgba(31, 53, 80, .05);
+}
+
+.v2-monitor-batch-sidesheet .v2-batch-search-dialog > label {
+ margin: 0;
+ color: #4d627a;
+ font-size: 11px;
+ font-weight: 680;
+}
+
+.v2-monitor-batch-sidesheet .v2-batch-search-dialog .semi-input-textarea-wrapper {
+ min-height: min(420px, 52dvh);
+ border-color: #d4dfeb;
+ border-radius: 10px;
+ background: #fbfcfe;
+}
+
+.v2-monitor-batch-sidesheet .v2-batch-search-dialog .semi-input-textarea-wrapper-focus {
+ border-color: #4386df;
+ background: #fff;
+ box-shadow: 0 0 0 3px rgba(45, 116, 210, .09);
+}
+
+.v2-monitor-batch-sidesheet .v2-batch-search-dialog .semi-input-textarea {
+ min-height: min(418px, 52dvh);
+ padding: 13px 14px;
+ font-size: 14px;
+ line-height: 1.75;
+}
+
+.v2-monitor-batch-sidesheet .v2-batch-search-summary {
+ display: flex;
+ min-height: 34px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin: 0;
+ border-radius: 8px;
+ background: #f4f8fe;
+ padding: 7px 9px;
+ color: #66788e;
+ font-size: 9px;
+}
+
+.v2-monitor-batch-sidesheet .v2-batch-search-summary strong {
+ color: #1268f3;
+ font-size: 12px;
+}
+
+.v2-monitor-qr-sidesheet .v2-monitor-qr-content {
+ min-height: 100%;
+ justify-content: flex-start;
+ overflow: hidden;
+ border: 1px solid #dce5ef;
+ border-radius: 12px;
+ background:
+ radial-gradient(circle at 50% 0%, rgba(18, 104, 243, .07), transparent 38%),
+ #fff;
+ padding: 15px;
+ box-shadow: 0 7px 22px rgba(31, 53, 80, .05);
+}
+
+.v2-monitor-qr-sidesheet .v2-monitor-qr-content > p {
+ max-width: 310px;
+ margin: 0 0 12px;
+ color: #66788e;
+ font-size: 10px;
+ line-height: 1.6;
+}
+
+.v2-monitor-qr-sidesheet .v2-monitor-qr-content > img {
+ width: min(240px, 64vw);
+ height: min(240px, 64vw);
+ border: 8px solid #fff;
+ border-radius: 14px;
+ box-shadow: 0 9px 28px rgba(27, 50, 78, .09);
+}
+
+.v2-monitor-qr-sidesheet .v2-monitor-qr-content > code {
+ max-width: 100%;
+ margin-top: 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #f7f9fc;
+ padding: 9px 11px;
+ color: #5c6d82;
+}
+
+.v2-monitor-qr-sidesheet .v2-monitor-qr-content > .v2-spinner {
+ margin: 96px;
+}
+
+@media (max-width: 680px) {
+ .v2-monitor-batch-sidesheet .v2-workspace-config-content,
+ .v2-monitor-qr-sidesheet .v2-workspace-config-content {
+ padding: 8px;
+ }
+
+ .v2-monitor-batch-sidesheet .v2-batch-search-dialog {
+ gap: 8px;
+ padding: 10px;
+ }
+
+ .v2-monitor-batch-sidesheet .v2-batch-search-dialog .semi-input-textarea-wrapper,
+ .v2-monitor-batch-sidesheet .v2-batch-search-dialog .semi-input-textarea {
+ min-height: min(300px, 35dvh);
+ }
+
+ .v2-monitor-batch-sidesheet .v2-batch-search-dialog .semi-input-textarea {
+ padding: 10px 11px;
+ font-size: 16px;
+ }
+
+ .v2-monitor-qr-sidesheet .v2-workspace-config-summary > span:nth-child(2) strong {
+ font-size: 10px;
+ }
+
+ .v2-monitor-qr-sidesheet .v2-monitor-qr-content {
+ padding: 10px;
+ }
+
+ .v2-monitor-qr-sidesheet .v2-monitor-qr-content > p {
+ margin-bottom: 8px;
+ font-size: 9px;
+ }
+
+ .v2-monitor-qr-sidesheet .v2-monitor-qr-content > img {
+ width: min(210px, 56vw);
+ height: min(210px, 56vw);
+ border-width: 6px;
+ }
+
+ .v2-monitor-qr-sidesheet .v2-monitor-qr-content > code {
+ margin-top: 8px;
+ padding-block: 7px;
+ font-size: 8px;
+ }
+}
+
/*
* Global short-landscape entry and navigation.
* Keep every authentication action in the first viewport, preserve practical