All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 components/RotatingFooterHint:统一文案+蓝色脉冲,4 秒轮换
- 新增 components/FeedbackFab:右下角悬浮按钮(渐变 + 心形信封 + 黄色脉冲点),
点击打开 4 步引导式弹窗
Step 1 选类型(💡新维度 / 🐛bug / 🎨界面 / 📝其他)
Step 2 描述需求 + 选当前板块(chip)
Step 3 留联系方式(可选)+ 提交概览
Step 4 ❤️ 成功页(弹簧 √ 动画)
顶部 spring 进度条,底部上一步/下一步,下拉手柄,背景点击或 X 关闭
- 后端 routes/feedback:bi_user_feedback 表(自动建表,含 status 字段)
POST /api/feedback/submit + GET /api/feedback/list
- Shell 全局挂载 FeedbackFab,自动从 hash 检测当前模块
- 各模块底部追加 RotatingFooterHint:
AssetsModule / MileageModule / SchedulingModule / EleImportPage
HydrogenOverview / HydrogenDaily / ElectricOverview / ElectricDaily
(HydrogenOverview 旧的内嵌实现已替换为共享组件)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
640 lines
33 KiB
TypeScript
640 lines
33 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||
import { Filter, RotateCcw, X, Search, ChevronDown, CheckSquare, Send, Clock, Download } from 'lucide-react';
|
||
import { motion, AnimatePresence } from 'motion/react';
|
||
import { fetchSuggestions, sendNotifyBatch } from './api';
|
||
import type { SchedulingResponse, SchedulingSuggestion, CandidateVehicle } from './types';
|
||
import SuggestionList from './SuggestionList';
|
||
import SuggestionDetail from './SuggestionDetail';
|
||
import NotificationHistory from './NotificationHistory';
|
||
import { exportSuggestionsCsv } from './csv-export';
|
||
import Blur from '../../components/Blur';
|
||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||
|
||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||
|
||
interface AdvancedFilters {
|
||
plateSearch: string;
|
||
region: string;
|
||
vehicleType: string;
|
||
customer: string;
|
||
department: string;
|
||
manager: string;
|
||
}
|
||
|
||
const EMPTY_FILTERS: AdvancedFilters = { plateSearch: '', region: '', vehicleType: '', customer: '', department: '', manager: '' };
|
||
|
||
function shortTargetName(name: string): string {
|
||
const match = name.match(/(\d+)[辆台](.+)/);
|
||
if (!match) return name;
|
||
const count = match[1];
|
||
let desc = match[2];
|
||
desc = desc.replace('4.5T普货', '普货');
|
||
desc = desc.replace('4.5T冷链车', '冷藏车');
|
||
desc = desc.replace('4.5T冷链', '冷藏车');
|
||
return `${count}台${desc}`;
|
||
}
|
||
|
||
function hasActiveFilters(f: AdvancedFilters): boolean {
|
||
return f.plateSearch !== '' || f.region !== '' || f.vehicleType !== '' || f.customer !== '';
|
||
}
|
||
|
||
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 relative">
|
||
{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="搜索..." autoFocus
|
||
className="w-full pl-7 pr-2 py-1.5 text-xs bg-slate-50 rounded border-none outline-none" />
|
||
</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>
|
||
);
|
||
}
|
||
|
||
/** Skeleton pulse block */
|
||
function Sk({ className }: { className?: string }) {
|
||
return <div className={`animate-pulse bg-slate-200/70 rounded ${className ?? ''}`} />;
|
||
}
|
||
|
||
function SkeletonPage() {
|
||
return (
|
||
<div className="min-h-screen bg-[#F0F4F8] font-sans p-3 md:p-6">
|
||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 md:pb-0">
|
||
{/* Cards skeleton */}
|
||
<div className="grid grid-cols-3 gap-2.5">
|
||
{[0, 1, 2].map(i => (
|
||
<div key={i} className="p-4 rounded-2xl bg-white border border-slate-100 space-y-2.5">
|
||
<Sk className="h-3 w-16" />
|
||
<Sk className="h-7 w-12" />
|
||
<Sk className="h-2.5 w-24" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* List card skeleton */}
|
||
<div className="bg-white rounded-2xl border border-slate-200/60 overflow-hidden">
|
||
{/* Header */}
|
||
<div className="px-4 py-3 border-b border-slate-100 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<Sk className="h-4 w-28" />
|
||
<div className="flex gap-2"><Sk className="h-6 w-6 rounded-lg" /><Sk className="h-6 w-6 rounded-lg" /></div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{[0, 1, 2, 3].map(i => <Sk key={i} className="h-7 w-20 rounded-full" />)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rows */}
|
||
<div className="divide-y divide-slate-50">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<div key={i} className="px-4 py-3 flex items-center gap-3">
|
||
<Sk className="w-1 h-10 rounded-full" />
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Sk className="h-3.5 w-20" />
|
||
<Sk className="h-3 w-10 rounded-full" />
|
||
<Sk className="h-3 w-14" />
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Sk className="h-2.5 w-28" />
|
||
<Sk className="h-2.5 w-16" />
|
||
<Sk className="h-2.5 w-14" />
|
||
</div>
|
||
</div>
|
||
<Sk className="h-4 w-8" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function pickBestCandidate(s: SchedulingSuggestion): CandidateVehicle | null {
|
||
// Business rule: at most one active intervention per suggestion. If ANY
|
||
// candidate is already intervened, skip the whole suggestion in batch flow.
|
||
const hasActive = s.candidates.some(
|
||
c => c.notificationStatus === 'sent' || c.notificationStatus === 'executed',
|
||
);
|
||
if (hasActive) return null;
|
||
return s.candidates.find(c => c.canQualifyAfterSwap) ?? s.candidates[0] ?? null;
|
||
}
|
||
|
||
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 [selectMode, setSelectMode] = useState(false);
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||
const [showBatchConfirm, setShowBatchConfirm] = useState(false);
|
||
const [batchInFlight, setBatchInFlight] = useState(false);
|
||
const [batchResultMsg, setBatchResultMsg] = useState<string | null>(null);
|
||
const [showHistory, setShowHistory] = useState(false);
|
||
const [historyRecentOnly, setHistoryRecentOnly] = useState(false);
|
||
|
||
const loadData = useCallback(async () => {
|
||
setLoading(true);
|
||
try { setData(await fetchSuggestions(selectedTargetId)); } finally { setLoading(false); }
|
||
}, [selectedTargetId]);
|
||
|
||
useEffect(() => { loadData(); }, [loadData]);
|
||
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
||
|
||
// Keep selectedSuggestion synced with latest data so candidate notification
|
||
// status changes (登记 / 取消干预) propagate into the open detail modal.
|
||
useEffect(() => {
|
||
if (!selectedSuggestion || !data) return;
|
||
const fresh = data.suggestions.find(s => s.id === selectedSuggestion.id);
|
||
if (!fresh) setSelectedSuggestion(null);
|
||
else if (fresh !== selectedSuggestion) setSelectedSuggestion(fresh);
|
||
}, [data, selectedSuggestion]);
|
||
|
||
const toggleSelect = useCallback((id: string) => {
|
||
setSelectedIds(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id); else next.add(id);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const exitSelectMode = useCallback(() => {
|
||
setSelectMode(false);
|
||
setSelectedIds(new Set());
|
||
setShowBatchConfirm(false);
|
||
}, []);
|
||
|
||
const batchItems = useMemo(() => {
|
||
if (!data) return [];
|
||
return [...selectedIds]
|
||
.map(id => data.suggestions.find(s => s.id === id))
|
||
.filter((s): s is SchedulingSuggestion => !!s)
|
||
.map(s => {
|
||
const candidate = pickBestCandidate(s);
|
||
if (!candidate) return null;
|
||
return { suggestion: s, candidate };
|
||
})
|
||
.filter((x): x is { suggestion: SchedulingSuggestion; candidate: CandidateVehicle } => !!x);
|
||
}, [data, selectedIds]);
|
||
|
||
const handleBatchSubmit = useCallback(async () => {
|
||
if (batchItems.length === 0) return;
|
||
setBatchInFlight(true);
|
||
try {
|
||
const resp = await sendNotifyBatch({
|
||
items: batchItems.map(i => ({
|
||
suggestionId: i.suggestion.id,
|
||
currentPlate: i.suggestion.currentVehicle.plateNumber,
|
||
candidatePlate: i.candidate.plateNumber,
|
||
})),
|
||
});
|
||
setBatchResultMsg(resp.message);
|
||
await loadData();
|
||
exitSelectMode();
|
||
} catch (e) {
|
||
console.error('batch notify failed:', e);
|
||
setBatchResultMsg('批量干预失败,请重试');
|
||
} finally {
|
||
setBatchInFlight(false);
|
||
}
|
||
}, [batchItems, loadData, exitSelectMode]);
|
||
|
||
const filterOptions = useMemo(() => {
|
||
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
||
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
||
for (const s of data.suggestions) {
|
||
const v = s.currentVehicle;
|
||
if (v.region) r.add(v.region);
|
||
if (v.vehicleType) t.add(v.vehicleType);
|
||
if (v.customer) c.add(v.customer);
|
||
if (v.department) d.add(v.department);
|
||
if (v.manager) m.add(v.manager);
|
||
}
|
||
return { regions: [...r].sort(), vehicleTypes: [...t].sort(), customers: [...c].sort(), departments: [...d].sort(), managers: [...m].sort() };
|
||
}, [data]);
|
||
|
||
const filteredSuggestions = useMemo(() => {
|
||
if (!data) return [];
|
||
let list = data.suggestions;
|
||
if (typeFilter === 'qualified') list = list.filter(s => s.type === 'replace_qualified');
|
||
if (typeFilter === 'hopeless') list = list.filter(s => s.type === 'rescue_hopeless');
|
||
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);
|
||
if (filters.department) list = list.filter(s => s.currentVehicle.department === filters.department);
|
||
if (filters.manager) list = list.filter(s => s.currentVehicle.manager === filters.manager);
|
||
return list;
|
||
}, [data, typeFilter, filters]);
|
||
|
||
const summary = data?.summary;
|
||
const activeFilterCount = [filters.plateSearch, filters.region, filters.vehicleType, filters.customer, filters.department, filters.manager].filter(Boolean).length;
|
||
|
||
// Initial load — full page skeleton
|
||
if (loading && !data) return <SkeletonPage />;
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#F0F4F8] text-slate-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">
|
||
|
||
{/* ===== Summary Cards ===== */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
||
{/* 里程高·换下 — warm orange */}
|
||
<button
|
||
onClick={() => setTypeFilter(typeFilter === 'qualified' ? 'all' : 'qualified')}
|
||
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
|
||
typeFilter === 'qualified'
|
||
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
|
||
: 'bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-200/60'
|
||
}`}
|
||
>
|
||
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'qualified' ? 'text-orange-100' : 'text-orange-600'}`}>
|
||
已完成考核目标
|
||
</div>
|
||
<div className={`text-2xl font-black ${typeFilter === 'qualified' ? 'text-white' : 'text-orange-700'}`}>
|
||
{loading && !data ? '-' : summary?.qualifiedCount ?? 0}
|
||
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>台</span>
|
||
</div>
|
||
<div className={`text-[9px] mt-0.5 ${typeFilter === 'qualified' ? 'text-orange-200' : 'text-orange-400'}`}>
|
||
换下,腾位给待达标车
|
||
</div>
|
||
</button>
|
||
|
||
{/* 里程低·换走 — cool blue */}
|
||
<button
|
||
onClick={() => setTypeFilter(typeFilter === 'hopeless' ? 'all' : 'hopeless')}
|
||
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
|
||
typeFilter === 'hopeless'
|
||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/25'
|
||
: 'bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200/60'
|
||
}`}
|
||
>
|
||
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'hopeless' ? 'text-blue-100' : 'text-blue-600'}`}>
|
||
预估无法达标
|
||
</div>
|
||
<div className={`text-2xl font-black ${typeFilter === 'hopeless' ? 'text-white' : 'text-blue-700'}`}>
|
||
{loading && !data ? '-' : summary?.hopelessCount ?? 0}
|
||
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>台</span>
|
||
</div>
|
||
<div className={`text-[9px] mt-0.5 ${typeFilter === 'hopeless' ? 'text-blue-200' : 'text-blue-400'}`}>
|
||
换走,换上快达标的车
|
||
</div>
|
||
</button>
|
||
|
||
{/* 替换建议 — neutral dark */}
|
||
<button
|
||
onClick={() => setTypeFilter('all')}
|
||
className={`p-3.5 rounded-2xl text-left transition-all cursor-pointer ${
|
||
typeFilter === 'all'
|
||
? 'bg-slate-800 text-white shadow-lg shadow-slate-800/25'
|
||
: 'bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/60'
|
||
}`}
|
||
>
|
||
<div className={`text-[10px] font-bold mb-1 ${typeFilter === 'all' ? 'text-slate-300' : 'text-slate-500'}`}>
|
||
替换建议
|
||
</div>
|
||
<div className={`text-2xl font-black ${typeFilter === 'all' ? 'text-white' : 'text-slate-800'}`}>
|
||
{loading && !data ? '-' : summary?.suggestionCount ?? 0}
|
||
<span className={`text-[10px] font-normal ml-1 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>条</span>
|
||
</div>
|
||
<div className={`text-[9px] mt-0.5 ${typeFilter === 'all' ? 'text-slate-400' : 'text-slate-400'}`}>
|
||
执行后预计 +{summary?.estimatedGain ?? 0} 台达标
|
||
</div>
|
||
</button>
|
||
|
||
{/* 近期已干预 — emerald */}
|
||
<button
|
||
onClick={() => { setShowHistory(true); setHistoryRecentOnly(true); }}
|
||
className="p-3.5 rounded-2xl text-left transition-all cursor-pointer bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200/60"
|
||
>
|
||
<div className="text-[10px] font-bold mb-1 text-emerald-600">
|
||
近期已干预
|
||
</div>
|
||
<div className="text-2xl font-black text-emerald-700">
|
||
{loading && !data ? '-' : summary?.recentInterventionCount ?? 0}
|
||
<span className="text-[10px] font-normal ml-1 text-emerald-400">条</span>
|
||
</div>
|
||
<div className="text-[9px] mt-0.5 text-emerald-400">
|
||
最近 7 天 · 点击查看
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
{/* ===== List Card ===== */}
|
||
<div className="bg-white rounded-2xl border border-slate-200/60 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={() => exportSuggestionsCsv(filteredSuggestions)}
|
||
disabled={filteredSuggestions.length === 0}
|
||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||
title="导出 CSV"
|
||
>
|
||
<Download size={15} />
|
||
</button>
|
||
<button
|
||
onClick={() => { setShowHistory(true); setHistoryRecentOnly(false); }}
|
||
className="p-1.5 text-slate-400 hover:text-slate-600 transition-colors rounded-lg hover:bg-slate-50 cursor-pointer"
|
||
title="调度记录"
|
||
>
|
||
<Clock size={15} />
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
if (selectMode) exitSelectMode();
|
||
else { setSelectMode(true); setSelectedSuggestion(null); }
|
||
}}
|
||
className={`relative p-1.5 transition-colors rounded-lg cursor-pointer ${
|
||
selectMode ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||
}`}
|
||
title={selectMode ? '退出多选' : '多选模式'}
|
||
>
|
||
<CheckSquare size={15} />
|
||
</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>
|
||
|
||
<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-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
全部批次
|
||
</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-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
{shortTargetName(t.name)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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/60 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>
|
||
<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>
|
||
<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>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<FilterSelect label="业务部门" options={filterOptions.departments} value={tempFilters.department} onChange={v => setTempFilters(prev => ({ ...prev, department: v }))} placeholder="全部部门" />
|
||
<FilterSelect label="业务负责人" options={filterOptions.managers} value={tempFilters.manager} onChange={v => setTempFilters(prev => ({ ...prev, manager: v }))} placeholder="全部负责人" />
|
||
</div>
|
||
<FilterSelect label="客户" options={filterOptions.customers} value={tempFilters.customer} onChange={v => setTempFilters(prev => ({ ...prev, customer: v }))} placeholder="全部客户" />
|
||
<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-slate-800 rounded-lg cursor-pointer hover:bg-slate-900 transition-colors shadow-sm">确认筛选</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-slate-100 text-slate-600 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-slate-100 text-slate-600 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-slate-100 text-slate-600 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.department && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.department} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, department: '' }))} /></span>}
|
||
{filters.manager && <span className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full flex items-center gap-1">{filters.manager} <X size={10} className="cursor-pointer" onClick={() => setFilters(prev => ({ ...prev, manager: '' }))} /></span>}
|
||
{filters.customer && <span className="text-[10px] bg-slate-100 text-slate-600 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>
|
||
)}
|
||
|
||
{(activeFilterCount > 0 || typeFilter !== 'all') && (
|
||
<div className="px-4 py-1.5 border-b border-slate-50 text-[10px] text-slate-400">共 {filteredSuggestions.length} 条结果</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
/* List skeleton while refreshing */
|
||
<div className="divide-y divide-slate-50">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} className="px-4 py-3 flex items-center gap-3">
|
||
<Sk className="w-1 h-10 rounded-full" />
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Sk className="h-3.5 w-20" />
|
||
<Sk className="h-3 w-10 rounded-full" />
|
||
<Sk className="h-3 w-14" />
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Sk className="h-2.5 w-28" />
|
||
<Sk className="h-2.5 w-16" />
|
||
<Sk className="h-2.5 w-14" />
|
||
</div>
|
||
</div>
|
||
<Sk className="h-4 w-8" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<SuggestionList
|
||
suggestions={filteredSuggestions}
|
||
onSelect={setSelectedSuggestion}
|
||
selectMode={selectMode}
|
||
selectedIds={selectedIds}
|
||
onToggleSelect={toggleSelect}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{selectedSuggestion && (
|
||
<SuggestionDetail suggestion={selectedSuggestion} onClose={() => setSelectedSuggestion(null)} onNotifySuccess={handleNotifySuccess} />
|
||
)}
|
||
|
||
{showHistory && (
|
||
<NotificationHistory
|
||
onClose={() => setShowHistory(false)}
|
||
onChange={loadData}
|
||
recentOnly={historyRecentOnly}
|
||
suggestions={data?.suggestions}
|
||
/>
|
||
)}
|
||
|
||
{/* Batch action bar */}
|
||
<AnimatePresence>
|
||
{selectMode && (
|
||
<motion.div
|
||
initial={{ y: 80, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
exit={{ y: 80, opacity: 0 }}
|
||
className="fixed bottom-4 left-3 right-3 md:left-auto md:right-6 md:bottom-6 md:w-[360px] z-40 bg-slate-900 text-white rounded-2xl shadow-2xl px-4 py-3 flex items-center justify-between gap-3"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-medium">已选</span>
|
||
<span className="text-lg font-black">{selectedIds.size}</span>
|
||
<span className="text-xs text-slate-400">条</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={exitSelectMode}
|
||
className="text-xs font-medium text-slate-300 hover:text-white px-2 py-1.5 cursor-pointer transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={() => setShowBatchConfirm(true)}
|
||
disabled={selectedIds.size === 0}
|
||
className="flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-400 text-white px-3 py-1.5 rounded-lg cursor-pointer disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
<Send size={12} /> 批量干预
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* Batch confirmation modal */}
|
||
{showBatchConfirm && (
|
||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[70] flex items-end sm:items-center justify-center" onClick={() => !batchInFlight && setShowBatchConfirm(false)}>
|
||
<motion.div
|
||
initial={{ y: 40, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
onClick={e => e.stopPropagation()}
|
||
className="bg-white rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:max-w-md overflow-hidden flex flex-col max-h-[80vh] sm:mx-4"
|
||
>
|
||
<div className="bg-slate-800 px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||
<span className="text-white font-bold text-sm">确认批量干预</span>
|
||
<button
|
||
onClick={() => !batchInFlight && setShowBatchConfirm(false)}
|
||
disabled={batchInFlight}
|
||
className="text-slate-400 hover:text-white transition-colors p-1 cursor-pointer disabled:opacity-50"
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
<div className="px-4 py-3 overflow-y-auto flex-1">
|
||
<p className="text-xs text-slate-500 mb-3">
|
||
将登记 <span className="font-bold text-slate-800">{batchItems.length}</span> 条干预,已排除无可用候选车的建议。
|
||
</p>
|
||
<div className="space-y-2">
|
||
{batchItems.map(({ suggestion, candidate }) => (
|
||
<div key={suggestion.id} className="text-[11px] bg-slate-50 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<span className="font-mono font-bold text-slate-900"><Blur>{suggestion.currentVehicle.plateNumber}</Blur></span>
|
||
<span className="text-slate-400">→</span>
|
||
<span className="font-mono font-bold text-blue-700"><Blur>{candidate.plateNumber}</Blur></span>
|
||
</div>
|
||
{candidate.canQualifyAfterSwap ? (
|
||
<span className="text-emerald-600 text-[9px] font-bold flex-shrink-0">可达标</span>
|
||
) : (
|
||
<span className="text-amber-500 text-[9px] font-bold flex-shrink-0">需关注</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{batchResultMsg && (
|
||
<p className="mt-3 text-[11px] text-slate-500">{batchResultMsg}</p>
|
||
)}
|
||
</div>
|
||
<div className="border-t border-slate-100 px-4 py-3 flex-shrink-0 flex gap-2">
|
||
<button
|
||
onClick={() => setShowBatchConfirm(false)}
|
||
disabled={batchInFlight}
|
||
className="flex-1 py-2 text-xs font-bold text-slate-500 bg-slate-50 hover:bg-slate-100 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleBatchSubmit}
|
||
disabled={batchInFlight || batchItems.length === 0}
|
||
className="flex-1 py-2 text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 rounded-lg cursor-pointer disabled:opacity-50 transition-colors"
|
||
>
|
||
{batchInFlight ? '登记中...' : `确认登记 ${batchItems.length} 条`}
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<RotatingFooterHint className="pb-4" />
|
||
</div>
|
||
);
|
||
}
|