feat(mileage): 外部三选筛选 + 车牌多选粘贴 + 运营区域 + xlsx 下载
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:
kkfluous
2026-04-29 15:19:00 +08:00
parent d1acdafa7e
commit 3809e785c1
11 changed files with 670 additions and 72 deletions

View 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="粘贴或输入车牌&#10;支持换行/逗号/空格分隔"
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>
);
}