feat(mileage): add statistic-time sorting and refresh control
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user