feat: integrate OneOS mileage APIs and release v1.1.10
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
Truck, Filter, ChevronDown,
|
||||
Maximize2, Minimize2, RotateCcw,
|
||||
ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts';
|
||||
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
|
||||
@@ -25,6 +26,72 @@ function defaultMileageDate(): string {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function vehicleStatisticTime(vehicle: MonitoringVehicle, nowMs: number): {
|
||||
label: string;
|
||||
title: string;
|
||||
color: string;
|
||||
} {
|
||||
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 };
|
||||
}
|
||||
|
||||
function normalizeRangeLabel(start: string, end: string): string {
|
||||
if (!start && !end) return '最新数据';
|
||||
if (start && end && start === end) return start;
|
||||
@@ -35,12 +102,15 @@ function fmtDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
type RangePreset = 'today' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
|
||||
type RangePreset = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
|
||||
|
||||
function getRangePreset(preset: RangePreset): { start: string; end: string } {
|
||||
const end = new Date(`${defaultMileageDate()}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
if (preset === 'thisWeek') {
|
||||
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') {
|
||||
@@ -295,9 +365,17 @@ export default function MonitoringView() {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [manualRefreshing, setManualRefreshing] = useState(false);
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const departments = filterOptions.departments;
|
||||
const plateNumbers = filterOptions.plates;
|
||||
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
|
||||
@@ -305,10 +383,6 @@ export default function MonitoringView() {
|
||||
const averageDailyKm = rangeDailyTotals.length > 0
|
||||
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
|
||||
: 0;
|
||||
const topLoadedVehicle = useMemo(
|
||||
() => vehicles.reduce<MonitoringVehicle | null>((best, vehicle) => (!best || vehicle.dailyKm > best.dailyKm ? vehicle : best), null),
|
||||
[vehicles],
|
||||
);
|
||||
const applyRangePreset = useCallback((preset: RangePreset) => {
|
||||
const range = getRangePreset(preset);
|
||||
setRangeStart(range.start);
|
||||
@@ -322,9 +396,9 @@ export default function MonitoringView() {
|
||||
}, [filterTargetNames]);
|
||||
|
||||
// 加载首页数据
|
||||
const loadFirstPage = useCallback(() => {
|
||||
setPageLoading(true);
|
||||
fetchMonitoring({
|
||||
const loadFirstPage = useCallback((showPageLoading = true) => {
|
||||
if (showPageLoading) setPageLoading(true);
|
||||
return fetchMonitoring({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: PAGE_SIZE,
|
||||
@@ -353,9 +427,22 @@ export default function MonitoringView() {
|
||||
setTotal(d.total);
|
||||
setPage(1);
|
||||
setHasMore(d.page < d.totalPages);
|
||||
}).catch(() => {}).finally(() => setPageLoading(false));
|
||||
setLastRefreshedAt(new Date());
|
||||
}).catch(() => {}).finally(() => {
|
||||
if (showPageLoading) setPageLoading(false);
|
||||
});
|
||||
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]);
|
||||
|
||||
const handleManualRefresh = useCallback(async () => {
|
||||
if (manualRefreshing) return;
|
||||
setManualRefreshing(true);
|
||||
try {
|
||||
await loadFirstPage(false);
|
||||
} finally {
|
||||
setManualRefreshing(false);
|
||||
}
|
||||
}, [loadFirstPage, manualRefreshing]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
@@ -437,7 +524,7 @@ export default function MonitoringView() {
|
||||
|
||||
// 每分钟自动刷新
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadFirstPage, 60 * 1000);
|
||||
const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadFirstPage]);
|
||||
|
||||
@@ -757,12 +844,18 @@ export default function MonitoringView() {
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{fullscreenVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
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 text-xs font-bold text-white"><Blur>{v.plate}</Blur></td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></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>
|
||||
@@ -788,89 +881,61 @@ export default function MonitoringView() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Ultra Compact Header - Two Rows */}
|
||||
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-3">
|
||||
{/* Top Row: Title & Sort */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-8 bg-blue-600 rounded-full"></div>
|
||||
<div>
|
||||
<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="text-lg font-black text-slate-900 leading-none">里程看板</h1>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1 text-slate-300 hover:text-blue-600 transition-colors"
|
||||
title="全屏视图"
|
||||
>
|
||||
<h1 className="truncate text-lg font-black leading-none text-slate-900">里程看板</h1>
|
||||
<button onClick={toggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={exporting}
|
||||
className="p-1 text-slate-300 hover:text-blue-600 transition-colors disabled:text-slate-200"
|
||||
title="下载当前筛选结果"
|
||||
>
|
||||
<button onClick={handleDownload} 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="flex items-center gap-1.5 mt-1">
|
||||
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">数据监控 • 每15分钟更新</span>
|
||||
<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 items-center gap-1 bg-slate-100 p-0.5 rounded-lg">
|
||||
<div className="flex gap-0.5">
|
||||
<button
|
||||
onClick={() => setSortBy('today')}
|
||||
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
|
||||
>
|
||||
区间
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSortBy('total')}
|
||||
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
|
||||
>
|
||||
累计
|
||||
<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={handleManualRefresh}
|
||||
disabled={manualRefreshing}
|
||||
className="flex h-7 items-center gap-1 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 className="hidden sm:inline">{manualRefreshing ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
|
||||
<button onClick={() => setSortBy('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={() => setSortBy('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={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')} className="rounded-md p-1 text-blue-600 transition-all hover:bg-white">
|
||||
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-[1px] h-3 bg-slate-200 mx-0.5"></div>
|
||||
<button
|
||||
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
|
||||
className="p-1 text-blue-600 hover:bg-white rounded-md transition-all"
|
||||
>
|
||||
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||
<BatchMultiSelect
|
||||
options={filterOptions.targetNames}
|
||||
selected={filterTargetNames}
|
||||
onChange={setFilterTargetNames}
|
||||
placeholder="批次型号"
|
||||
/>
|
||||
<SearchableSelect
|
||||
options={filterOptions.regions}
|
||||
value={filterRegion}
|
||||
onChange={setFilterRegion}
|
||||
placeholder="运营区域"
|
||||
/>
|
||||
<PlateMultiSelect
|
||||
allPlates={plateNumbers}
|
||||
selected={filterPlates}
|
||||
onChange={setFilterPlates}
|
||||
placeholder="按车牌"
|
||||
/>
|
||||
<div className="grid flex-1 grid-cols-3 gap-1.5">
|
||||
<BatchMultiSelect options={filterOptions.targetNames} selected={filterTargetNames} onChange={setFilterTargetNames} placeholder="批次型号" />
|
||||
<SearchableSelect options={filterOptions.regions} value={filterRegion} onChange={setFilterRegion} placeholder="运营区域" />
|
||||
<PlateMultiSelect allPlates={plateNumbers} selected={filterPlates} onChange={setFilterPlates} placeholder="按车牌" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 || filterBrands.length > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
|
||||
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>
|
||||
@@ -879,6 +944,7 @@ export default function MonitoringView() {
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
|
||||
{([
|
||||
['today', '今天'],
|
||||
['yesterday', '昨天'],
|
||||
['thisWeek', '本周'],
|
||||
['thisMonth', '本月'],
|
||||
['last15', '近15天'],
|
||||
@@ -890,19 +956,13 @@ export default function MonitoringView() {
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => applyRangePreset(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'
|
||||
}`}
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -937,6 +997,7 @@ export default function MonitoringView() {
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{([
|
||||
['today', '今天'],
|
||||
['yesterday', '昨天'],
|
||||
['thisWeek', '本周'],
|
||||
['thisMonth', '本月'],
|
||||
['last7', '近7天'],
|
||||
@@ -1150,15 +1211,16 @@ export default function MonitoringView() {
|
||||
setRangeStart(today); setRangeEnd(today);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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 px-2 py-1 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-bold">
|
||||
<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">×</button>
|
||||
</span>
|
||||
))}
|
||||
<button onClick={clearAll} className="text-[10px] font-bold text-rose-500 hover:text-rose-600 ml-auto">
|
||||
清除
|
||||
清除全部
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -1167,19 +1229,19 @@ export default function MonitoringView() {
|
||||
{/* Sticky header: KPI + 清单标题 */}
|
||||
<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="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden">
|
||||
<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 === 'today' ? (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 === 'today' ? stats.totalToday : stats.totalAll).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="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||||
<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 === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</div>
|
||||
<div className="text-[7px] text-slate-400">km/台</div>
|
||||
</div>
|
||||
<div className="bg-white p-2.5 rounded-xl border border-gray-100 shadow-sm">
|
||||
<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>
|
||||
@@ -1223,42 +1285,35 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_76px_76px] items-center gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_96px_96px]">
|
||||
<div className="min-w-0">
|
||||
<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 className="mt-1.5 rounded-lg bg-slate-50 px-2 py-1 ring-1 ring-slate-100">
|
||||
<div className="text-[8px] font-black text-slate-400">当前列表最高</div>
|
||||
{topLoadedVehicle ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
|
||||
<span className="whitespace-nowrap text-[10px] font-black text-slate-700">{topLoadedVehicle.plate}</span>
|
||||
<span className="whitespace-nowrap text-[10px] font-black text-blue-600 tabular-nums">
|
||||
{topLoadedVehicle.dailyKm.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-0.5 text-[10px] font-black text-slate-300">-</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 px-2 py-1.5 text-right">
|
||||
<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="text-xs font-black text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
|
||||
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-50 px-2 py-1.5 text-right">
|
||||
<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="text-xs font-black text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</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-2">
|
||||
<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>
|
||||
|
||||
{/* Vehicle List */}
|
||||
@@ -1287,15 +1342,16 @@ export default function MonitoringView() {
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{filteredVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key={v.plate}
|
||||
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 cursor-pointer transition-all"
|
||||
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={() => setDetailVehicle(v)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||
<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" />
|
||||
@@ -1309,13 +1365,34 @@ export default function MonitoringView() {
|
||||
{v.isOnline ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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="text-right flex-shrink-0 ml-2 flex flex-col items-end">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user