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>
);
}