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() {
|
export default function MonitoringView() {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterDept, setFilterDept] = useState('All');
|
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 [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = 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-700">|</span>
|
||||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.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-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-700">|</span>
|
||||||
<span className="text-slate-500">
|
<span className="text-slate-500">
|
||||||
来源 <span className="text-white font-black">
|
来源 <span className="text-white font-black">
|
||||||
@@ -1047,16 +1047,21 @@ export default function MonitoringView() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleManualRefresh}
|
onClick={handleManualRefresh}
|
||||||
disabled={manualRefreshing}
|
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"
|
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="仅刷新当前筛选结果"
|
title="局部刷新当前筛选数据"
|
||||||
>
|
>
|
||||||
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
|
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
|
||||||
<span className="hidden sm:inline">{manualRefreshing ? '刷新中' : '刷新'}</span>
|
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
|
<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('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={() => 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} />}
|
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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="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={`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="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">
|
<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>
|
||||||
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
|
||||||
</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="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-[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 className="text-[7px] text-slate-400">km/台</div>
|
||||||
</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="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';
|
const BASE = '/api/mileage';
|
||||||
|
|
||||||
export async function fetchMonitoring(params?: {
|
export async function fetchMonitoring(params?: {
|
||||||
sortBy?: string;
|
sortBy?: 'today' | 'total' | 'statisticTime';
|
||||||
sortOrder?: string;
|
sortOrder?: 'asc' | 'desc';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ interface ExportContext {
|
|||||||
date?: string;
|
date?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
sortBy: 'today' | 'total';
|
sortBy: 'today' | 'total' | 'statisticTime';
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_HEADERS = [
|
const BASE_HEADERS = [
|
||||||
@@ -172,6 +172,11 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
const dateTag = start && end
|
const dateTag = start && end
|
||||||
? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}`
|
? `${start.replace(/-/g, '')}-${end.replace(/-/g, '')}`
|
||||||
: `${y}${m}${d}`;
|
: `${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);
|
XLSX.writeFile(wb, filename);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,11 @@ function normalizeRange(startQuery: string, endQuery: string): { start: string;
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get('/', async (c) => {
|
app.get('/', async (c) => {
|
||||||
const sortBy = c.req.query('sortBy') || 'today';
|
const requestedSortBy = c.req.query('sortBy');
|
||||||
const sortOrder = c.req.query('sortOrder') || 'desc';
|
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 limit = Number(c.req.query('limit')) || 50;
|
||||||
const page = Number(c.req.query('page')) || 1;
|
const page = Number(c.req.query('page')) || 1;
|
||||||
const date = c.req.query('date') || '';
|
const date = c.req.query('date') || '';
|
||||||
@@ -189,9 +192,26 @@ app.get('/', async (c) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
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 valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0);
|
||||||
const valB = sortBy === 'today' ? b.dailyKm : (b.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;
|
const offset = (page - 1) * limit;
|
||||||
|
|||||||
Reference in New Issue
Block a user