feat(mileage): add OneOS source priority controls
This commit is contained in:
@@ -4,10 +4,15 @@ import {
|
||||
Truck, Filter, ChevronDown,
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
|
||||
RefreshCw,
|
||||
RefreshCw, ArrowLeftRight, Power,
|
||||
} from 'lucide-react';
|
||||
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts';
|
||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||
import type {
|
||||
MileageSourceGroup,
|
||||
MonitoringVehicle,
|
||||
MonitoringStats,
|
||||
MonitoringFilters,
|
||||
} from './types';
|
||||
import { fetchMonitoring } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
import PlateMultiSelect from './PlateMultiSelect';
|
||||
@@ -19,6 +24,116 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
||||
'交投190辆4.5T冷链车',
|
||||
]);
|
||||
const HIGH_MILEAGE_ALERT_KM = 800;
|
||||
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
|
||||
|
||||
const MILEAGE_SOURCE_META = {
|
||||
instrument: { label: '仪表数据', protocol: '32960 > MQTT' },
|
||||
gps: { label: 'GPS数据', protocol: 'JT808' },
|
||||
} as const;
|
||||
|
||||
function vehicleSourceDisplay(vehicle: MonitoringVehicle): {
|
||||
label: string;
|
||||
title: string;
|
||||
className: string;
|
||||
} {
|
||||
if (vehicle.sourceCategory === 'INSTRUMENT') {
|
||||
return {
|
||||
label: '仪表数据',
|
||||
title: vehicle.sourceProtocol || 'GB32960 / MQTT',
|
||||
className: 'bg-violet-50 text-violet-600',
|
||||
};
|
||||
}
|
||||
if (vehicle.sourceCategory === 'GPS') {
|
||||
return {
|
||||
label: 'GPS数据',
|
||||
title: vehicle.sourceProtocol || 'JT808',
|
||||
className: 'bg-emerald-50 text-emerald-600',
|
||||
};
|
||||
}
|
||||
if (vehicle.sourceCategory === 'MIXED') {
|
||||
return {
|
||||
label: '仪表+GPS',
|
||||
title: '所选区间内包含仪表数据和 GPS 数据',
|
||||
className: 'bg-blue-50 text-blue-600',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: vehicle.isDataSynced ? '来源待接口' : '无数据',
|
||||
title: vehicle.isDataSynced
|
||||
? 'OneOS 当前响应未返回 sourceProtocol'
|
||||
: '所选来源没有有效数据',
|
||||
className: vehicle.isDataSynced
|
||||
? 'bg-amber-50 text-amber-600'
|
||||
: 'bg-slate-100 text-slate-400',
|
||||
};
|
||||
}
|
||||
|
||||
function SourcePriorityControl({
|
||||
priority,
|
||||
onChange,
|
||||
}: {
|
||||
priority: MileageSourceGroup[];
|
||||
onChange: (next: MileageSourceGroup[]) => void;
|
||||
}) {
|
||||
const ordered = [
|
||||
...priority,
|
||||
...ALL_MILEAGE_SOURCE_GROUPS.filter(group => !priority.includes(group)),
|
||||
];
|
||||
const toggle = (group: MileageSourceGroup) => {
|
||||
if (priority.includes(group)) {
|
||||
if (priority.length === 1) return;
|
||||
onChange(priority.filter(item => item !== group));
|
||||
return;
|
||||
}
|
||||
onChange([...priority, group]);
|
||||
};
|
||||
const swap = () => {
|
||||
if (priority.length === 2) onChange([priority[1], priority[0]]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
|
||||
<span className="shrink-0 text-[9px] font-black text-slate-400">数据来源</span>
|
||||
{ordered.map(group => {
|
||||
const enabledIndex = priority.indexOf(group);
|
||||
const enabled = enabledIndex >= 0;
|
||||
const meta = MILEAGE_SOURCE_META[group];
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
type="button"
|
||||
onClick={() => toggle(group)}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-1 transition-all ${
|
||||
enabled
|
||||
? 'border-blue-100 bg-blue-50 text-blue-700'
|
||||
: 'border-slate-100 bg-slate-50 text-slate-400'
|
||||
}`}
|
||||
title={enabled && priority.length === 1 ? '至少保留一种数据来源' : enabled ? '点击停用' : '点击启用'}
|
||||
>
|
||||
<span className={`flex h-4 w-4 items-center justify-center rounded text-[8px] font-black ${
|
||||
enabled ? 'bg-blue-600 text-white' : 'bg-slate-200 text-slate-500'
|
||||
}`}>
|
||||
{enabled ? enabledIndex + 1 : <Power size={9} />}
|
||||
</span>
|
||||
<span className="text-left leading-tight">
|
||||
<span className="block text-[9px] font-black">{meta.label}</span>
|
||||
<span className="block text-[7px] font-bold opacity-60">{enabled ? meta.protocol : '已停用'}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={swap}
|
||||
disabled={priority.length !== 2}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-slate-100 bg-white text-slate-400 transition-all hover:border-blue-100 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
title="切换数据来源优先级"
|
||||
>
|
||||
<ArrowLeftRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultMileageDate(): string {
|
||||
const now = new Date();
|
||||
@@ -354,6 +469,7 @@ export default function MonitoringView() {
|
||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
||||
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
|
||||
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument', 'gps']);
|
||||
|
||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||
@@ -418,6 +534,7 @@ export default function MonitoringView() {
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||
sourcePriority,
|
||||
}).then(d => {
|
||||
setVehicles(d.vehicles);
|
||||
setStats(d.stats);
|
||||
@@ -431,7 +548,7 @@ export default function MonitoringView() {
|
||||
}).catch(() => {}).finally(() => {
|
||||
if (showPageLoading) setPageLoading(false);
|
||||
});
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
|
||||
|
||||
const handleManualRefresh = useCallback(async () => {
|
||||
if (manualRefreshing) return;
|
||||
@@ -468,12 +585,13 @@ export default function MonitoringView() {
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||
sourcePriority,
|
||||
}).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, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands]);
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]);
|
||||
|
||||
// 筛选/排序变化时重新加载
|
||||
useEffect(() => {
|
||||
@@ -513,6 +631,7 @@ export default function MonitoringView() {
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||
sourcePriority,
|
||||
});
|
||||
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
||||
} catch (err) {
|
||||
@@ -520,7 +639,7 @@ export default function MonitoringView() {
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]);
|
||||
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
|
||||
|
||||
// 每分钟自动刷新
|
||||
useEffect(() => {
|
||||
@@ -592,12 +711,13 @@ export default function MonitoringView() {
|
||||
startDate: rangeStart || undefined,
|
||||
endDate: rangeEnd || undefined,
|
||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||
sourcePriority,
|
||||
}).then(d => {
|
||||
setFullscreenVehicles(d.vehicles);
|
||||
setFullscreenStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands]);
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]);
|
||||
|
||||
// 全屏时禁止背景滚动
|
||||
useEffect(() => {
|
||||
@@ -665,6 +785,12 @@ export default function MonitoringView() {
|
||||
<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>
|
||||
<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">
|
||||
@@ -845,6 +971,7 @@ export default function MonitoringView() {
|
||||
{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">
|
||||
@@ -855,6 +982,12 @@ export default function MonitoringView() {
|
||||
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||
{statisticTime.label}
|
||||
</div>
|
||||
<span
|
||||
className={`mt-0.5 inline-flex rounded px-1 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
||||
title={sourceDisplay.title}
|
||||
>
|
||||
{sourceDisplay.label}
|
||||
</span>
|
||||
</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>
|
||||
@@ -926,6 +1059,8 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SourcePriorityControl priority={sourcePriority} onChange={setSourcePriority} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grid flex-1 grid-cols-3 gap-1.5">
|
||||
<BatchMultiSelect options={filterOptions.targetNames} selected={filterTargetNames} onChange={setFilterTargetNames} placeholder="批次型号" />
|
||||
@@ -1343,6 +1478,7 @@ export default function MonitoringView() {
|
||||
{filteredVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
const sourceDisplay = vehicleSourceDisplay(v);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -1368,6 +1504,12 @@ export default function MonitoringView() {
|
||||
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||
{statisticTime.label}
|
||||
</div>
|
||||
<span
|
||||
className={`mt-0.5 inline-flex rounded px-1 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
||||
title={sourceDisplay.title}
|
||||
>
|
||||
{sourceDisplay.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 md:hidden">
|
||||
<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>
|
||||
@@ -1435,7 +1577,11 @@ export default function MonitoringView() {
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
|
||||
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
|
||||
<VehicleDetailModal
|
||||
vehicle={detailVehicle}
|
||||
onClose={() => setDetailVehicle(null)}
|
||||
sourcePriority={sourcePriority}
|
||||
/>
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
<AnimatePresence>
|
||||
|
||||
Reference in New Issue
Block a user