feat: 实现里程管理实时监控视图(1:1 复刻原型)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,774 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import {
|
||||||
|
Truck, Search, Filter, ChevronDown,
|
||||||
|
Maximize2, Minimize2, RotateCcw,
|
||||||
|
ArrowUp, ArrowDown,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { MonitoringVehicle } 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 => (
|
||||||
|
<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() {
|
export default function MonitoringView() {
|
||||||
return <div>MonitoringView placeholder</div>;
|
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 [filterYear, setFilterYear] = 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 [allVehicles, setAllVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => fetchMonitoring().then(d => setAllVehicles(d.vehicles)).catch(() => {});
|
||||||
|
load();
|
||||||
|
const interval = setInterval(load, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (!isFullscreen) {
|
||||||
|
const elem = document.documentElement;
|
||||||
|
if (elem.requestFullscreen) {
|
||||||
|
elem.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsFullscreen(!isFullscreen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredVehicles = useMemo(() => {
|
||||||
|
return allVehicles.filter(v => {
|
||||||
|
const matchesSearch = v.plate.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(v.customer || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesDept = filterDept === 'All' || v.department === filterDept;
|
||||||
|
const matchesPlate = filterPlate === 'All' || v.plate === filterPlate;
|
||||||
|
const matchesProject = filterProject === 'All' || v.customer === filterProject;
|
||||||
|
const matchesEntity = filterEntity === 'All' || true;
|
||||||
|
const matchesRegion = filterRegionCode === 'All' || true;
|
||||||
|
|
||||||
|
// Mileage range filter
|
||||||
|
const mileage = v.dailyKm || 0;
|
||||||
|
const minMileage = filterMileageRange.min === '' ? -Infinity : Number(filterMileageRange.min);
|
||||||
|
const maxMileage = filterMileageRange.max === '' ? Infinity : Number(filterMileageRange.max);
|
||||||
|
const matchesMileage = mileage >= minMileage && mileage <= maxMileage;
|
||||||
|
|
||||||
|
return matchesSearch && matchesDept && matchesPlate && matchesProject && matchesEntity && matchesRegion && matchesMileage;
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const valA = sortBy === 'today' ? (a.dailyKm || 0) : (a.totalKm || 0);
|
||||||
|
const valB = sortBy === 'today' ? (b.dailyKm || 0) : (b.totalKm || 0);
|
||||||
|
return sortOrder === 'desc' ? valB - valA : valA - valB;
|
||||||
|
});
|
||||||
|
}, [allVehicles, searchTerm, filterDept, sortBy, sortOrder, filterPlate, filterProject, filterEntity, filterRegionCode, filterMileageRange]);
|
||||||
|
|
||||||
|
const departments = Array.from(new Set(allVehicles.map(v => v.department).filter(Boolean))) as string[];
|
||||||
|
const plateNumbers = Array.from(new Set(allVehicles.map(v => v.plate).filter(Boolean)));
|
||||||
|
const projects = Array.from(new Set(allVehicles.map(v => v.customer).filter(Boolean))) as string[];
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const totalToday = filteredVehicles.reduce((sum, v) => sum + (v.dailyKm || 0), 0);
|
||||||
|
const totalAll = filteredVehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||||
|
|
||||||
|
const activeTotal = sortBy === 'today' ? totalToday : totalAll;
|
||||||
|
const activeAvg = filteredVehicles.length > 0 ? activeTotal / filteredVehicles.length : 0;
|
||||||
|
|
||||||
|
return { totalToday, totalAll, activeTotal, activeAvg, count: filteredVehicles.length };
|
||||||
|
}, [filteredVehicles, sortBy]);
|
||||||
|
|
||||||
|
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.count}
|
||||||
|
<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.activeAvg.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}</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Year */}
|
||||||
|
<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={filterYear}
|
||||||
|
onChange={(e) => setFilterYear(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="All">无限制</option>
|
||||||
|
<option value="2026">2026年</option>
|
||||||
|
<option value="2025">2025年</option>
|
||||||
|
<option value="2024">2024年</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>
|
||||||
|
{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>
|
||||||
|
<option value="羚牛氢能">羚牛氢能</option>
|
||||||
|
<option value="其他主体">其他主体</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="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">当日里程区间 (KM)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="最小值"
|
||||||
|
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={filterMileageRange.min}
|
||||||
|
onChange={(e) => setFilterMileageRange(prev => ({ ...prev, min: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="text-slate-300">{'\u2264'} 值 {'\u2264'}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="最大值"
|
||||||
|
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={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');
|
||||||
|
setFilterYear('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">
|
||||||
|
{stats.activeTotal.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.activeAvg.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.count}</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">{filteredVehicles.length} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-1.5">
|
||||||
|
{filteredVehicles.slice(0, 60).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-[9px] font-bold text-slate-600 truncate max-w-[80px]">{v.customer || '未分配'}</span>
|
||||||
|
<span className="text-[8px] text-slate-300 font-bold uppercase">{v.department?.replace('业务', '')}</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()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[8px] font-bold text-slate-300">
|
||||||
|
{v.totalKm?.toLocaleString()}
|
||||||
|
</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 && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user