All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 弹窗改为 right-0 锚右,宽度 min(280px, vw-24px),避免在窄列触发器下右侧溢出 - 移除 onPaste 自动 apply(避免与已输入文本拼接出非预期 token),改为粘贴入框 + 点击「添加」或 Cmd/Ctrl+Enter 确认 - placeholder 文案补充提示 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
7.8 KiB
TypeScript
198 lines
7.8 KiB
TypeScript
import { useState, useRef, useEffect, useMemo } from 'react';
|
||
import { motion, AnimatePresence } from 'motion/react';
|
||
import { ChevronDown, X, AlertTriangle } from 'lucide-react';
|
||
|
||
interface Props {
|
||
allPlates: string[];
|
||
selected: string[];
|
||
onChange: (plates: string[]) => void;
|
||
placeholder?: string;
|
||
}
|
||
|
||
function parseInput(text: string): string[] {
|
||
return text
|
||
.split(/[\s,;,;、]+/)
|
||
.map(s => s.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
export default function PlateMultiSelect({ allPlates, selected, onChange, placeholder = '按车牌(可多选/粘贴)' }: Props) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [text, setText] = useState('');
|
||
const [search, setSearch] = useState('');
|
||
const [unmatched, setUnmatched] = useState<string[]>([]);
|
||
const wrapRef = useRef<HTMLDivElement>(null);
|
||
|
||
const allSet = useMemo(() => new Set(allPlates), [allPlates]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setIsOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [isOpen]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!search) return allPlates.slice(0, 200);
|
||
const q = search.toLowerCase();
|
||
return allPlates.filter(p => p.toLowerCase().includes(q)).slice(0, 200);
|
||
}, [allPlates, search]);
|
||
|
||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||
|
||
const apply = (input: string) => {
|
||
const tokens = parseInput(input);
|
||
if (tokens.length === 0) return;
|
||
const matched: string[] = [];
|
||
const missed: string[] = [];
|
||
const seen = new Set(selected);
|
||
for (const t of tokens) {
|
||
if (allSet.has(t)) {
|
||
if (!seen.has(t)) {
|
||
matched.push(t);
|
||
seen.add(t);
|
||
}
|
||
} else {
|
||
missed.push(t);
|
||
}
|
||
}
|
||
if (matched.length > 0) onChange([...selected, ...matched]);
|
||
setUnmatched(missed);
|
||
setText('');
|
||
};
|
||
|
||
const togglePlate = (plate: string) => {
|
||
if (selectedSet.has(plate)) {
|
||
onChange(selected.filter(p => p !== plate));
|
||
} else {
|
||
onChange([...selected, plate]);
|
||
}
|
||
};
|
||
|
||
const removePlate = (plate: string) => {
|
||
onChange(selected.filter(p => p !== plate));
|
||
};
|
||
|
||
const clearAll = () => {
|
||
onChange([]);
|
||
setUnmatched([]);
|
||
setText('');
|
||
};
|
||
|
||
const display = selected.length === 0
|
||
? placeholder
|
||
: selected.length === 1
|
||
? selected[0]
|
||
: `${selected[0]} 等 ${selected.length} 个车牌`;
|
||
|
||
return (
|
||
<div className="relative" ref={wrapRef}>
|
||
<div
|
||
onClick={() => setIsOpen(o => !o)}
|
||
className={`w-full bg-slate-50 rounded-lg py-1.5 px-2 text-[10px] font-bold cursor-pointer flex items-center justify-between gap-1 ${selected.length > 0 ? 'text-blue-600 ring-1 ring-blue-200' : 'text-slate-600'}`}
|
||
>
|
||
<span className="truncate">{display}</span>
|
||
<ChevronDown size={10} className="text-slate-400 flex-shrink-0" />
|
||
</div>
|
||
|
||
<AnimatePresence>
|
||
{isOpen && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -5 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -5 }}
|
||
className="absolute z-50 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl"
|
||
style={{ width: 'min(280px, calc(100vw - 24px))', minWidth: '100%' }}
|
||
>
|
||
<div className="p-2 space-y-2">
|
||
<textarea
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
placeholder="粘贴或输入车牌 支持换行/逗号/空格分隔,回车或点添加确认"
|
||
className="w-full bg-slate-50 border-none rounded-lg p-2 text-[11px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30 resize-none"
|
||
rows={3}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault();
|
||
apply(text);
|
||
}
|
||
}}
|
||
/>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-[10px] text-slate-400">已选 <span className="font-bold text-blue-600">{selected.length}</span> 个</span>
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => apply(text)}
|
||
disabled={!text.trim()}
|
||
className="px-2 py-1 bg-blue-600 text-white rounded-md text-[10px] font-bold disabled:bg-slate-200 disabled:text-slate-400"
|
||
>添加</button>
|
||
<button
|
||
onClick={clearAll}
|
||
className="px-2 py-1 bg-slate-100 text-slate-500 rounded-md text-[10px] font-bold"
|
||
>清空</button>
|
||
</div>
|
||
</div>
|
||
|
||
{unmatched.length > 0 && (
|
||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 space-y-1">
|
||
<div className="flex items-center gap-1 text-amber-700">
|
||
<AlertTriangle size={10} />
|
||
<span className="text-[10px] font-bold">{unmatched.length} 个车牌未匹配</span>
|
||
<button
|
||
onClick={() => setUnmatched([])}
|
||
className="ml-auto text-amber-500 hover:text-amber-700"
|
||
><X size={10} /></button>
|
||
</div>
|
||
<div className="text-[10px] text-amber-600 break-all max-h-16 overflow-y-auto leading-relaxed">
|
||
{unmatched.join(',')}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selected.length > 0 && (
|
||
<div className="flex flex-wrap gap-1 max-h-20 overflow-y-auto p-1 bg-slate-50 rounded-lg">
|
||
{selected.map(p => (
|
||
<span key={p} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white border border-blue-100 text-blue-600 rounded text-[10px] font-bold">
|
||
{p}
|
||
<button onClick={() => removePlate(p)} className="text-blue-400 hover:text-blue-700"><X size={9} /></button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="border-t border-slate-100 pt-2">
|
||
<input
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="搜索车牌"
|
||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||
/>
|
||
<div className="mt-1 max-h-40 overflow-y-auto">
|
||
{filtered.map(p => (
|
||
<div
|
||
key={p}
|
||
onClick={() => togglePlate(p)}
|
||
className={`px-2 py-1 text-[10px] font-bold cursor-pointer flex items-center gap-1.5 rounded ${selectedSet.has(p) ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'}`}
|
||
>
|
||
<span className={`w-3 h-3 rounded border ${selectedSet.has(p) ? 'bg-blue-600 border-blue-600' : 'border-slate-300'} flex items-center justify-center`}>
|
||
{selectedSet.has(p) && <span className="text-white text-[8px] leading-none">✓</span>}
|
||
</span>
|
||
{p}
|
||
</div>
|
||
))}
|
||
{filtered.length === 0 && (
|
||
<div className="px-2 py-1 text-[10px] text-slate-300 italic">无匹配项</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|