22 changed files with 1510 additions and 267 deletions
+10
View File
@@ -160,6 +160,8 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已完成里程考核归因、车辆详情和每日汇报跳转。
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
- 车辆级里程详情继承 `mileageStart``mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。
- 里程实时监控的日期、来源、批次、区域、车牌、归属、高级筛选和排序已纳入独立 URL 状态;分享或刷新链接可恢复完整查询,车辆详情前进后退保留列表上下文且不把下钻车牌误写为筛选车牌。
- 已完成氢能站点、客户下钻代码。
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
@@ -171,6 +173,14 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
+14 -2
View File
@@ -5,17 +5,20 @@ import {
reconcileMileageAttribution,
type AttributionDimension,
} from './attribution';
import { ErrorState } from '../../components/ui/surface';
interface Props {
vehicles: TargetVehicle[];
dimension: AttributionDimension;
selectedValue?: string;
loading?: boolean;
error?: string | null;
targetMileagePerVehicle: number;
expectedShortfallMileage?: number;
assessmentLabel?: string;
onDimensionChange: (dimension: AttributionDimension) => void;
onSelect: (value: string) => void;
onRetry?: () => void;
}
function fmtKm(value: number): string {
@@ -27,11 +30,13 @@ export default function MileageAttributionView({
dimension,
selectedValue,
loading = false,
error,
targetMileagePerVehicle,
expectedShortfallMileage,
assessmentLabel,
onDimensionChange,
onSelect,
onRetry,
}: Props) {
const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
@@ -49,9 +54,14 @@ export default function MileageAttributionView({
<h2 className="text-sm font-black text-slate-800"></h2>
</div>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{assessmentLabel ? `${assessmentLabel} · ` : ''}{totalBehind} {fmtKm(totalShortfall)} km
{assessmentLabel ? `${assessmentLabel} · ` : ''}
{loading
? '正在加载归因车辆'
: error
? '归因数据暂不可用'
: `${totalBehind} 台未达标,累计缺口 ${fmtKm(totalShortfall)} km`}
</p>
{!loading && reconciliation && (
{!loading && !error && reconciliation && (
<p
role={reconciliation.matches ? 'status' : 'alert'}
className={`mt-1 text-[10px] font-black ${reconciliation.matches ? 'text-emerald-600' : 'text-rose-600'}`}
@@ -84,6 +94,8 @@ export default function MileageAttributionView({
{loading ? (
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">...</div>
) : error ? (
<ErrorState message={error} onRetry={onRetry} variant="inline" />
) : groups.length === 0 ? (
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400"></div>
) : (
+382 -131
View File
@@ -24,6 +24,12 @@ import {
type MileageDrillContext,
} from './drill-context';
import { formatMileageSnapshotTime } from './data-freshness';
import {
buildMileageMonitoringUrl,
parseMileageMonitoringContext,
type MileageMonitoringContext,
} from './monitoring-context';
import { buildMonitoringKpis } from './monitoring-kpis';
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
'交投40辆4.5T普货',
@@ -31,6 +37,7 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([
]);
const HIGH_MILEAGE_ALERT_KM = 800;
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
type MonitoringRequestParams = NonNullable<Parameters<typeof fetchMonitoring>[0]>;
const MILEAGE_SOURCE_META = {
instrument: { label: '仪表数据', protocol: null },
@@ -453,35 +460,48 @@ export default function MonitoringView() {
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
parseMileageDrillContext(window.location.search)
));
const [searchTerm, setSearchTerm] = useState('');
const [filterDept, setFilterDept] = useState('All');
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [initialMonitoringContext] = useState(() => parseMileageMonitoringContext(window.location.search));
const [searchTerm, setSearchTerm] = useState(initialMonitoringContext.search || '');
const [filterDept, setFilterDept] = useState(initialMonitoringContext.department || 'All');
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>(initialMonitoringContext.sortBy);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(initialMonitoringContext.sortOrder);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
const [fullscreenLoading, setFullscreenLoading] = useState(false);
const [fullscreenError, setFullscreenError] = useState<string | null>(null);
const [fullscreenSnapshotKey, setFullscreenSnapshotKey] = useState<string | null>(null);
const [fullscreenUpdatedAt, setFullscreenUpdatedAt] = useState<string | null>(null);
const [fullscreenDateRange, setFullscreenDateRange] = useState<{ start: string; end: string } | undefined>();
// New filters from image
const [filterPlates, setFilterPlates] = useState<string[]>(() => (
drillContext.level === 'vehicle' && drillContext.plate ? [drillContext.plate] : []
const [filterPlates, setFilterPlates] = useState<string[]>(initialMonitoringContext.plates);
const [drillPlateFallback, setDrillPlateFallback] = useState<string | undefined>(() => (
drillContext.level === 'vehicle' && drillContext.plate ? drillContext.plate : undefined
));
const [filterCustomer, setFilterCustomer] = useState('All');
const [filterProject, setFilterProject] = useState('All');
const [filterEntity, setFilterEntity] = useState('All');
const [filterRentStatus, setFilterRentStatus] = useState('All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
const [filterRegion, setFilterRegion] = useState('All');
const [filterBrands, setFilterBrands] = useState<string[]>([]);
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [filterCustomer, setFilterCustomer] = useState(initialMonitoringContext.customer || 'All');
const [filterProject, setFilterProject] = useState(initialMonitoringContext.project || 'All');
const [filterEntity, setFilterEntity] = useState(initialMonitoringContext.entity || 'All');
const [filterRentStatus, setFilterRentStatus] = useState(initialMonitoringContext.rentStatus || 'All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState(initialMonitoringContext.platePrefix || 'All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>(initialMonitoringContext.targetNames);
const [filterRegion, setFilterRegion] = useState(initialMonitoringContext.region || 'All');
const [filterBrands, setFilterBrands] = useState<string[]>(initialMonitoringContext.brands);
const [filterMileageRange, setFilterMileageRange] = useState({
min: initialMonitoringContext.mileageMin || '',
max: initialMonitoringContext.mileageMax || '',
});
const [appliedMileageRange, setAppliedMileageRange] = useState({
min: initialMonitoringContext.mileageMin || '',
max: initialMonitoringContext.mileageMax || '',
});
const [exporting, setExporting] = useState(false);
const [rangeStart, setRangeStart] = useState(() => drillContext.startDate || defaultMileageDate());
const [rangeEnd, setRangeEnd] = useState(() => drillContext.endDate || defaultMileageDate());
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(drillContext.sourcePriority);
const [exportStatus, setExportStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [rangeStart, setRangeStart] = useState(() => initialMonitoringContext.startDate || drillContext.startDate || defaultMileageDate());
const [rangeEnd, setRangeEnd] = useState(() => initialMonitoringContext.endDate || drillContext.endDate || defaultMileageDate());
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(initialMonitoringContext.sourcePriority);
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
@@ -494,14 +514,14 @@ export default function MonitoringView() {
const [loadingMore, setLoadingMore] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [pageError, setPageError] = useState<{ message: string; scope: 'first' | 'more' } | null>(null);
const [pageSnapshotKey, setPageSnapshotKey] = useState<string | null>(null);
const [manualRefreshing, setManualRefreshing] = useState(false);
const [dataUpdatedAt, setDataUpdatedAt] = useState<string | null>(null);
const [showBackToTop, setShowBackToTop] = useState(false);
const [relativeNow, setRelativeNow] = useState(() => Date.now());
const firstPageRequestRef = useRef(0);
const loadMoreRequestRef = useRef(0);
const PAGE_SIZE = 50;
const detailVehicle = drillContext.level === 'vehicle' && drillContext.plate
? vehicles.find(vehicle => vehicle.plate === drillContext.plate) || null
: null;
const commitDrillContext = useCallback((next: MileageDrillContext, mode: 'push' | 'replace') => {
const url = buildMileageDrillUrl(window.location, next);
@@ -510,6 +530,7 @@ export default function MonitoringView() {
}, []);
const openVehicleDetail = useCallback((vehicle: MonitoringVehicle) => {
setDrillPlateFallback(undefined);
commitDrillContext({
level: 'vehicle',
plate: vehicle.plate,
@@ -520,6 +541,7 @@ export default function MonitoringView() {
}, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]);
const closeVehicleDetail = useCallback(() => {
setDrillPlateFallback(undefined);
commitDrillContext({
level: 'overview',
startDate: effectiveRange.start,
@@ -536,11 +558,31 @@ export default function MonitoringView() {
useEffect(() => {
const handlePopState = () => {
const next = parseMileageDrillContext(window.location.search);
const nextMonitoring = parseMileageMonitoringContext(window.location.search);
setDrillContext(next);
if (next.startDate) setRangeStart(next.startDate);
if (next.endDate) setRangeEnd(next.endDate);
setSourcePriority(next.sourcePriority);
if (next.level === 'vehicle' && next.plate) setFilterPlates([next.plate]);
setRangeStart(nextMonitoring.startDate || defaultMileageDate());
setRangeEnd(nextMonitoring.endDate || defaultMileageDate());
setSourcePriority(nextMonitoring.sourcePriority);
setFilterTargetNames(nextMonitoring.targetNames);
setFilterRegion(nextMonitoring.region || 'All');
setFilterPlates(nextMonitoring.plates);
setDrillPlateFallback(next.level === 'vehicle' && next.plate ? next.plate : undefined);
setFilterDept(nextMonitoring.department || 'All');
setFilterCustomer(nextMonitoring.customer || 'All');
setFilterProject(nextMonitoring.project || 'All');
setFilterEntity(nextMonitoring.entity || 'All');
setFilterRentStatus(nextMonitoring.rentStatus || 'All');
setFilterBrands(nextMonitoring.brands);
setFilterPlatePrefix(nextMonitoring.platePrefix || 'All');
setSearchTerm(nextMonitoring.search || '');
const nextMileageRange = {
min: nextMonitoring.mileageMin || '',
max: nextMonitoring.mileageMax || '',
};
setFilterMileageRange(nextMileageRange);
setAppliedMileageRange(nextMileageRange);
setSortBy(nextMonitoring.sortBy);
setSortOrder(nextMonitoring.sortOrder);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
@@ -548,11 +590,6 @@ export default function MonitoringView() {
const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates;
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
const isRangeMode = !!effectiveRange.start && !!effectiveRange.end && effectiveRange.start !== effectiveRange.end;
const averageDailyKm = rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
const applyRangePreset = useCallback((preset: RangePreset) => {
const range = getRangePreset(preset);
setRangeStart(range.start);
@@ -565,33 +602,133 @@ export default function MonitoringView() {
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetNames]);
const requestPlates = useMemo(() => Array.from(new Set([
...filterPlates,
...(drillPlateFallback ? [drillPlateFallback] : []),
])), [drillPlateFallback, filterPlates]);
const monitoringFilters = useMemo<MonitoringRequestParams>(() => ({
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: requestPlates.length > 0 ? requestPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}), [
appliedMileageRange.max,
appliedMileageRange.min,
filterBrands,
filterCustomer,
filterDept,
filterEntity,
filterPlatePrefix,
filterProject,
filterRegion,
filterRentStatus,
filterTargetNames,
rangeEnd,
rangeStart,
requestPlates,
searchTerm,
sourcePriority,
]);
const monitoringUrlContext = useMemo<MileageMonitoringContext>(() => ({
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
sourcePriority,
targetNames: filterTargetNames,
region: filterRegion !== 'All' ? filterRegion : undefined,
plates: filterPlates,
department: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
brands: filterBrands,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
search: searchTerm || undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
sortBy,
sortOrder,
}), [
appliedMileageRange.max,
appliedMileageRange.min,
filterBrands,
filterCustomer,
filterDept,
filterEntity,
filterPlatePrefix,
filterPlates,
filterProject,
filterRegion,
filterRentStatus,
filterTargetNames,
rangeEnd,
rangeStart,
searchTerm,
sortBy,
sortOrder,
sourcePriority,
]);
useEffect(() => {
const nextUrl = buildMileageMonitoringUrl(window.location, monitoringUrlContext);
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (nextUrl !== currentUrl) window.history.replaceState(null, '', nextUrl);
}, [monitoringUrlContext]);
const monitoringRequestKey = useMemo(
() => JSON.stringify({ ...monitoringFilters, sortBy, sortOrder }),
[monitoringFilters, sortBy, sortOrder],
);
const pageHasSnapshot = pageSnapshotKey === monitoringRequestKey;
const fullscreenHasSnapshot = fullscreenSnapshotKey === monitoringRequestKey;
const pageUpdatedAt = pageHasSnapshot ? dataUpdatedAt : null;
const detailVehicle = pageHasSnapshot && drillContext.level === 'vehicle' && drillContext.plate
? vehicles.find(vehicle => vehicle.plate === drillContext.plate) || null
: null;
const displayedRange = pageHasSnapshot
? effectiveRange
: { start: rangeStart, end: rangeEnd };
const rangeLabel = normalizeRangeLabel(displayedRange.start, displayedRange.end);
const isRangeMode = !!displayedRange.start && !!displayedRange.end && displayedRange.start !== displayedRange.end;
const averageDailyKm = pageHasSnapshot && rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
const pageKpis = buildMonitoringKpis(stats, displayedRange);
const fullscreenKpis = buildMonitoringKpis(
fullscreenStats,
fullscreenHasSnapshot ? fullscreenDateRange : { start: rangeStart, end: rangeEnd },
);
// 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
const requestId = ++firstPageRequestRef.current;
const requestKey = monitoringRequestKey;
++loadMoreRequestRef.current;
setLoadingMore(false);
if (showPageLoading) setPageLoading(true);
setPageError(null);
return fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
force,
}).then(d => {
if (requestId !== firstPageRequestRef.current) return;
setVehicles(d.vehicles);
setStats(d.stats);
setFilterOptions(d.filters);
@@ -601,15 +738,17 @@ export default function MonitoringView() {
setPage(1);
setHasMore(d.page < d.totalPages);
setDataUpdatedAt(d.updatedAt);
setPageSnapshotKey(requestKey);
}).catch(error => {
if (requestId !== firstPageRequestRef.current) return;
setPageError({
message: error instanceof Error ? error.message : '里程数据加载失败,请稍后重试',
scope: 'first',
});
}).finally(() => {
if (showPageLoading) setPageLoading(false);
if (requestId === firstPageRequestRef.current) setPageLoading(false);
});
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
}, [monitoringFilters, monitoringRequestKey, rangeEnd, rangeStart, sortBy, sortOrder]);
const handleManualRefresh = useCallback(async () => {
if (manualRefreshing) return;
@@ -625,41 +764,31 @@ export default function MonitoringView() {
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
const nextPage = page + 1;
const requestId = ++loadMoreRequestRef.current;
setLoadingMore(true);
setPageError(null);
fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: nextPage,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}).then(d => {
if (requestId !== loadMoreRequestRef.current) return;
setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage);
setHasMore(nextPage < d.totalPages);
setDataUpdatedAt(d.updatedAt);
}).catch(error => {
if (requestId !== loadMoreRequestRef.current) return;
setPageError({
message: error instanceof Error ? error.message : '更多车辆加载失败,请稍后重试',
scope: 'more',
});
}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]);
}).finally(() => {
if (requestId === loadMoreRequestRef.current) setLoadingMore(false);
});
}, [hasMore, loadingMore, monitoringFilters, page, sortBy, sortOrder]);
// 筛选/排序变化时重新加载
useEffect(() => {
@@ -669,6 +798,7 @@ export default function MonitoringView() {
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
useEffect(() => {
if (filterPlates.length === 0) return;
if (filterOptions.plates.length === 0) return;
const valid = new Set(filterOptions.plates);
const next = filterPlates.filter(p => valid.has(p));
if (next.length !== filterPlates.length) setFilterPlates(next);
@@ -678,36 +808,27 @@ export default function MonitoringView() {
const handleDownload = useCallback(async () => {
if (exporting) return;
setExporting(true);
setExportStatus(null);
try {
const d = await fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
});
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
setExportStatus({ type: 'success', message: `已导出 ${d.vehicles.length} 辆车辆` });
} catch (err) {
console.error('export failed', err);
setExportStatus({
type: 'error',
message: err instanceof Error ? err.message : '导出失败,请稍后重试',
});
} finally {
setExporting(false);
}
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
}, [exporting, monitoringFilters, rangeEnd, rangeStart, sortBy, sortOrder]);
// 每分钟自动刷新
useEffect(() => {
@@ -733,7 +854,7 @@ export default function MonitoringView() {
document.body.scrollTop = 0;
};
const filteredVehicles = vehicles;
const filteredVehicles = pageHasSnapshot ? vehicles : [];
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
@@ -742,31 +863,31 @@ export default function MonitoringView() {
// 全屏时加载全部数据(无分页),筛选变化时重新加载
useEffect(() => {
if (!isFullscreen) return;
let cancelled = false;
setFullscreenLoading(true);
setFullscreenError(null);
fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}).then(d => {
if (cancelled) return;
setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats);
setFilterOptions(d.filters);
setDataUpdatedAt(d.updatedAt);
}).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]);
setFullscreenUpdatedAt(d.updatedAt);
setFullscreenDateRange(d.dateRange || { start: rangeStart, end: rangeEnd });
setFullscreenSnapshotKey(monitoringRequestKey);
}).catch(error => {
if (cancelled) return;
setFullscreenError(error instanceof Error ? error.message : '全屏监控加载失败,请稍后重试');
}).finally(() => {
if (!cancelled) setFullscreenLoading(false);
});
return () => { cancelled = true; };
}, [fullscreenRefresh, isFullscreen, monitoringFilters, monitoringRequestKey, rangeEnd, rangeStart, sortBy, sortOrder]);
// 全屏时禁止背景滚动
useEffect(() => {
@@ -804,6 +925,9 @@ export default function MonitoringView() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-label="里程全屏监控"
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
style={
forceLandscape
@@ -827,13 +951,13 @@ export default function MonitoringView() {
<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-500">{fullscreenKpis.distanceShortLabel} <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenKpis.distanceKm).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-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenKpis.cumulativeOdometerKm).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-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenKpis.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-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenKpis.averagePerVehicleKm.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">
@@ -845,11 +969,14 @@ export default function MonitoringView() {
<div className="flex items-center gap-2">
<button
onClick={() => { setFullscreenRefresh(n => n + 1); }}
disabled={fullscreenLoading}
aria-label={fullscreenLoading ? '正在刷新全屏监控' : '刷新全屏监控'}
title={fullscreenLoading ? '刷新中' : '刷新全屏监控'}
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
>
<RotateCcw size={13} />
</button>
<button onClick={toggleFullscreen} className="p-1.5 text-slate-500 hover:text-white transition-colors">
<button onClick={toggleFullscreen} aria-label="退出全屏监控" title="退出全屏" className="p-1.5 text-slate-500 hover:text-white transition-colors">
<Minimize2 size={14} />
</button>
</div>
@@ -879,9 +1006,24 @@ export default function MonitoringView() {
{/* Table Area */}
<div className="flex-1 overflow-hidden flex flex-col">
{fullscreenError && fullscreenHasSnapshot && (
<div role="alert" className="flex flex-shrink-0 items-center justify-between gap-3 border-b border-amber-500/20 bg-amber-500/10 px-3 py-1.5 text-[10px] font-bold text-amber-300">
<span className="truncate" title={fullscreenError}>
{formatMileageSnapshotTime(fullscreenUpdatedAt)}
</span>
<button
type="button"
onClick={() => setFullscreenRefresh(value => value + 1)}
disabled={fullscreenLoading}
className="shrink-0 text-amber-200 underline underline-offset-2 disabled:opacity-50"
>
</button>
</div>
)}
<div className="flex-1 overflow-auto relative">
{fullscreenLoading && (
{fullscreenLoading && fullscreenHasSnapshot && (
<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" />
@@ -889,7 +1031,29 @@ export default function MonitoringView() {
</div>
</div>
)}
<table className="w-full text-left border-collapse">
{!fullscreenHasSnapshot && (
<div className="flex min-h-full items-center justify-center px-6 py-20">
{fullscreenLoading ? (
<div className="flex items-center gap-2 text-xs font-bold text-slate-400" role="status">
<RotateCcw size={14} className="animate-spin" />
...
</div>
) : (
<div className="max-w-md text-center" role="alert">
<p className="text-sm font-bold text-rose-300"></p>
<p className="mt-1 text-[10px] text-slate-500">{fullscreenError || '未取得当前筛选条件下的数据'}</p>
<button
type="button"
onClick={() => setFullscreenRefresh(value => value + 1)}
className="mt-3 rounded border border-slate-700 px-3 py-1.5 text-[10px] font-bold text-slate-300 hover:border-blue-500 hover:text-blue-300"
>
</button>
</div>
)}
</div>
)}
{fullscreenHasSnapshot && <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>
@@ -1017,6 +1181,13 @@ export default function MonitoringView() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-20 text-center text-xs font-bold text-slate-500">
</td>
</tr>
)}
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
@@ -1058,7 +1229,7 @@ export default function MonitoringView() {
);
})}
</tbody>
</table>
</table>}
</div>
</div>
</motion.div>
@@ -1072,10 +1243,16 @@ export default function MonitoringView() {
<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={toggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
<button onClick={toggleFullscreen} aria-label="打开全屏监控" 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 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
<button
onClick={handleDownload}
disabled={exporting}
aria-label={exporting ? '正在导出当前筛选结果' : '导出当前筛选结果'}
className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200"
title={exporting ? '导出中' : '下载当前筛选结果'}
>
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
</button>
</div>
@@ -1083,9 +1260,9 @@ export default function MonitoringView() {
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<span
className="truncate text-[9px] font-bold tracking-tight text-slate-400"
title={`OneOS 分钟级快照 · 数据截至 ${formatMileageSnapshotTime(dataUpdatedAt)}`}
title={`OneOS 分钟级快照 · 数据截至 ${formatMileageSnapshotTime(pageUpdatedAt)}`}
>
OneOS · {formatMileageSnapshotTime(dataUpdatedAt)}
OneOS · {formatMileageSnapshotTime(pageUpdatedAt)}
</span>
</div>
</div>
@@ -1093,7 +1270,7 @@ export default function MonitoringView() {
<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">
{formatMileageSnapshotTime(dataUpdatedAt)}
{formatMileageSnapshotTime(pageUpdatedAt)}
</span>
<button
type="button"
@@ -1106,9 +1283,9 @@ export default function MonitoringView() {
<span>{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={() => setSortBy('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 aria-pressed={sortBy === 'today'} title="按所选日期区间里程排序" 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 aria-pressed={sortBy === 'total'} title="按最新累计仪表里程排序" 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 aria-pressed={sortBy === 'statisticTime'} title="按最后有效数据时间排序" onClick={() => setSortBy('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={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
@@ -1120,6 +1297,32 @@ export default function MonitoringView() {
</div>
</div>
{exportStatus && (
<div
role={exportStatus.type === 'error' ? 'alert' : 'status'}
className={`flex items-center justify-between gap-3 border-t pt-2 text-[10px] font-bold ${
exportStatus.type === 'error'
? 'border-rose-100 text-rose-600'
: 'border-emerald-100 text-emerald-600'
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
{exportStatus.type === 'success' && <Check size={12} className="shrink-0" />}
<span className="truncate" title={exportStatus.message}>{exportStatus.message}</span>
</span>
{exportStatus.type === 'error' && (
<button
type="button"
onClick={handleDownload}
disabled={exporting}
className="shrink-0 underline underline-offset-2 disabled:opacity-50"
>
</button>
)}
</div>
)}
<SourcePriorityControl priority={sourcePriority} onChange={setSourcePriority} />
<div className="flex items-center gap-2">
@@ -1422,30 +1625,73 @@ export default function MonitoringView() {
);
})()}
{pageError?.scope === 'first' && (
<div
role="alert"
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2 ${
pageHasSnapshot
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-rose-200 bg-rose-50 text-rose-700'
}`}
>
<div className="min-w-0">
<p className="truncate text-[10px] font-black" title={pageError.message}>
{pageHasSnapshot
? `刷新失败,仍展示截至 ${formatMileageSnapshotTime(pageUpdatedAt)} 的数据`
: '当前筛选数据暂不可用'}
</p>
{!pageHasSnapshot && <p className="mt-0.5 truncate text-[9px] opacity-70">{pageError.message}</p>}
</div>
<button
type="button"
onClick={() => pageHasSnapshot ? handleManualRefresh() : loadFirstPage()}
disabled={pageLoading || manualRefreshing}
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-lg border border-current/20 bg-white/70 px-2 text-[9px] font-black disabled:opacity-50"
>
<RefreshCw size={11} className={pageLoading || manualRefreshing ? 'animate-spin' : ''} />
</button>
</div>
)}
{/* 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={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading && pageHasSnapshot ? '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-[7px] font-bold text-slate-500 uppercase tracking-wider">{pageKpis.distanceLabel}</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></>}
{pageLoading && !pageHasSnapshot
? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div>
: <>{pageHasSnapshot ? Math.round(pageKpis.distanceKm).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-sm font-black text-slate-800 leading-tight">
{pageLoading && !pageHasSnapshot
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
: pageHasSnapshot ? pageKpis.averagePerVehicleKm.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-sm font-black text-slate-800 leading-tight">
{pageLoading && !pageHasSnapshot
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
: pageHasSnapshot ? pageKpis.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 ? (
{pageLoading && !pageHasSnapshot ? (
<div className="h-[74px] bg-slate-50 animate-pulse" />
) : !pageHasSnapshot ? (
<div className="flex h-[74px] items-center justify-center px-3 text-[10px] font-bold text-slate-400">
</div>
) : isRangeMode ? (
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
<div className="min-w-0">
@@ -1491,11 +1737,11 @@ export default function MonitoringView() {
</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 className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(pageKpis.distanceKm).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 className="mt-1 text-xs font-black tabular-nums text-slate-800">{Math.round(pageKpis.averagePerVehicleKm).toLocaleString()}</div>
</div>
</div>
)}
@@ -1503,7 +1749,7 @@ export default function MonitoringView() {
<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-400">
{filteredVehicles.length} / {total}
{pageHasSnapshot ? `已加载 ${filteredVehicles.length} / 共 ${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">
@@ -1517,7 +1763,7 @@ export default function MonitoringView() {
{/* Vehicle List */}
<div className="space-y-1.5">
{pageLoading && (
{pageLoading && !pageHasSnapshot && (
<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">
@@ -1619,18 +1865,18 @@ export default function MonitoringView() {
})}
</div>
{filteredVehicles.length === 0 && !loadingMore && !pageError && (
{pageHasSnapshot && filteredVehicles.length === 0 && !loadingMore && !pageError && (
<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>
)}
{pageError && (
{pageError?.scope === 'more' && pageHasSnapshot && (
<div className="rounded-xl border border-rose-100 bg-rose-50 px-3 py-3 text-center">
<p className="text-[10px] font-bold text-rose-600">{pageError.message}</p>
<button
type="button"
onClick={() => pageError.scope === 'more' ? loadMore() : loadFirstPage()}
onClick={loadMore}
disabled={pageLoading || loadingMore}
className="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-[10px] font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
>
@@ -1639,7 +1885,7 @@ export default function MonitoringView() {
</button>
</div>
)}
{hasMore && !pageError && filteredVehicles.length > 0 && (
{pageHasSnapshot && hasMore && !pageError && filteredVehicles.length > 0 && (
<div className="flex flex-col items-center gap-1.5 py-4">
<button
type="button"
@@ -1655,7 +1901,7 @@ export default function MonitoringView() {
</span>
</div>
)}
{!hasMore && filteredVehicles.length > 0 && (
{pageHasSnapshot && !hasMore && filteredVehicles.length > 0 && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-300"> {total} </span>
</div>
@@ -1663,9 +1909,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}
/>
{/* 回到顶部按钮 */}
+279 -60
View File
@@ -6,14 +6,16 @@ import {
Cell, LabelList,
} from 'recharts';
import {
Truck, ChevronDown, Maximize2, Minimize2,
AlertTriangle, Truck, ChevronDown, Maximize2, Minimize2,
Search, ArrowUpDown, X, RotateCcw, Calendar,
} from 'lucide-react';
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import Blur from '../../components/Blur';
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
import { weightedCompletionRate } from '../../shared/analytics/metrics';
import { formatMileageSnapshotDateTime } from './data-freshness';
import { assessVehicleCoverage } from './assessment-coverage';
import MileageAttributionView from './MileageAttributionView';
import {
filterAttributionVehicles,
@@ -63,13 +65,28 @@ function shortTargetName(name: string): string {
return `${count}${desc}`;
}
function getRequestErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message && error.message !== 'Failed to fetch') {
return error.message;
}
return fallback;
}
export default function StatisticsView() {
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
parseMileageDrillContext(window.location.search)
));
const [targets, setTargets] = useState<TargetSummary[]>([]);
const [targetsLoading, setTargetsLoading] = useState(true);
const [targetsError, setTargetsError] = useState<string | null>(null);
const [targetsRequestVersion, setTargetsRequestVersion] = useState(0);
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [trendError, setTrendError] = useState<string | null>(null);
const [trendRequestVersion, setTrendRequestVersion] = useState(0);
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
const [targetVehicleLoadingMap, setTargetVehicleLoadingMap] = useState<Record<number, boolean>>({});
const [targetVehicleErrorMap, setTargetVehicleErrorMap] = useState<Record<number, string>>({});
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
const [attributionDimension, setAttributionDimension] = useState<AttributionDimension>(() => (
drillContext.level === 'breakdown' && drillContext.dimension === 'customer' ? 'customer' : 'department'
@@ -87,13 +104,20 @@ export default function StatisticsView() {
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false);
const [viewAllError, setViewAllError] = useState<string | null>(null);
const [viewAllRequestVersion, setViewAllRequestVersion] = useState(0);
const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null);
const [viewAllLoadedDate, setViewAllLoadedDate] = useState<string | null>(null);
const [viewAllLoadedTargetId, setViewAllLoadedTargetId] = useState<number | null>(null);
const targetVehicleRequestsRef = useRef(new Set<number>());
const initialDrillContextRef = useRef(drillContext);
const selectedTarget = targets.find(t => t.id === selectedTargetId);
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
const selectedCoverage = selectedTarget
? assessVehicleCoverage(selectedTarget.declaredVehicleCount, selectedTarget.vehicleCount)
: null;
const selectedAssessmentYear = selectedAssessment?.yearNumber;
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
const selectedRemaining = selectedAssessment?.remaining ?? 0;
@@ -120,12 +144,36 @@ export default function StatisticsView() {
setDrillContext(next);
}, []);
const retryTargets = useCallback(() => {
setTargetsRequestVersion(version => version + 1);
}, []);
const retryTrend = useCallback(() => {
setTrendRequestVersion(version => version + 1);
}, []);
const retryViewAllVehicles = useCallback(() => {
setViewAllRequestVersion(version => version + 1);
}, []);
const loadCurrentTargetVehicles = useCallback((targetId: number) => {
if (targetVehicleRequestsRef.current.has(targetId)) return;
targetVehicleRequestsRef.current.add(targetId);
setTargetVehicleLoadingMap(prev => ({ ...prev, [targetId]: true }));
setTargetVehicleErrorMap(prev => {
const next = { ...prev };
delete next[targetId];
return next;
});
fetchTargetVehicles(targetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
}).catch(() => {}).finally(() => {
}).catch(error => {
setTargetVehicleErrorMap(prev => ({
...prev,
[targetId]: getRequestErrorMessage(error, '无法获取考核车辆数据,请稍后重试。'),
}));
}).finally(() => {
setTargetVehicleLoadingMap(prev => ({ ...prev, [targetId]: false }));
targetVehicleRequestsRef.current.delete(targetId);
});
}, []);
@@ -154,6 +202,9 @@ export default function StatisticsView() {
setViewAllSearch('');
setViewAllDate(getDefaultDate());
setViewAllHistoricalVehicles(null);
setViewAllLoadedDate(null);
setViewAllLoadedTargetId(null);
setViewAllError(null);
commitDrillContext({
level: 'breakdown',
targetId: selectedTargetId,
@@ -171,6 +222,9 @@ export default function StatisticsView() {
setViewAllSearch('');
setViewAllDate(getDefaultDate());
setViewAllHistoricalVehicles(null);
setViewAllLoadedDate(null);
setViewAllLoadedTargetId(null);
setViewAllError(null);
commitDrillContext({
level: 'breakdown',
targetId: target.id,
@@ -211,9 +265,13 @@ export default function StatisticsView() {
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, [selectedAssessmentYear, selectedTargetId, viewAllDate, viewAllTargetId]);
// Load targets on mount
// Load targets on mount and through the shared retry path.
useEffect(() => {
let cancelled = false;
setTargetsLoading(true);
setTargetsError(null);
fetchTargets().then(data => {
if (cancelled) return;
const initialContext = initialDrillContextRef.current;
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
const ordered = focused
@@ -238,28 +296,75 @@ export default function StatisticsView() {
setDrillContext(normalizedContext);
}
}
} else {
setSelectedTargetId(null);
setExpandedTargetId(null);
setAssessmentYearMap({});
}
}).catch(() => {});
}, []);
}).catch(error => {
if (!cancelled) {
setTargetsError(getRequestErrorMessage(error, '无法获取考核目标数据,请检查服务连接后重试。'));
}
}).finally(() => {
if (!cancelled) setTargetsLoading(false);
});
return () => {
cancelled = true;
};
}, [targetsRequestVersion]);
// Load trend when selectedTargetId changes
useEffect(() => {
if (selectedTargetId === null) return;
if (selectedTargetId === null) {
setTrendData([]);
setTrendError(null);
setTrendLoading(false);
return;
}
let cancelled = false;
setExpandedTargetId(selectedTargetId);
if (!targetVehiclesMap[selectedTargetId]) {
loadCurrentTargetVehicles(selectedTargetId);
}
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
}, [selectedTargetId]);
setTrendLoading(true);
setTrendError(null);
fetchTrend(selectedTargetId).then(data => {
if (!cancelled) setTrendData(data);
}).catch(error => {
if (!cancelled) {
setTrendData([]);
setTrendError(getRequestErrorMessage(error, '无法获取里程趋势,请稍后重试。'));
}
}).finally(() => {
if (!cancelled) setTrendLoading(false);
});
return () => {
cancelled = true;
};
}, [selectedTargetId, trendRequestVersion]);
// Re-fetch target vehicles when viewAllDate changes
useEffect(() => {
if (viewAllTargetId === null) return;
let cancelled = false;
setViewAllLoading(true);
setViewAllError(null);
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
if (cancelled) return;
setViewAllHistoricalVehicles(data);
}).catch(() => {}).finally(() => setViewAllLoading(false));
}, [viewAllTargetId, viewAllDate]);
setViewAllLoadedDate(viewAllDate);
setViewAllLoadedTargetId(viewAllTargetId);
}).catch(error => {
if (!cancelled) {
setViewAllError(getRequestErrorMessage(error, '无法获取所选日期的车辆明细,请稍后重试。'));
}
}).finally(() => {
if (!cancelled) setViewAllLoading(false);
});
return () => {
cancelled = true;
};
}, [viewAllTargetId, viewAllDate, viewAllRequestVersion]);
useEffect(() => {
const handlePopState = () => {
@@ -284,9 +389,10 @@ export default function StatisticsView() {
return () => window.removeEventListener('popstate', handlePopState);
}, [targets]);
const hasViewAllSnapshot = viewAllHistoricalVehicles !== null
&& viewAllLoadedTargetId === viewAllTargetId;
const viewAllVehicles = useMemo(() => {
const vehicles = viewAllHistoricalVehicles
|| (viewAllTargetId === null ? [] : targetVehiclesMap[viewAllTargetId] || []);
const vehicles = hasViewAllSnapshot ? viewAllHistoricalVehicles : [];
if (
drillContext.level === 'breakdown'
&& drillContext.dimensionValue
@@ -295,7 +401,7 @@ export default function StatisticsView() {
return filterAttributionVehicles(vehicles, drillContext.dimension, drillContext.dimensionValue);
}
return vehicles;
}, [drillContext, targetVehiclesMap, viewAllHistoricalVehicles, viewAllTargetId]);
}, [drillContext, hasViewAllSnapshot, viewAllHistoricalVehicles]);
const searchedViewAllVehicles = useMemo(() => (
viewAllVehicles.filter(vehicle => (
@@ -303,6 +409,30 @@ export default function StatisticsView() {
))
), [viewAllSearch, viewAllVehicles]);
if (targetsLoading && targets.length === 0) {
return <LoadingState label="正在加载考核统计" />;
}
if (targetsError && targets.length === 0) {
return (
<ErrorState
title="考核统计加载失败"
message={targetsError}
onRetry={retryTargets}
retrying={targetsLoading}
/>
);
}
if (!targetsLoading && targets.length === 0) {
return (
<EmptyState
title="暂无考核目标"
description="当前权限范围内没有有效考核目标"
/>
);
}
return (
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
{/* Project Selector */}
@@ -322,6 +452,16 @@ export default function StatisticsView() {
))}
</div>
{targetsError && (
<ErrorState
title="考核统计刷新失败"
message={`${targetsError} 当前仍展示上一次成功加载的数据。`}
onRetry={retryTargets}
retrying={targetsLoading}
variant="inline"
/>
)}
{selectedTarget && (
<motion.div
initial={{ opacity: 0, y: 8 }}
@@ -353,20 +493,36 @@ export default function StatisticsView() {
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
<div className={`mt-1 text-lg font-black ${trendLoading || trendError ? 'text-slate-500' : trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{trendLoading ? '加载中' : trendError ? '暂不可用' : `${trendDelta >= 0 ? '+' : ''}${fmtKm(trendDelta)}`}
</div>
<div className="mt-1 text-[10px] font-bold text-slate-400"> {fmtPercent(selectedQualifiedRate)}</div>
</div>
</motion.div>
)}
{selectedTarget && selectedCoverage && selectedCoverage.status !== 'matched' && (
<div role="alert" className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-amber-900">
<AlertTriangle size={15} className="mt-0.5 shrink-0 text-amber-600" />
<div className="min-w-0 text-[11px] font-bold leading-5">
<span className="font-black"></span>
{selectedCoverage.declaredCount} {selectedCoverage.activeCount}
{selectedCoverage.status === 'missing'
? `缺少 ${Math.abs(selectedCoverage.difference)}`
: `超出 ${selectedCoverage.difference}`}
</div>
</div>
)}
{selectedTarget && (
<MileageAttributionView
vehicles={selectedTargetVehicles}
dimension={attributionDimension}
selectedValue={selectedBreakdownValue}
loading={!targetVehiclesMap[selectedTarget.id]}
loading={targetVehicleLoadingMap[selectedTarget.id]
|| (!targetVehiclesMap[selectedTarget.id] && !targetVehicleErrorMap[selectedTarget.id])}
error={targetVehicleErrorMap[selectedTarget.id]}
targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)}
expectedShortfallMileage={selectedAssessment?.remaining}
assessmentLabel={selectedAssessment?.label}
@@ -375,6 +531,7 @@ export default function StatisticsView() {
if (drillContext.level === 'breakdown') closeVehiclePanel();
}}
onSelect={openAttributionGroup}
onRetry={() => loadCurrentTargetVehicles(selectedTarget.id)}
/>
)}
@@ -439,6 +596,13 @@ export default function StatisticsView() {
</div>
</div>
<div className="h-[280px] w-full min-w-0">
{trendLoading ? (
<LoadingState label="正在加载里程趋势" variant="inline" />
) : trendError ? (
<ErrorState message={trendError} onRetry={retryTrend} variant="inline" />
) : trendData.length === 0 ? (
<EmptyState title="暂无趋势数据" description="当前考核目标在最近 7 天没有可用里程" variant="inline" />
) : (
<ResponsiveContainer width="100%" height={280} minWidth={0}>
{chartType === 'bar' ? (
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
@@ -481,6 +645,7 @@ export default function StatisticsView() {
</AreaChart>
)}
</ResponsiveContainer>
)}
</div>
</div>
</div>
@@ -734,10 +899,14 @@ export default function StatisticsView() {
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { fetchTargets().then(data => { setTargets(data); }).catch(() => {}); }}
type="button"
onClick={retryTargets}
disabled={targetsLoading}
title="刷新考核统计"
aria-label="刷新考核统计"
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
>
<RotateCcw size={13} />
<RotateCcw size={13} className={targetsLoading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => setIsTableFullscreen(false)}
@@ -748,6 +917,15 @@ export default function StatisticsView() {
</div>
</div>
{targetsError && (
<div role="alert" className="flex items-center justify-between gap-3 border-b border-rose-900/50 bg-rose-950/60 px-3 py-2 text-[10px] font-bold text-rose-200">
<span>{targetsError} </span>
<button type="button" onClick={retryTargets} disabled={targetsLoading} className="shrink-0 text-rose-100 underline disabled:opacity-60">
</button>
</div>
)}
{/* Table Area */}
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse">
@@ -847,7 +1025,7 @@ export default function StatisticsView() {
{viewAllTargetName || selectedTarget?.targetName || '考核车辆'}
</h3>
<p className="mt-1 text-xs font-bold uppercase tracking-widest text-slate-400">
{selectedAssessment ? `${selectedAssessment.label} · ` : ''}
{selectedAssessment ? `${selectedAssessment.label} · ` : ''}
</p>
{selectedBreakdownValue && drillContext.dimension !== 'assessment-target' && (
<p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p>
@@ -861,7 +1039,7 @@ export default function StatisticsView() {
</button>
</div>
<div className="px-6 py-3 border-b border-slate-50 space-y-2">
<div className="px-4 py-3 border-b border-slate-50 space-y-2 md:px-6">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
@@ -879,16 +1057,27 @@ export default function StatisticsView() {
type="date"
value={viewAllDate}
onChange={(e) => setViewAllDate(e.target.value)}
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]"
className="w-[145px] bg-slate-50 py-2 pl-8 pr-2 text-xs font-bold text-slate-700 border border-slate-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{viewAllLoading ? '加载中...' : (() => {
const totalKm = searchedViewAllVehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
return `${searchedViewAllVehicles.length} · 合计 ${fmtKm(totalKm)} km`;
})()}
{viewAllLoading
? hasViewAllSnapshot
? `正在加载 ${viewAllDate} · 当前显示 ${viewAllLoadedDate}`
: `正在加载 ${viewAllDate}`
: viewAllError
? hasViewAllSnapshot
? `当前显示 ${viewAllLoadedDate} 已成功数据`
: `${viewAllDate} 暂不可用`
: (() => {
const totalKm = searchedViewAllVehicles.reduce(
(sum, vehicle) => sum + (vehicle.todayMileage || 0),
0,
);
return `${viewAllLoadedDate || viewAllDate} · ${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
})()}
</span>
<button
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
@@ -901,43 +1090,73 @@ export default function StatisticsView() {
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
{[...searchedViewAllVehicles].sort((a, b) => {
const valA = a.todayMileage || 0;
const valB = b.todayMileage || 0;
return viewAllSort === 'desc' ? valB - valA : valA - valB;
}).map(tv => (
<button
type="button"
key={tv.plateNumber}
onClick={() => openVehicleDiagnostic(tv)}
className="flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-2 text-left shadow-sm transition-all hover:border-blue-200 hover:shadow-md"
>
<div className="flex items-center gap-3 overflow-hidden flex-1">
<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" />
{viewAllLoading && !hasViewAllSnapshot ? (
<LoadingState label={`正在加载 ${viewAllDate} 车辆明细`} variant="inline" />
) : viewAllError && !hasViewAllSnapshot ? (
<ErrorState
title="车辆明细加载失败"
message={viewAllError}
onRetry={retryViewAllVehicles}
retrying={viewAllLoading}
variant="inline"
/>
) : (
<>
{viewAllError && (
<ErrorState
title={`${viewAllDate} 车辆明细刷新失败`}
message={`${viewAllError} 当前保留 ${viewAllLoadedDate} 已成功加载的数据。`}
onRetry={retryViewAllVehicles}
retrying={viewAllLoading}
variant="inline"
/>
)}
{!viewAllLoading && !viewAllError && searchedViewAllVehicles.length === 0 ? (
<EmptyState
title={viewAllHistoricalVehicles?.length ? '没有匹配车辆' : '该日期暂无车辆里程'}
description={viewAllHistoricalVehicles?.length ? '请调整车牌搜索条件' : `${viewAllLoadedDate || viewAllDate} 未返回可展示的车辆数据`}
variant="inline"
/>
) : null}
{[...searchedViewAllVehicles].sort((a, b) => {
const valA = a.todayMileage || 0;
const valB = b.todayMileage || 0;
return viewAllSort === 'desc' ? valB - valA : valA - valB;
}).map(tv => (
<button
type="button"
key={tv.plateNumber}
onClick={() => openVehicleDiagnostic(tv)}
className="flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-2 text-left shadow-sm transition-all hover:border-blue-200 hover:shadow-md"
>
<div className="flex items-center gap-3 overflow-hidden flex-1">
<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 ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{tv.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
</div>
</div>
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{tv.isOnline ? '在线' : '离线'}
</span>
<div className="text-right flex-shrink-0 ml-2">
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2">
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
</div>
</button>
))}
</button>
))}
</>
)}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100">
+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,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assessVehicleCoverage } from './assessment-coverage.js';
test('reports matched, missing, and excess assessment vehicles', () => {
assert.deepEqual(assessVehicleCoverage(50, 46), {
status: 'missing',
declaredCount: 50,
activeCount: 46,
difference: -4,
coverageRate: 92,
});
assert.equal(assessVehicleCoverage(40, 40).status, 'matched');
assert.equal(assessVehicleCoverage(40, 42).status, 'excess');
});
test('normalizes invalid counts without inventing configured vehicles', () => {
assert.deepEqual(assessVehicleCoverage(Number.NaN, -2), {
status: 'matched',
declaredCount: 0,
activeCount: 0,
difference: 0,
coverageRate: 100,
});
});
@@ -0,0 +1,26 @@
export type AssessmentCoverageStatus = 'matched' | 'missing' | 'excess';
export interface AssessmentCoverage {
status: AssessmentCoverageStatus;
declaredCount: number;
activeCount: number;
difference: number;
coverageRate: number;
}
function count(value: number): number {
return Math.max(0, Math.trunc(Number(value) || 0));
}
export function assessVehicleCoverage(declaredCount: number, activeCount: number): AssessmentCoverage {
const declared = count(declaredCount);
const active = count(activeCount);
const difference = active - declared;
return {
status: difference === 0 ? 'matched' : difference < 0 ? 'missing' : 'excess',
declaredCount: declared,
activeCount: active,
difference,
coverageRate: declared > 0 ? active / declared * 100 : active === 0 ? 100 : 0,
};
}
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMileageMonitoringUrl,
parseMileageMonitoringContext,
} from './monitoring-context.js';
test('parses a complete mileage monitoring context', () => {
const context = parseMileageMonitoringContext(
'?mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=gps,instrument'
+ '&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86'
+ '&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageDepartment=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'
+ '&mileageCustomer=%E5%AE%A2%E6%88%B7A&mileageProject=%E9%A1%B9%E7%9B%AEA&mileageEntity=%E4%B8%BB%E4%BD%93A'
+ '&mileageRentStatus=%E7%A7%9F%E8%B5%81&mileageBrand=%E7%8E%B0%E4%BB%A3&mileagePlatePrefix=%E7%B2%A4A'
+ '&mileageSearch=123&mileageMin=10&mileageMax=500&mileageSort=total&mileageOrder=asc',
);
assert.deepEqual(context, {
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['gps', 'instrument'],
targetNames: ['交投190辆', '羚牛136辆'],
region: '广州',
plates: ['粤A12345'],
department: '业务一部',
customer: '客户A',
project: '项目A',
entity: '主体A',
rentStatus: '租赁',
brands: ['现代'],
platePrefix: '粤A',
search: '123',
mileageMin: '10',
mileageMax: '500',
sortBy: 'total',
sortOrder: 'asc',
});
});
test('normalizes invalid monitoring URL values', () => {
const context = parseMileageMonitoringContext(
'?mileageStart=invalid&mileageSource=unknown&mileageBatch=&mileageRegion=All&mileageSort=unknown&mileageOrder=unknown',
);
assert.deepEqual(context, {
startDate: undefined,
endDate: undefined,
sourcePriority: ['instrument'],
targetNames: [],
region: undefined,
plates: [],
department: undefined,
customer: undefined,
project: undefined,
entity: undefined,
rentStatus: undefined,
brands: [],
platePrefix: undefined,
search: undefined,
mileageMin: undefined,
mileageMax: undefined,
sortBy: 'today',
sortOrder: 'desc',
});
});
test('builds monitoring state without removing drill or unrelated context', () => {
const url = buildMileageMonitoringUrl(
{
pathname: '/asset',
search: '?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageBatch=old',
hash: '#mileage',
},
{
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['instrument'],
targetNames: ['交投190辆', '羚牛136辆'],
region: '广州',
plates: ['粤A12345'],
department: undefined,
customer: undefined,
project: undefined,
entity: undefined,
rentStatus: undefined,
brands: [],
platePrefix: undefined,
search: undefined,
mileageMin: undefined,
mileageMax: undefined,
sortBy: 'statisticTime',
sortOrder: 'asc',
},
);
assert.equal(
url,
'/asset?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageSort=statisticTime&mileageOrder=asc#mileage',
);
});
+136
View File
@@ -0,0 +1,136 @@
import type { MileageSourceGroup } from './types';
export type MonitoringSortBy = 'today' | 'total' | 'statisticTime';
export type MonitoringSortOrder = 'asc' | 'desc';
export interface MileageMonitoringContext {
startDate?: string;
endDate?: string;
sourcePriority: MileageSourceGroup[];
targetNames: string[];
region?: string;
plates: string[];
department?: string;
customer?: string;
project?: string;
entity?: string;
rentStatus?: string;
brands: string[];
platePrefix?: string;
search?: string;
mileageMin?: string;
mileageMax?: string;
sortBy: MonitoringSortBy;
sortOrder: MonitoringSortOrder;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const CONTEXT_KEYS = [
'mileageStart',
'mileageEnd',
'mileageSource',
'mileageBatch',
'mileageRegion',
'mileageFilterPlate',
'mileageDepartment',
'mileageCustomer',
'mileageProject',
'mileageEntity',
'mileageRentStatus',
'mileageBrand',
'mileagePlatePrefix',
'mileageSearch',
'mileageMin',
'mileageMax',
'mileageSort',
'mileageOrder',
] as const;
function validDate(value: string | null): string | undefined {
if (!value || !DATE_PATTERN.test(value)) return undefined;
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
return Number.isFinite(timestamp) ? value : undefined;
}
function boundedValue(value: string | null, maxLength = 200): string | undefined {
const normalized = value?.trim();
if (!normalized || normalized === 'All') return undefined;
return normalized.slice(0, maxLength);
}
function uniqueValues(values: string[], limit = 200): string[] {
return Array.from(new Set(values.map(value => boundedValue(value)).filter((value): value is string => !!value))).slice(0, limit);
}
function parseSourcePriority(value: string | null): MileageSourceGroup[] {
const sources = value
?.split(',')
.filter((source): source is MileageSourceGroup => source === 'instrument' || source === 'gps');
return sources && sources.length > 0 ? Array.from(new Set(sources)).slice(0, 2) : ['instrument'];
}
function appendValues(params: URLSearchParams, key: string, values: string[]): void {
uniqueValues(values).forEach(value => params.append(key, value));
}
export function parseMileageMonitoringContext(search: string): MileageMonitoringContext {
const params = new URLSearchParams(search);
const rawSortBy = params.get('mileageSort');
const rawSortOrder = params.get('mileageOrder');
return {
startDate: validDate(params.get('mileageStart')),
endDate: validDate(params.get('mileageEnd')),
sourcePriority: parseSourcePriority(params.get('mileageSource')),
targetNames: uniqueValues(params.getAll('mileageBatch')),
region: boundedValue(params.get('mileageRegion')),
plates: uniqueValues(params.getAll('mileageFilterPlate')),
department: boundedValue(params.get('mileageDepartment')),
customer: boundedValue(params.get('mileageCustomer')),
project: boundedValue(params.get('mileageProject')),
entity: boundedValue(params.get('mileageEntity')),
rentStatus: boundedValue(params.get('mileageRentStatus')),
brands: uniqueValues(params.getAll('mileageBrand')),
platePrefix: boundedValue(params.get('mileagePlatePrefix')),
search: boundedValue(params.get('mileageSearch')),
mileageMin: boundedValue(params.get('mileageMin'), 32),
mileageMax: boundedValue(params.get('mileageMax'), 32),
sortBy: rawSortBy === 'total' || rawSortBy === 'statisticTime' ? rawSortBy : 'today',
sortOrder: rawSortOrder === 'asc' ? 'asc' : 'desc',
};
}
export function buildMileageMonitoringUrl(
location: LocationParts,
context: MileageMonitoringContext,
): string {
const params = new URLSearchParams(location.search);
CONTEXT_KEYS.forEach(key => params.delete(key));
if (context.startDate) params.set('mileageStart', context.startDate);
if (context.endDate) params.set('mileageEnd', context.endDate);
if (context.sourcePriority.length > 0) params.set('mileageSource', context.sourcePriority.join(','));
appendValues(params, 'mileageBatch', context.targetNames);
if (context.region) params.set('mileageRegion', context.region);
appendValues(params, 'mileageFilterPlate', context.plates);
if (context.department) params.set('mileageDepartment', context.department);
if (context.customer) params.set('mileageCustomer', context.customer);
if (context.project) params.set('mileageProject', context.project);
if (context.entity) params.set('mileageEntity', context.entity);
if (context.rentStatus) params.set('mileageRentStatus', context.rentStatus);
appendValues(params, 'mileageBrand', context.brands);
if (context.platePrefix) params.set('mileagePlatePrefix', context.platePrefix);
if (context.search) params.set('mileageSearch', context.search);
if (context.mileageMin) params.set('mileageMin', context.mileageMin);
if (context.mileageMax) params.set('mileageMax', context.mileageMax);
if (context.sortBy !== 'today') params.set('mileageSort', context.sortBy);
if (context.sortOrder !== 'desc') params.set('mileageOrder', context.sortOrder);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildMonitoringKpis } from './monitoring-kpis.js';
test('builds daily monitoring KPIs from the selected-day flow metric', () => {
const model = buildMonitoringKpis(
{ totalToday: 120, totalAll: 3_000, vehicleCount: 3, yesterdayTotal: 100 },
{ start: '2026-08-07', end: '2026-08-07' },
);
assert.deepEqual(model, {
distanceMetricId: 'mileage.daily_total',
distanceLabel: '当日总里程',
distanceShortLabel: '当日',
distanceKm: 120,
averagePerVehicleKm: 40,
vehicleCount: 3,
cumulativeOdometerKm: 3_000,
});
});
test('builds range KPIs without substituting cumulative odometer values', () => {
const model = buildMonitoringKpis(
{ totalToday: 900, totalAll: 30_000, vehicleCount: 6, yesterdayTotal: 0 },
{ start: '2026-08-01', end: '2026-08-07' },
);
assert.equal(model.distanceMetricId, 'mileage.period_total');
assert.equal(model.distanceLabel, '区间总里程');
assert.equal(model.distanceKm, 900);
assert.equal(model.averagePerVehicleKm, 150);
assert.equal(model.cumulativeOdometerKm, 30_000);
});
test('normalizes invalid monitoring KPI inputs', () => {
const model = buildMonitoringKpis({
totalToday: Number.NaN,
totalAll: -10,
vehicleCount: Number.POSITIVE_INFINITY,
yesterdayTotal: 0,
});
assert.equal(model.distanceKm, 0);
assert.equal(model.averagePerVehicleKm, 0);
assert.equal(model.vehicleCount, 0);
assert.equal(model.cumulativeOdometerKm, 0);
});
+31
View File
@@ -0,0 +1,31 @@
import type { MonitoringStats } from './types';
export interface MonitoringKpiModel {
distanceMetricId: 'mileage.daily_total' | 'mileage.period_total';
distanceLabel: '当日总里程' | '区间总里程';
distanceShortLabel: '当日' | '区间';
distanceKm: number;
averagePerVehicleKm: number;
vehicleCount: number;
cumulativeOdometerKm: number;
}
export function buildMonitoringKpis(
stats: MonitoringStats,
dateRange?: { start: string; end: string },
): MonitoringKpiModel {
const isRange = !!dateRange?.start && !!dateRange?.end && dateRange.start !== dateRange.end;
const vehicleCount = Number.isFinite(stats.vehicleCount) ? Math.max(0, stats.vehicleCount) : 0;
const distanceKm = Number.isFinite(stats.totalToday) ? Math.max(0, stats.totalToday) : 0;
const cumulativeOdometerKm = Number.isFinite(stats.totalAll) ? Math.max(0, stats.totalAll) : 0;
return {
distanceMetricId: isRange ? 'mileage.period_total' : 'mileage.daily_total',
distanceLabel: isRange ? '区间总里程' : '当日总里程',
distanceShortLabel: isRange ? '区间' : '当日',
distanceKm,
averagePerVehicleKm: vehicleCount > 0 ? distanceKm / vehicleCount : 0,
vehicleCount,
cumulativeOdometerKm,
};
}
+1
View File
@@ -65,6 +65,7 @@ export interface TargetSummary {
id: number;
targetName: string;
snapshotUpdatedAt: string | null;
declaredVehicleCount: number;
vehicleCount: number;
totalMileagePerVehicle: number;
annualMileagePerVehicle: number;
@@ -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';
}
+4 -3
View File
@@ -7,9 +7,10 @@ test('returns the published mileage metric contract', async () => {
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 6);
assert.equal(payload.catalogVersion, 7);
assert.deepEqual(payload.domains, ['mileage']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.average_per_vehicle'));
});
test('returns the published hydrogen metric contract', async () => {
@@ -27,7 +28,7 @@ test('returns the published electric metric contract', async () => {
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 6);
assert.equal(payload.catalogVersion, 7);
assert.deepEqual(payload.domains, ['electric']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.charge_total_fee'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.blended_cost_intensity'));
@@ -39,7 +40,7 @@ test('returns the published ETC metric contract', async () => {
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 6);
assert.equal(payload.catalogVersion, 7);
assert.deepEqual(payload.domains, ['etc']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.total_amount'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.bill_receivable'));
+13 -2
View File
@@ -18,6 +18,10 @@ import {
storeManualMonitoringSnapshot,
} from './manual-snapshot.js';
import { sumMileageKm } from './precision.js';
import {
rangeMileageSnapshotCache,
type RangeSnapshotStatus,
} from './range-snapshot.js';
const app = new Hono();
@@ -148,7 +152,7 @@ app.get('/', async (c) => {
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
let dateRange: { start: string; end: string } | undefined;
let dataUpdatedAt: string | undefined;
let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' = 'bypass';
let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' | RangeSnapshotStatus = 'bypass';
if (range) {
const cache = getCache();
@@ -193,7 +197,14 @@ app.get('/', async (c) => {
} else {
cacheStatus = cacheEligible ? 'miss' : 'bypass';
try {
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
const loaded = await rangeMileageSnapshotCache.load({
startDate: range.start,
endDate: range.end,
protocolPriority,
force,
}, () => queryRangeMileage(range.start, range.end, protocolPriority));
const { result } = loaded;
cacheStatus = loaded.status;
allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals;
dateRange = { start: result.start, end: result.end };
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { RangeMileageSnapshotCache } from './range-snapshot.js';
import type { RangeMileageResult } from './cache.js';
function result(start = '2026-08-01', end = '2026-08-07'): RangeMileageResult {
return { vehicles: [], dailyTotals: [], start, end };
}
const input = {
startDate: '2026-08-01',
endDate: '2026-08-07',
protocolPriority: ['GB32960'] as const,
};
test('reuses a completed range snapshot until its TTL expires', async () => {
let now = 1_000;
let calls = 0;
const cache = new RangeMileageSnapshotCache({ ttlMs: 60_000, now: () => now });
const loader = async () => {
calls += 1;
return result();
};
assert.equal((await cache.load(input, loader)).status, 'range-miss');
assert.equal((await cache.load(input, loader)).status, 'range-hit');
now = 61_000;
assert.equal((await cache.load(input, loader)).status, 'range-miss');
assert.equal(calls, 2);
});
test('deduplicates concurrent requests for the same range', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
let resolve!: (value: RangeMileageResult) => void;
const pending = new Promise<RangeMileageResult>(done => { resolve = done; });
const loader = async () => {
calls += 1;
return pending;
};
const first = cache.load(input, loader);
const second = cache.load(input, loader);
resolve(result());
assert.equal((await first).status, 'range-miss');
assert.equal((await second).status, 'range-inflight');
assert.equal(calls, 1);
});
test('keeps date ranges and source protocols in separate cache keys', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
const loader = async () => {
calls += 1;
return result();
};
await cache.load(input, loader);
await cache.load({ ...input, endDate: '2026-08-06' }, loader);
await cache.load({ ...input, protocolPriority: ['MQTT'] }, loader);
assert.equal(calls, 3);
});
test('force refresh bypasses a completed snapshot', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
const loader = async () => {
calls += 1;
return result();
};
await cache.load(input, loader);
const refreshed = await cache.load({ ...input, force: true }, loader);
assert.equal(refreshed.status, 'range-refresh');
assert.equal(calls, 2);
});
test('does not cache failed range requests', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
const loader = async () => {
calls += 1;
if (calls === 1) throw new Error('upstream unavailable');
return result();
};
await assert.rejects(cache.load(input, loader), /upstream unavailable/);
const recovered = await cache.load(input, loader);
assert.equal(recovered.status, 'range-miss');
assert.equal(calls, 2);
});
@@ -0,0 +1,97 @@
import type { RangeMileageResult } from './cache.js';
import type { OneOsProtocol } from './source-policy.js';
export type RangeSnapshotStatus = 'range-hit' | 'range-miss' | 'range-refresh' | 'range-inflight';
export interface RangeSnapshotLoadResult {
result: RangeMileageResult;
status: RangeSnapshotStatus;
}
interface RangeSnapshotEntry {
result: RangeMileageResult;
expiresAt: number;
}
interface RangeSnapshotCacheOptions {
ttlMs?: number;
maxEntries?: number;
now?: () => number;
}
interface RangeSnapshotInput {
startDate: string;
endDate: string;
protocolPriority: readonly OneOsProtocol[];
force?: boolean;
}
const DEFAULT_TTL_MS = 60 * 1000;
const DEFAULT_MAX_ENTRIES = 8;
function snapshotKey(input: RangeSnapshotInput): string {
return [input.startDate, input.endDate, input.protocolPriority.join('>')].join(':');
}
export class RangeMileageSnapshotCache {
private readonly entries = new Map<string, RangeSnapshotEntry>();
private readonly inflight = new Map<string, Promise<RangeMileageResult>>();
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly now: () => number;
constructor(options: RangeSnapshotCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
this.now = options.now ?? Date.now;
}
async load(
input: RangeSnapshotInput,
loader: () => Promise<RangeMileageResult>,
): Promise<RangeSnapshotLoadResult> {
const key = snapshotKey(input);
if (!input.force) {
const entry = this.entries.get(key);
if (entry && entry.expiresAt > this.now()) {
this.entries.delete(key);
this.entries.set(key, entry);
return { result: entry.result, status: 'range-hit' };
}
}
const existing = this.inflight.get(key);
if (existing) {
return { result: await existing, status: 'range-inflight' };
}
const pending = loader()
.then(result => {
this.entries.delete(key);
this.entries.set(key, {
result,
expiresAt: this.now() + this.ttlMs,
});
while (this.entries.size > this.maxEntries) {
const oldest = this.entries.keys().next().value as string | undefined;
if (!oldest) break;
this.entries.delete(oldest);
}
return result;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, pending);
return {
result: await pending,
status: input.force ? 'range-refresh' : 'range-miss',
};
}
clear(): void {
this.entries.clear();
this.inflight.clear();
}
}
export const rangeMileageSnapshotCache = new RangeMileageSnapshotCache();
+2 -1
View File
@@ -201,7 +201,8 @@ app.get('/', async (c) => {
id: t.id,
targetName: t.target_name,
snapshotUpdatedAt: s.snapshot_updated_at || null,
vehicleCount: Number(s.total) || t.vehicle_count,
declaredVehicleCount: Number(t.vehicle_count) || 0,
vehicleCount: s.total == null ? 0 : Number(s.total) || 0,
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
+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);
}
});
+4
View File
@@ -23,6 +23,10 @@ test('lists mileage metrics without exposing the catalog array', () => {
metrics.find(metric => metric.id === 'mileage.cumulative_odometer_sum')?.timeSemantics,
'snapshot',
);
assert.equal(
metrics.find(metric => metric.id === 'mileage.average_per_vehicle')?.formula,
'mileage.period_total / NULLIF(mileage.vehicle_count, 0)',
);
});
test('publishes hydrogen cost, revenue, and gross profit as separate metrics', () => {
+14 -1
View File
@@ -17,7 +17,7 @@ export interface MetricDefinition {
drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order' | 'etc-toll-record' | 'etc-bill';
}
export const METRIC_CATALOG_VERSION = 6;
export const METRIC_CATALOG_VERSION = 7;
const MILEAGE_METRICS: readonly MetricDefinition[] = [
{
@@ -59,6 +59,19 @@ const MILEAGE_METRICS: readonly MetricDefinition[] = [
dimensions: ['department', 'region', 'customer', 'vehicle-model'],
drillEntity: 'vehicle',
},
{
id: 'mileage.average_per_vehicle',
domain: 'mileage',
label: '平均单车里程',
description: '所选自然日或日期区间总里程除以当前筛选范围内的去重监控车辆数,不使用累计仪表值。',
unit: 'km',
aggregation: 'derived',
timeSemantics: 'flow',
formula: 'mileage.period_total / NULLIF(mileage.vehicle_count, 0)',
sources: ['OneOS mileage API', 'Asset database'],
dimensions: ['date', 'department', 'region', 'customer', 'vehicle-model'],
drillEntity: 'vehicle',
},
{
id: 'mileage.assessment_completion_rate',
domain: 'mileage',