feat: unify monitor action workbenches

This commit is contained in:
lingniu
2026-07-19 08:11:34 +08:00
parent af71468e63
commit 49ae75937e
4 changed files with 223 additions and 33 deletions

View File

@@ -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 <Modal
className="v2-batch-search-modal"
return <WorkspaceSideSheet
className="v2-monitor-batch-sidesheet"
variant="editor"
visible
centered
closeOnEsc
maskClosable
width={520}
title={<div className="v2-batch-search-title"><strong id="batch-search-title"></strong><span> Excel</span></div>}
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={<IconSearch />}
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: <IconSearch />, onClick: () => onApply(terms.join('')) }}
onCancel={onClose}
footer={<div className="v2-batch-search-actions"><Button onClick={onClose}></Button><Button theme="solid" disabled={!terms.length} onClick={() => onApply(terms.join(''))}>{terms.length}</Button></div>}
>
<div className="v2-batch-search-dialog">
<label htmlFor="batch-vehicle-search"></label>
<TextArea id="batch-vehicle-search" autoFocus value={draft} onChange={setDraft} autosize={{ minRows: 8, maxRows: 14 }} resize="vertical" placeholder={'粤A12345\n粤B67890\n粤C24680'} />
<div className="v2-batch-search-summary"><span> <strong>{terms.length}</strong> </span><em> {MAX_MONITOR_SEARCH_TERMS} </em></div>
<div className="v2-batch-search-summary" role="status"><span> <strong>{terms.length}</strong> </span><em> {MAX_MONITOR_SEARCH_TERMS} </em></div>
</div>
</Modal>;
</WorkspaceSideSheet>;
}
type AddressCoordinate = { longitude: number; latitude: number; key: string };
@@ -135,15 +150,12 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mo
</Card>;
}
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 <Modal
className="v2-monitor-qr-modal"
const qrState = qr ? '已生成' : error ? '生成失败' : '生成中';
const copyLabel = copyState === 'copied' ? '已复制访问地址' : copyState === 'error' ? '复制失败,重试' : '复制访问地址';
return <WorkspaceSideSheet
className="v2-monitor-qr-sidesheet"
variant="action"
visible
centered
width={390}
ariaLabel="手机端全局监控"
closeLabel="关闭手机端入口"
dialogId="v2-monitor-mobile-entry"
placement={mobile ? 'bottom' : 'right'}
width={mobile ? undefined : 430}
height={mobile ? 'min(82dvh, 566px)' : undefined}
title="手机端全局监控"
closeIcon={<IconClose aria-label="关闭手机端入口" />}
closeOnEsc
maskClosable
description="扫码后使用现有账号进入全局监控"
icon={<IconQrCode />}
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={<Button block theme="solid" aria-live="polite" onClick={() => void copyURL()}>{copyState === 'copied' ? '已复制访问地址' : copyState === 'error' ? '复制失败,重试' : '复制访问地址'}</Button>}
>
<div className="v2-monitor-qr-content">
<span className="v2-monitor-qr-icon"><IconQrCode /></span>
<p>使 Token</p>
<p>使 Token</p>
{qr ? <img src={qr} alt="全局监控手机端二维码" /> : error ? <div className="v2-monitor-qr-error" role="alert"><span>{error}</span><Button theme="light" type="danger" onClick={() => setAttempt((value) => value + 1)}></Button></div> : <span className="v2-spinner" role="status" aria-label="正在生成二维码" />}
<code>{url}</code>
<span className="v2-sr-only" aria-live="polite">{copyLabel}</span>
</div>
</Modal>;
</WorkspaceSideSheet>;
}
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
@@ -380,7 +407,7 @@ export default function MonitorPage() {
<Button className="v2-monitor-mobile-entry" theme="light" icon={<IconQrCode />} aria-label="打开手机端入口" onClick={() => setMobileEntryOpen(true)}><span className="v2-monitor-mobile-entry-label"></span></Button>
</div>
</Card>
{batchSearchOpen ? <BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
{batchSearchOpen ? <BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} mobile={mobileLayout} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
<Card className="v2-kpis" bodyStyle={{ padding: 0 }} aria-label="车辆整体统计">
{[
@@ -450,7 +477,7 @@ export default function MonitorPage() {
</div>
<time dateTime={lastSyncAt?.toISOString()}><span><small></small><b>{lastSyncAt ? lastSyncAt.toLocaleTimeString('zh-CN', { hour12: false }) : '等待数据'}</b></span></time>
</Card>
{mobileEntryOpen ? <MobileEntry onClose={() => setMobileEntryOpen(false)} /> : null}
{mobileEntryOpen ? <MobileEntry mobile={mobileLayout} onClose={() => setMobileEntryOpen(false)} /> : null}
</div>
);
}

View File

@@ -454,8 +454,10 @@ describe('V2 production entry', () => {
expect(workspaceMetricRailSource).toContain('<CardGroup className={secondaryClassName}');
expect(corePageSources.MonitorPage).toContain('<Input');
expect(corePageSources.MonitorPage).toContain('<Select');
expect(corePageSources.MonitorPage).toContain('<Modal');
expect(corePageSources.MonitorPage).toContain('v2-monitor-qr-modal');
expect(corePageSources.MonitorPage).toContain('<WorkspaceSideSheet');
expect(corePageSources.MonitorPage).toContain('v2-monitor-batch-sidesheet');
expect(corePageSources.MonitorPage).toContain('v2-monitor-qr-sidesheet');
expect(corePageSources.MonitorPage).not.toContain('<Modal');
expect(corePageSources.MonitorPage).not.toContain('v2-monitor-qr-backdrop');
expect(corePageSources.MonitorPage).toContain('<TextArea');
expect(corePageSources.MonitorPage).toContain('<SegmentedTabs');

View File

@@ -36,6 +36,7 @@ export type WorkspaceSideSheetProps = {
placement?: 'left' | 'right' | 'top' | 'bottom';
width?: string | number;
height?: string | number;
closeOnEsc?: boolean;
summaryItems?: WorkspaceSideSheetSummaryItem[];
footerNote?: ReactNode;
secondaryActions?: WorkspaceSideSheetAction[];
@@ -59,6 +60,7 @@ export function WorkspaceSideSheet({
placement = 'right',
width = 460,
height,
closeOnEsc = true,
summaryItems = [],
footerNote,
secondaryActions = [],
@@ -89,6 +91,7 @@ export function WorkspaceSideSheet({
visible={visible}
aria-label={ariaLabel}
placement={placement}
closeOnEsc={closeOnEsc}
width={placement === 'left' || placement === 'right' ? width : undefined}
height={placement === 'top' || placement === 'bottom' ? height : undefined}
title={<div className="v2-workspace-config-title">

View File

@@ -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