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 { motion, AnimatePresence } from 'motion/react';
import { import {
Truck, Search, Filter, ChevronDown, Truck, Search, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw, Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ArrowUp, ArrowDown, ChevronsUp,
} from 'lucide-react'; } from 'lucide-react';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types'; import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import { fetchMonitoring } from './api'; import { fetchMonitoring } from './api';
@@ -111,20 +111,24 @@ export default function MonitoringView() {
const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [] }); const [filterOptions, setFilterOptions] = useState<MonitoringFilters>({ departments: [], customers: [], plates: [] });
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [hasMore, setHasMore] = useState(true);
const [updatedAt, setUpdatedAt] = useState(''); const [loadingMore, setLoadingMore] = useState(false);
const [showBackToTop, setShowBackToTop] = useState(false);
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const listEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const departments = filterOptions.departments; const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates; const plateNumbers = filterOptions.plates;
const projects = filterOptions.customers; const projects = filterOptions.customers;
const loadData = useCallback((p = page) => { // 加载首页数据
const loadFirstPage = useCallback(() => {
fetchMonitoring({ fetchMonitoring({
sortBy, sortBy,
sortOrder, sortOrder,
limit: PAGE_SIZE, limit: PAGE_SIZE,
page: p, page: 1,
search: searchTerm || undefined, search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined, dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterProject !== 'All' ? filterProject : undefined, customer: filterProject !== 'All' ? filterProject : undefined,
@@ -133,22 +137,54 @@ export default function MonitoringView() {
setStats(d.stats); setStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
setTotal(d.total); setTotal(d.total);
setPage(d.page); setPage(1);
setTotalPages(d.totalPages); setHasMore(d.page < d.totalPages);
setUpdatedAt(d.updatedAt);
}).catch(() => {}); }).catch(() => {});
}, [sortBy, sortOrder, searchTerm, filterDept, filterProject, page]);
// 筛选/排序变化时重置到第1页
useEffect(() => {
setPage(1);
loadData(1);
}, [sortBy, sortOrder, searchTerm, filterDept, filterProject]); }, [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(() => { useEffect(() => {
loadData(page); loadFirstPage();
}, [page]); }, [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; const filteredVehicles = vehicles;
@@ -706,11 +742,11 @@ export default function MonitoringView() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between px-2 mb-1"> <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-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>
<div className="grid grid-cols-1 gap-1.5"> <div className="grid grid-cols-1 gap-1.5">
{filteredVehicles.slice(0, 60).map((v) => ( {filteredVehicles.map((v) => (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -762,35 +798,40 @@ export default function MonitoringView() {
))} ))}
</div> </div>
{filteredVehicles.length === 0 && ( {filteredVehicles.length === 0 && !loadingMore && (
<div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100"> <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> <p className="text-xs font-bold text-slate-300"></p>
</div> </div>
)} )}
{/* 分页控件 */} {/* 加载更多提示 */}
{totalPages > 1 && ( {loadingMore && (
<div className="flex items-center justify-center gap-3 py-3"> <div className="py-4 text-center">
<button <span className="text-[10px] font-bold text-slate-400">...</span>
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>
</div> </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> </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>
</> </>
); );
} }