feat(mileage): 外部三选筛选 + 车牌多选粘贴 + 运营区域 + xlsx 下载
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 外部行改为 批次型号 / 运营区域 / 车牌多选;按部门、按客户移入详情面板 - 车牌多选支持从 Excel 粘贴(换行/逗号/空格分隔),未匹配项显示警告 - 新增运营区域筛选:基于 136 批次区域映射(华东/华南/西南/西北) - 新增 xlsx 数据下载,导出当前筛选结果(带表头样式与列宽) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
198
src/modules/mileage/PlateMultiSelect.tsx
Normal file
198
src/modules/mileage/PlateMultiSelect.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
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 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl w-[280px] max-w-[calc(100vw-32px)]"
|
||||
style={{ minWidth: '100%' }}
|
||||
>
|
||||
<div className="p-2 space-y-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const pasted = e.clipboardData.getData('text');
|
||||
if (pasted && /[\s,;,;、]/.test(pasted)) {
|
||||
e.preventDefault();
|
||||
apply(text + (text ? ' ' : '') + pasted);
|
||||
}
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user