feat: unify monitor action workbenches
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user