fix: preserve vehicle drill date context
This commit is contained in:
@@ -160,6 +160,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
|
|
||||||
- 已完成里程考核归因、车辆详情和每日汇报跳转。
|
- 已完成里程考核归因、车辆详情和每日汇报跳转。
|
||||||
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
|
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
|
||||||
|
- 车辆级里程详情继承 `mileageStart`、`mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。
|
||||||
- 已完成氢能站点、客户下钻代码。
|
- 已完成氢能站点、客户下钻代码。
|
||||||
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
|
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
|
||||||
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
|
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
|
||||||
|
|||||||
@@ -1663,9 +1663,14 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VehicleDetailModal
|
<VehicleDetailModal
|
||||||
|
key={detailVehicle
|
||||||
|
? `${detailVehicle.plate}|${drillContext.startDate || effectiveRange.start}|${drillContext.endDate || effectiveRange.end}`
|
||||||
|
: 'closed'}
|
||||||
vehicle={detailVehicle}
|
vehicle={detailVehicle}
|
||||||
onClose={closeVehicleDetail}
|
onClose={closeVehicleDetail}
|
||||||
sourcePriority={sourcePriority}
|
sourcePriority={sourcePriority}
|
||||||
|
initialStartDate={drillContext.startDate || effectiveRange.start}
|
||||||
|
initialEndDate={drillContext.endDate || effectiveRange.end}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部按钮 */}
|
||||||
|
|||||||
@@ -1,57 +1,42 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||||
import { X, Truck } from 'lucide-react';
|
import { RefreshCw, X, Truck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
||||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||||
import Blur from '../../components/Blur';
|
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 {
|
interface Props {
|
||||||
vehicle: MonitoringVehicle | null;
|
vehicle: MonitoringVehicle | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
sourcePriority: MileageSourceGroup[];
|
sourcePriority: MileageSourceGroup[];
|
||||||
|
initialStartDate?: string;
|
||||||
|
initialEndDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
const RANGE_TABS: { key: VehicleDetailRangeKey; label: string }[] = [
|
||||||
|
|
||||||
const RANGE_TABS: { key: RangeKey; label: string }[] = [
|
|
||||||
{ key: 'last15', label: '近 15 天' },
|
{ key: 'last15', label: '近 15 天' },
|
||||||
{ key: 'month', label: '本月' },
|
{ key: 'month', label: '本月' },
|
||||||
{ key: 'quarter', 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 {
|
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)
|
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
||||||
void key;
|
void key;
|
||||||
return date.slice(5);
|
return date.slice(5);
|
||||||
@@ -63,30 +48,65 @@ function daySourceLabel(day: VehicleRecentDay): string {
|
|||||||
return day.isDataSynced ? '来源待接口' : '无数据';
|
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 [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||||
|
const [loadedRequestKey, setLoadedRequestKey] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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();
|
const dragControls = useDragControls();
|
||||||
|
const resolvedRange = useMemo(
|
||||||
// 切换车辆时重置区间为默认
|
() => resolveVehicleDetailRange(range, contextRange),
|
||||||
useEffect(() => {
|
[contextRange, range],
|
||||||
if (vehicle) setRange('last15');
|
);
|
||||||
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
|
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(() => {
|
useEffect(() => {
|
||||||
if (!vehicle) return;
|
if (!vehicle || !requestKey) return;
|
||||||
const { start, end } = rangeFor(range);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setDays([]);
|
setError(null);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
fetchVehicleRecent(vehicle.plate, {
|
||||||
.then(d => { if (!cancelled) setDays(d.days); })
|
start: resolvedRange.start,
|
||||||
.catch(() => { if (!cancelled) setDays([]); })
|
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); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [requestKey, requestVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 锁滚动
|
// 锁滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,8 +115,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [vehicle]);
|
}, [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 stats = useMemo(() => {
|
||||||
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
||||||
const synced = historyDays.filter(d => d.isDataSynced).length;
|
const synced = historyDays.filter(d => d.isDataSynced).length;
|
||||||
@@ -107,12 +130,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
|
|
||||||
// 骨架天数:根据区间预估
|
// 骨架天数:根据区间预估
|
||||||
const skeletonCount = useMemo(() => {
|
const skeletonCount = useMemo(() => {
|
||||||
if (range === 'last15') return 15;
|
const start = new Date(`${resolvedRange.start}T00:00:00`);
|
||||||
const { start, end } = rangeFor(range);
|
const end = new Date(`${resolvedRange.end}T00:00:00`);
|
||||||
const s = new Date(start);
|
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86400000) + 1);
|
||||||
const e = new Date(end);
|
}, [resolvedRange.end, resolvedRange.start]);
|
||||||
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
|
const showInitialLoading = loading && !hasSnapshot;
|
||||||
}, [range]);
|
const showUnavailable = !!error && !hasSnapshot;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<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"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="vehicle-detail-title"
|
||||||
>
|
>
|
||||||
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
|
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
|
||||||
<div
|
<div
|
||||||
@@ -160,7 +186,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<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'}`}>
|
<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 ? '在线' : '离线'}
|
{vehicle.isOnline ? '在线' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
@@ -173,19 +199,22 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 时间范围切换 */}
|
{/* 时间范围切换 */}
|
||||||
<div className="px-4 pt-3">
|
<div className="px-4 pt-3">
|
||||||
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
|
<div className="relative inline-flex max-w-full overflow-x-auto bg-slate-100 p-0.5 rounded-lg" role="tablist" aria-label="车辆里程区间">
|
||||||
{RANGE_TABS.map(tab => (
|
{rangeTabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setRange(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 && (
|
{range === tab.key && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -198,28 +227,48 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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 */}
|
{/* KPI cards */}
|
||||||
<div className="px-4 py-3 grid grid-cols-3 gap-2">
|
<div className="px-4 py-3 grid grid-cols-3 gap-2">
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<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" />
|
{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></>}
|
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<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" />
|
{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></>}
|
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<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" />
|
{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></>}
|
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,8 +282,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-slate-50">
|
<div className="bg-white rounded-xl border border-slate-50">
|
||||||
<div className="h-[140px]">
|
<div className="h-[140px]">
|
||||||
{loading ? (
|
{showInitialLoading ? (
|
||||||
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
<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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
<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="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
||||||
{loading ? (
|
{showInitialLoading ? (
|
||||||
<SkeletonList count={Math.min(skeletonCount, 15)} />
|
<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
|
<motion.div
|
||||||
key={range}
|
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';
|
||||||
|
}
|
||||||
@@ -98,7 +98,11 @@ app.get('/:plate/recent', async (c) => {
|
|||||||
});
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('vehicle recent error:', e);
|
console.error('vehicle recent error:', e);
|
||||||
return c.json({ plate, days: [] }, 500);
|
return c.json({
|
||||||
|
error: '车辆近期里程服务暂时不可用',
|
||||||
|
code: 'MILEAGE_RECENT_UNAVAILABLE',
|
||||||
|
retryable: true,
|
||||||
|
}, 503);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user