All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
承接上一笔修复:兜底拿到 totalKm 后,今日里程也应该理解为 0(这车 在系统里有数据,只是今天没增量),而不是再贴「未对接」标签。 涉及: - MonitoringView 表格 + 卡片:dailyKm 显示与对应颜色 / amber 点 - xlsx-export「今日里程」列 判定改成 (isDataSynced || totalKm != null); isOnline / 在线-离线 标签不变(基于 dailyKm > 0,与本次语义无关)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
988 lines
50 KiB
TypeScript
988 lines
50 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||
import { motion, AnimatePresence } from 'motion/react';
|
||
import {
|
||
Truck, Filter, ChevronDown,
|
||
Maximize2, Minimize2, RotateCcw,
|
||
ArrowUp, ArrowDown, ChevronsUp, Download,
|
||
} from 'lucide-react';
|
||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||
import { fetchMonitoring } from './api';
|
||
import Blur from '../../components/Blur';
|
||
import PlateMultiSelect from './PlateMultiSelect';
|
||
import { exportMileageXlsx } from './xlsx-export';
|
||
import VehicleDetailModal from './VehicleDetailModal';
|
||
|
||
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 === '__EMPTY__' ? '无值' : 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 === '__EMPTY__' ? '无值' : 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);
|
||
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
|
||
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
|
||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||
|
||
// New filters from image
|
||
const [filterPlates, setFilterPlates] = useState<string[]>([]);
|
||
const [filterCustomer, setFilterCustomer] = useState('All');
|
||
const [filterProject, setFilterProject] = useState('All');
|
||
const [filterEntity, setFilterEntity] = useState('All');
|
||
const [filterRentStatus, setFilterRentStatus] = useState('All');
|
||
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
|
||
const [filterTargetName, setFilterTargetName] = useState('All');
|
||
const [filterRegion, setFilterRegion] = useState('All');
|
||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||
const [exporting, setExporting] = useState(false);
|
||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||
const [filterDate, setFilterDate] = useState(() => {
|
||
const now = new Date();
|
||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||
});
|
||
|
||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] });
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [hasMore, setHasMore] = useState(true);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [pageLoading, setPageLoading] = useState(true);
|
||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||
const PAGE_SIZE = 50;
|
||
|
||
const departments = filterOptions.departments;
|
||
const plateNumbers = filterOptions.plates;
|
||
|
||
// 加载首页数据
|
||
const loadFirstPage = useCallback(() => {
|
||
setPageLoading(true);
|
||
fetchMonitoring({
|
||
sortBy,
|
||
sortOrder,
|
||
limit: PAGE_SIZE,
|
||
page: 1,
|
||
search: searchTerm || undefined,
|
||
dept: filterDept !== 'All' ? filterDept : undefined,
|
||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||
project: filterProject !== 'All' ? filterProject : undefined,
|
||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||
mileageMin: appliedMileageRange.min || undefined,
|
||
mileageMax: appliedMileageRange.max || undefined,
|
||
date: filterDate || undefined,
|
||
}).then(d => {
|
||
setVehicles(d.vehicles);
|
||
setStats(d.stats);
|
||
setFilterOptions(d.filters);
|
||
setTotal(d.total);
|
||
setPage(1);
|
||
setHasMore(d.page < d.totalPages);
|
||
}).catch(() => {}).finally(() => setPageLoading(false));
|
||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||
|
||
// 加载更多
|
||
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,
|
||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||
project: filterProject !== 'All' ? filterProject : undefined,
|
||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||
mileageMin: appliedMileageRange.min || undefined,
|
||
mileageMax: appliedMileageRange.max || undefined,
|
||
date: filterDate || undefined,
|
||
}).then(d => {
|
||
setVehicles(prev => [...prev, ...d.vehicles]);
|
||
setPage(nextPage);
|
||
setHasMore(nextPage < d.totalPages);
|
||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
|
||
|
||
// 筛选/排序变化时重新加载
|
||
useEffect(() => {
|
||
loadFirstPage();
|
||
}, [loadFirstPage]);
|
||
|
||
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
||
useEffect(() => {
|
||
if (filterPlates.length === 0) return;
|
||
const valid = new Set(filterOptions.plates);
|
||
const next = filterPlates.filter(p => valid.has(p));
|
||
if (next.length !== filterPlates.length) setFilterPlates(next);
|
||
}, [filterOptions.plates, filterPlates]);
|
||
|
||
// 下载当前筛选结果为 xlsx
|
||
const handleDownload = useCallback(async () => {
|
||
if (exporting) return;
|
||
setExporting(true);
|
||
try {
|
||
const d = await fetchMonitoring({
|
||
sortBy,
|
||
sortOrder,
|
||
limit: 9999,
|
||
page: 1,
|
||
search: searchTerm || undefined,
|
||
dept: filterDept !== 'All' ? filterDept : undefined,
|
||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||
project: filterProject !== 'All' ? filterProject : undefined,
|
||
entity: filterEntity !== 'All' ? filterEntity : undefined,
|
||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||
mileageMin: appliedMileageRange.min || undefined,
|
||
mileageMax: appliedMileageRange.max || undefined,
|
||
date: filterDate || undefined,
|
||
});
|
||
exportMileageXlsx(d.vehicles, { date: filterDate, sortBy });
|
||
} catch (err) {
|
||
console.error('export failed', err);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
|
||
|
||
// 每分钟自动刷新
|
||
useEffect(() => {
|
||
const timer = setInterval(loadFirstPage, 60 * 1000);
|
||
return () => clearInterval(timer);
|
||
}, [loadFirstPage]);
|
||
|
||
// 触底检测:用 IntersectionObserver 监听哨兵元素
|
||
const loadMoreRef = useRef(loadMore);
|
||
loadMoreRef.current = loadMore;
|
||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const sentinel = sentinelRef.current;
|
||
if (!sentinel) return;
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries[0].isIntersecting) {
|
||
loadMoreRef.current();
|
||
}
|
||
},
|
||
{ rootMargin: '200px' }
|
||
);
|
||
observer.observe(sentinel);
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
// 回到顶部按钮:用 IntersectionObserver 检测顶部哨兵是否离开视口
|
||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
const el = topSentinelRef.current;
|
||
if (!el) return;
|
||
const observer = new IntersectionObserver(
|
||
([entry]) => setShowBackToTop(!entry.isIntersecting),
|
||
);
|
||
observer.observe(el);
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
const scrollToTop = () => {
|
||
window.scrollTo(0, 0);
|
||
document.documentElement.scrollTop = 0;
|
||
document.body.scrollTop = 0;
|
||
};
|
||
|
||
const filteredVehicles = vehicles;
|
||
|
||
const toggleFullscreen = () => {
|
||
setIsFullscreen(!isFullscreen);
|
||
};
|
||
|
||
// 全屏时加载全部数据(无分页),筛选变化时重新加载
|
||
useEffect(() => {
|
||
if (!isFullscreen) return;
|
||
setFullscreenLoading(true);
|
||
fetchMonitoring({
|
||
sortBy,
|
||
sortOrder,
|
||
limit: 9999,
|
||
page: 1,
|
||
search: searchTerm || undefined,
|
||
dept: filterDept !== 'All' ? filterDept : undefined,
|
||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
|
||
targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
|
||
region: filterRegion !== 'All' ? filterRegion : undefined,
|
||
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
|
||
date: filterDate || undefined,
|
||
}).then(d => {
|
||
setFullscreenVehicles(d.vehicles);
|
||
setFullscreenStats(d.stats);
|
||
setFilterOptions(d.filters);
|
||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, filterDate, fullscreenRefresh]);
|
||
|
||
// 全屏时禁止背景滚动
|
||
useEffect(() => {
|
||
if (isFullscreen) {
|
||
document.body.style.overflow = 'hidden';
|
||
} else {
|
||
document.body.style.overflow = '';
|
||
}
|
||
return () => { document.body.style.overflow = ''; };
|
||
}, [isFullscreen]);
|
||
|
||
// 检测是否在小程序 webview 中(微信/抖音/支付宝等),且当前是竖屏
|
||
// 小程序 webview 无法调用系统旋转 API,只能用 CSS rotate 强制横屏
|
||
const forceLandscape = useMemo(() => {
|
||
if (typeof window === 'undefined') return false;
|
||
const ua = navigator.userAgent || '';
|
||
const isMiniProgram =
|
||
/miniProgram/i.test(ua) ||
|
||
/toutiaomicroapp/i.test(ua) ||
|
||
/AlipayClient/i.test(ua) ||
|
||
(window as any).__wxjs_environment === 'miniprogram';
|
||
const isPortrait = window.innerHeight > window.innerWidth;
|
||
return isMiniProgram && isPortrait;
|
||
}, [isFullscreen]);
|
||
|
||
return (
|
||
<>
|
||
{/* 顶部哨兵:离开视口时显示回到顶部按钮 */}
|
||
<div ref={topSentinelRef} className="h-0" />
|
||
|
||
{/* Fullscreen Landscape View Overlay */}
|
||
<AnimatePresence>
|
||
{isFullscreen && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
|
||
style={
|
||
forceLandscape
|
||
? {
|
||
// 小程序 webview 无法真横屏,强制 CSS 旋转 90 度模拟横屏
|
||
top: 0,
|
||
left: '100vw',
|
||
width: '100vh',
|
||
height: '100vw',
|
||
transform: 'rotate(90deg)',
|
||
transformOrigin: 'top left',
|
||
}
|
||
: { top: 0, left: 0, right: 0, bottom: 0 }
|
||
}
|
||
>
|
||
{/* Top bar: compact inline KPI */}
|
||
<div className="flex-shrink-0 px-3 py-2 border-b border-slate-800/60 flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-0.5 h-4 bg-blue-500 rounded-full"></div>
|
||
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
||
</div>
|
||
<div className="flex items-center gap-3 text-[10px]">
|
||
<span className="text-slate-500">今日 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||
<span className="text-slate-700">|</span>
|
||
<span className="text-slate-500">累计 <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||
<span className="text-slate-700">|</span>
|
||
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
|
||
<span className="text-slate-700">|</span>
|
||
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'today' ? fullscreenStats.totalToday : fullscreenStats.totalAll) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => { setFullscreenRefresh(n => n + 1); }}
|
||
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
||
>
|
||
<RotateCcw size={13} />
|
||
</button>
|
||
<button onClick={toggleFullscreen} className="p-1.5 text-slate-500 hover:text-white transition-colors">
|
||
<Minimize2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Batch selector + legend */}
|
||
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
|
||
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
|
||
<button
|
||
onClick={() => setFilterTargetName('All')}
|
||
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === 'All' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
||
>全部</button>
|
||
{filterOptions.targetNames.map(n => (
|
||
<button
|
||
key={n}
|
||
onClick={() => setFilterTargetName(filterTargetName === n ? 'All' : n)}
|
||
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === n ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
||
>{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[8px] text-slate-500 flex-shrink-0 ml-2">
|
||
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block"></span>在线</span>
|
||
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-slate-500 inline-block"></span>离线</span>
|
||
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block"></span>未对接</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Table Area */}
|
||
<div className="flex-1 overflow-hidden flex flex-col">
|
||
|
||
<div className="flex-1 overflow-auto relative">
|
||
{fullscreenLoading && (
|
||
<div className="absolute inset-0 bg-slate-950/60 z-20 flex items-center justify-center">
|
||
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
|
||
<RotateCcw size={14} className="animate-spin" />
|
||
加载中...
|
||
</div>
|
||
</div>
|
||
)}
|
||
<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/60">
|
||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase w-12 text-center">状态</th>
|
||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||
<div className="flex flex-col gap-1">
|
||
<span>车牌号</span>
|
||
<span className="text-[9px] text-slate-500 font-normal">
|
||
{filterPlates.length === 0 ? '全部' : `已选 ${filterPlates.length}`}
|
||
</span>
|
||
</div>
|
||
</th>
|
||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||
<div className="flex flex-col gap-1">
|
||
<span>客户</span>
|
||
<select
|
||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||
value={filterCustomer}
|
||
onChange={(e) => setFilterCustomer(e.target.value)}
|
||
>
|
||
<option value="All">全部客户</option>
|
||
<option value="__EMPTY__">无值</option>
|
||
{filterOptions.customers.map(p => <option key={p} value={p}>{p}</option>)}
|
||
</select>
|
||
</div>
|
||
</th>
|
||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||
<div className="flex flex-col gap-1">
|
||
<span>租赁状态</span>
|
||
<select
|
||
className="bg-slate-800 border-none rounded px-2 py-0.5 text-[9px] text-slate-300 outline-none focus:ring-1 focus:ring-blue-500/30"
|
||
value={filterRentStatus}
|
||
onChange={(e) => setFilterRentStatus(e.target.value)}
|
||
>
|
||
<option value="All">全部状态</option>
|
||
{filterOptions.rentStatuses.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
</th>
|
||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||
<div className="flex flex-col gap-1">
|
||
<span>部门</span>
|
||
<select
|
||
className="bg-slate-800 border-none rounded px-2 py-0.5 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>
|
||
<option value="__EMPTY__">无值</option>
|
||
{departments.map(d => <option key={d} value={d}>{d.replace('业务', '')}</option>)}
|
||
</select>
|
||
</div>
|
||
</th>
|
||
<th
|
||
className="px-3 py-2 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="px-3 py-2 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>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/30">
|
||
{fullscreenVehicles.map((v) => (
|
||
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
||
<td className="px-3 py-2 text-center">
|
||
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
|
||
</td>
|
||
<td className="px-3 py-2 text-xs font-bold text-white"><Blur>{v.plate}</Blur></td>
|
||
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
|
||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
|
||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? 'text-blue-400' : 'text-amber-400'}`}>
|
||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-slate-600'}`}>
|
||
{v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||
</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>
|
||
<button
|
||
onClick={handleDownload}
|
||
disabled={exporting}
|
||
className="p-1 text-slate-300 hover:text-blue-600 transition-colors disabled:text-slate-200"
|
||
title="下载当前筛选结果"
|
||
>
|
||
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download 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">实时监控 • 每分钟更新</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: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||
<SearchableSelect
|
||
options={filterOptions.targetNames}
|
||
value={filterTargetName}
|
||
onChange={setFilterTargetName}
|
||
placeholder="批次型号"
|
||
/>
|
||
<SearchableSelect
|
||
options={filterOptions.regions}
|
||
value={filterRegion}
|
||
onChange={setFilterRegion}
|
||
placeholder="运营区域"
|
||
/>
|
||
<PlateMultiSelect
|
||
allPlates={plateNumbers}
|
||
selected={filterPlates}
|
||
onChange={setFilterPlates}
|
||
placeholder="按车牌"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
|
||
>
|
||
<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">
|
||
{/* Date */}
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">查询日期</label>
|
||
<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)}
|
||
/>
|
||
</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>
|
||
<option value="__EMPTY__">无值</option>
|
||
{departments.map(d => <option key={d} value={d}>{d}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Customer */}
|
||
<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={filterCustomer}
|
||
onChange={(e) => setFilterCustomer(e.target.value)}
|
||
>
|
||
<option value="All">无限制</option>
|
||
<option value="__EMPTY__">无值</option>
|
||
{filterOptions.customers.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{/* 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>
|
||
|
||
{/* Rent Status */}
|
||
<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={filterRentStatus}
|
||
onChange={(e) => setFilterRentStatus(e.target.value)}
|
||
>
|
||
<option value="All">无限制</option>
|
||
{filterOptions.rentStatuses.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{/* 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>
|
||
|
||
{/* Plate Prefix */}
|
||
<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={filterPlatePrefix}
|
||
onChange={(e) => setFilterPlatePrefix(e.target.value)}
|
||
>
|
||
<option value="All">无限制</option>
|
||
{filterOptions.platePrefixes.map(p => <option key={p.prefix} value={p.prefix}>{p.prefix}({p.count})</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');
|
||
setFilterPlates([]);
|
||
setFilterCustomer('All');
|
||
setFilterProject('All');
|
||
setFilterEntity('All');
|
||
setFilterPlatePrefix('All');
|
||
setFilterTargetName('All');
|
||
setFilterRegion('All');
|
||
setFilterMileageRange({ min: '', max: '' });
|
||
setAppliedMileageRange({ min: '', max: '' });
|
||
}}
|
||
className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
|
||
>
|
||
重置所有
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setAppliedMileageRange({ ...filterMileageRange });
|
||
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>
|
||
|
||
{/* Active Filter Tags */}
|
||
{(() => {
|
||
const tags: { label: string; onClear: () => void }[] = [];
|
||
if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
|
||
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') });
|
||
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]} 等${filterPlates.length}`}`, onClear: () => setFilterPlates([]) });
|
||
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
|
||
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
|
||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
|
||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
|
||
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => setSearchTerm('') });
|
||
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, min: '' })); setAppliedMileageRange(prev => ({ ...prev, min: '' })); } });
|
||
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { setFilterMileageRange(prev => ({ ...prev, max: '' })); setAppliedMileageRange(prev => ({ ...prev, max: '' })); } });
|
||
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => setFilterPlatePrefix('All') });
|
||
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
|
||
if (tags.length === 0) return null;
|
||
const clearAll = () => {
|
||
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
||
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); setFilterRegion('All');
|
||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||
setFilterDate('');
|
||
};
|
||
return (
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{tags.map((tag, i) => (
|
||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-bold">
|
||
{tag.label}
|
||
<button onClick={tag.onClear} className="hover:text-blue-800 ml-0.5">×</button>
|
||
</span>
|
||
))}
|
||
<button onClick={clearAll} className="text-[10px] font-bold text-rose-500 hover:text-rose-600 ml-auto">
|
||
清除
|
||
</button>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Sticky header: KPI + 清单标题 */}
|
||
<div className="sticky top-[44px] z-20 bg-[#F8F9FB] pt-1 pb-1 space-y-2">
|
||
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
|
||
<div className="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden">
|
||
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? '今日' : '累计'}总里程</div>
|
||
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
|
||
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
|
||
</div>
|
||
</div>
|
||
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||
<div className="text-[7px] font-bold text-slate-400 uppercase">平均单车</div>
|
||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</div>
|
||
<div className="text-[7px] text-slate-400">km/台</div>
|
||
</div>
|
||
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||
<div className="text-[7px] font-bold text-slate-400 uppercase">监控台数</div>
|
||
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : stats.vehicleCount}</div>
|
||
<div className="text-[7px] text-slate-400">台</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between px-2">
|
||
<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>
|
||
|
||
{/* Vehicle List */}
|
||
<div className="space-y-1.5">
|
||
|
||
{pageLoading && (
|
||
<div className="space-y-1.5">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<div key={i} className="bg-white px-3 py-3 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between animate-pulse">
|
||
<div className="flex items-center gap-3 flex-1">
|
||
<div className="w-8 h-8 rounded-lg bg-slate-100"></div>
|
||
<div className="space-y-1.5 flex-1">
|
||
<div className="h-3 bg-slate-100 rounded w-24"></div>
|
||
<div className="h-2 bg-slate-50 rounded w-36"></div>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1.5 text-right">
|
||
<div className="h-4 bg-slate-100 rounded w-16 ml-auto"></div>
|
||
<div className="h-2 bg-slate-50 rounded w-20 ml-auto"></div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</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 cursor-pointer transition-all"
|
||
onClick={() => setDetailVehicle(v)}
|
||
>
|
||
<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"><Blur>{v.plate}</Blur></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.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
|
||
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></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 && v.totalKm == null && (
|
||
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
||
)}
|
||
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none">今</span>
|
||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? 'text-blue-600' : 'text-amber-600'}`}>
|
||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-[7px] font-black text-slate-400/60 bg-slate-100 w-3 h-3 rounded flex items-center justify-center leading-none">总</span>
|
||
<span className="text-[8px] font-bold text-slate-300">
|
||
{v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'}
|
||
</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={sentinelRef} className="h-1" />
|
||
</div>
|
||
|
||
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
|
||
|
||
{/* 回到顶部按钮 */}
|
||
<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>
|
||
</>
|
||
);
|
||
}
|