feat(mileage): add statistic-time sorting and refresh control
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-07-23 15:44:01 +08:00
parent 99b4efda6d
commit 17c839a35e
4 changed files with 46 additions and 16 deletions

View File

@@ -446,7 +446,7 @@ const BatchMultiSelect = ({
export default function MonitoringView() {
const [searchTerm, setSearchTerm] = useState('');
const [filterDept, setFilterDept] = useState('All');
const [sortBy, setSortBy] = useState<'today' | 'total'>('today');
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -786,7 +786,7 @@ export default function MonitoringView() {
<span className="text-slate-700">|</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">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'today' ? fullscreenStats.totalToday : fullscreenStats.totalAll) / fullscreenStats.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 === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</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">
@@ -1047,16 +1047,21 @@ export default function MonitoringView() {
type="button"
onClick={handleManualRefresh}
disabled={manualRefreshing}
className="flex h-7 items-center gap-1 rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60"
title="刷新当前筛选结果"
className="flex h-7 items-center gap-1 whitespace-nowrap rounded-lg border border-blue-100 bg-blue-50 px-2 text-[9px] font-bold text-blue-600 disabled:cursor-wait disabled:opacity-60"
title="局部刷新当前筛选数据"
>
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
<span className="hidden sm:inline">{manualRefreshing ? '刷新中' : '刷新'}</span>
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
</button>
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
<button onClick={() => setSortBy('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button onClick={() => setSortBy('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')} className="rounded-md p-1 text-blue-600 transition-all hover:bg-white">
<button onClick={() => setSortBy('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
title={sortOrder === 'desc' ? '当前倒序,点击切换正序' : '当前正序,点击切换倒序'}
>
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
</button>
</div>
@@ -1369,15 +1374,15 @@ export default function MonitoringView() {
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}>
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'today' ? (isRangeMode ? '区间' : '当日') : '累计'}</div>
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}</div>
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>}
</div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase"></div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}</div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div>
<div className="text-[7px] text-slate-400">km/</div>
</div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">

View File

@@ -12,8 +12,8 @@ import { fetchJson } from '../../auth/api-client';
const BASE = '/api/mileage';
export async function fetchMonitoring(params?: {
sortBy?: string;
sortOrder?: string;
sortBy?: 'today' | 'total' | 'statisticTime';
sortOrder?: 'asc' | 'desc';
limit?: number;
page?: number;
search?: string;

View File

@@ -5,7 +5,7 @@ interface ExportContext {
date?: string;
startDate?: string;
endDate?: string;
sortBy: 'today' | 'total';
sortBy: 'today' | 'total' | 'statisticTime';
}
const BASE_HEADERS = [
@@ -172,6 +172,11 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
const dateTag = start && end
? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}`
: `${y}${m}${d}`;
const filename = `里程看板_${dateTag}_${hh}${mm}_${ctx.sortBy === 'today' ? (isRange ? '区间' : '今日') : '累计'}.xlsx`;
const sortLabel = ctx.sortBy === 'total'
? '累计'
: ctx.sortBy === 'statisticTime'
? '统计时间'
: isRange ? '区间' : '今日';
const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`;
XLSX.writeFile(wb, filename);
}

View File

@@ -104,8 +104,11 @@ function normalizeRange(startQuery: string, endQuery: string): { start: string;
}
app.get('/', async (c) => {
const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc';
const requestedSortBy = c.req.query('sortBy');
const sortBy = requestedSortBy === 'total' || requestedSortBy === 'statisticTime'
? requestedSortBy
: 'today';
const sortOrder = c.req.query('sortOrder') === 'asc' ? 'asc' : 'desc';
const limit = Number(c.req.query('limit')) || 50;
const page = Number(c.req.query('page')) || 1;
const date = c.req.query('date') || '';
@@ -189,9 +192,26 @@ app.get('/', async (c) => {
};
const sorted = [...filtered].sort((a, b) => {
if (sortBy === 'statisticTime') {
const rawTimeA = a.dataTime || a.updatedAt || a.calculatedAt;
const rawTimeB = b.dataTime || b.updatedAt || b.calculatedAt;
const parsedTimeA = rawTimeA ? Date.parse(rawTimeA) : Number.NaN;
const parsedTimeB = rawTimeB ? Date.parse(rawTimeB) : Number.NaN;
const timeA = Number.isFinite(parsedTimeA) ? parsedTimeA : null;
const timeB = Number.isFinite(parsedTimeB) ? parsedTimeB : null;
// Missing/invalid statistic times always stay at the end for both directions.
if (timeA == null && timeB == null) return a.plate.localeCompare(b.plate, 'zh-CN');
if (timeA == null) return 1;
if (timeB == null) return -1;
const timeDiff = sortOrder === 'desc' ? timeB - timeA : timeA - timeB;
return timeDiff || a.plate.localeCompare(b.plate, 'zh-CN');
}
const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0);
const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0);
return sortOrder === 'desc' ? valB - valA : valA - valB;
const mileageDiff = sortOrder === 'desc' ? valB - valA : valA - valB;
return mileageDiff || a.plate.localeCompare(b.plate, 'zh-CN');
});
const offset = (page - 1) * limit;