331 lines
17 KiB
TypeScript
331 lines
17 KiB
TypeScript
import type { Dispatch, SetStateAction } from 'react';
|
|
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
|
|
import { motion } from 'motion/react';
|
|
import Blur from '../../../../components/Blur';
|
|
import type { MileageSourceGroup, MonitoringFilters, MonitoringStats, MonitoringVehicle } from '../../types';
|
|
import { vehicleStatisticTime } from '../oneos-time';
|
|
import { MILEAGE_SOURCE_META, vehicleSourceDisplay } from '../source-display';
|
|
|
|
type SortBy = 'today' | 'total' | 'statisticTime';
|
|
type SortOrder = 'asc' | 'desc';
|
|
|
|
export default function FullscreenMonitoring({
|
|
forceLandscape,
|
|
fullscreenStats,
|
|
sortBy,
|
|
sourcePriority,
|
|
onRefresh,
|
|
fullscreenLoading,
|
|
onToggleFullscreen,
|
|
filterTargetNames,
|
|
onFilterTargetNamesChange,
|
|
filterOptions,
|
|
filterPlates,
|
|
filterCustomer,
|
|
onFilterCustomerChange,
|
|
filterBrands,
|
|
onFilterBrandsChange,
|
|
filterRentStatus,
|
|
onFilterRentStatusChange,
|
|
filterDept,
|
|
onFilterDeptChange,
|
|
departments,
|
|
sortOrder,
|
|
onSortByChange,
|
|
onSortOrderChange,
|
|
fullscreenVehicles,
|
|
isHighMileageAlert,
|
|
relativeNow,
|
|
}: {
|
|
forceLandscape: boolean;
|
|
fullscreenStats: MonitoringStats;
|
|
sortBy: SortBy;
|
|
sourcePriority: MileageSourceGroup[];
|
|
onRefresh: () => void;
|
|
fullscreenLoading: boolean;
|
|
onToggleFullscreen: () => void;
|
|
filterTargetNames: string[];
|
|
onFilterTargetNamesChange: Dispatch<SetStateAction<string[]>>;
|
|
filterOptions: MonitoringFilters;
|
|
filterPlates: string[];
|
|
filterCustomer: string;
|
|
onFilterCustomerChange: (value: string) => void;
|
|
filterBrands: string[];
|
|
onFilterBrandsChange: (value: string[]) => void;
|
|
filterRentStatus: string;
|
|
onFilterRentStatusChange: (value: string) => void;
|
|
filterDept: string;
|
|
onFilterDeptChange: (value: string) => void;
|
|
departments: string[];
|
|
sortOrder: SortOrder;
|
|
onSortByChange: Dispatch<SetStateAction<SortBy>>;
|
|
onSortOrderChange: Dispatch<SetStateAction<SortOrder>>;
|
|
fullscreenVehicles: MonitoringVehicle[];
|
|
isHighMileageAlert: (vehicle: MonitoringVehicle) => boolean;
|
|
relativeNow: number;
|
|
}) {
|
|
return (
|
|
<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 === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</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">
|
|
{sourcePriority.map(group => MILEAGE_SOURCE_META[group].label).join(' > ')}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onRefresh}
|
|
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
|
>
|
|
<RotateCcw size={13} />
|
|
</button>
|
|
<button onClick={onToggleFullscreen} 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={() => onFilterTargetNamesChange([])}
|
|
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.length === 0 ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
|
|
>全部</button>
|
|
{filterOptions.targetNames.map(n => (
|
|
<button
|
|
key={n}
|
|
onClick={() => onFilterTargetNamesChange(prev => prev.includes(n) ? prev.filter(item => item !== n) : [...prev, n])}
|
|
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.includes(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>
|
|
<input
|
|
type="text"
|
|
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 w-20"
|
|
placeholder={filterCustomer === 'All' ? '全部' : filterCustomer}
|
|
defaultValue=""
|
|
onBlur={(e) => {
|
|
const val = e.target.value.trim();
|
|
onFilterCustomerChange(val || 'All');
|
|
e.target.value = '';
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
const val = (e.target as HTMLInputElement).value.trim();
|
|
onFilterCustomerChange(val || 'All');
|
|
(e.target as HTMLInputElement).value = '';
|
|
}
|
|
}}
|
|
/>
|
|
</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>
|
|
<input
|
|
type="text"
|
|
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 w-16"
|
|
placeholder={filterBrands.length === 0 ? '全部' : `已选${filterBrands.length}`}
|
|
defaultValue=""
|
|
onBlur={(e) => {
|
|
const val = e.target.value.trim();
|
|
if (val) {
|
|
onFilterBrandsChange(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]);
|
|
}
|
|
e.target.value = '';
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
const val = (e.target as HTMLInputElement).value.trim();
|
|
if (val) {
|
|
onFilterBrandsChange(filterBrands.includes(val) ? filterBrands.filter(b => b !== val) : [...filterBrands, val]);
|
|
}
|
|
(e.target as HTMLInputElement).value = '';
|
|
}
|
|
}}
|
|
/>
|
|
</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) => onFilterRentStatusChange(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) => onFilterDeptChange(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') {
|
|
onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc');
|
|
} else {
|
|
onSortByChange('today');
|
|
onSortOrderChange('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') {
|
|
onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc');
|
|
} else {
|
|
onSortByChange('total');
|
|
onSortOrderChange('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) => {
|
|
const highMileageAlert = isHighMileageAlert(v);
|
|
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
|
const sourceDisplay = vehicleSourceDisplay(v);
|
|
return (
|
|
<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">
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
|
|
<span
|
|
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
|
title={sourceDisplay.title}
|
|
>
|
|
{sourceDisplay.label}
|
|
</span>
|
|
</div>
|
|
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
|
{statisticTime.label}
|
|
</div>
|
|
</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.brand || '-'}</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) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
|
|
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{v.mileageAnomaly ? '' : '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>
|
|
);
|
|
}
|