diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index fced716..8bca863 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -160,6 +160,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 已完成里程考核归因、车辆详情和每日汇报跳转。 - 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。 +- 车辆级里程详情继承 `mileageStart`、`mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。 - 已完成氢能站点、客户下钻代码。 - 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。 - 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。 diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index cd3447f..ea27104 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -1663,9 +1663,14 @@ export default function MonitoringView() { {/* 回到顶部按钮 */} diff --git a/src/modules/mileage/VehicleDetailModal.tsx b/src/modules/mileage/VehicleDetailModal.tsx index 5ef2a0a..df8a501 100644 --- a/src/modules/mileage/VehicleDetailModal.tsx +++ b/src/modules/mileage/VehicleDetailModal.tsx @@ -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([]); + const [loadedRequestKey, setLoadedRequestKey] = useState(null); const [loading, setLoading] = useState(false); - const [range, setRange] = useState('last15'); + const [error, setError] = useState(null); + const [requestVersion, setRequestVersion] = useState(0); + const [range, setRange] = useState(() => ( + 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 ( @@ -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 —— 长按下滑可关闭 */}
- {vehicle.plate} + {vehicle.plate} {vehicle.isOnline ? '在线' : '离线'} @@ -173,19 +199,22 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
- {/* 时间范围切换 */}
-
- {RANGE_TABS.map(tab => ( +
+ {rangeTabs.map(tab => (
+ {loading && hasSnapshot ? ( + + 刷新中 + + ) : null}
+ {error ? ( +
+ setRequestVersion(version => version + 1)} + retrying={loading} + variant="inline" + /> +
+ ) : null} + {/* KPI cards */}
区间合计
- {loading ? - : <>{Math.round(stats.totalKm).toLocaleString()}km} + {showInitialLoading ? + : showUnavailable ? + : <>{Math.round(stats.totalKm).toLocaleString()}km}
日均
- {loading ? - : <>{Math.round(stats.avg).toLocaleString()}km} + {showInitialLoading ? + : showUnavailable ? + : <>{Math.round(stats.avg).toLocaleString()}km}
有数据天
- {loading ? - : <>{stats.synced}/{stats.totalDays}} + {showInitialLoading ? + : showUnavailable ? + : <>{stats.synced}/{stats.totalDays}}
@@ -233,8 +282,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
- {loading ? ( + {showInitialLoading ? ( + ) : showUnavailable ? ( +
所选区间趋势暂不可用
+ ) : historyDays.length === 0 ? ( + ) : ( @@ -269,8 +322,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: {/* 每日明细 */}
每日明细
- {loading ? ( + {showInitialLoading ? ( + ) : showUnavailable ? ( +
请重试加载车辆每日明细
+ ) : historyDays.length === 0 ? ( +
所选区间暂无每日明细
) : ( { + 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); +}); diff --git a/src/modules/mileage/vehicle-detail-range.ts b/src/modules/mileage/vehicle-detail-range.ts new file mode 100644 index 0000000..c94b54b --- /dev/null +++ b/src/modules/mileage/vehicle-detail-range.ts @@ -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'; +} diff --git a/src/server/routes/mileage/vehicle-recent.ts b/src/server/routes/mileage/vehicle-recent.ts index 623867a..d70b3b4 100644 --- a/src/server/routes/mileage/vehicle-recent.ts +++ b/src/server/routes/mileage/vehicle-recent.ts @@ -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); } });