fix: preserve vehicle drill date context

This commit is contained in:
kkfluous
2026-08-07 18:16:14 +08:00
parent 017001e889
commit 6bf88a25e3
6 changed files with 239 additions and 67 deletions
+1
View File
@@ -160,6 +160,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已完成里程考核归因、车辆详情和每日汇报跳转。
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
- 车辆级里程详情继承 `mileageStart``mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。
- 已完成氢能站点、客户下钻代码。
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
+5
View File
@@ -1663,9 +1663,14 @@ export default function MonitoringView() {
</div>
<VehicleDetailModal
key={detailVehicle
? `${detailVehicle.plate}|${drillContext.startDate || effectiveRange.start}|${drillContext.endDate || effectiveRange.end}`
: 'closed'}
vehicle={detailVehicle}
onClose={closeVehicleDetail}
sourcePriority={sourcePriority}
initialStartDate={drillContext.startDate || effectiveRange.start}
initialEndDate={drillContext.endDate || effectiveRange.end}
/>
{/* 回到顶部按钮 */}
+123 -66
View File
@@ -1,57 +1,42 @@
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence, useDragControls } from 'motion/react';
import { X, Truck } from 'lucide-react';
import { RefreshCw, X, Truck } from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
} from 'recharts';
import type { MileageSourceGroup, MonitoringVehicle } from './types';
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
import Blur from '../../components/Blur';
import { EmptyState, ErrorState } from '../../components/ui/surface';
import {
includeCurrentDayInVehicleDetail,
normalizeVehicleDetailContext,
resolveVehicleDetailRange,
vehicleDetailContextLabel,
type VehicleDetailRangeKey,
} from './vehicle-detail-range';
interface Props {
vehicle: MonitoringVehicle | null;
onClose: () => void;
sourcePriority: MileageSourceGroup[];
initialStartDate?: string;
initialEndDate?: string;
}
type RangeKey = 'last15' | 'month' | 'quarter';
const RANGE_TABS: { key: RangeKey; label: string }[] = [
const RANGE_TABS: { key: VehicleDetailRangeKey; label: string }[] = [
{ key: 'last15', label: '近 15 天' },
{ key: 'month', label: '本月' },
{ key: 'quarter', label: '本季度' },
];
function fmtYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = fmtYmd(today);
if (key === 'last15') {
const start = new Date(today);
start.setDate(today.getDate() - 14);
return { start: fmtYmd(start), end, rangeLabel: '近 15 天' };
}
if (key === 'month') {
const start = new Date(today.getFullYear(), today.getMonth(), 1);
return { start: fmtYmd(start), end, rangeLabel: '本月' };
}
const q = Math.floor(today.getMonth() / 3);
const start = new Date(today.getFullYear(), q * 3, 1);
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
}
function isToday(date: string): boolean {
return date === fmtYmd(new Date());
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
return date === today;
}
function formatLabel(date: string, key: RangeKey): string {
function formatLabel(date: string, key: VehicleDetailRangeKey): string {
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD
void key;
return date.slice(5);
@@ -63,30 +48,65 @@ function daySourceLabel(day: VehicleRecentDay): string {
return day.isDataSynced ? '来源待接口' : '无数据';
}
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
export default function VehicleDetailModal({
vehicle,
onClose,
sourcePriority,
initialStartDate,
initialEndDate,
}: Props) {
const contextRange = useMemo(
() => normalizeVehicleDetailContext(initialStartDate, initialEndDate),
[initialEndDate, initialStartDate],
);
const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loadedRequestKey, setLoadedRequestKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [range, setRange] = useState<RangeKey>('last15');
const [error, setError] = useState<string | null>(null);
const [requestVersion, setRequestVersion] = useState(0);
const [range, setRange] = useState<VehicleDetailRangeKey>(() => (
contextRange ? 'context' : 'last15'
));
const dragControls = useDragControls();
// 切换车辆时重置区间为默认
useEffect(() => {
if (vehicle) setRange('last15');
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
const resolvedRange = useMemo(
() => resolveVehicleDetailRange(range, contextRange),
[contextRange, range],
);
const sourceKey = sourcePriority.join(',');
const requestKey = vehicle
? `${vehicle.plate}|${resolvedRange.start}|${resolvedRange.end}|${sourceKey}`
: null;
const hasSnapshot = requestKey !== null && loadedRequestKey === requestKey;
const rangeTabs = useMemo(() => (
contextRange
? [{ key: 'context' as const, label: vehicleDetailContextLabel(contextRange) }, ...RANGE_TABS]
: RANGE_TABS
), [contextRange]);
// 拉取数据(车辆或区间变化)
useEffect(() => {
if (!vehicle) return;
const { start, end } = rangeFor(range);
if (!vehicle || !requestKey) return;
setLoading(true);
setDays([]);
setError(null);
let cancelled = false;
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
.then(d => { if (!cancelled) setDays(d.days); })
.catch(() => { if (!cancelled) setDays([]); })
fetchVehicleRecent(vehicle.plate, {
start: resolvedRange.start,
end: resolvedRange.end,
sourcePriority,
})
.then(d => {
if (cancelled) return;
setDays(d.days);
setLoadedRequestKey(requestKey);
})
.catch(cause => {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : '车辆近期里程加载失败,请稍后重试。');
}
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
}, [requestKey, requestVersion]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动
useEffect(() => {
@@ -95,8 +115,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
return () => { document.body.style.overflow = ''; };
}, [vehicle]);
// 排除"今日"列(数据未到位时易引起误读)
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
// 预设区间排除未完整的今日;显式下钻区间严格保留用户选择的日期。
const historyDays = useMemo(() => {
if (!hasSnapshot) return [];
return includeCurrentDayInVehicleDetail(range) ? days : days.filter(d => !isToday(d.date));
}, [days, hasSnapshot, range]);
const stats = useMemo(() => {
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
const synced = historyDays.filter(d => d.isDataSynced).length;
@@ -107,12 +130,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
// 骨架天数:根据区间预估
const skeletonCount = useMemo(() => {
if (range === 'last15') return 15;
const { start, end } = rangeFor(range);
const s = new Date(start);
const e = new Date(end);
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
}, [range]);
const start = new Date(`${resolvedRange.start}T00:00:00`);
const end = new Date(`${resolvedRange.end}T00:00:00`);
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86400000) + 1);
}, [resolvedRange.end, resolvedRange.start]);
const showInitialLoading = loading && !hasSnapshot;
const showUnavailable = !!error && !hasSnapshot;
return (
<AnimatePresence>
@@ -142,6 +165,9 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
}}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="vehicle-detail-title"
>
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
<div
@@ -160,7 +186,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
<span id="vehicle-detail-title" className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
{vehicle.isOnline ? '在线' : '离线'}
</span>
@@ -173,19 +199,22 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</div>
</div>
</div>
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
<button type="button" onClick={onClose} aria-label="关闭车辆详情" className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
<X size={18} />
</button>
</div>
{/* 时间范围切换 */}
<div className="px-4 pt-3">
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
{RANGE_TABS.map(tab => (
<div className="relative inline-flex max-w-full overflow-x-auto bg-slate-100 p-0.5 rounded-lg" role="tablist" aria-label="车辆里程区间">
{rangeTabs.map(tab => (
<button
type="button"
key={tab.key}
onClick={() => setRange(tab.key)}
className={`relative px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
role="tab"
aria-selected={range === tab.key}
className={`relative shrink-0 px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
>
{range === tab.key && (
<motion.div
@@ -198,29 +227,49 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</button>
))}
</div>
{loading && hasSnapshot ? (
<span className="ml-2 inline-flex items-center gap-1 text-[9px] font-bold text-slate-400">
<RefreshCw size={10} className="animate-spin" />
</span>
) : null}
</div>
{error ? (
<div className="px-4 pt-3">
<ErrorState
title="车辆近期里程加载失败"
message={hasSnapshot ? `${error} 当前保留上一次成功加载的数据。` : error}
onRetry={() => setRequestVersion(version => version + 1)}
retrying={loading}
variant="inline"
/>
</div>
) : null}
{/* KPI cards */}
<div className="px-4 py-3 grid grid-cols-3 gap-2">
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
{showInitialLoading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
: showUnavailable ? <span className="text-slate-300"></span>
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
{showInitialLoading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
: showUnavailable ? <span className="text-slate-300"></span>
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
{showInitialLoading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
: showUnavailable ? <span className="text-slate-300"></span>
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
</div>
</div>
</div>
@@ -233,8 +282,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</div>
<div className="bg-white rounded-xl border border-slate-50">
<div className="h-[140px]">
{loading ? (
{showInitialLoading ? (
<SkeletonBars count={Math.min(skeletonCount, 30)} />
) : showUnavailable ? (
<div className="flex h-full items-center justify-center text-[10px] font-bold text-slate-300"></div>
) : historyDays.length === 0 ? (
<EmptyState title="暂无里程数据" description="所选区间没有可展示的车辆里程" variant="inline" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
@@ -269,8 +322,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
{/* 每日明细 */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
<div className="text-[10px] font-bold text-slate-500 mb-1.5"></div>
{loading ? (
{showInitialLoading ? (
<SkeletonList count={Math.min(skeletonCount, 15)} />
) : showUnavailable ? (
<div className="py-6 text-center text-[10px] font-bold text-slate-300"></div>
) : historyDays.length === 0 ? (
<div className="py-6 text-center text-[10px] font-bold text-slate-300"></div>
) : (
<motion.div
key={range}
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
includeCurrentDayInVehicleDetail,
normalizeVehicleDetailContext,
resolveVehicleDetailRange,
vehicleDetailContextLabel,
} from './vehicle-detail-range.js';
const NOW = new Date(2026, 7, 7, 12, 0, 0);
test('normalizes and preserves a selected vehicle drill range', () => {
const context = normalizeVehicleDetailContext('2026-08-07', '2026-08-05');
assert.deepEqual(context, { start: '2026-08-05', end: '2026-08-07' });
assert.deepEqual(resolveVehicleDetailRange('context', context, NOW), context);
});
test('rejects invalid context dates and resolves calendar presets', () => {
assert.equal(normalizeVehicleDetailContext('2026-02-30', '2026-03-01'), null);
assert.deepEqual(resolveVehicleDetailRange('last15', null, NOW), {
start: '2026-07-24',
end: '2026-08-07',
});
assert.deepEqual(resolveVehicleDetailRange('month', null, NOW), {
start: '2026-08-01',
end: '2026-08-07',
});
assert.deepEqual(resolveVehicleDetailRange('quarter', null, NOW), {
start: '2026-07-01',
end: '2026-08-07',
});
});
test('labels selected dates and includes today only for explicit context', () => {
const context = { start: '2026-08-07', end: '2026-08-07' };
assert.equal(vehicleDetailContextLabel(context), '8月7日');
assert.equal(includeCurrentDayInVehicleDetail('context'), true);
assert.equal(includeCurrentDayInVehicleDetail('last15'), false);
});
@@ -0,0 +1,66 @@
export type VehicleDetailRangeKey = 'context' | 'last15' | 'month' | 'quarter';
export interface VehicleDetailDateRange {
start: string;
end: string;
}
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
function fmtYmd(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function validDate(value?: string): string | null {
if (!value) return null;
const match = DATE_PATTERN.exec(value);
if (!match) return null;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return fmtYmd(date) === value ? value : null;
}
export function normalizeVehicleDetailContext(
startDate?: string,
endDate?: string,
): VehicleDetailDateRange | null {
const start = validDate(startDate);
const end = validDate(endDate);
if (!start || !end) return null;
return start <= end ? { start, end } : { start: end, end: start };
}
export function resolveVehicleDetailRange(
key: VehicleDetailRangeKey,
context: VehicleDetailDateRange | null,
now = new Date(),
): VehicleDetailDateRange {
if (key === 'context' && context) return context;
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = fmtYmd(today);
if (key === 'month') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end };
}
if (key === 'quarter') {
const quarterStartMonth = Math.floor(today.getMonth() / 3) * 3;
return { start: fmtYmd(new Date(today.getFullYear(), quarterStartMonth, 1)), end };
}
const start = new Date(today);
start.setDate(today.getDate() - 14);
return { start: fmtYmd(start), end };
}
export function vehicleDetailContextLabel(context: VehicleDetailDateRange): string {
if (context.start === context.end) {
return `${Number(context.start.slice(5, 7))}${Number(context.start.slice(8, 10))}`;
}
return '所选区间';
}
export function includeCurrentDayInVehicleDetail(key: VehicleDetailRangeKey): boolean {
return key === 'context';
}
+5 -1
View File
@@ -98,7 +98,11 @@ app.get('/:plate/recent', async (c) => {
});
} catch (e: unknown) {
console.error('vehicle recent error:', e);
return c.json({ plate, days: [] }, 500);
return c.json({
error: '车辆近期里程服务暂时不可用',
code: 'MILEAGE_RECENT_UNAVAILABLE',
retryable: true,
}, 503);
}
});