Files
ln-bi/src/modules/mileage/MonitoringView.tsx
kkfluous 863ab17b58 fix: 筛选器和显示优化
- 删除年份筛选
- 项目筛选改用真实项目数据(ln_vehicle_contract.project_name)
- 主体查询改用 tab_truck → tab_org 的 org_name
- 里程区间改为两个独立条件(里程≥ / 里程≤)
- 未分配客户显示为 -
- 统计报表日期格式改为 M.D(如 3.25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:06:56 +08:00

825 lines
39 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Truck, Search, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronsUp,
} from 'lucide-react';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import { fetchMonitoring } from './api';
const SearchableSelect = ({
options,
value,
onChange,
placeholder
}: {
options: string[],
value: string,
onChange: (val: string) => void,
placeholder: string
}) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search) return options;
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
}, [options, search]);
return (
<div className="relative">
<div className="relative">
<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 === 'All' ? placeholder : value}
value={search}
onFocus={() => setIsOpen(true)}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => {
// Delay to allow clicking an option
setTimeout(() => setIsOpen(false), 200);
}}
/>
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</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 max-h-40 overflow-y-auto"
>
<div
className="px-3 py-2 text-[10px] font-bold text-blue-600 hover:bg-slate-50 cursor-pointer"
onClick={() => {
onChange('All');
setSearch('');
setIsOpen(false);
}}
>
</div>
{filtered.map((opt: string) => (
<div
key={opt}
className="px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50"
onClick={() => {
onChange(opt);
setSearch('');
setIsOpen(false);
}}
>
{opt}
</div>
))}
{filtered.length === 0 && (
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default function MonitoringView() {
const [searchTerm, setSearchTerm] = useState('');
const [filterDept, setFilterDept] = useState('All');
const [sortBy, setSortBy] = useState<'today' | 'total'>('today');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// New filters from image
const [filterPlate, setFilterPlate] = useState('All');
const [filterDateRange, setFilterDateRange] = useState({ start: '', end: '' });
const [filterDate, setFilterDate] = useState('2026-04-01');
const [filterProject, setFilterProject] = useState('All');
const [filterEntity, setFilterEntity] = useState('All');
const [filterRegionCode, setFilterRegionCode] = useState('All');
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, onlineCount: 0, vehicleCount: 0 });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [] });
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [showBackToTop, setShowBackToTop] = useState(false);
const PAGE_SIZE = 50;
const listEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates;
const projects = filterOptions.customers;
// 加载首页数据
const loadFirstPage = useCallback(() => {
fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
}).then(d => {
setVehicles(d.vehicles);
setStats(d.stats);
setFilterOptions(d.filters);
setTotal(d.total);
setPage(1);
setHasMore(d.page < d.totalPages);
}).catch(() => {});
}, [sortBy, sortOrder, searchTerm, filterDept, filterProject, filterEntity]);
// 加载更多
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
const nextPage = page + 1;
setLoadingMore(true);
fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: nextPage,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
}).then(d => {
setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage);
setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterProject, filterEntity, page, loadingMore, hasMore]);
// 筛选/排序变化时重新加载
useEffect(() => {
loadFirstPage();
}, [loadFirstPage]);
// 滚动触底检测 + 回到顶部按钮
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY;
setShowBackToTop(scrollY > 600);
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = window.innerHeight;
if (scrollHeight - scrollY - clientHeight < 200) {
loadMore();
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [loadMore]);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const filteredVehicles = vehicles;
const toggleFullscreen = () => {
if (!isFullscreen) {
const elem = document.documentElement;
if (elem.requestFullscreen) {
elem.requestFullscreen().catch(() => {});
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
}
}
setIsFullscreen(!isFullscreen);
};
return (
<>
{/* Fullscreen Landscape View Overlay */}
<AnimatePresence>
{isFullscreen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-slate-950 flex flex-col p-4 landscape:flex-row gap-4 overflow-hidden"
>
{/* Sidebar / Top Stats in Landscape */}
<div className="flex flex-col gap-4 w-full landscape:w-72 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
<h2 className="text-white font-bold text-lg"></h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setFilterDept('All');
setFilterProject('All');
setFilterPlate('All');
setSearchTerm('');
}}
className="p-2 bg-slate-800 text-slate-400 rounded-full hover:text-blue-400 transition-colors"
title="重置筛选"
>
<RotateCcw size={16} />
</button>
<button
onClick={toggleFullscreen}
className="p-2 bg-slate-800 text-slate-400 rounded-full hover:text-white transition-colors"
>
<Minimize2 size={20} />
</button>
</div>
</div>
{/* KPI Cards in Fullscreen */}
<div className="grid grid-cols-2 landscape:grid-cols-1 gap-3">
<div className={`bg-slate-900/50 border p-4 rounded-2xl transition-all ${sortBy === 'today' ? 'border-blue-500/50 ring-1 ring-blue-500/20' : 'border-slate-800'}`}>
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-2xl font-black text-white tracking-tighter">
{stats.totalToday.toLocaleString()}
<span className="text-blue-400 text-xs ml-2">KM</span>
</div>
</div>
<div className={`bg-slate-900/50 border p-4 rounded-2xl transition-all ${sortBy === 'total' ? 'border-blue-500/50 ring-1 ring-blue-500/20' : 'border-slate-800'}`}>
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-2xl font-black text-white tracking-tighter">
{stats.totalAll.toLocaleString()}
<span className="text-blue-400 text-xs ml-2">KM</span>
</div>
</div>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-2xl font-black text-white tracking-tighter">
{stats.vehicleCount}
<span className="text-blue-400 text-xs ml-2"></span>
</div>
</div>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1">
({sortBy === 'today' ? '今日' : '累计'})
</div>
<div className="text-2xl font-black text-white tracking-tighter">
{(stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}
<span className="text-blue-400 text-xs ml-2">KM</span>
</div>
</div>
</div>
</div>
{/* Main Table Area */}
<div className="flex-1 bg-slate-900/30 border border-slate-800 rounded-2xl overflow-hidden flex flex-col">
<div className="p-4 border-b border-slate-800 flex justify-between items-center bg-slate-900/50">
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest"></span>
<div className="flex items-center gap-4 text-[10px] text-slate-500">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span>线</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-slate-500"></div>
<span>线</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-amber-400"></div>
<span></span>
</div>
<span>: {new Date().toLocaleTimeString()}</span>
</div>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800">
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1.5">
<span></span>
<select
className="bg-slate-800 border-none rounded-lg px-2 py-1 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterPlate}
onChange={(e) => setFilterPlate(e.target.value)}
>
<option value="All"></option>
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
</th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">线</th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1.5">
<span></span>
<select
className="bg-slate-800 border-none rounded-lg px-2 py-1 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterProject}
onChange={(e) => setFilterProject(e.target.value)}
>
<option value="All"></option>
{projects.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
</th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase">
<div className="flex flex-col gap-1.5">
<span></span>
<select
className="bg-slate-800 border-none rounded-lg px-2 py-1 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
value={filterDept}
onChange={(e) => setFilterDept(e.target.value)}
>
<option value="All"></option>
{departments.map(d => <option key={d} value={d}>{d.replace('业务', '')}</option>)}
</select>
</div>
</th>
<th
className="p-4 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
onClick={() => {
if (sortBy === 'today') {
setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc');
} else {
setSortBy('today');
setSortOrder('desc');
}
}}
>
<div className="flex items-center justify-end gap-1">
<span></span>
{sortBy === 'today' && (
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
)}
</div>
</th>
<th
className="p-4 text-[10px] font-bold text-slate-500 uppercase text-right cursor-pointer hover:text-blue-400 transition-colors"
onClick={() => {
if (sortBy === 'total') {
setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc');
} else {
setSortBy('total');
setSortOrder('desc');
}
}}
>
<div className="flex items-center justify-end gap-1">
<span></span>
{sortBy === 'total' && (
sortOrder === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />
)}
</div>
</th>
<th className="p-4 text-[10px] font-bold text-slate-500 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{filteredVehicles.map((v) => (
<tr key={v.plate} className="hover:bg-slate-800/30 transition-colors group">
<td className="p-4 text-sm font-bold text-white">{v.plate}</td>
<td className="p-4">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${v.isOnline ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]' : 'bg-slate-600'}`}></div>
<span className={`text-[10px] font-bold ${v.isOnline ? 'text-green-500' : 'text-slate-500'}`}>
{v.isOnline ? '在线' : '离线'}
</span>
</div>
</td>
<td className="p-4 text-xs text-slate-400">{v.customer}</td>
<td className="p-4 text-xs text-slate-400">{v.department || v.rentStatus || ''}</td>
<td className="p-4 text-right">
<div className="flex flex-col items-end">
<div className="flex items-center gap-1.5">
{!v.isDataSynced && (
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></div>
)}
<span className={`text-sm font-mono font-bold ${v.isDataSynced ? 'text-blue-400' : 'text-amber-400'}`}>
{v.dailyKm?.toLocaleString()} <span className="text-[8px] text-slate-500 font-bold">km</span>
</span>
</div>
{!v.isDataSynced && <span className="text-[8px] text-amber-500/50 font-bold"></span>}
</div>
</td>
<td className="p-4 text-right">
<div className="flex flex-col items-end">
<span className={`text-sm font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-amber-400/70'}`}>
{v.totalKm?.toLocaleString()} <span className="text-[8px] text-slate-500 font-bold">km</span>
</span>
</div>
</td>
<td className="p-4">
<span className={`px-2 py-0.5 rounded-full ${v.isOnline ? 'bg-green-500/10 text-green-500' : 'bg-slate-500/10 text-slate-500'} text-[9px] font-bold uppercase`}>
{v.isOnline ? '运行中' : '静止/离线'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Ultra Compact Header - Two Rows */}
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-3">
{/* Top Row: Title & Sort */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-1 h-8 bg-blue-600 rounded-full"></div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-black text-slate-900 leading-none"></h1>
<button
onClick={toggleFullscreen}
className="p-1 text-slate-300 hover:text-blue-600 transition-colors"
title="全屏视图"
>
<Maximize2 size={14} />
</button>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight"> 40min更新</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 bg-slate-100 p-0.5 rounded-lg">
<div className="flex gap-0.5">
<button
onClick={() => setSortBy('today')}
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
>
</button>
<button
onClick={() => setSortBy('total')}
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
>
</button>
</div>
<div className="w-[1px] h-3 bg-slate-200 mx-0.5"></div>
<button
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="p-1 text-blue-600 hover:bg-white rounded-md transition-all"
>
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
</button>
</div>
</div>
{/* Bottom Row: Quick Filters & Advanced Filter Icon */}
<div className="flex items-center gap-2">
<div className="flex-1 grid grid-cols-3 gap-1.5">
<SearchableSelect
options={departments}
value={filterDept}
onChange={setFilterDept}
placeholder="按部门"
/>
<SearchableSelect
options={projects}
value={filterProject}
onChange={setFilterProject}
placeholder="按客户"
/>
<SearchableSelect
options={plateNumbers}
value={filterPlate}
onChange={setFilterPlate}
placeholder="按车牌"
/>
</div>
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterPlate !== 'All' || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
>
<Filter size={16} />
</button>
</div>
</div>
{/* Expandable Filter Panel */}
<AnimatePresence>
{isFilterOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4">
{/* Search Key */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-300" size={14} />
<input
type="text"
placeholder="车牌、项目、车型..."
className="w-full pl-9 pr-4 py-2 bg-slate-50 border-none rounded-xl text-xs focus:ring-2 focus:ring-blue-500/20"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Plate Number */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterPlate}
onChange={(e) => setFilterPlate(e.target.value)}
>
<option value="All"></option>
{plateNumbers.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
</div>
{/* Date Range */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<div className="flex items-center gap-2">
<input
type="date"
className="flex-1 bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDateRange.start}
onChange={(e) => setFilterDateRange(prev => ({ ...prev, start: e.target.value }))}
/>
<span className="text-slate-300">-</span>
<input
type="date"
className="flex-1 bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDateRange.end}
onChange={(e) => setFilterDateRange(prev => ({ ...prev, end: e.target.value }))}
/>
</div>
</div>
{/* Single Date */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<div className="relative">
<input
type="date"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
/>
<button
onClick={() => setFilterDate('2026-04-01')}
className="absolute right-10 top-1/2 -translate-y-1/2 text-slate-400 hover:text-blue-500"
>
<RotateCcw size={12} />
</button>
</div>
</div>
{/* Project */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterProject}
onChange={(e) => setFilterProject(e.target.value)}
>
<option value="All"></option>
{filterOptions.projects.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Department */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterDept}
onChange={(e) => setFilterDept(e.target.value)}
>
<option value="All"></option>
{departments.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
{/* Entity */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterEntity}
onChange={(e) => setFilterEntity(e.target.value)}
>
<option value="All"></option>
{filterOptions.entities.map(e => <option key={e} value={e}>{e}</option>)}
</select>
</div>
</div>
{/* Region Code */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<select
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterRegionCode}
onChange={(e) => setFilterRegionCode(e.target.value)}
>
<option value="All"></option>
<option value="330400">330400 ()</option>
<option value="440100">440100 (广)</option>
<option value="110100">110100 ()</option>
</select>
</div>
{/* Mileage Range */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"> (KM)</label>
<input
type="number"
placeholder="不限"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterMileageRange.min}
onChange={(e) => setFilterMileageRange(prev => ({ ...prev, min: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"> (KM)</label>
<input
type="number"
placeholder="不限"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={filterMileageRange.max}
onChange={(e) => setFilterMileageRange(prev => ({ ...prev, max: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-50">
<button
onClick={() => {
setSearchTerm('');
setFilterDept('All');
setFilterPlate('All');
setFilterDateRange({ start: '', end: '' });
setFilterDate('2026-04-01');
setFilterProject('All');
setFilterEntity('All');
setFilterRegionCode('All');
setFilterMileageRange({ min: '', max: '' });
}}
className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
>
</button>
<button
onClick={() => setIsFilterOpen(false)}
className="bg-blue-600 text-white px-6 py-2 rounded-xl text-xs font-bold shadow-lg shadow-blue-100"
>
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* High Density KPI Grid */}
<div className="grid grid-cols-4 gap-2">
<div className="col-span-2 bg-slate-900 p-3 rounded-2xl text-white relative overflow-hidden">
<div className="text-[8px] font-bold text-slate-500 uppercase tracking-wider mb-1">
{sortBy === 'today' ? '今日' : '累计'} (KM)
</div>
<div className="text-2xl font-black tracking-tighter flex items-baseline gap-1">
{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()}
{sortBy === 'today' && <span className="text-blue-400 text-[10px] font-bold">{'\u2191'}12%</span>}
</div>
<div className="absolute -right-4 -bottom-4 w-12 h-12 bg-blue-500/10 rounded-full blur-xl"></div>
</div>
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm">
<div className="text-[8px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-sm font-black text-slate-800">{(stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</div>
<div className="text-[7px] text-slate-400 mt-0.5">KM/</div>
</div>
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm">
<div className="text-[8px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-sm font-black text-slate-800">{stats.vehicleCount}</div>
<div className="text-[7px] text-slate-400 mt-0.5"></div>
</div>
</div>
{/* High-Density Vehicle List - Two Columns on Tablet, One on Mobile */}
<div className="space-y-1.5">
<div className="flex items-center justify-between px-2 mb-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span>
<span className="text-[9px] font-bold text-slate-300">{total} </span>
</div>
<div className="grid grid-cols-1 gap-1.5">
{filteredVehicles.map((v) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
key={v.plate}
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 transition-all"
onClick={() => {
navigator.clipboard.writeText(v.plate);
}}
>
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${v.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} title={v.isOnline ? '在线' : '离线'}></div>
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono">{v.plate}</span>
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{v.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{v.department ? v.department.replace('业务', '') : v.rentStatus || ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{v.customer || '-'}</span>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2 flex flex-col items-end">
<div className="flex items-center gap-1 mb-0.5">
{!v.isDataSynced && (
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
)}
<div className={`text-sm font-black leading-none ${v.isDataSynced ? 'text-blue-600' : 'text-amber-600'}`}>
{v.dailyKm?.toLocaleString()} <span className="text-[8px] text-slate-400">km</span>
</div>
</div>
<div className="flex items-center gap-1">
<span className="text-[8px] font-bold text-slate-300">
{v.totalKm?.toLocaleString()} km
</span>
{!v.isDataSynced && (
<span className="text-[7px] font-bold text-amber-500/70 bg-amber-50 px-1 rounded"></span>
)}
</div>
</div>
</motion.div>
))}
</div>
{filteredVehicles.length === 0 && !loadingMore && (
<div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100">
<p className="text-xs font-bold text-slate-300"></p>
</div>
)}
{/* 加载更多提示 */}
{loadingMore && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-400">...</span>
</div>
)}
{!hasMore && filteredVehicles.length > 0 && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-300"> {total} </span>
</div>
)}
<div ref={listEndRef} />
</div>
{/* 回到顶部按钮 */}
<AnimatePresence>
{showBackToTop && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={scrollToTop}
className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-40 w-10 h-10 bg-blue-600 text-white rounded-full shadow-lg shadow-blue-200 flex items-center justify-center active:scale-95 transition-transform"
>
<ChevronsUp size={18} />
</motion.button>
)}
</AnimatePresence>
</>
);
}