feat: 实时监控改为瀑布流无限滚动+回到顶部

- 移除分页按钮,改为滚动触底自动加载下一页
- 滚动超过600px时显示蓝色回到顶部按钮
- 底部提示加载状态(加载中.../已加载全部 N 条)
- 筛选/排序变化时自动重置为首页

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-01 22:52:05 +08:00
parent d8f25448d0
commit 2f6269e071

View File

@@ -1,9 +1,9 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Truck, Search, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronLeft, ChevronRight,
ArrowUp, ArrowDown, ChevronsUp,
} from 'lucide-react';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import { fetchMonitoring } from './api';
@@ -111,20 +111,24 @@ export default function MonitoringView() {
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [] });
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [updatedAt, setUpdatedAt] = useState('');
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [showBackToTop, setShowBackToTop] = useState(false);
const PAGE_SIZE = 50;
const listEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates;
const projects = filterOptions.customers;
const loadData = useCallback((p = page) => {
// 加载首页数据
const loadFirstPage = useCallback(() => {
fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: p,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterProject !== 'All' ? filterProject : undefined,
@@ -133,22 +137,54 @@ export default function MonitoringView() {
setStats(d.stats);
setFilterOptions(d.filters);
setTotal(d.total);
setPage(d.page);
setTotalPages(d.totalPages);
setUpdatedAt(d.updatedAt);
setPage(1);
setHasMore(d.page < d.totalPages);
}).catch(() => {});
}, [sortBy, sortOrder, searchTerm, filterDept, filterProject, page]);
// 筛选/排序变化时重置到第1页
useEffect(() => {
setPage(1);
loadData(1);
}, [sortBy, sortOrder, searchTerm, filterDept, filterProject]);
// 翻页时加载
// 加载更多
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
const nextPage = page + 1;
setLoadingMore(true);
fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: nextPage,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterProject !== 'All' ? filterProject : undefined,
}).then(d => {
setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage);
setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterProject, page, loadingMore, hasMore]);
// 筛选/排序变化时重新加载
useEffect(() => {
loadData(page);
}, [page]);
loadFirstPage();
}, [loadFirstPage]);
// 滚动触底检测 + 回到顶部按钮
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY;
setShowBackToTop(scrollY > 600);
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = window.innerHeight;
if (scrollHeight - scrollY - clientHeight < 200) {
loadMore();
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [loadMore]);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const filteredVehicles = vehicles;
@@ -706,11 +742,11 @@ export default function MonitoringView() {
<div className="space-y-1.5">
<div className="flex items-center justify-between px-2 mb-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span>
<span className="text-[9px] font-bold text-slate-300">{filteredVehicles.length} </span>
<span className="text-[9px] font-bold text-slate-300">{total} </span>
</div>
<div className="grid grid-cols-1 gap-1.5">
{filteredVehicles.slice(0, 60).map((v) => (
{filteredVehicles.map((v) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -762,35 +798,40 @@ export default function MonitoringView() {
))}
</div>
{filteredVehicles.length === 0 && (
{filteredVehicles.length === 0 && !loadingMore && (
<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>
)}
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 py-3">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1}
className="p-1.5 rounded-lg bg-white border border-slate-100 shadow-sm disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
>
<ChevronLeft size={14} className="text-slate-600" />
</button>
<span className="text-[10px] font-bold text-slate-400">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages}
className="p-1.5 rounded-lg bg-white border border-slate-100 shadow-sm disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
>
<ChevronRight size={14} className="text-slate-600" />
</button>
{/* 加载更多提示 */}
{loadingMore && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-400">...</span>
</div>
)}
{!hasMore && filteredVehicles.length > 0 && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-300"> {total} </span>
</div>
)}
<div ref={listEndRef} />
</div>
{/* 回到顶部按钮 */}
<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>
</>
);
}