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([]); const [fullscreenStats, setFullscreenStats] = useState({ 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([]); 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([]); const [filterRegion, setFilterRegion] = useState('All'); const [filterBrands, setFilterBrands] = useState([]); const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); const [exporting, setExporting] = useState(false); const [detailVehicle, setDetailVehicle] = useState(null); const [rangeStart, setRangeStart] = useState(defaultMileageDate); const [rangeEnd, setRangeEnd] = useState(defaultMileageDate); const [sourcePriority, setSourcePriority] = useState(['instrument']); const [vehicles, setVehicles] = useState([]); const [stats, setStats] = useState({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); const [filterOptions, setFilterOptions] = useState({ 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(null); const [showBackToTop, setShowBackToTop] = useState(false); const [relativeNow, setRelativeNow] = useState(() => Date.now()); const PAGE_SIZE = 50; const monitoringQuery = useMemo(() => ({ 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(() => ({ 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(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(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 ( <> {pageLoading && } {/* 顶部哨兵:离开视口时显示回到顶部按钮 */}
{/* Fullscreen Landscape View Overlay */} {isFullscreen && ( { 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} /> )} {/* Expandable Filter Panel */} {isFilterOpen && ( )} {/* Active Filter Tags */} {/* Sticky header: KPI + 清单标题 */} {/* Vehicle List */} setDetailVehicle(null)} sourcePriority={sourcePriority} /> {/* 回到顶部按钮 */} {showBackToTop && ( )} ); }