feat(scheduling): add advanced filter panel matching prototype
- Filter icon in list header with active count badge - Expandable filter panel: plate search, region select, vehicle type select, customer select - FilterSelect component with search for long option lists - Active filter tags shown as removable pills below header - Temp/confirmed filter pattern (edit → confirm/cancel) - Result count displayed when filters active Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Filter, RotateCcw, X, Search, ChevronDown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchSuggestions } from './api';
|
||||
import type { SchedulingResponse, SchedulingSuggestion } from './types';
|
||||
import SuggestionList from './SuggestionList';
|
||||
@@ -7,6 +8,15 @@ import SuggestionDetail from './SuggestionDetail';
|
||||
|
||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||
|
||||
interface AdvancedFilters {
|
||||
plateSearch: string;
|
||||
region: string;
|
||||
vehicleType: string;
|
||||
customer: string;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '' };
|
||||
|
||||
function shortTargetName(name: string): string {
|
||||
const match = name.match(/(\d+)[辆台](.+)/);
|
||||
if (!match) return name;
|
||||
@@ -18,12 +28,91 @@ function shortTargetName(name: string): string {
|
||||
return `${count}台${desc}`;
|
||||
}
|
||||
|
||||
function hasActiveFilters(f: AdvancedFilters): boolean {
|
||||
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
|
||||
}
|
||||
|
||||
/** Dropdown select with search */
|
||||
function FilterSelect({ label, options, value, onChange, placeholder }: {
|
||||
label: string;
|
||||
options: string[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = options.filter(o => o.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="space-y-1">
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold">{label}</label>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2 text-xs text-left cursor-pointer hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<span className={value ? 'text-slate-800 font-medium' : 'text-slate-400'}>{value || placeholder}</span>
|
||||
<ChevronDown size={14} className={`text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="bg-white border border-slate-200 rounded-lg shadow-lg max-h-48 overflow-hidden z-10">
|
||||
{options.length > 5 && (
|
||||
<div className="p-1.5 border-b border-slate-100">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="搜索..."
|
||||
className="w-full pl-7 pr-2 py-1.5 text-xs bg-slate-50 rounded border-none outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-y-auto max-h-36">
|
||||
<button
|
||||
onClick={() => { onChange(''); setOpen(false); setSearch(''); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${!value ? 'text-blue-600 font-bold' : 'text-slate-400'}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{filtered.map(opt => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => { onChange(opt); setOpen(false); setSearch(''); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs hover:bg-slate-50 cursor-pointer ${value === opt ? 'text-blue-600 font-bold bg-blue-50' : 'text-slate-700'}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchedulingModule() {
|
||||
const [data, setData] = useState<SchedulingResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | undefined>(undefined);
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<SchedulingSuggestion | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [filters, setFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
||||
const [tempFilters, setTempFilters] = useState<AdvancedFilters>(EMPTY_FILTERS);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -36,133 +125,309 @@ export default function SchedulingModule() {
|
||||
}, [selectedTargetId]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
||||
|
||||
// Compute filter options from data
|
||||
const filterOptions = useMemo(() => {
|
||||
if (!data) return { regions: [], vehicleTypes: [], customers: [] };
|
||||
const regions = new Set<string>();
|
||||
const vehicleTypes = new Set<string>();
|
||||
const customers = new Set<string>();
|
||||
for (const s of data.suggestions) {
|
||||
const v = s.currentVehicle;
|
||||
if (v.region) regions.add(v.region);
|
||||
if (v.vehicleType) vehicleTypes.add(v.vehicleType);
|
||||
if (v.customer) customers.add(v.customer);
|
||||
}
|
||||
return {
|
||||
regions: Array.from(regions).sort(),
|
||||
vehicleTypes: Array.from(vehicleTypes).sort(),
|
||||
customers: Array.from(customers).sort(),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (typeFilter === 'qualified') return data.suggestions.filter(s => s.type === 'replace_qualified');
|
||||
if (typeFilter === 'hopeless') return data.suggestions.filter(s => s.type === 'rescue_hopeless');
|
||||
return data.suggestions;
|
||||
}, [data, typeFilter]);
|
||||
let list = data.suggestions;
|
||||
|
||||
const cardConfigs = [
|
||||
{
|
||||
key: 'qualified' as TypeFilter,
|
||||
label: '已达标',
|
||||
count: data?.summary.qualifiedCount ?? 0,
|
||||
unit: '台',
|
||||
sub: null,
|
||||
// Muted teal/green
|
||||
idle: 'border-slate-200 bg-white',
|
||||
active: 'border-emerald-500 bg-emerald-50 ring-1 ring-emerald-500/20',
|
||||
labelColor: 'text-slate-500',
|
||||
labelActive: 'text-emerald-600',
|
||||
numColor: 'text-slate-800',
|
||||
numActive: 'text-emerald-700',
|
||||
},
|
||||
{
|
||||
key: 'hopeless' as TypeFilter,
|
||||
label: '无望达标',
|
||||
count: data?.summary.hopelessCount ?? 0,
|
||||
unit: '台',
|
||||
sub: null,
|
||||
// Warm red
|
||||
idle: 'border-slate-200 bg-white',
|
||||
active: 'border-rose-500 bg-rose-50 ring-1 ring-rose-500/20',
|
||||
labelColor: 'text-slate-500',
|
||||
labelActive: 'text-rose-600',
|
||||
numColor: 'text-slate-800',
|
||||
numActive: 'text-rose-700',
|
||||
},
|
||||
{
|
||||
key: 'all' as TypeFilter,
|
||||
label: '可干预',
|
||||
count: data?.summary.suggestionCount ?? 0,
|
||||
unit: '条',
|
||||
sub: data ? `+${data.summary.estimatedGain} 台可达标` : null,
|
||||
// Blue accent
|
||||
idle: 'border-slate-200 bg-white',
|
||||
active: 'border-blue-500 bg-blue-50 ring-1 ring-blue-500/20',
|
||||
labelColor: 'text-slate-500',
|
||||
labelActive: 'text-blue-600',
|
||||
numColor: 'text-slate-800',
|
||||
numActive: 'text-blue-700',
|
||||
},
|
||||
];
|
||||
// Type filter from cards
|
||||
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
|
||||
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
|
||||
|
||||
// Advanced filters
|
||||
if (filters.plateSearch) {
|
||||
const q = filters.plateSearch.toLowerCase();
|
||||
list = list.filter(s => s.currentVehicle.plateNumber.toLowerCase().includes(q));
|
||||
}
|
||||
if (filters.region) list = list.filter(s => s.currentVehicle.region === filters.region);
|
||||
if (filters.vehicleType) list = list.filter(s => s.currentVehicle.vehicleType === filters.vehicleType);
|
||||
if (filters.customer) list = list.filter(s => s.currentVehicle.customer === filters.customer);
|
||||
|
||||
return list;
|
||||
}, [data, typeFilter, filters]);
|
||||
|
||||
const summary = data?.summary;
|
||||
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6" style={{ overflowX: 'clip' }}>
|
||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
||||
|
||||
{/* Batch Selector */}
|
||||
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar">
|
||||
{/* ========== Top: Summary Cards ========== */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
selectedTargetId === undefined
|
||||
? 'bg-slate-800 text-white shadow-md'
|
||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
|
||||
className={`p-4 rounded-2xl text-left transition-all cursor-pointer ${
|
||||
typeFilter === 'qualified'
|
||||
? 'bg-amber-100 border-2 border-amber-400 shadow-sm'
|
||||
: 'bg-amber-50 border border-amber-100'
|
||||
}`}
|
||||
>
|
||||
全部批次
|
||||
<div className="text-[10px] font-bold text-amber-700 mb-1">已达标车辆</div>
|
||||
<div className="text-2xl font-black text-amber-800">
|
||||
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
|
||||
<span className="text-xs font-normal text-amber-600 ml-1">台</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-amber-600 mt-1">本年完成率 ≥ 120%</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
|
||||
className={`p-4 rounded-2xl text-left transition-all cursor-pointer ${
|
||||
typeFilter === 'hopeless'
|
||||
? 'bg-blue-100 border-2 border-blue-400 shadow-sm'
|
||||
: 'bg-blue-50 border border-blue-100'
|
||||
}`}
|
||||
>
|
||||
<div className="text-[10px] font-bold text-blue-700 mb-1">无望达标</div>
|
||||
<div className="text-2xl font-black text-blue-800">
|
||||
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
|
||||
<span className="text-xs font-normal text-blue-600 ml-1">台</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-blue-600 mt-1">本年完成率 < 60%</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTypeFilter('all')}
|
||||
className={`p-4 rounded-2xl text-left transition-all cursor-pointer ${
|
||||
typeFilter === 'all'
|
||||
? 'bg-emerald-100 border-2 border-emerald-400 shadow-sm'
|
||||
: 'bg-emerald-50 border border-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<div className="text-[10px] font-bold text-emerald-700 mb-1">可干预建议</div>
|
||||
<div className="text-2xl font-black text-emerald-800">
|
||||
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
|
||||
<span className="text-xs font-normal text-emerald-600 ml-1">条</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-emerald-600 mt-1">
|
||||
预计 +{summary?.estimatedGain ?? 0} 台可达标
|
||||
</div>
|
||||
</button>
|
||||
{data?.targets.map((target) => (
|
||||
<button
|
||||
key={target.id}
|
||||
onClick={() => { setSelectedTargetId(target.id); setTypeFilter('all'); }}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
||||
selectedTargetId === target.id
|
||||
? 'bg-slate-800 text-white shadow-md'
|
||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(target.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Summary Cards — clickable filter */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{cardConfigs.map(cfg => {
|
||||
const isActive = typeFilter === cfg.key;
|
||||
return (
|
||||
<button
|
||||
key={cfg.key}
|
||||
onClick={() => setTypeFilter(isActive ? 'all' : cfg.key)}
|
||||
className={`p-3 rounded-2xl border text-left transition-all cursor-pointer ${isActive ? cfg.active : cfg.idle}`}
|
||||
>
|
||||
<div className={`text-[10px] font-bold mb-1 transition-colors ${isActive ? cfg.labelActive : cfg.labelColor}`}>
|
||||
{cfg.label}
|
||||
</div>
|
||||
<div className={`text-xl font-black transition-colors ${isActive ? cfg.numActive : cfg.numColor}`}>
|
||||
{cfg.count}
|
||||
<span className={`text-[10px] font-normal ml-1 ${isActive ? cfg.labelActive : 'text-slate-400'}`}>{cfg.unit}</span>
|
||||
</div>
|
||||
{cfg.sub && (
|
||||
<div className={`text-[9px] mt-0.5 ${isActive ? cfg.labelActive : 'text-slate-400'}`}>{cfg.sub}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* ========== Bottom: List Card ========== */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-slate-900">智能调度干预清单</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
||||
>
|
||||
<RotateCcw size={15} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowFilter(!showFilter); setTempFilters(filters); }}
|
||||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||||
showFilter || activeFilterCount > 0
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Filter size={15} />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-[8px] font-bold rounded-full flex items-center justify-center">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestion List */}
|
||||
{/* Batch selector pills */}
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => { setSelectedTargetId(undefined); setTypeFilter('all'); }}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === undefined
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
|
||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
全部批次
|
||||
</button>
|
||||
{data?.targets.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => { setSelectedTargetId(t.id); setTypeFilter('all'); }}
|
||||
className={`px-4 py-1.5 rounded-full text-[11px] font-bold whitespace-nowrap transition-all cursor-pointer ${
|
||||
selectedTargetId === t.id
|
||||
? 'bg-blue-600 text-white shadow-md shadow-blue-600/20'
|
||||
: 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{shortTargetName(t.name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filter Panel */}
|
||||
<AnimatePresence>
|
||||
{showFilter && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden border-b border-slate-100"
|
||||
>
|
||||
<div className="px-4 py-4 bg-slate-50/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-700">高级筛选</span>
|
||||
{hasActiveFilters(tempFilters) && (
|
||||
<button
|
||||
onClick={() => setTempFilters(EMPTY_FILTERS)}
|
||||
className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plate search */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold">车牌号</label>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={tempFilters.plateSearch}
|
||||
onChange={e => setTempFilters(prev => ({ ...prev, plateSearch: e.target.value }))}
|
||||
placeholder="搜索车牌号..."
|
||||
className="w-full pl-8 pr-3 py-2 bg-white rounded-lg text-xs border border-slate-200 outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selects in 2-column grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FilterSelect
|
||||
label="运营区域"
|
||||
options={filterOptions.regions}
|
||||
value={tempFilters.region}
|
||||
onChange={v => setTempFilters(prev => ({ ...prev, region: v }))}
|
||||
placeholder="全部区域"
|
||||
/>
|
||||
<FilterSelect
|
||||
label="车辆类型"
|
||||
options={filterOptions.vehicleTypes}
|
||||
value={tempFilters.vehicleType}
|
||||
onChange={v => setTempFilters(prev => ({ ...prev, vehicleType: v }))}
|
||||
placeholder="全部类型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FilterSelect
|
||||
label="客户"
|
||||
options={filterOptions.customers}
|
||||
value={tempFilters.customer}
|
||||
onChange={v => setTempFilters(prev => ({ ...prev, customer: v }))}
|
||||
placeholder="全部客户"
|
||||
/>
|
||||
|
||||
{/* Apply / Cancel */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => setShowFilter(false)}
|
||||
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-white border border-slate-200 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setFilters(tempFilters); setShowFilter(false); }}
|
||||
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 rounded-lg cursor-pointer hover:bg-blue-700 transition-colors shadow-sm shadow-blue-200"
|
||||
>
|
||||
确认筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Active filter tags */}
|
||||
{activeFilterCount > 0 && !showFilter && (
|
||||
<div className="px-4 py-2 border-b border-slate-100 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] text-slate-400">筛选:</span>
|
||||
{filters.plateSearch && (
|
||||
<span className="text-[10px] bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
车牌 "{filters.plateSearch}"
|
||||
<X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, plateSearch: '' }))} />
|
||||
</span>
|
||||
)}
|
||||
{filters.region && (
|
||||
<span className="text-[10px] bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
{filters.region}
|
||||
<X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, region: '' }))} />
|
||||
</span>
|
||||
)}
|
||||
{filters.vehicleType && (
|
||||
<span className="text-[10px] bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
{filters.vehicleType}
|
||||
<X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, vehicleType: '' }))} />
|
||||
</span>
|
||||
)}
|
||||
{filters.customer && (
|
||||
<span className="text-[10px] bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
{filters.customer}
|
||||
<X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, customer: '' }))} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||
className="text-[10px] text-slate-400 hover:text-slate-600 cursor-pointer"
|
||||
>
|
||||
清除全部
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result count when filtered */}
|
||||
{(activeFilterCount > 0 || typeFilter !== 'all') && (
|
||||
<div className="px-4 py-1.5 border-b border-slate-50 text-[10px] text-slate-400">
|
||||
共 {filteredSuggestions.length} 条结果
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List body */}
|
||||
{loading && !data ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<SuggestionList
|
||||
suggestions={filteredSuggestions}
|
||||
onSelect={setSelectedSuggestion}
|
||||
loading={loading}
|
||||
onRefresh={loadData}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedSuggestion && (
|
||||
<SuggestionDetail
|
||||
suggestion={selectedSuggestion}
|
||||
|
||||
Reference in New Issue
Block a user