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>
|
||||
|
||||
@@ -4,13 +4,14 @@ import { X, Truck } from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||
} from 'recharts';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||
import Blur from '../../components/Blur';
|
||||
|
||||
interface Props {
|
||||
vehicle: MonitoringVehicle | null;
|
||||
onClose: () => void;
|
||||
sourcePriority: MileageSourceGroup[];
|
||||
}
|
||||
|
||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
||||
@@ -56,7 +57,13 @@ function formatLabel(date: string, key: RangeKey): string {
|
||||
return date.slice(5);
|
||||
}
|
||||
|
||||
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
||||
function daySourceLabel(day: VehicleRecentDay): string {
|
||||
if (day.sourceCategory === 'INSTRUMENT') return '仪表数据';
|
||||
if (day.sourceCategory === 'GPS') return 'GPS数据';
|
||||
return day.isDataSynced ? '来源待接口' : '无数据';
|
||||
}
|
||||
|
||||
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
|
||||
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [range, setRange] = useState<RangeKey>('last15');
|
||||
@@ -74,12 +81,12 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
||||
setLoading(true);
|
||||
setDays([]);
|
||||
let cancelled = false;
|
||||
fetchVehicleRecent(vehicle.plate, { start, end })
|
||||
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
||||
.then(d => { if (!cancelled) setDays(d.days); })
|
||||
.catch(() => { if (!cancelled) setDays([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 锁滚动
|
||||
useEffect(() => {
|
||||
@@ -280,7 +287,21 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
||||
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
|
||||
<div className="w-[88px] flex-shrink-0">
|
||||
<div className="text-[11px] font-mono font-bold text-slate-600">{d.date}</div>
|
||||
<div
|
||||
className={`text-[8px] font-bold ${
|
||||
d.sourceCategory === 'INSTRUMENT'
|
||||
? 'text-violet-500'
|
||||
: d.sourceCategory === 'GPS'
|
||||
? 'text-emerald-500'
|
||||
: 'text-amber-500'
|
||||
}`}
|
||||
title={d.sourceProtocol || 'OneOS 当前未返回 sourceProtocol'}
|
||||
>
|
||||
{daySourceLabel(d)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||
import type {
|
||||
MileageSourceCategory,
|
||||
MileageSourceGroup,
|
||||
MileageSourceProtocol,
|
||||
MonitoringData,
|
||||
TargetSummary,
|
||||
TargetVehicle,
|
||||
TrendPoint,
|
||||
} from './types';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
|
||||
const BASE = '/api/mileage';
|
||||
@@ -25,6 +33,7 @@ export async function fetchMonitoring(params?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
brands?: string[];
|
||||
sourcePriority?: MileageSourceGroup[];
|
||||
}): Promise<MonitoringData> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
||||
@@ -56,6 +65,7 @@ export async function fetchMonitoring(params?: {
|
||||
if (params?.date) query.set('date', params.date);
|
||||
if (params?.startDate) query.set('startDate', params.startDate);
|
||||
if (params?.endDate) query.set('endDate', params.endDate);
|
||||
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
|
||||
const qs = query.toString();
|
||||
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -82,6 +92,8 @@ export interface VehicleRecentDay {
|
||||
date: string;
|
||||
dailyKm: number;
|
||||
isDataSynced: boolean;
|
||||
sourceProtocol: MileageSourceProtocol | null;
|
||||
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
|
||||
}
|
||||
|
||||
export interface VehicleRecentResponse {
|
||||
@@ -89,16 +101,23 @@ export interface VehicleRecentResponse {
|
||||
start?: string;
|
||||
end?: string;
|
||||
days: VehicleRecentDay[];
|
||||
sourcePriority?: MileageSourceGroup[];
|
||||
}
|
||||
|
||||
export async function fetchVehicleRecent(
|
||||
plate: string,
|
||||
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
||||
range: {
|
||||
days?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
sourcePriority?: MileageSourceGroup[];
|
||||
} = { days: 15 },
|
||||
): Promise<VehicleRecentResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (range.start) params.set('start', range.start);
|
||||
if (range.end) params.set('end', range.end);
|
||||
if (range.days != null) params.set('days', String(range.days));
|
||||
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
|
||||
return fetchJson<VehicleRecentResponse>(
|
||||
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
||||
);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export type MileageSourceGroup = 'instrument' | 'gps';
|
||||
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS' | 'MIXED';
|
||||
export type MileageSourceProtocol = 'GB32960' | 'MQTT' | 'JT808';
|
||||
|
||||
export interface MonitoringVehicle {
|
||||
plate: string;
|
||||
vin: string;
|
||||
@@ -5,6 +9,9 @@ export interface MonitoringVehicle {
|
||||
dailyMileage?: Record<string, number>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
sourceProtocol: MileageSourceProtocol | null;
|
||||
sourceCategory: MileageSourceCategory | null;
|
||||
dailySourceProtocols?: Record<string, MileageSourceProtocol | null>;
|
||||
dataTime: string | null;
|
||||
calculatedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
@@ -51,6 +58,7 @@ export interface MonitoringData {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
updatedAt: string;
|
||||
sourcePriority: MileageSourceGroup[];
|
||||
}
|
||||
|
||||
export interface TargetSummary {
|
||||
|
||||
@@ -9,10 +9,17 @@ interface ExportContext {
|
||||
}
|
||||
|
||||
const BASE_HEADERS = [
|
||||
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||
'运营区域',
|
||||
] as const;
|
||||
|
||||
function sourceLabel(v: MonitoringVehicle): string {
|
||||
if (v.sourceCategory === 'INSTRUMENT') return '仪表数据';
|
||||
if (v.sourceCategory === 'GPS') return 'GPS数据';
|
||||
if (v.sourceCategory === 'MIXED') return '仪表数据+GPS数据';
|
||||
return v.isDataSynced ? '来源待接口' : '无数据';
|
||||
}
|
||||
|
||||
function statusLabel(v: MonitoringVehicle): string {
|
||||
if (!v.isDataSynced) return '未对接';
|
||||
return v.isOnline ? '在线' : '离线';
|
||||
@@ -49,6 +56,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
...vehicles.map(v => {
|
||||
const baseRow = [
|
||||
statusLabel(v),
|
||||
sourceLabel(v),
|
||||
v.plate,
|
||||
v.customer || '',
|
||||
v.department || '',
|
||||
@@ -71,6 +79,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
const numFixedCols = BASE_HEADERS.length;
|
||||
const wsCols: { wch: number }[] = [
|
||||
{ wch: 8 }, // 状态
|
||||
{ wch: 16 }, // 数据来源
|
||||
{ wch: 12 }, // 车牌号
|
||||
{ wch: 28 }, // 客户
|
||||
{ wch: 14 }, // 业务部门
|
||||
@@ -111,7 +120,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
// 每日明细 sheet:保留原有格式
|
||||
if (dayKeys.length > 0) {
|
||||
const detailHeaders = [
|
||||
'车牌号', '客户', '业务部门', '项目', '租赁状态', '运营区域',
|
||||
'车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域',
|
||||
...dayKeys.map(day => `${day}里程(km)`),
|
||||
'区间合计(km)',
|
||||
'累计里程(km)',
|
||||
@@ -120,6 +129,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
detailHeaders,
|
||||
...vehicles.map(v => [
|
||||
v.plate,
|
||||
sourceLabel(v),
|
||||
v.customer || '',
|
||||
v.department || '',
|
||||
v.project || '',
|
||||
@@ -133,6 +143,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
|
||||
detailWs['!cols'] = [
|
||||
{ wch: 12 },
|
||||
{ wch: 16 },
|
||||
{ wch: 28 },
|
||||
{ wch: 14 },
|
||||
{ wch: 16 },
|
||||
@@ -142,9 +153,9 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
{ wch: 14 },
|
||||
{ wch: 14 },
|
||||
];
|
||||
detailWs['!freeze'] = { xSplit: 6, ySplit: 1 } as never;
|
||||
detailWs['!freeze'] = { xSplit: 7, ySplit: 1 } as never;
|
||||
for (let r = 1; r < detailData.length; r++) {
|
||||
for (let c = 6; c < detailHeaders.length; c++) {
|
||||
for (let c = 7; c < detailHeaders.length; c++) {
|
||||
const ref = XLSX.utils.encode_cell({ r, c });
|
||||
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user