feat(mileage): add OneOS source priority controls

This commit is contained in:
kkfluous
2026-07-23 14:11:52 +08:00
parent becacecc67
commit d8773ff0a0
12 changed files with 448 additions and 44 deletions

View File

@@ -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>