200 lines
7.5 KiB
TypeScript
200 lines
7.5 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js';
|
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
|
import type { AuthUser } from '../../auth/types.js';
|
|
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
|
|
|
|
const app = new Hono();
|
|
|
|
const EMPTY_RESPONSE: MonitoringResponse = {
|
|
vehicles: [],
|
|
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
|
|
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [] },
|
|
total: 0,
|
|
page: 1,
|
|
totalPages: 1,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
function applyFilters(vehicles: CachedVehicle[], params: {
|
|
search: string; dept: string; customer: string; project: string;
|
|
entity: string; rentStatus: string; plate: string; platePrefix: string;
|
|
targetNames: string[]; region: string; mileageMin: string; mileageMax: string;
|
|
}): CachedVehicle[] {
|
|
let result = vehicles;
|
|
|
|
if (params.search) {
|
|
const q = params.search.toLowerCase();
|
|
result = result.filter(v =>
|
|
v.plate.toLowerCase().includes(q) ||
|
|
(v.customer || '').toLowerCase().includes(q) ||
|
|
(v.project || '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept);
|
|
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === params.customer);
|
|
if (params.project) result = result.filter(v => v.project === params.project);
|
|
if (params.entity) result = result.filter(v => v.entity === params.entity);
|
|
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
|
|
if (params.plate) {
|
|
const wanted = new Set(params.plate.split(',').map(s => s.trim()).filter(Boolean));
|
|
if (wanted.size > 0) result = result.filter(v => wanted.has(v.plate));
|
|
}
|
|
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
|
|
if (params.region) result = result.filter(v => v.region === params.region);
|
|
if (params.targetNames.length > 0) {
|
|
const selectedTargets = new Set(params.targetNames);
|
|
result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName)));
|
|
}
|
|
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
|
|
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
|
|
|
|
return result;
|
|
}
|
|
|
|
function parseTargetNames(reqUrl: string): string[] {
|
|
const params = new URL(reqUrl).searchParams;
|
|
const raw = [
|
|
...params.getAll('targetName'),
|
|
...params.getAll('targetNames'),
|
|
];
|
|
const names = raw.flatMap(item => item.split(','))
|
|
.map(item => item.trim())
|
|
.filter(Boolean);
|
|
return Array.from(new Set(names));
|
|
}
|
|
|
|
function parseYmd(value: string): Date | null {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
if (!match) return null;
|
|
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
date.setHours(0, 0, 0, 0);
|
|
return Number.isFinite(date.getTime()) ? date : null;
|
|
}
|
|
|
|
function fmtYmd(date: Date): string {
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
function normalizeRange(startQuery: string, endQuery: string): { start: string; end: string } | null {
|
|
if (!startQuery && !endQuery) return null;
|
|
const start = parseYmd(startQuery || endQuery);
|
|
const end = parseYmd(endQuery || startQuery);
|
|
if (!start || !end) return null;
|
|
const a = start <= end ? start : end;
|
|
let b = start <= end ? end : start;
|
|
const span = Math.round((b.getTime() - a.getTime()) / 86400000) + 1;
|
|
if (span > 366) {
|
|
b = new Date(a);
|
|
b.setDate(a.getDate() + 365);
|
|
}
|
|
return { start: fmtYmd(a), end: fmtYmd(b) };
|
|
}
|
|
|
|
app.get('/', async (c) => {
|
|
const sortBy = c.req.query('sortBy') || 'today';
|
|
const sortOrder = c.req.query('sortOrder') || 'desc';
|
|
const limit = Number(c.req.query('limit')) || 50;
|
|
const page = Number(c.req.query('page')) || 1;
|
|
const date = c.req.query('date') || '';
|
|
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
|
|
|
|
const filterParams = {
|
|
search: c.req.query('search') || '',
|
|
dept: c.req.query('dept') || '',
|
|
customer: c.req.query('customer') || '',
|
|
project: c.req.query('project') || '',
|
|
entity: c.req.query('entity') || '',
|
|
rentStatus: c.req.query('rentStatus') || '',
|
|
plate: c.req.query('plate') || '',
|
|
platePrefix: c.req.query('platePrefix') || '',
|
|
targetNames: parseTargetNames(c.req.url),
|
|
region: c.req.query('region') || '',
|
|
mileageMin: c.req.query('mileageMin') || '',
|
|
mileageMax: c.req.query('mileageMax') || '',
|
|
};
|
|
|
|
let allVehicles: CachedVehicle[];
|
|
let filters: MonitoringFilters;
|
|
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
|
|
let dateRange: { start: string; end: string } | undefined;
|
|
|
|
if (range) {
|
|
try {
|
|
const result = await queryRangeMileage(range.start, range.end);
|
|
allVehicles = result.vehicles;
|
|
rangeDailyTotals = result.dailyTotals;
|
|
dateRange = { start: result.start, end: result.end };
|
|
filters = buildDateFilters(allVehicles);
|
|
} catch (e: unknown) {
|
|
console.error('monitoring range query error:', e);
|
|
return c.json(EMPTY_RESPONSE, 500);
|
|
}
|
|
} else if (date) {
|
|
try {
|
|
allVehicles = await queryDateMileage(date);
|
|
filters = buildDateFilters(allVehicles);
|
|
} catch (e: unknown) {
|
|
console.error('monitoring date query error:', e);
|
|
return c.json(EMPTY_RESPONSE, 500);
|
|
}
|
|
} else {
|
|
const cache = getCache();
|
|
if (!cache) return c.json(EMPTY_RESPONSE);
|
|
allVehicles = cache.vehicles;
|
|
filters = cache.filters;
|
|
}
|
|
|
|
// 权限过滤
|
|
const user = (c as any).get('user') as AuthUser | undefined;
|
|
if (user) {
|
|
allVehicles = filterByPermission(allVehicles, user);
|
|
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
|
|
}
|
|
|
|
// 区域级联:选中运营区域时,下游筛选选项(车牌等)只展示该区域车辆
|
|
if (filterParams.region) {
|
|
const regionScope = allVehicles.filter(v => v.region === filterParams.region);
|
|
filters = buildDateFilters(regionScope);
|
|
}
|
|
|
|
const filtered = applyFilters(allVehicles, filterParams);
|
|
if (rangeDailyTotals && filtered.length !== allVehicles.length) {
|
|
rangeDailyTotals = rangeDailyTotals.map(item => ({
|
|
...item,
|
|
totalKm: filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0),
|
|
}));
|
|
}
|
|
|
|
const stats = {
|
|
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
|
|
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
|
|
vehicleCount: filtered.length,
|
|
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
|
|
};
|
|
|
|
const sorted = [...filtered].sort((a, b) => {
|
|
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 offset = (page - 1) * limit;
|
|
const paged = sorted.slice(offset, offset + limit);
|
|
const total = filtered.length;
|
|
|
|
return c.json({
|
|
vehicles: maskCustomerNames(paged),
|
|
stats,
|
|
filters,
|
|
rangeDailyTotals,
|
|
dateRange,
|
|
total,
|
|
page,
|
|
totalPages: Math.ceil(total / limit),
|
|
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
|
|
});
|
|
});
|
|
|
|
export default app;
|