feat(web): add visible batch vehicle search
This commit is contained in:
@@ -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 } } });
|
||||
|
||||
@@ -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="车辆整体统计">
|
||||
{[
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user