feat(web): add visible batch vehicle search

This commit is contained in:
lingniu
2026-07-16 02:02:56 +08:00
parent c493df37f1
commit 1e80278fee
3 changed files with 67 additions and 2 deletions

View File

@@ -179,6 +179,26 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as
});
});
test('opens an explicit batch editor and applies copied plate rows', async () => {
const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(screen.getByRole('button', { name: '批量' }));
const editor = screen.getByRole('textbox', { name: /每行一个车牌/ });
fireEvent.change(editor, { target: { value: '粤a12345\n粤B67890\n粤A12345' } });
expect(screen.getByText('2', { selector: '.v2-batch-search-summary strong' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '应用搜索2' }));
expect(screen.queryByRole('dialog', { name: '批量搜索车辆' })).not.toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('粤A12345粤B67890');
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
await waitFor(() => {
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
});
});
test('hides stale fleet rows while a pasted batch is still loading', () => {
monitorQueryFlags.isPlaceholderData = true;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

View File

@@ -16,6 +16,26 @@ const statuses = ['', 'online', 'offline', 'driving', 'idle'];
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
const MOBILE_MONITOR_QUERY = '(max-width: 760px)';
function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(initialValue);
const terms = useMemo(() => parseMonitorSearchTerms(draft), [draft]);
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [onClose]);
return <div className="v2-batch-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<section className="v2-batch-search-dialog" role="dialog" aria-modal="true" aria-labelledby="batch-search-title">
<header><div><strong id="batch-search-title"></strong><span> Excel</span></div><button type="button" onClick={onClose} aria-label="关闭批量搜索"><IconClose /></button></header>
<label htmlFor="batch-vehicle-search"></label>
<textarea id="batch-vehicle-search" autoFocus value={draft} onChange={(event) => setDraft(event.target.value)} 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>
<footer><button type="button" className="v2-secondary-button" onClick={onClose}></button><button type="button" className="v2-primary-button" disabled={!terms.length} onClick={() => onApply(terms.join(''))}>{terms.length}</button></footer>
</section>
</div>;
}
function useMobileMonitorLayout() {
const [mobile, setMobile] = useState(() => window.matchMedia(MOBILE_MONITOR_QUERY).matches);
useEffect(() => {
@@ -181,6 +201,7 @@ export default function MonitorPage() {
const [listOffset, setListOffset] = useState(0);
const [listLimit, setListLimit] = useState(50);
const [keyword, setKeyword] = useState('');
const [batchSearchOpen, setBatchSearchOpen] = useState(false);
const deferredKeyword = useDeferredValue(keyword);
const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]);
const [protocol, setProtocol] = useState('');
@@ -255,7 +276,7 @@ export default function MonitorPage() {
return (
<div className="v2-monitor-page">
<section className="v2-filterbar" aria-label="车辆筛选">
<label className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
<div className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
<IconSearch />
<input
aria-label="搜索车辆"
@@ -270,8 +291,9 @@ export default function MonitorPage() {
}}
placeholder="车牌 / VIN可批量粘贴车牌"
/>
<button type="button" className="v2-search-batch-action" onClick={() => setBatchSearchOpen(true)}></button>
{searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length}` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
</label>
</div>
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
</select>
@@ -283,6 +305,7 @@ export default function MonitorPage() {
<div className="v2-monitor-mode" aria-label="监控视图"><button type="button" className={mode === 'map' ? 'is-active' : ''} onClick={() => setMode('map')}><IconMapPin /></button><button type="button" className={mode === 'list' ? 'is-active' : ''} onClick={() => { setMode('list'); setDetailOpen(false); }}><IconList /></button></div>
<button type="button" className="v2-monitor-mobile-entry" onClick={() => setMobileEntryOpen(true)}><IconQrCode /></button>
</section>
{batchSearchOpen ? <BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
<section className="v2-kpis" aria-label="车辆整体统计">
{[

View File

@@ -62,6 +62,24 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-search-field.is-batch { border-color: #9fc0f7; background: #fbfdff; }
.v2-search-batch-count { flex: 0 0 auto; border-radius: 999px; background: var(--v2-blue-soft); padding: 3px 7px; color: var(--v2-blue); font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap; }
.v2-search-batch-count.has-missing { background: #fff4e5; color: #b45309; }
.v2-search-batch-action { flex: 0 0 auto; border: 0; border-left: 1px solid #dce4ef; background: transparent; padding: 0 2px 0 10px; color: var(--v2-blue); font-size: 11px; font-weight: 700; white-space: nowrap; cursor: pointer; }
.v2-search-batch-action:hover { color: #124ea1; }
.v2-batch-search-backdrop { position: fixed; z-index: 1300; inset: 0; display: grid; place-items: center; background: rgba(15, 27, 46, .4); padding: 20px; backdrop-filter: blur(3px); }
.v2-batch-search-dialog { width: min(520px, 100%); border: 1px solid rgba(255,255,255,.75); border-radius: 14px; background: #fff; padding: 20px; box-shadow: 0 24px 70px rgba(13, 33, 62, .24); }
.v2-batch-search-dialog > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.v2-batch-search-dialog > header div { display: grid; gap: 4px; }
.v2-batch-search-dialog > header strong { color: #17253a; font-size: 17px; }
.v2-batch-search-dialog > header span { color: #728096; font-size: 12px; }
.v2-batch-search-dialog > header button { display: grid; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 7px; background: #f3f6fa; color: #627086; cursor: pointer; }
.v2-batch-search-dialog > label { display: block; margin-bottom: 8px; color: #536176; font-size: 12px; font-weight: 600; }
.v2-batch-search-dialog textarea { display: block; width: 100%; min-height: 210px; resize: vertical; border: 1px solid #ccd8e7; border-radius: 9px; background: #fbfcfe; padding: 12px 14px; color: #1d2a3d; outline: 0; font: 13px/1.75 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.v2-batch-search-dialog textarea:focus { border-color: #4386df; background: #fff; box-shadow: 0 0 0 3px rgba(45, 116, 210, .1); }
.v2-batch-search-summary { display: flex; justify-content: space-between; gap: 12px; margin-top: 9px; color: #758297; font-size: 11px; }
.v2-batch-search-summary strong { color: var(--v2-blue); font-size: 13px; }
.v2-batch-search-summary em { color: #8994a5; font-style: normal; }
.v2-batch-search-dialog > footer { display: flex; justify-content: flex-end; gap: 9px; margin-top: 18px; }
.v2-batch-search-dialog > footer button { min-width: 92px; }
.v2-batch-search-dialog > footer button:disabled { opacity: .45; cursor: not-allowed; }
.v2-filterbar select { height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 30px 0 11px; color: #4d5c70; outline: 0; font-size: 12px; }
.v2-primary-button, .v2-secondary-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 0 14px; cursor: pointer; font-size: 12px; font-weight: 700; }
.v2-primary-button { border: 1px solid var(--v2-blue); background: var(--v2-blue); color: #fff; box-shadow: 0 5px 12px rgba(18,104,243,.18); }
@@ -1302,6 +1320,10 @@ button, a { -webkit-tap-highlight-color: transparent; }
}
@media (max-width: 760px) {
.v2-batch-search-backdrop { align-items: end; padding: 0; }
.v2-batch-search-dialog { width: 100%; border-radius: 16px 16px 0 0; padding: 18px 16px calc(16px + env(safe-area-inset-bottom)); }
.v2-batch-search-dialog textarea { min-height: 32vh; font-size: 16px; }
.v2-batch-search-dialog > footer button { min-width: 0; flex: 1; height: 44px; }
.v2-access-page-v3 { gap: 9px; padding: 10px 8px 18px; }
.v2-access-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
.v2-access-heading h2 { font-size: 19px; }