support mileage batch multi-select
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -3,7 +3,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Truck, Filter, ChevronDown,
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download, Check,
|
||||
} from 'lucide-react';
|
||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||
import { fetchMonitoring } from './api';
|
||||
@@ -98,6 +98,129 @@ const SearchableSelect = ({
|
||||
);
|
||||
};
|
||||
|
||||
const BatchMultiSelect = ({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
options: string[],
|
||||
selected: string[],
|
||||
onChange: (val: string[]) => void,
|
||||
placeholder: string
|
||||
}) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return options;
|
||||
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
|
||||
}, [options, search]);
|
||||
const label = selected.length === 0
|
||||
? placeholder
|
||||
: selected.length === options.length
|
||||
? '全部批次'
|
||||
: selected.length === 1
|
||||
? selected[0]
|
||||
: `已选 ${selected.length} 个批次`;
|
||||
|
||||
const toggle = (opt: string) => {
|
||||
if (selectedSet.has(opt)) {
|
||||
onChange(selected.filter(item => item !== opt));
|
||||
} else {
|
||||
onChange([...selected, opt]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 pl-2 pr-6 text-left text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20"
|
||||
onClick={() => setIsOpen(open => !open)}
|
||||
>
|
||||
<span className="block truncate">{label}</span>
|
||||
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
</button>
|
||||
|
||||
<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 overflow-hidden"
|
||||
>
|
||||
<div className="p-2 border-b border-slate-50">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
|
||||
placeholder="搜索批次"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-slate-50">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-2 py-1 text-[10px] font-bold text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
onClick={() => onChange(options)}
|
||||
>
|
||||
全选批次
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-2 py-1 text-[10px] font-bold text-slate-400 hover:bg-slate-50 rounded-lg"
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
不限
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-44 overflow-y-auto">
|
||||
{filtered.map((opt: string) => {
|
||||
const checked = selectedSet.has(opt);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={opt}
|
||||
className="w-full px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50 flex items-center justify-between gap-2 text-left"
|
||||
onClick={() => toggle(opt)}
|
||||
>
|
||||
<span className="truncate">{opt}</span>
|
||||
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 ${checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200 text-transparent'}`}>
|
||||
<Check size={10} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
|
||||
无匹配项
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MonitoringView() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterDept, setFilterDept] = useState('All');
|
||||
@@ -117,7 +240,7 @@ export default function MonitoringView() {
|
||||
const [filterEntity, setFilterEntity] = useState('All');
|
||||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
||||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
||||
const [filterTargetName, setFilterTargetName] = useState('All');
|
||||
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
|
||||
const [filterRegion, setFilterRegion] = useState('All');
|
||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||
@@ -145,9 +268,9 @@ export default function MonitoringView() {
|
||||
|
||||
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
|
||||
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|
||||
|| HIGH_MILEAGE_ALERT_TARGETS.has(filterTargetName);
|
||||
|| filterTargetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name));
|
||||
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
|
||||
}, [filterTargetName]);
|
||||
}, [filterTargetNames]);
|
||||
|
||||
// 加载首页数据
|
||||
const loadFirstPage = useCallback(() => {
|
||||
@@ -164,7 +287,7 @@ export default function MonitoringView() {
|
||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
@@ -178,7 +301,7 @@ export default function MonitoringView() {
|
||||
setPage(1);
|
||||
setHasMore(d.page < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setPageLoading(false));
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
@@ -197,7 +320,7 @@ export default function MonitoringView() {
|
||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
@@ -208,7 +331,7 @@ export default function MonitoringView() {
|
||||
setPage(nextPage);
|
||||
setHasMore(nextPage < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||||
|
||||
// 筛选/排序变化时重新加载
|
||||
useEffect(() => {
|
||||
@@ -240,7 +363,7 @@ export default function MonitoringView() {
|
||||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
mileageMin: appliedMileageRange.min || undefined,
|
||||
@@ -253,7 +376,7 @@ export default function MonitoringView() {
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||||
|
||||
// 每分钟自动刷新
|
||||
useEffect(() => {
|
||||
@@ -319,7 +442,7 @@ export default function MonitoringView() {
|
||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||||
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
|
||||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||||
date: filterDate || undefined,
|
||||
@@ -328,7 +451,7 @@ export default function MonitoringView() {
|
||||
setFullscreenStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, filterDate, fullscreenRefresh]);
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, filterDate, fullscreenRefresh]);
|
||||
|
||||
// 全屏时禁止背景滚动
|
||||
useEffect(() => {
|
||||
@@ -415,14 +538,14 @@ export default function MonitoringView() {
|
||||
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => setFilterTargetName('All')}
|
||||
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === 'All' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
||||
onClick={() => setFilterTargetNames([])}
|
||||
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.length === 0 ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
||||
>全部</button>
|
||||
{filterOptions.targetNames.map(n => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setFilterTargetName(filterTargetName === n ? 'All' : n)}
|
||||
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === n ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
||||
onClick={() => setFilterTargetNames(prev => prev.includes(n) ? prev.filter(item => item !== n) : [...prev, n])}
|
||||
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.includes(n) ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
||||
>{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -629,10 +752,10 @@ export default function MonitoringView() {
|
||||
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||
<SearchableSelect
|
||||
<BatchMultiSelect
|
||||
options={filterOptions.targetNames}
|
||||
value={filterTargetName}
|
||||
onChange={setFilterTargetName}
|
||||
selected={filterTargetNames}
|
||||
onChange={setFilterTargetNames}
|
||||
placeholder="批次型号"
|
||||
/>
|
||||
<SearchableSelect
|
||||
@@ -651,7 +774,7 @@ export default function MonitoringView() {
|
||||
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
</button>
|
||||
@@ -799,7 +922,7 @@ export default function MonitoringView() {
|
||||
setFilterProject('All');
|
||||
setFilterEntity('All');
|
||||
setFilterPlatePrefix('All');
|
||||
setFilterTargetName('All');
|
||||
setFilterTargetNames([]);
|
||||
setFilterRegion('All');
|
||||
setFilterMileageRange({ min: '', max: '' });
|
||||
setAppliedMileageRange({ min: '', max: '' });
|
||||
@@ -826,7 +949,10 @@ export default function MonitoringView() {
|
||||
{/* Active Filter Tags */}
|
||||
{(() => {
|
||||
const tags: { label: string; onClear: () => void }[] = [];
|
||||
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
|
||||
if (filterTargetNames.length > 0) tags.push({
|
||||
label: filterTargetNames.length === filterOptions.targetNames.length ? '批次: 全部批次' : `批次: ${filterTargetNames.length === 1 ? filterTargetNames[0] : `${filterTargetNames[0]} 等${filterTargetNames.length}`}`,
|
||||
onClear: () => setFilterTargetNames([])
|
||||
});
|
||||
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') });
|
||||
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]} 等${filterPlates.length}`}`, onClear: () => setFilterPlates([]) });
|
||||
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
||||
@@ -842,7 +968,7 @@ export default function MonitoringView() {
|
||||
if (tags.length === 0) return null;
|
||||
const clearAll = () => {
|
||||
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
||||
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); setFilterRegion('All');
|
||||
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All');
|
||||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||||
setFilterDate('');
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function fetchMonitoring(params?: {
|
||||
rentStatus?: string;
|
||||
platePrefix?: string;
|
||||
targetName?: string;
|
||||
targetNames?: string[];
|
||||
region?: string;
|
||||
plate?: string;
|
||||
mileageMin?: string;
|
||||
@@ -35,6 +36,11 @@ export async function fetchMonitoring(params?: {
|
||||
if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
|
||||
if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
|
||||
if (params?.targetName) query.set('targetName', params.targetName);
|
||||
if (params?.targetNames) {
|
||||
params.targetNames.forEach(name => {
|
||||
if (name) query.append('targetName', name);
|
||||
});
|
||||
}
|
||||
if (params?.region) query.set('region', params.region);
|
||||
if (params?.plate) query.set('plate', params.plate);
|
||||
if (params?.mileageMin) query.set('mileageMin', params.mileageMin);
|
||||
|
||||
@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
|
||||
function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
search: string; dept: string; customer: string; project: string;
|
||||
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
||||
targetName: string; region: string; mileageMin: string; mileageMax: string;
|
||||
targetNames: string[]; region: string; mileageMin: string; mileageMax: string;
|
||||
}): CachedVehicle[] {
|
||||
let result = vehicles;
|
||||
|
||||
@@ -42,10 +42,9 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
}
|
||||
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
|
||||
if (params.region) result = result.filter(v => v.region === params.region);
|
||||
if (params.targetName) {
|
||||
const cache = getCache();
|
||||
const tPlates = cache?.targetPlatesMap.get(params.targetName);
|
||||
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
|
||||
if (params.targetNames.length > 0) {
|
||||
const selectedTargets = new Set(params.targetNames);
|
||||
result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName)));
|
||||
}
|
||||
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
|
||||
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
|
||||
@@ -53,6 +52,18 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseTargetNames(reqUrl: string): string[] {
|
||||
const params = new URL(reqUrl).searchParams;
|
||||
const raw = [
|
||||
...params.getAll('targetName'),
|
||||
...params.getAll('targetNames'),
|
||||
];
|
||||
const names = raw.flatMap(item => item.split(','))
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(names));
|
||||
}
|
||||
|
||||
app.get('/', async (c) => {
|
||||
const sortBy = c.req.query('sortBy') || 'today';
|
||||
const sortOrder = c.req.query('sortOrder') || 'desc';
|
||||
@@ -69,7 +80,7 @@ app.get('/', async (c) => {
|
||||
rentStatus: c.req.query('rentStatus') || '',
|
||||
plate: c.req.query('plate') || '',
|
||||
platePrefix: c.req.query('platePrefix') || '',
|
||||
targetName: c.req.query('targetName') || '',
|
||||
targetNames: parseTargetNames(c.req.url),
|
||||
region: c.req.query('region') || '',
|
||||
mileageMin: c.req.query('mileageMin') || '',
|
||||
mileageMax: c.req.query('mileageMax') || '',
|
||||
|
||||
Reference in New Issue
Block a user