feat: 多项优化 - 全屏加载全部数据、无值筛选、刷新按钮、加载动画、负值显示为0
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:
kkfluous
2026-04-02 10:52:45 +08:00
parent 06a2edc470
commit adc9c3a9db
5 changed files with 338 additions and 224 deletions

View File

@@ -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>