Files
ln-bi/src/modules/mileage/MonitoringView.tsx
T
2026-09-20 10:07:08 +08:00

506 lines
20 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
ChevronsUp,
} from 'lucide-react';
import type {
MileageSourceGroup,
MonitoringVehicle,
MonitoringStats,
MonitoringFilters,
} from './types';
import { fetchMonitoring } from './api';
import { exportMileageXlsx } from './xlsx-export';
import VehicleDetailModal from './VehicleDetailModal';
import MileageLoadingOverlay from './MileageLoadingOverlay';
import {
defaultMileageDate,
getRangePreset,
normalizeRangeLabel,
type RangePreset,
} from './monitoring/date-range';
import {
buildExportMonitoringQuery,
buildFullscreenMonitoringQuery,
buildInitialMonitoringQuery,
buildNextPageMonitoringQuery,
type MonitoringQueryModel,
} from './monitoring/query-model';
import ActiveFilterTags from './monitoring/components/ActiveFilterTags';
import FilterPanel from './monitoring/components/FilterPanel';
import FullscreenMonitoring from './monitoring/components/FullscreenMonitoring';
import MonitoringSummary from './monitoring/components/MonitoringSummary';
import MonitoringToolbar from './monitoring/components/MonitoringToolbar';
import VehicleList from './monitoring/components/VehicleList';
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
'交投40辆4.5T普货',
'交投190辆4.5T冷链车',
]);
const HIGH_MILEAGE_ALERT_KM = 800;
export default function MonitoringView() {
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 [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);
// New filters from image
const [filterPlates, setFilterPlates] = useState<string[]>([]);
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 [exporting, setExporting] = useState(false);
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument']);
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] });
const [rangeDailyTotals, setRangeDailyTotals] = useState<{ date: string; totalKm: number }[]>([]);
const [effectiveRange, setEffectiveRange] = useState<{ start: string; end: string }>(() => ({ start: defaultMileageDate(), end: defaultMileageDate() }));
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [manualRefreshing, setManualRefreshing] = useState(false);
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
const [showBackToTop, setShowBackToTop] = useState(false);
const [relativeNow, setRelativeNow] = useState(() => Date.now());
const PAGE_SIZE = 50;
const monitoringQuery = useMemo<MonitoringQueryModel>(() => ({
sortBy,
sortOrder,
searchTerm,
department: filterDept,
customer: filterCustomer,
project: filterProject,
entity: filterEntity,
rentStatus: filterRentStatus,
platePrefix: filterPlatePrefix,
targetNames: filterTargetNames,
region: filterRegion,
plates: filterPlates,
mileageRange: appliedMileageRange,
rangeStart,
rangeEnd,
brands: filterBrands,
sourcePriority,
}), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
// Keep this model and dependency list separate: fullscreen historically omits
// project, entity and mileage-range, so adding those states would change both
// its request parameters and refresh triggers.
const fullscreenQuery = useMemo<MonitoringQueryModel>(() => ({
sortBy,
sortOrder,
searchTerm,
department: filterDept,
customer: filterCustomer,
project: 'All',
entity: 'All',
rentStatus: filterRentStatus,
platePrefix: filterPlatePrefix,
targetNames: filterTargetNames,
region: filterRegion,
plates: filterPlates,
mileageRange: { min: '', max: '' },
rangeStart,
rangeEnd,
brands: filterBrands,
sourcePriority,
}), [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, filterBrands, sourcePriority]);
useEffect(() => {
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
return () => clearInterval(timer);
}, []);
const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates;
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
const isRangeMode = !!effectiveRange.start && !!effectiveRange.end && effectiveRange.start !== effectiveRange.end;
const applyRangePreset = useCallback((preset: RangePreset) => {
const range = getRangePreset(preset);
setRangeStart(range.start);
setRangeEnd(range.end);
}, []);
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|| filterTargetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name));
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetNames]);
const queryRequestRef = useRef(0);
// 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true) => {
const requestId = ++queryRequestRef.current;
if (showPageLoading) setPageLoading(true);
return fetchMonitoring(buildInitialMonitoringQuery(monitoringQuery, PAGE_SIZE)).then(d => {
if (requestId !== queryRequestRef.current) return;
setVehicles(d.vehicles);
setStats(d.stats);
setFilterOptions(d.filters);
setRangeDailyTotals(d.rangeDailyTotals || []);
setEffectiveRange(d.dateRange || { start: rangeStart, end: rangeEnd });
setTotal(d.total);
setPage(1);
setHasMore(d.page < d.totalPages);
setLastRefreshedAt(new Date());
}).catch(() => {}).finally(() => {
if (requestId === queryRequestRef.current) setPageLoading(false);
});
}, [monitoringQuery, rangeStart, rangeEnd]);
const handleManualRefresh = useCallback(async () => {
if (manualRefreshing || pageLoading) return;
setManualRefreshing(true);
try {
await loadFirstPage(false);
} finally {
setManualRefreshing(false);
}
}, [loadFirstPage, manualRefreshing, pageLoading]);
// 加载更多
const loadMore = useCallback(() => {
if (pageLoading || loadingMore || !hasMore) return;
const requestId = queryRequestRef.current;
const nextPage = page + 1;
setLoadingMore(true);
fetchMonitoring(buildNextPageMonitoringQuery(monitoringQuery, PAGE_SIZE, nextPage)).then(d => {
if (requestId !== queryRequestRef.current) return;
setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage);
setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false));
}, [monitoringQuery, page, pageLoading, loadingMore, hasMore]);
// 筛选/排序变化时重新加载
useEffect(() => {
loadFirstPage();
}, [loadFirstPage]);
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
useEffect(() => {
if (filterPlates.length === 0) return;
const valid = new Set(filterOptions.plates);
const next = filterPlates.filter(p => valid.has(p));
if (next.length !== filterPlates.length) setFilterPlates(next);
}, [filterOptions.plates, filterPlates]);
// 下载当前筛选结果为 xlsx
const handleDownload = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
const d = await fetchMonitoring(buildExportMonitoringQuery(monitoringQuery));
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
} catch (err) {
console.error('export failed', err);
} finally {
setExporting(false);
}
}, [exporting, monitoringQuery, rangeStart, rangeEnd, sortBy]);
// 每分钟自动刷新
useEffect(() => {
const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000);
return () => clearInterval(timer);
}, [loadFirstPage]);
// 触底检测:用 IntersectionObserver 监听哨兵元素
const loadMoreRef = useRef(loadMore);
loadMoreRef.current = loadMore;
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMoreRef.current();
}
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
// 回到顶部按钮:用 IntersectionObserver 检测顶部哨兵是否离开视口
const topSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = topSentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => setShowBackToTop(!entry.isIntersecting),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const scrollToTop = () => {
window.scrollTo(0, 0);
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
};
const filteredVehicles = vehicles;
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// 全屏时加载全部数据(无分页),筛选变化时重新加载
useEffect(() => {
if (!isFullscreen) return;
setFullscreenLoading(true);
fetchMonitoring(buildFullscreenMonitoringQuery(fullscreenQuery)).then(d => {
setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats);
setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, fullscreenQuery, fullscreenRefresh]);
// 全屏时禁止背景滚动
useEffect(() => {
if (isFullscreen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [isFullscreen]);
// 检测是否在小程序 webview 中(微信/抖音/支付宝等),且当前是竖屏
// 小程序 webview 无法调用系统旋转 API,只能用 CSS rotate 强制横屏
const forceLandscape = useMemo(() => {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent || '';
const isMiniProgram =
/miniProgram/i.test(ua) ||
/toutiaomicroapp/i.test(ua) ||
/AlipayClient/i.test(ua) ||
(window as any).__wxjs_environment === 'miniprogram';
const isPortrait = window.innerHeight > window.innerWidth;
return isMiniProgram && isPortrait;
}, [isFullscreen]);
return (
<>
<AnimatePresence>
{pageLoading && <MileageLoadingOverlay />}
</AnimatePresence>
{/* 顶部哨兵:离开视口时显示回到顶部按钮 */}
<div ref={topSentinelRef} className="h-0" />
{/* Fullscreen Landscape View Overlay */}
<AnimatePresence>
{isFullscreen && (
<FullscreenMonitoring
forceLandscape={forceLandscape}
fullscreenStats={fullscreenStats}
sortBy={sortBy}
sourcePriority={sourcePriority}
onRefresh={() => { setFullscreenRefresh(n => n + 1); }}
fullscreenLoading={fullscreenLoading}
onToggleFullscreen={toggleFullscreen}
filterTargetNames={filterTargetNames}
onFilterTargetNamesChange={setFilterTargetNames}
filterOptions={filterOptions}
filterPlates={filterPlates}
filterCustomer={filterCustomer}
onFilterCustomerChange={setFilterCustomer}
filterBrands={filterBrands}
onFilterBrandsChange={setFilterBrands}
filterRentStatus={filterRentStatus}
onFilterRentStatusChange={setFilterRentStatus}
filterDept={filterDept}
onFilterDeptChange={setFilterDept}
departments={departments}
sortOrder={sortOrder}
onSortByChange={setSortBy}
onSortOrderChange={setSortOrder}
fullscreenVehicles={fullscreenVehicles}
isHighMileageAlert={isHighMileageAlert}
relativeNow={relativeNow}
/>
)}
</AnimatePresence>
<MonitoringToolbar
onToggleFullscreen={toggleFullscreen}
onDownload={handleDownload}
exporting={exporting}
lastRefreshedAt={lastRefreshedAt}
onManualRefresh={handleManualRefresh}
manualRefreshing={manualRefreshing}
sortBy={sortBy}
onSortByChange={setSortBy}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
sourcePriority={sourcePriority}
onSourcePriorityChange={setSourcePriority}
filterOptions={filterOptions}
filterTargetNames={filterTargetNames}
onFilterTargetNamesChange={setFilterTargetNames}
filterRegion={filterRegion}
onFilterRegionChange={setFilterRegion}
plateNumbers={plateNumbers}
filterPlates={filterPlates}
onFilterPlatesChange={setFilterPlates}
isFilterOpen={isFilterOpen}
onFilterOpenChange={setIsFilterOpen}
searchTerm={searchTerm}
filterDept={filterDept}
filterCustomer={filterCustomer}
filterRentStatus={filterRentStatus}
filterProject={filterProject}
filterBrands={filterBrands}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onApplyRangePreset={applyRangePreset}
rangeLabel={rangeLabel}
/>
{/* Expandable Filter Panel */}
<AnimatePresence>
{isFilterOpen && (
<FilterPanel
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onRangeStartChange={setRangeStart}
onRangeEndChange={setRangeEnd}
onApplyRangePreset={applyRangePreset}
departments={departments}
filterDept={filterDept}
onFilterDeptChange={setFilterDept}
filterOptions={filterOptions}
filterCustomer={filterCustomer}
onFilterCustomerChange={setFilterCustomer}
filterBrands={filterBrands}
onFilterBrandsChange={setFilterBrands}
filterProject={filterProject}
onFilterProjectChange={setFilterProject}
filterRentStatus={filterRentStatus}
onFilterRentStatusChange={setFilterRentStatus}
filterEntity={filterEntity}
onFilterEntityChange={setFilterEntity}
filterPlatePrefix={filterPlatePrefix}
onFilterPlatePrefixChange={setFilterPlatePrefix}
filterMileageRange={filterMileageRange}
onFilterMileageRangeChange={setFilterMileageRange}
onSearchTermChange={setSearchTerm}
onFilterPlatesChange={setFilterPlates}
onFilterTargetNamesChange={setFilterTargetNames}
onFilterRegionChange={setFilterRegion}
onAppliedMileageRangeChange={setAppliedMileageRange}
onFilterOpenChange={setIsFilterOpen}
/>
)}
</AnimatePresence>
{/* Active Filter Tags */}
<ActiveFilterTags
filterTargetNames={filterTargetNames}
filterOptions={filterOptions}
onFilterTargetNamesChange={setFilterTargetNames}
filterRegion={filterRegion}
onFilterRegionChange={setFilterRegion}
filterPlates={filterPlates}
onFilterPlatesChange={setFilterPlates}
filterRentStatus={filterRentStatus}
onFilterRentStatusChange={setFilterRentStatus}
filterDept={filterDept}
onFilterDeptChange={setFilterDept}
filterCustomer={filterCustomer}
onFilterCustomerChange={setFilterCustomer}
filterBrands={filterBrands}
onFilterBrandsChange={setFilterBrands}
filterProject={filterProject}
onFilterProjectChange={setFilterProject}
filterEntity={filterEntity}
onFilterEntityChange={setFilterEntity}
searchTerm={searchTerm}
onSearchTermChange={setSearchTerm}
onFilterMileageRangeChange={setFilterMileageRange}
appliedMileageRange={appliedMileageRange}
onAppliedMileageRangeChange={setAppliedMileageRange}
filterPlatePrefix={filterPlatePrefix}
onFilterPlatePrefixChange={setFilterPlatePrefix}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onRangeStartChange={setRangeStart}
onRangeEndChange={setRangeEnd}
/>
{/* Sticky header: KPI + 清单标题 */}
<MonitoringSummary
pageLoading={pageLoading}
sortBy={sortBy}
isRangeMode={isRangeMode}
stats={stats}
rangeLabel={rangeLabel}
rangeDailyTotals={rangeDailyTotals}
total={total}
/>
{/* Vehicle List */}
<VehicleList
pageLoading={pageLoading}
vehicles={filteredVehicles}
isHighMileageAlert={isHighMileageAlert}
relativeNow={relativeNow}
isRangeMode={isRangeMode}
onVehicleClick={setDetailVehicle}
loadingMore={loadingMore}
hasMore={hasMore}
total={total}
sentinelRef={sentinelRef}
/>
<VehicleDetailModal
vehicle={detailVehicle}
onClose={() => setDetailVehicle(null)}
sourcePriority={sourcePriority}
/>
{/* 回到顶部按钮 */}
<AnimatePresence>
{showBackToTop && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={scrollToTop}
className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-40 w-10 h-10 bg-blue-600 text-white rounded-full shadow-lg shadow-blue-200 flex items-center justify-center active:scale-95 transition-transform"
>
<ChevronsUp size={18} />
</motion.button>
)}
</AnimatePresence>
</>
);
}