feat: 多项优化 - 全屏加载全部数据、无值筛选、刷新按钮、加载动画、负值显示为0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 全屏监控一次加载全部车辆数据,支持完整滚动和筛选 - 客户/部门筛选增加"无值"选项筛选空数据 - 全屏刷新按钮实际触发数据重新加载,带旋转动画 - 全屏筛选时显示加载遮罩 - 负值里程前端显示为0 - 未对接车机显示"未对接"替代"-" - 删除"未同步"标签 - 统计报表配色统一为白色主题、KPI联动选中项目 - 统计报表全屏表格列合并优化 - 车辆明细面板增加日期选择、租赁状态/部门/客户信息、里程合计 - 每分钟自动刷新数据 - 清除按钮修复租赁状态重置 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ const SearchableSelect = ({
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
|
||||
placeholder={value === 'All' ? placeholder : value}
|
||||
placeholder={value === 'All' ? placeholder : value === '__EMPTY__' ? '无值' : value}
|
||||
value={search}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -73,7 +73,7 @@ const SearchableSelect = ({
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
{opt === '__EMPTY__' ? '无值' : opt}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
@@ -95,6 +95,10 @@ export default function MonitoringView() {
|
||||
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 [filterPlate, setFilterPlate] = useState('All');
|
||||
@@ -183,6 +187,12 @@ export default function MonitoringView() {
|
||||
loadFirstPage();
|
||||
}, [loadFirstPage]);
|
||||
|
||||
// 每分钟自动刷新
|
||||
useEffect(() => {
|
||||
const timer = setInterval(loadFirstPage, 60 * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadFirstPage]);
|
||||
|
||||
// 触底检测:用 IntersectionObserver 监听哨兵元素
|
||||
const loadMoreRef = useRef(loadMore);
|
||||
loadMoreRef.current = loadMore;
|
||||
@@ -226,6 +236,28 @@ export default function MonitoringView() {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// 全屏时加载全部数据(无分页),筛选变化时重新加载
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return;
|
||||
setFullscreenLoading(true);
|
||||
fetchMonitoring({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: 9999,
|
||||
page: 1,
|
||||
search: searchTerm || undefined,
|
||||
dept: filterDept !== 'All' ? filterDept : undefined,
|
||||
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
|
||||
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
|
||||
plate: filterPlate !== 'All' ? filterPlate : undefined,
|
||||
date: filterDate || undefined,
|
||||
}).then(d => {
|
||||
setFullscreenVehicles(d.vehicles);
|
||||
setFullscreenStats(d.stats);
|
||||
setFilterOptions(d.filters);
|
||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
||||
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlate, filterDate, fullscreenRefresh]);
|
||||
|
||||
// 全屏时禁止背景滚动
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
@@ -258,19 +290,19 @@ export default function MonitoringView() {
|
||||
<h2 className="text-white font-bold text-xs">全屏监控</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="text-slate-500">今日 <span className="text-white font-black">{Math.round(stats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-500">今日 <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">累计 <span className="text-white font-black">{Math.round(stats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-500">累计 <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{stats.vehicleCount}</span> 台</span>
|
||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
|
||||
<span className="text-slate-700">|</span>
|
||||
<span className="text-slate-500">均 <span className="text-white font-black">{(stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
||||
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'today' ? fullscreenStats.totalToday : fullscreenStats.totalAll) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterPlate('All'); setSearchTerm(''); }}
|
||||
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
||||
onClick={() => { setFullscreenRefresh(n => n + 1); }}
|
||||
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
@@ -300,7 +332,15 @@ export default function MonitoringView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{fullscreenLoading && (
|
||||
<div className="absolute inset-0 bg-slate-950/60 z-20 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
|
||||
<RotateCcw size={14} className="animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||
<tr className="border-b border-slate-800/60">
|
||||
@@ -327,6 +367,7 @@ export default function MonitoringView() {
|
||||
onChange={(e) => setFilterCustomer(e.target.value)}
|
||||
>
|
||||
<option value="All">全部客户</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{filterOptions.customers.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -353,6 +394,7 @@ export default function MonitoringView() {
|
||||
onChange={(e) => setFilterDept(e.target.value)}
|
||||
>
|
||||
<option value="All">全部部门</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d.replace('业务', '')}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -396,7 +438,7 @@ export default function MonitoringView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{filteredVehicles.map((v) => (
|
||||
{fullscreenVehicles.map((v) => (
|
||||
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
||||
<td className="px-3 py-2 text-center">
|
||||
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : v.isDataSynced ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
|
||||
@@ -407,12 +449,12 @@ export default function MonitoringView() {
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-blue-400' : 'text-amber-400'}`}>
|
||||
{v.isDataSynced ? <>{v.dailyKm?.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : '-'}
|
||||
{v.isDataSynced ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||
{v.isDataSynced && v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : '-'}
|
||||
{v.isDataSynced && v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -478,13 +520,13 @@ export default function MonitoringView() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid grid-cols-3 gap-1.5">
|
||||
<SearchableSelect
|
||||
options={departments}
|
||||
options={['__EMPTY__', ...departments]}
|
||||
value={filterDept}
|
||||
onChange={setFilterDept}
|
||||
placeholder="按部门"
|
||||
/>
|
||||
<SearchableSelect
|
||||
options={filterOptions.customers}
|
||||
options={['__EMPTY__', ...filterOptions.customers]}
|
||||
value={filterCustomer}
|
||||
onChange={setFilterCustomer}
|
||||
placeholder="按客户"
|
||||
@@ -550,6 +592,7 @@ export default function MonitoringView() {
|
||||
onChange={(e) => setFilterDept(e.target.value)}
|
||||
>
|
||||
<option value="All">无限制</option>
|
||||
<option value="__EMPTY__">无值</option>
|
||||
{departments.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -658,8 +701,8 @@ export default function MonitoringView() {
|
||||
{(() => {
|
||||
const tags: { label: string; onClear: () => void }[] = [];
|
||||
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
|
||||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept}`, onClear: () => setFilterDept('All') });
|
||||
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer}`, onClear: () => setFilterCustomer('All') });
|
||||
if (filterDept !== 'All') tags.push({ label: `部门: ${filterDept === '__EMPTY__' ? '无值' : filterDept}`, onClear: () => setFilterDept('All') });
|
||||
if (filterCustomer !== 'All') tags.push({ label: `客户: ${filterCustomer === '__EMPTY__' ? '无值' : filterCustomer}`, onClear: () => setFilterCustomer('All') });
|
||||
if (filterProject !== 'All') tags.push({ label: `项目: ${filterProject}`, onClear: () => setFilterProject('All') });
|
||||
if (filterEntity !== 'All') tags.push({ label: `主体: ${filterEntity}`, onClear: () => setFilterEntity('All') });
|
||||
if (filterPlate !== 'All') tags.push({ label: `车牌: ${filterPlate}`, onClear: () => setFilterPlate('All') });
|
||||
@@ -670,7 +713,7 @@ export default function MonitoringView() {
|
||||
if (filterDate) tags.push({ label: `日期: ${filterDate}`, onClear: () => setFilterDate('') });
|
||||
if (tags.length === 0) return null;
|
||||
const clearAll = () => {
|
||||
setFilterDept('All'); setFilterCustomer('All'); setFilterProject('All'); setFilterEntity('All');
|
||||
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
|
||||
setFilterPlate('All'); setSearchTerm(''); setFilterRegionCode('All');
|
||||
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
|
||||
setFilterDate('');
|
||||
@@ -763,17 +806,14 @@ export default function MonitoringView() {
|
||||
)}
|
||||
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none">今</span>
|
||||
<div className={`text-sm font-black leading-none ${v.isDataSynced ? 'text-blue-600' : 'text-amber-600'}`}>
|
||||
{v.isDataSynced ? <>{v.dailyKm?.toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : '-'}
|
||||
{v.isDataSynced ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[7px] font-black text-slate-400/60 bg-slate-100 w-3 h-3 rounded flex items-center justify-center leading-none">总</span>
|
||||
<span className="text-[8px] font-bold text-slate-300">
|
||||
{v.isDataSynced && v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '-'}
|
||||
{v.isDataSynced && v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'}
|
||||
</span>
|
||||
{!v.isDataSynced && (
|
||||
<span className="text-[7px] font-bold text-amber-500/70 bg-amber-50 px-1 rounded">未同步</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user