perf: 实时监控改为缓存+分页架构

后端:
- 每2分钟刷新全量数据到内存缓存(并行查询两库)
- 预计算统计信息(totalToday/totalAll/onlineCount/vehicleCount)
- 预提取筛选选项(departments/customers/plates)
- API 直接从缓存读取,支持分页(每页50条)+筛选+排序

前端:
- KPI 统计使用后端返回的 stats
- 车辆列表分页,带翻页控件
- 筛选选项从后端 filters 获取

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-01 22:23:25 +08:00
parent 1fb9d53873
commit aa024f1b64
4 changed files with 196 additions and 105 deletions

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Truck, Search, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown,
ArrowUp, ArrowDown, ChevronLeft, ChevronRight,
} from 'lucide-react';
import type { MonitoringVehicle } from './types';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import { fetchMonitoring } from './api';
const SearchableSelect = ({
@@ -63,7 +63,7 @@ const SearchableSelect = ({
>
</div>
{filtered.map(opt => (
{filtered.map((opt: string) => (
<div
key={opt}
className="px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50"
@@ -106,44 +106,52 @@ export default function MonitoringView() {
const [filterRegionCode, setFilterRegionCode] = useState('All');
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [filteredVehicles, setFilteredVehicles] = useState<MonitoringVehicle[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [departments, setDepartments] = useState<string[]>([]);
const [plateNumbers, setPlateNumbers] = useState<string[]>([]);
const [projects, setProjects] = useState<string[]>([]);
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, onlineCount: 0, vehicleCount: 0 });
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 PAGE_SIZE = 50;
// 加载筛选选项(仅首次,用轻量请求)
useEffect(() => {
fetchMonitoring({ limit: 2000 }).then(d => {
const depts = Array.from(new Set(d.vehicles.map(v => v.department).filter(Boolean))) as string[];
const plates = Array.from(new Set(d.vehicles.map(v => v.plate).filter(Boolean)));
const custs = Array.from(new Set(d.vehicles.map(v => v.customer).filter(Boolean))) as string[];
setDepartments(depts);
setPlateNumbers(plates);
setProjects(custs);
const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates;
const projects = filterOptions.customers;
const loadData = useCallback((p = page) => {
fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: p,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterProject !== 'All' ? filterProject : undefined,
}).then(d => {
setVehicles(d.vehicles);
setStats(d.stats);
setFilterOptions(d.filters);
setTotal(d.total);
setPage(d.page);
setTotalPages(d.totalPages);
setUpdatedAt(d.updatedAt);
}).catch(() => {});
}, []);
}, [sortBy, sortOrder, searchTerm, filterDept, filterProject, page]);
// 加载数据(带服务端筛选/排序/分页)
// 筛选/排序变化时重置到第1页
useEffect(() => {
const load = () => {
fetchMonitoring({
sortBy,
sortOrder,
limit: 100,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterProject !== 'All' ? filterProject : undefined,
}).then(d => {
setFilteredVehicles(d.vehicles);
setTotalCount(d.total);
}).catch(() => {});
};
load();
const interval = setInterval(load, 60000);
return () => clearInterval(interval);
setPage(1);
loadData(1);
}, [sortBy, sortOrder, searchTerm, filterDept, filterProject]);
// 翻页时加载
useEffect(() => {
loadData(page);
}, [page]);
const filteredVehicles = vehicles;
const toggleFullscreen = () => {
if (!isFullscreen) {
const elem = document.documentElement;
@@ -158,16 +166,6 @@ export default function MonitoringView() {
setIsFullscreen(!isFullscreen);
};
const stats = useMemo(() => {
const totalToday = filteredVehicles.reduce((sum, v) => sum + (v.dailyKm || 0), 0);
const totalAll = filteredVehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
const activeTotal = sortBy === 'today' ? totalToday : totalAll;
const activeAvg = filteredVehicles.length > 0 ? activeTotal / filteredVehicles.length : 0;
return { totalToday, totalAll, activeTotal, activeAvg, count: totalCount };
}, [filteredVehicles, sortBy, totalCount]);
return (
<>
{/* Fullscreen Landscape View Overlay */}
@@ -227,7 +225,7 @@ export default function MonitoringView() {
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl">
<div className="text-[10px] font-bold text-slate-500 uppercase mb-1"></div>
<div className="text-2xl font-black text-white tracking-tighter">
{stats.count}
{stats.vehicleCount}
<span className="text-blue-400 text-xs ml-2"></span>
</div>
</div>
@@ -236,7 +234,7 @@ export default function MonitoringView() {
({sortBy === 'today' ? '今日' : '累计'})
</div>
<div className="text-2xl font-black text-white tracking-tighter">
{stats.activeAvg.toFixed(0)}
{(stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}
<span className="text-blue-400 text-xs ml-2">KM</span>
</div>
</div>
@@ -687,19 +685,19 @@ export default function MonitoringView() {
{sortBy === 'today' ? '今日' : '累计'} (KM)
</div>
<div className="text-2xl font-black tracking-tighter flex items-baseline gap-1">
{stats.activeTotal.toLocaleString()}
{(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()}
{sortBy === 'today' && <span className="text-blue-400 text-[10px] font-bold">{'\u2191'}12%</span>}
</div>
<div className="absolute -right-4 -bottom-4 w-12 h-12 bg-blue-500/10 rounded-full blur-xl"></div>
</div>
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm">
<div className="text-[8px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-sm font-black text-slate-800">{stats.activeAvg.toFixed(0)}</div>
<div className="text-sm font-black text-slate-800">{(stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</div>
<div className="text-[7px] text-slate-400 mt-0.5">KM/</div>
</div>
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm">
<div className="text-[8px] font-bold text-slate-400 uppercase mb-1"></div>
<div className="text-sm font-black text-slate-800">{stats.count}</div>
<div className="text-sm font-black text-slate-800">{stats.vehicleCount}</div>
<div className="text-[7px] text-slate-400 mt-0.5"></div>
</div>
</div>
@@ -769,6 +767,29 @@ export default function MonitoringView() {
<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>
</div>
)}
</div>
</>
);

View File

@@ -12,7 +12,7 @@ export async function fetchMonitoring(params?: {
sortBy?: string;
sortOrder?: string;
limit?: number;
offset?: number;
page?: number;
search?: string;
dept?: string;
customer?: string;
@@ -21,7 +21,7 @@ export async function fetchMonitoring(params?: {
if (params?.sortBy) query.set('sortBy', params.sortBy);
if (params?.sortOrder) query.set('sortOrder', params.sortOrder);
if (params?.limit) query.set('limit', String(params.limit));
if (params?.offset) query.set('offset', String(params.offset));
if (params?.page) query.set('page', String(params.page));
if (params?.search) query.set('search', params.search);
if (params?.dept) query.set('dept', params.dept);
if (params?.customer) query.set('customer', params.customer);

View File

@@ -11,9 +11,26 @@ export interface MonitoringVehicle {
manager: string | null;
}
export interface MonitoringStats {
totalToday: number;
totalAll: number;
onlineCount: number;
vehicleCount: number;
}
export interface MonitoringFilters {
departments: string[];
customers: string[];
plates: string[];
}
export interface MonitoringData {
vehicles: MonitoringVehicle[];
stats: MonitoringStats;
filters: MonitoringFilters;
total: number;
page: number;
totalPages: number;
updatedAt: string;
}