refactor: modularize application domains

This commit is contained in:
kkfluous
2026-08-13 12:03:34 +08:00
parent d610e4b841
commit 06fe75b4c7
142 changed files with 14645 additions and 8885 deletions
@@ -0,0 +1,121 @@
import type { Dispatch, SetStateAction } from 'react';
import type { MonitoringFilters } from '../../types';
import { defaultMileageDate, normalizeRangeLabel } from '../date-range';
type MileageRange = { min: string; max: string };
export default function ActiveFilterTags({
filterTargetNames,
filterOptions,
onFilterTargetNamesChange,
filterRegion,
onFilterRegionChange,
filterPlates,
onFilterPlatesChange,
filterRentStatus,
onFilterRentStatusChange,
filterDept,
onFilterDeptChange,
filterCustomer,
onFilterCustomerChange,
filterBrands,
onFilterBrandsChange,
filterProject,
onFilterProjectChange,
filterEntity,
onFilterEntityChange,
searchTerm,
onSearchTermChange,
onFilterMileageRangeChange,
appliedMileageRange,
onAppliedMileageRangeChange,
filterPlatePrefix,
onFilterPlatePrefixChange,
rangeStart,
rangeEnd,
onRangeStartChange,
onRangeEndChange,
}: {
filterTargetNames: string[];
filterOptions: MonitoringFilters;
onFilterTargetNamesChange: (value: string[]) => void;
filterRegion: string;
onFilterRegionChange: (value: string) => void;
filterPlates: string[];
onFilterPlatesChange: (value: string[]) => void;
filterRentStatus: string;
onFilterRentStatusChange: (value: string) => void;
filterDept: string;
onFilterDeptChange: (value: string) => void;
filterCustomer: string;
onFilterCustomerChange: (value: string) => void;
filterBrands: string[];
onFilterBrandsChange: (value: string[]) => void;
filterProject: string;
onFilterProjectChange: (value: string) => void;
filterEntity: string;
onFilterEntityChange: (value: string) => void;
searchTerm: string;
onSearchTermChange: (value: string) => void;
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
appliedMileageRange: MileageRange;
onAppliedMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
filterPlatePrefix: string;
onFilterPlatePrefixChange: (value: string) => void;
rangeStart: string;
rangeEnd: string;
onRangeStartChange: (value: string) => void;
onRangeEndChange: (value: string) => void;
}) {
const tags: { label: string; onClear: () => void }[] = [];
if (filterTargetNames.length > 0) tags.push({
label: filterTargetNames.length === filterOptions.targetNames.length ? '批次: 全部批次' : `批次: ${filterTargetNames.length === 1 ? filterTargetNames[0] : `${filterTargetNames[0]}${filterTargetNames.length}`}`,
onClear: () => onFilterTargetNamesChange([])
});
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => onFilterRegionChange('All') });
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]}${filterPlates.length}`}`, onClear: () => onFilterPlatesChange([]) });
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => onFilterRentStatusChange('All') });
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => onFilterDeptChange('All') });
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => onFilterCustomerChange('All') });
if (filterBrands.length > 0) tags.push({
label: `品牌: ${filterBrands.length === 1 ? filterBrands[0] : `${filterBrands[0]}${filterBrands.length}`}`,
onClear: () => onFilterBrandsChange([])
});
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => onFilterProjectChange('All') });
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => onFilterEntityChange('All') });
if (searchTerm) tags.push({ label: `搜索: ${searchTerm}`, onClear: () => onSearchTermChange('') });
if (appliedMileageRange.min) tags.push({ label: `里程≥${appliedMileageRange.min}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, min: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, min: '' })); } });
if (appliedMileageRange.max) tags.push({ label: `里程≤${appliedMileageRange.max}`, onClear: () => { onFilterMileageRangeChange(prev => ({ ...prev, max: '' })); onAppliedMileageRangeChange(prev => ({ ...prev, max: '' })); } });
if (filterPlatePrefix !== 'All') tags.push({ label: `车牌段: ${filterPlatePrefix}`, onClear: () => onFilterPlatePrefixChange('All') });
if (rangeStart || rangeEnd) tags.push({
label: `区间: ${normalizeRangeLabel(rangeStart, rangeEnd)}`,
onClear: () => {
const today = defaultMileageDate();
onRangeStartChange(today);
onRangeEndChange(today);
}
});
if (tags.length === 0) return null;
const clearAll = () => {
onFilterDeptChange('All'); onFilterCustomerChange('All'); onFilterRentStatusChange('All'); onFilterProjectChange('All'); onFilterEntityChange('All');
onFilterPlatesChange([]); onSearchTermChange(''); onFilterPlatePrefixChange('All'); onFilterTargetNamesChange([]); onFilterRegionChange('All');
onFilterMileageRangeChange({ min: '', max: '' }); onAppliedMileageRangeChange({ min: '', max: '' });
onFilterBrandsChange([]);
const today = defaultMileageDate();
onRangeStartChange(today); onRangeEndChange(today);
};
return (
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
<span className="mr-1 text-[10px] font-black text-slate-500"></span>
{tags.map((tag, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-2 py-1 text-[10px] font-bold text-blue-600">
{tag.label}
<button onClick={tag.onClear} className="hover:text-blue-800 ml-0.5">&times;</button>
</span>
))}
<button onClick={clearAll} className="text-[10px] font-bold text-rose-500 hover:text-rose-600 ml-auto">
</button>
</div>
);
}
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { Check, ChevronDown } from 'lucide-react';
export default function BatchMultiSelect({
options,
selected,
onChange,
placeholder,
}: {
options: string[],
selected: string[],
onChange: (val: string[]) => void,
placeholder: string
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const selectedSet = useMemo(() => new Set(selected), [selected]);
const filtered = useMemo(() => {
if (!search) return options;
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
}, [options, search]);
const label = selected.length === 0
? placeholder
: selected.length === options.length
? '全部批次'
: selected.length === 1
? selected[0]
: `已选 ${selected.length} 个批次`;
const toggle = (opt: string) => {
if (selectedSet.has(opt)) {
onChange(selected.filter(item => item !== opt));
} else {
onChange([...selected, opt]);
}
};
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setIsOpen(false);
setSearch('');
}
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [isOpen]);
return (
<div ref={rootRef} className="relative">
<button
type="button"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 pl-2 pr-6 text-left text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20"
onClick={() => setIsOpen(open => !open)}
>
<span className="block truncate">{label}</span>
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</button>
<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 overflow-hidden"
>
<div className="p-2 border-b border-slate-50">
<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={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-slate-50">
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-blue-600 hover:bg-blue-50 rounded-lg"
onClick={() => onChange(options)}
>
</button>
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-slate-400 hover:bg-slate-50 rounded-lg"
onClick={() => onChange([])}
>
</button>
</div>
<div className="max-h-44 overflow-y-auto">
{filtered.map((opt: string) => {
const checked = selectedSet.has(opt);
return (
<button
type="button"
key={opt}
className="w-full px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50 flex items-center justify-between gap-2 text-left"
onClick={() => toggle(opt)}
>
<span className="truncate">{opt}</span>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 ${checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200 text-transparent'}`}>
<Check size={10} />
</span>
</button>
);
})}
{filtered.length === 0 && (
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,272 @@
import type { Dispatch, SetStateAction } from 'react';
import { motion } from 'motion/react';
import type { MonitoringFilters } from '../../types';
import { defaultMileageDate, type RangePreset } from '../date-range';
import BatchMultiSelect from './BatchMultiSelect';
import SearchableSelect from './SearchableSelect';
type MileageRange = { min: string; max: string };
export default function FilterPanel({
rangeStart,
rangeEnd,
onRangeStartChange,
onRangeEndChange,
onApplyRangePreset,
departments,
filterDept,
onFilterDeptChange,
filterOptions,
filterCustomer,
onFilterCustomerChange,
filterBrands,
onFilterBrandsChange,
filterProject,
onFilterProjectChange,
filterRentStatus,
onFilterRentStatusChange,
filterEntity,
onFilterEntityChange,
filterPlatePrefix,
onFilterPlatePrefixChange,
filterMileageRange,
onFilterMileageRangeChange,
onSearchTermChange,
onFilterPlatesChange,
onFilterTargetNamesChange,
onFilterRegionChange,
onAppliedMileageRangeChange,
onFilterOpenChange,
}: {
rangeStart: string;
rangeEnd: string;
onRangeStartChange: (value: string) => void;
onRangeEndChange: (value: string) => void;
onApplyRangePreset: (preset: RangePreset) => void;
departments: string[];
filterDept: string;
onFilterDeptChange: (value: string) => void;
filterOptions: MonitoringFilters;
filterCustomer: string;
onFilterCustomerChange: (value: string) => void;
filterBrands: string[];
onFilterBrandsChange: (value: string[]) => void;
filterProject: string;
onFilterProjectChange: (value: string) => void;
filterRentStatus: string;
onFilterRentStatusChange: (value: string) => void;
filterEntity: string;
onFilterEntityChange: (value: string) => void;
filterPlatePrefix: string;
onFilterPlatePrefixChange: (value: string) => void;
filterMileageRange: MileageRange;
onFilterMileageRangeChange: Dispatch<SetStateAction<MileageRange>>;
onSearchTermChange: (value: string) => void;
onFilterPlatesChange: (value: string[]) => void;
onFilterTargetNamesChange: (value: string[]) => void;
onFilterRegionChange: (value: string) => void;
onAppliedMileageRangeChange: (value: MileageRange) => void;
onFilterOpenChange: (value: boolean) => void;
}) {
return (
<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 range */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<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={rangeStart}
onChange={(e) => onRangeStartChange(e.target.value)}
/>
<span className="text-[10px] font-bold text-slate-300"></span>
<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={rangeEnd}
onChange={(e) => onRangeEndChange(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 pt-1">
{([
['today', '今天'],
['yesterday', '昨天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last7', '近7天'],
['last15', '近15天'],
] as Array<[RangePreset, string]>).map(([preset, label]) => (
<button
key={preset}
type="button"
className="rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
onClick={() => onApplyRangePreset(preset)}
>
{label}
</button>
))}
</div>
</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) => onFilterDeptChange(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>
<SearchableSelect
options={filterOptions.customers}
value={filterCustomer}
onChange={onFilterCustomerChange}
placeholder="搜索客户"
/>
</div>
</div>
{/* Brand multi-select */}
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"></label>
<BatchMultiSelect
options={filterOptions.brands}
selected={filterBrands}
onChange={onFilterBrandsChange}
placeholder="选择品牌"
/>
</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) => onFilterProjectChange(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) => onFilterRentStatusChange(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) => onFilterEntityChange(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) => onFilterPlatePrefixChange(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) => onFilterMileageRangeChange(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) => onFilterMileageRangeChange(prev => ({ ...prev, max: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-50">
<button
onClick={() => {
onSearchTermChange('');
onFilterDeptChange('All');
onFilterPlatesChange([]);
onFilterCustomerChange('All');
onFilterProjectChange('All');
onFilterEntityChange('All');
onFilterPlatePrefixChange('All');
onFilterTargetNamesChange([]);
onFilterRegionChange('All');
onFilterMileageRangeChange({ min: '', max: '' });
onAppliedMileageRangeChange({ min: '', max: '' });
onFilterBrandsChange([]);
const today = defaultMileageDate();
onRangeStartChange(today);
onRangeEndChange(today);
}}
className="text-[10px] font-bold text-slate-400 hover:text-slate-600"
>
</button>
<button
onClick={() => {
onAppliedMileageRangeChange({ ...filterMileageRange });
onFilterOpenChange(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>
);
}
@@ -0,0 +1,330 @@
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) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : '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>
);
}
@@ -0,0 +1,116 @@
import { CalendarDays } from 'lucide-react';
import { Bar, BarChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
import type { MonitoringStats } from '../../types';
export default function MonitoringSummary({
pageLoading,
sortBy,
isRangeMode,
stats,
rangeLabel,
rangeDailyTotals,
total,
}: {
pageLoading: boolean;
sortBy: 'today' | 'total' | 'statisticTime';
isRangeMode: boolean;
stats: MonitoringStats;
rangeLabel: string;
rangeDailyTotals: { date: string; totalKm: number }[];
total: number;
}) {
const averageDailyKm = rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
return (
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}</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 === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
</div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 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 === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div>
<div className="text-[7px] text-slate-400">km/</div>
</div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 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="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
{pageLoading ? (
<div className="h-[74px] bg-slate-50 animate-pulse" />
) : isRangeMode ? (
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
<div className="min-w-0">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
<CalendarDays size={12} className="text-blue-500" />
</div>
<div className="mt-1 text-[9px] font-bold text-slate-400">{rangeDailyTotals.length} · km</div>
</div>
<div className="h-[58px] min-w-0">
{rangeDailyTotals.length === 0 ? (
<div className="flex h-full items-center justify-center rounded-lg bg-slate-50 text-[10px] font-bold text-slate-300"></div>
) : (
<ResponsiveContainer width="100%" height={58} minWidth={0}>
<BarChart data={rangeDailyTotals} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
<XAxis dataKey="date" hide />
<Tooltip
formatter={(value) => [`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} km`, '当日里程']}
labelFormatter={(label) => `日期 ${String(label)}`}
contentStyle={{ borderRadius: 10, borderColor: '#e2e8f0', fontSize: 11 }}
cursor={{ fill: 'rgba(37, 99, 235, 0.06)' }}
/>
{averageDailyKm > 0 && <ReferenceLine y={averageDailyKm} stroke="#f59e0b" strokeDasharray="3 3" />}
<Bar dataKey="totalKm" radius={[3, 3, 0, 0]} fill="#38bdf8" />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="text-right">
<div className="text-[9px] font-black text-slate-400"></div>
<div className="text-xs font-black text-slate-800">{Math.round(averageDailyKm).toLocaleString()}</div>
<div className="text-[9px] font-bold text-slate-400">km</div>
</div>
</div>
) : (
<div className="grid min-h-[64px] grid-cols-[minmax(0,1fr)_86px_86px] items-stretch gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_110px_110px]">
<div className="flex min-w-0 flex-col justify-center">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
<CalendarDays size={12} className="text-blue-500" />
</div>
<div className="mt-1 truncate text-[9px] font-bold text-slate-400">{rangeLabel} · km</div>
</div>
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-blue-400"></div>
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
</div>
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-slate-400"></div>
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between px-1">
<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 className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid">
<span> / </span>
<span> / </span>
<span className="text-right">{isRangeMode ? '区间里程' : '当日里程'}</span>
<span className="text-right"></span>
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import type { Dispatch, SetStateAction } from 'react';
import { ArrowDown, ArrowUp, Download, Filter, Maximize2, RefreshCw, RotateCcw } from 'lucide-react';
import PlateMultiSelect from '../../PlateMultiSelect';
import type { MileageSourceGroup, MonitoringFilters } from '../../types';
import { getRangePreset, type RangePreset } from '../date-range';
import BatchMultiSelect from './BatchMultiSelect';
import SearchableSelect from './SearchableSelect';
import SourcePriorityControl from './SourcePriorityControl';
type SortBy = 'today' | 'total' | 'statisticTime';
type SortOrder = 'asc' | 'desc';
export default function MonitoringToolbar({
onToggleFullscreen,
onDownload,
exporting,
lastRefreshedAt,
onManualRefresh,
manualRefreshing,
sortBy,
onSortByChange,
sortOrder,
onSortOrderChange,
sourcePriority,
onSourcePriorityChange,
filterOptions,
filterTargetNames,
onFilterTargetNamesChange,
filterRegion,
onFilterRegionChange,
plateNumbers,
filterPlates,
onFilterPlatesChange,
isFilterOpen,
onFilterOpenChange,
searchTerm,
filterDept,
filterCustomer,
filterRentStatus,
filterProject,
filterBrands,
rangeStart,
rangeEnd,
onApplyRangePreset,
rangeLabel,
}: {
onToggleFullscreen: () => void;
onDownload: () => void;
exporting: boolean;
lastRefreshedAt: Date | null;
onManualRefresh: () => void;
manualRefreshing: boolean;
sortBy: SortBy;
onSortByChange: Dispatch<SetStateAction<SortBy>>;
sortOrder: SortOrder;
onSortOrderChange: Dispatch<SetStateAction<SortOrder>>;
sourcePriority: MileageSourceGroup[];
onSourcePriorityChange: (value: MileageSourceGroup[]) => void;
filterOptions: MonitoringFilters;
filterTargetNames: string[];
onFilterTargetNamesChange: (value: string[]) => void;
filterRegion: string;
onFilterRegionChange: (value: string) => void;
plateNumbers: string[];
filterPlates: string[];
onFilterPlatesChange: (value: string[]) => void;
isFilterOpen: boolean;
onFilterOpenChange: (value: boolean) => void;
searchTerm: string;
filterDept: string;
filterCustomer: string;
filterRentStatus: string;
filterProject: string;
filterBrands: string[];
rangeStart: string;
rangeEnd: string;
onApplyRangePreset: (preset: RangePreset) => void;
rangeLabel: string;
}) {
return (
<div className="flex flex-col gap-3 rounded-2xl border border-gray-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div className="h-8 w-1 flex-shrink-0 rounded-full bg-blue-600" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-lg font-black leading-none text-slate-900"></h1>
<button onClick={onToggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
<Maximize2 size={14} />
</button>
<button onClick={onDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
</button>
</div>
<div className="mt-1 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<span className="text-[9px] font-bold uppercase tracking-tight text-slate-400"> OneOS </span>
</div>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
</span>
<button
type="button"
onClick={onManualRefresh}
disabled={manualRefreshing}
className="flex h-7 items-center gap-1 whitespace-nowrap rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60"
title="局部刷新当前筛选数据"
>
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
</button>
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
<button onClick={() => onSortByChange('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button onClick={() => onSortByChange('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button onClick={() => onSortByChange('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button
onClick={() => onSortOrderChange(sortOrder === 'desc' ? 'asc' : 'desc')}
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
title={sortOrder === 'desc' ? '当前倒序,点击切换正序' : '当前正序,点击切换倒序'}
>
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
</button>
</div>
</div>
</div>
<SourcePriorityControl priority={sourcePriority} onChange={onSourcePriorityChange} />
<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={onFilterTargetNamesChange} placeholder="批次型号" />
<SearchableSelect options={filterOptions.regions} value={filterRegion} onChange={onFilterRegionChange} placeholder="运营区域" />
<PlateMultiSelect allPlates={plateNumbers} selected={filterPlates} onChange={onFilterPlatesChange} placeholder="按车牌" />
</div>
<button
onClick={() => onFilterOpenChange(!isFilterOpen)}
className={`flex-shrink-0 rounded-lg border p-1.5 transition-all ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 || filterBrands.length > 0 ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-transparent bg-slate-50 text-slate-400'}`}
title="高级筛选"
>
<Filter size={16} />
</button>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
{([
['today', '今天'],
['yesterday', '昨天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last15', '近15天'],
] as Array<[RangePreset, string]>).map(([preset, label]) => {
const range = getRangePreset(preset);
const active = rangeStart === range.start && rangeEnd === range.end;
return (
<button
key={preset}
type="button"
onClick={() => onApplyRangePreset(preset)}
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${active ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm' : 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'}`}
>
{label}
</button>
);
})}
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span>
</div>
</div>
);
}
@@ -0,0 +1,83 @@
import { useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ChevronDown } from 'lucide-react';
export default function 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>
);
}
@@ -0,0 +1,75 @@
import { ArrowLeftRight, Power } from 'lucide-react';
import type { MileageSourceGroup } from '../../types';
import {
ALL_MILEAGE_SOURCE_GROUPS,
MILEAGE_SOURCE_META,
} from '../source-display';
export default 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 min-h-[9px] 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>
);
}
@@ -0,0 +1,157 @@
import type { RefObject } from 'react';
import { Truck } from 'lucide-react';
import { motion } from 'motion/react';
import Blur from '../../../../components/Blur';
import type { MonitoringVehicle } from '../../types';
import { vehicleStatisticTime } from '../oneos-time';
import { vehicleSourceDisplay } from '../source-display';
export default function VehicleList({
pageLoading,
vehicles,
isHighMileageAlert,
relativeNow,
isRangeMode,
onVehicleClick,
loadingMore,
hasMore,
total,
sentinelRef,
}: {
pageLoading: boolean;
vehicles: MonitoringVehicle[];
isHighMileageAlert: (vehicle: MonitoringVehicle) => boolean;
relativeNow: number;
isRangeMode: boolean;
onVehicleClick: (vehicle: MonitoringVehicle) => void;
loadingMore: boolean;
hasMore: boolean;
total: number;
sentinelRef: RefObject<HTMLDivElement | null>;
}) {
return (
<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">
{vehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
key={v.plate}
className="grid cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 shadow-sm transition-all hover:border-blue-200 hover:shadow-md active:bg-slate-50 md:grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] md:px-4"
onClick={() => onVehicleClick(v)}
>
<div className="flex min-w-0 items-center gap-3 overflow-hidden">
<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 flex-wrap 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>
<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={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label}
</div>
<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>
</div>
</div>
</div>
<div className="hidden min-w-0 md:block">
<div className="truncate text-xs font-bold text-slate-700"><Blur>{v.customer || '-'}</Blur></div>
<div className="mt-1 truncate text-[9px] font-medium text-slate-400">
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
</div>
</div>
<div className="hidden text-right md:block">
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
{Math.max(0, v.dailyKm || 0).toLocaleString()}
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
</div>
</div>
<div className="hidden text-right md:block">
<div className="text-xs font-black tabular-nums text-slate-700">
{v.totalKm != null ? v.totalKm.toLocaleString() : '-'}
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
</div>
</div>
<div className="flex flex-shrink-0 flex-col items-end text-right md:hidden">
<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/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}></span>
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : '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>
{vehicles.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 && vehicles.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>
);
}
@@ -0,0 +1,45 @@
export type RangePreset = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
function formatLocalDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
/**
* The monitoring day rolls over at 05:00 local time. Before then the dashboard
* continues to use the previous calendar day, matching the existing UI rule.
*/
export function defaultMileageDate(now = new Date()): string {
const date = new Date(now);
if (date.getHours() < 5) date.setDate(date.getDate() - 1);
return formatLocalDate(date);
}
export function normalizeRangeLabel(start: string, end: string): string {
if (!start && !end) return '最新数据';
if (start && end && start === end) return start;
return `${start || end}${end || start}`;
}
export function getRangePreset(
preset: RangePreset,
now = new Date(),
): { start: string; end: string } {
const end = new Date(`${defaultMileageDate(now)}T00:00:00`);
const start = new Date(end);
if (preset === 'yesterday') {
start.setDate(start.getDate() - 1);
end.setDate(end.getDate() - 1);
} else if (preset === 'thisWeek') {
const day = start.getDay() || 7;
start.setDate(start.getDate() - day + 1);
} else if (preset === 'thisMonth') {
start.setDate(1);
} else if (preset === 'last7') {
start.setDate(start.getDate() - 6);
} else if (preset === 'last15') {
start.setDate(start.getDate() - 14);
}
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { MonitoringVehicle } from '../types';
import { defaultMileageDate, getRangePreset, normalizeRangeLabel } from './date-range';
import { formatAbsoluteTime, parseOneOsTime, vehicleStatisticTime } from './oneos-time';
import {
buildExportMonitoringQuery,
buildFullscreenMonitoringQuery,
buildInitialMonitoringQuery,
buildNextPageMonitoringQuery,
type MonitoringQueryModel,
} from './query-model';
import { vehicleSourceDisplay } from './source-display';
const baseVehicle: MonitoringVehicle = {
plate: '粤A00001',
vin: 'TESTVIN',
dailyKm: 0,
totalKm: 0,
source: 'ONEOS',
sourceProtocol: null,
sourceCategory: null,
dataTime: null,
calculatedAt: null,
updatedAt: null,
isOnline: false,
isDataSynced: false,
customer: null,
department: null,
manager: null,
rentStatus: null,
entity: null,
project: null,
region: null,
inventoryLocation: null,
brand: null,
targetNames: [],
};
const queryModel: MonitoringQueryModel = {
sortBy: 'statisticTime',
sortOrder: 'asc',
searchTerm: '粤A',
department: '业务一部',
customer: '客户甲',
project: '项目甲',
entity: '主体甲',
rentStatus: '租赁',
platePrefix: '粤A',
targetNames: ['批次甲'],
region: '广州',
plates: ['粤A00001', '粤A00002'],
mileageRange: { min: '10', max: '500' },
rangeStart: '2026-08-01',
rangeEnd: '2026-08-12',
brands: ['现代'],
sourcePriority: ['instrument', 'gps'],
};
test('monitoring date rolls over at 05:00 and presets retain existing ranges', () => {
assert.equal(defaultMileageDate(new Date(2026, 7, 13, 4, 59)), '2026-08-12');
assert.equal(defaultMileageDate(new Date(2026, 7, 13, 5, 0)), '2026-08-13');
const now = new Date(2026, 7, 13, 12, 0);
assert.deepEqual(getRangePreset('today', now), { start: '2026-08-13', end: '2026-08-13' });
assert.deepEqual(getRangePreset('yesterday', now), { start: '2026-08-12', end: '2026-08-12' });
assert.deepEqual(getRangePreset('thisWeek', now), { start: '2026-08-10', end: '2026-08-13' });
assert.deepEqual(getRangePreset('thisMonth', now), { start: '2026-08-01', end: '2026-08-13' });
assert.deepEqual(getRangePreset('last7', now), { start: '2026-08-07', end: '2026-08-13' });
assert.deepEqual(getRangePreset('last15', now), { start: '2026-07-30', end: '2026-08-13' });
assert.equal(normalizeRangeLabel('', ''), '最新数据');
assert.equal(normalizeRangeLabel('2026-08-12', '2026-08-12'), '2026-08-12');
assert.equal(normalizeRangeLabel('2026-08-01', '2026-08-12'), '2026-08-01 至 2026-08-12');
});
test('OneOS timestamps keep China Standard Time parsing and relative labels', () => {
assert.equal(
parseOneOsTime('2026-08-13 09:30:05'),
Date.parse('2026-08-13T09:30:05+08:00'),
);
assert.equal(parseOneOsTime('invalid'), null);
assert.equal(formatAbsoluteTime('2026-08-13 09:30:05'), '2026/08/13 09:30:05');
const display = vehicleStatisticTime(
{
...baseVehicle,
dataTime: '2026-08-13 09:30:05',
calculatedAt: '2026-08-13 09:30:06',
updatedAt: '2026-08-13 09:30:07',
isDataSynced: true,
},
Date.parse('2026-08-13T09:31:05+08:00'),
);
assert.deepEqual(display, {
label: '1分钟前',
title: [
'数据时间:2026/08/13 09:30:05',
'计算时间:2026/08/13 09:30:06',
'更新时间:2026/08/13 09:30:07',
].join('\n'),
color: 'text-emerald-500',
});
assert.deepEqual(vehicleStatisticTime({ ...baseVehicle, isDataSynced: true }, Date.now()), {
label: '统计时间待接口',
title: 'OneOS 当前未返回车辆级数据时间',
color: 'text-amber-500',
});
});
test('vehicle source display retains all source and missing-data states', () => {
assert.deepEqual(vehicleSourceDisplay({
...baseVehicle,
sourceCategory: 'INSTRUMENT',
sourceProtocol: 'MQTT',
}), {
label: '仪表数据',
title: 'MQTT',
className: 'bg-violet-50 text-violet-600',
});
assert.equal(vehicleSourceDisplay({ ...baseVehicle, sourceCategory: 'GPS' }).title, 'JT808');
assert.equal(vehicleSourceDisplay({ ...baseVehicle, sourceCategory: 'MIXED' }).label, '仪表+GPS');
assert.equal(vehicleSourceDisplay({ ...baseVehicle, isDataSynced: true }).label, '来源待接口');
assert.equal(vehicleSourceDisplay(baseVehicle).label, '无数据');
});
test('four monitoring query builders preserve pagination and fullscreen differences', () => {
const initial = buildInitialMonitoringQuery(queryModel, 50);
assert.equal(initial.limit, 50);
assert.equal(initial.page, 1);
assert.equal(initial.project, '项目甲');
assert.equal(initial.entity, '主体甲');
assert.equal(initial.mileageMin, '10');
assert.equal(initial.mileageMax, '500');
assert.equal(initial.plate, '粤A00001,粤A00002');
const nextPage = buildNextPageMonitoringQuery(queryModel, 50, 3);
assert.deepEqual(nextPage, { ...initial, page: 3 });
const exported = buildExportMonitoringQuery(queryModel);
assert.deepEqual(exported, { ...initial, limit: 9999 });
const fullscreen = buildFullscreenMonitoringQuery(queryModel);
assert.equal(fullscreen.limit, 9999);
assert.equal(fullscreen.page, 1);
assert.equal(fullscreen.customer, '客户甲');
assert.equal('project' in fullscreen, false);
assert.equal('entity' in fullscreen, false);
assert.equal('mileageMin' in fullscreen, false);
assert.equal('mileageMax' in fullscreen, false);
});
test('query model converts empty selections exactly as the existing requests do', () => {
const query = buildInitialMonitoringQuery({
...queryModel,
searchTerm: '',
department: 'All',
customer: 'All',
project: 'All',
entity: 'All',
rentStatus: 'All',
platePrefix: 'All',
targetNames: [],
region: 'All',
plates: [],
mileageRange: { min: '', max: '' },
rangeStart: '',
rangeEnd: '',
brands: [],
}, 50);
assert.equal(query.search, undefined);
assert.equal(query.dept, undefined);
assert.equal(query.project, undefined);
assert.equal(query.plate, undefined);
assert.equal(query.mileageMin, undefined);
assert.equal(query.startDate, undefined);
assert.equal(query.brands, undefined);
assert.deepEqual(query.sourcePriority, ['instrument', 'gps']);
});
@@ -0,0 +1,74 @@
import type { MonitoringVehicle } from '../types';
export interface StatisticTimeDisplay {
label: string;
title: string;
color: string;
}
/** OneOS timestamps without an offset are defined as China Standard Time. */
export function parseOneOsTime(value: string | null | undefined): number | null {
if (!value) return null;
let normalized = value.trim().replace(' ', 'T');
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) normalized += '+08:00';
const timestamp = Date.parse(normalized);
return Number.isFinite(timestamp) ? timestamp : null;
}
export function formatAbsoluteTime(value: string | null | undefined): string | null {
const timestamp = parseOneOsTime(value);
if (timestamp === null) return null;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(new Date(timestamp));
}
export function vehicleStatisticTime(
vehicle: MonitoringVehicle,
nowMs: number,
): StatisticTimeDisplay {
const primaryTime = vehicle.dataTime || vehicle.updatedAt || vehicle.calculatedAt;
const timestamp = parseOneOsTime(primaryTime);
if (timestamp === null) {
return {
label: vehicle.isDataSynced ? '统计时间待接口' : '无有效数据时间',
title: 'OneOS 当前未返回车辆级数据时间',
color: vehicle.isDataSynced ? 'text-amber-500' : 'text-slate-500',
};
}
const deltaSeconds = Math.floor((nowMs - timestamp) / 1000);
let label: string;
if (deltaSeconds < -60) label = '时间异常';
else if (deltaSeconds < 5) label = '刚刚';
else if (deltaSeconds < 60) label = `${deltaSeconds}秒前`;
else if (deltaSeconds < 3600) label = `${Math.floor(deltaSeconds / 60)}分钟前`;
else if (deltaSeconds < 86400) label = `${Math.floor(deltaSeconds / 3600)}小时前`;
else label = `${Math.floor(deltaSeconds / 86400)}天前`;
const absoluteDataTime = formatAbsoluteTime(vehicle.dataTime);
const absoluteCalculatedAt = formatAbsoluteTime(vehicle.calculatedAt);
const absoluteUpdatedAt = formatAbsoluteTime(vehicle.updatedAt);
const title = [
absoluteDataTime ? `数据时间:${absoluteDataTime}` : null,
absoluteCalculatedAt ? `计算时间:${absoluteCalculatedAt}` : null,
absoluteUpdatedAt ? `更新时间:${absoluteUpdatedAt}` : null,
].filter(Boolean).join('\n');
const ageSeconds = Math.max(0, deltaSeconds);
const color = ageSeconds <= 300
? 'text-emerald-500'
: ageSeconds <= 3600
? 'text-blue-500'
: ageSeconds <= 86400
? 'text-amber-500'
: 'text-rose-500';
return { label, title, color };
}
@@ -0,0 +1,96 @@
import type { fetchMonitoring } from '../api';
import type { MileageSourceGroup } from '../types';
export type MonitoringRequestParams = NonNullable<Parameters<typeof fetchMonitoring>[0]>;
export interface MonitoringQueryModel {
sortBy: 'today' | 'total' | 'statisticTime';
sortOrder: 'asc' | 'desc';
searchTerm: string;
department: string;
customer: string;
project: string;
entity: string;
rentStatus: string;
platePrefix: string;
targetNames: string[];
region: string;
plates: string[];
mileageRange: { min: string; max: string };
rangeStart: string;
rangeEnd: string;
brands: string[];
sourcePriority: MileageSourceGroup[];
}
function optionalValue(value: string): string | undefined {
return value || undefined;
}
function selectedValue(value: string): string | undefined {
return value !== 'All' ? value : undefined;
}
function buildSharedQuery(
model: MonitoringQueryModel,
limit: number,
page: number,
): MonitoringRequestParams {
return {
sortBy: model.sortBy,
sortOrder: model.sortOrder,
limit,
page,
search: optionalValue(model.searchTerm),
dept: selectedValue(model.department),
customer: selectedValue(model.customer),
rentStatus: selectedValue(model.rentStatus),
platePrefix: selectedValue(model.platePrefix),
targetNames: model.targetNames.length > 0 ? model.targetNames : undefined,
region: selectedValue(model.region),
plate: model.plates.length > 0 ? model.plates.join(',') : undefined,
startDate: optionalValue(model.rangeStart),
endDate: optionalValue(model.rangeEnd),
brands: model.brands.length > 0 ? model.brands : undefined,
sourcePriority: model.sourcePriority,
};
}
function buildPagedQuery(
model: MonitoringQueryModel,
limit: number,
page: number,
): MonitoringRequestParams {
return {
...buildSharedQuery(model, limit, page),
project: selectedValue(model.project),
entity: selectedValue(model.entity),
mileageMin: optionalValue(model.mileageRange.min),
mileageMax: optionalValue(model.mileageRange.max),
};
}
export function buildInitialMonitoringQuery(
model: MonitoringQueryModel,
pageSize: number,
): MonitoringRequestParams {
return buildPagedQuery(model, pageSize, 1);
}
export function buildNextPageMonitoringQuery(
model: MonitoringQueryModel,
pageSize: number,
page: number,
): MonitoringRequestParams {
return buildPagedQuery(model, pageSize, page);
}
export function buildExportMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
return buildPagedQuery(model, 9999, 1);
}
export function buildFullscreenMonitoringQuery(model: MonitoringQueryModel): MonitoringRequestParams {
// Characterization boundary: fullscreen historically omits these four filters.
// Keep project, entity, mileageMin and mileageMax out until a separate bug fix is approved.
return buildSharedQuery(model, 9999, 1);
}
@@ -0,0 +1,47 @@
import type { MileageSourceGroup, MonitoringVehicle } from '../types';
export const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
export const MILEAGE_SOURCE_META = {
instrument: { label: '仪表数据', protocol: null },
gps: { label: 'GPS数据', protocol: 'JT808' },
} as const;
export interface VehicleSourceDisplay {
label: string;
title: string;
className: string;
}
export function vehicleSourceDisplay(vehicle: MonitoringVehicle): VehicleSourceDisplay {
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',
};
}