This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.22",
|
||||
"version": "1.1.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.22",
|
||||
"version": "1.1.23",
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.22",
|
||||
"version": "1.1.23",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
|
||||
@@ -391,7 +391,6 @@ export default function MonitoringView() {
|
||||
rangeEnd={rangeEnd}
|
||||
onRangeStartChange={setRangeStart}
|
||||
onRangeEndChange={setRangeEnd}
|
||||
onApplyRangePreset={applyRangePreset}
|
||||
departments={departments}
|
||||
filterDept={filterDept}
|
||||
onFilterDeptChange={setFilterDept}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import type { MonitoringFilters } from '../../types';
|
||||
import { defaultMileageDate, type RangePreset } from '../date-range';
|
||||
import { DateRangePicker } from '../../../../components/ui/DateRangePicker';
|
||||
import { defaultMileageDate, getRangePreset, type RangePreset } from '../date-range';
|
||||
import BatchMultiSelect from './BatchMultiSelect';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
|
||||
@@ -12,7 +13,6 @@ export default function FilterPanel({
|
||||
rangeEnd,
|
||||
onRangeStartChange,
|
||||
onRangeEndChange,
|
||||
onApplyRangePreset,
|
||||
departments,
|
||||
filterDept,
|
||||
onFilterDeptChange,
|
||||
@@ -42,7 +42,6 @@ export default function FilterPanel({
|
||||
rangeEnd: string;
|
||||
onRangeStartChange: (value: string) => void;
|
||||
onRangeEndChange: (value: string) => void;
|
||||
onApplyRangePreset: (preset: RangePreset) => void;
|
||||
departments: string[];
|
||||
filterDept: string;
|
||||
onFilterDeptChange: (value: string) => void;
|
||||
@@ -76,47 +75,25 @@ export default function FilterPanel({
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4">
|
||||
{/* Date range */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">查询时间区间</label>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
aria-label="开始日期"
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={rangeStart}
|
||||
onChange={(e) => onRangeStartChange(e.target.value)}
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-slate-300">至</span>
|
||||
<input
|
||||
type="date"
|
||||
aria-label="结束日期"
|
||||
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
value={rangeEnd}
|
||||
onChange={(e) => onRangeEndChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{rangeStart && rangeEnd && rangeStart > rangeEnd && <p role="alert" className="text-xs text-red-600">开始日期不能晚于结束日期</p>}
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{([
|
||||
<DateRangePicker
|
||||
label="查询时间区间"
|
||||
value={{ start: rangeStart, end: rangeEnd }}
|
||||
onChange={({ start, end }) => {
|
||||
onRangeStartChange(start);
|
||||
onRangeEndChange(end);
|
||||
}}
|
||||
quickPicks={([
|
||||
['today', '今天'],
|
||||
['yesterday', '昨天'],
|
||||
['thisWeek', '本周'],
|
||||
['thisMonth', '本月'],
|
||||
['last7', '近7天'],
|
||||
['last15', '近15天'],
|
||||
] as Array<[RangePreset, string]>).map(([preset, label]) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
|
||||
onClick={() => onApplyRangePreset(preset)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
] satisfies Array<[RangePreset, string]>).map(([preset, label]) => ({
|
||||
label,
|
||||
range: getRangePreset(preset),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Department */}
|
||||
|
||||
@@ -331,7 +331,8 @@ export default function FullscreenMonitoring({
|
||||
</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.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
{v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">暂无读数</span>}
|
||||
{v.totalKmFallbackDate && <span className="block text-[10px] text-slate-400">截至 {v.totalKmFallbackDate}</span>}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -113,7 +113,8 @@ export default function VehicleList({
|
||||
<div className="hidden text-right md:block">
|
||||
<div className="text-xs font-black tabular-nums text-slate-700">
|
||||
{v.totalKm != null ? v.totalKm.toLocaleString() : '-'}
|
||||
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
|
||||
{v.totalKm != null && <span className="ml-1 text-[9px] font-bold text-slate-400">km</span>}
|
||||
{v.totalKmFallbackDate && <div className="text-[10px] font-normal text-slate-500">截至 {v.totalKmFallbackDate}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 flex-col items-end text-right md:hidden">
|
||||
@@ -129,7 +130,8 @@ export default function VehicleList({
|
||||
<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.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'}
|
||||
{v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '暂无读数'}
|
||||
{v.totalKmFallbackDate && <span className="block">截至 {v.totalKmFallbackDate}</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface MonitoringVehicle {
|
||||
mileageAnomaly?: string | null;
|
||||
dailyMileage?: Record<string, number | null>;
|
||||
totalKm: number | null;
|
||||
totalKmFallbackDate?: string;
|
||||
source: string;
|
||||
sourceProtocol: MileageSourceProtocol | null;
|
||||
sourceCategory: MileageSourceCategory | null;
|
||||
|
||||
@@ -50,6 +50,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
...(dayKeys.length > 0 ? dailyHeaders : []),
|
||||
isRange ? '区间合计(km)' : '当日里程(km)',
|
||||
'累计里程(km)',
|
||||
...(isRange ? ['累计读数日期'] : []),
|
||||
];
|
||||
|
||||
const summaryData: (string | number)[][] = [
|
||||
@@ -71,6 +72,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
...dailyCols,
|
||||
mileageCell(v, 'today'),
|
||||
mileageCell(v, 'total'),
|
||||
...(isRange ? [v.totalKm != null ? v.totalKmFallbackDate || end : ''] : []),
|
||||
];
|
||||
}),
|
||||
];
|
||||
@@ -92,6 +94,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
|
||||
wsCols.push({ wch: 14 }); // 区间合计
|
||||
wsCols.push({ wch: 14 }); // 累计里程
|
||||
if (isRange) wsCols.push({ wch: 16 });
|
||||
ws['!cols'] = wsCols;
|
||||
|
||||
// 冻结首行 + 固定列
|
||||
@@ -126,6 +129,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
...dayKeys.map(day => `${day}里程(km)`),
|
||||
'区间合计(km)',
|
||||
'累计里程(km)',
|
||||
...(isRange ? ['累计读数日期'] : []),
|
||||
];
|
||||
const detailData: (string | number)[][] = [
|
||||
detailHeaders,
|
||||
@@ -140,6 +144,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'),
|
||||
mileageCell(v, 'today'),
|
||||
v.totalKm != null ? v.totalKm : '',
|
||||
...(isRange ? [v.totalKm != null ? v.totalKmFallbackDate || end : ''] : []),
|
||||
]),
|
||||
];
|
||||
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
|
||||
@@ -154,6 +159,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
...dayKeys.map(() => ({ wch: 14 })),
|
||||
{ wch: 14 },
|
||||
{ wch: 14 },
|
||||
...(isRange ? [{ wch: 16 }] : []),
|
||||
];
|
||||
detailWs['!freeze'] = { xSplit: 7, ySplit: 1 } as never;
|
||||
for (let r = 1; r < detailData.length; r++) {
|
||||
|
||||
@@ -127,7 +127,7 @@ export async function queryRangeMileage(
|
||||
]);
|
||||
const { summary, yesterdayRows } = cached.result;
|
||||
return {
|
||||
vehicles: buildSummaryMileage(summary, yesterdayRows, infoMap, targetRows, regionMap),
|
||||
vehicles: buildSummaryMileage(summary, yesterdayRows, infoMap, targetRows, regionMap, cached.result.priorOdometers),
|
||||
summary,
|
||||
start: startDate, end: endDate,
|
||||
queriedAt: cached.queriedAt,
|
||||
|
||||
@@ -54,11 +54,14 @@ test('summary integration preserves scope, valid partial mileage, trend nulls, r
|
||||
assert.equal(first.vehicles[0].plate,'TEST1');assert.equal(first.stats.totalToday,50);
|
||||
assert.equal(first.rangeDailyTotals?.[1].totalKm,null);
|
||||
assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1);
|
||||
assert.equal(requests.filter(r=>r.url.endsWith('/range/query')).length,0);
|
||||
assert.deepEqual(requests.filter(r=>r.url.endsWith('/range/query')).map(r=>r.body.plateNumbers), [['TEST0']]);
|
||||
const second=await read('&page=2&limit=1&includeDailyTrend=0');assert.equal(second.vehicles[0].periodMileageKm,20);
|
||||
const sorted=await read('&sortOrder=asc&includeDailyTrend=0');assert.equal(sorted.vehicles[0].periodMileageKm,0);
|
||||
const exported=await read('&limit=9999&includeDailyTrend=0');
|
||||
assert.equal(exported.vehicles.length,4);assert.equal(exported.vehicles[3].periodMileageKm,null);
|
||||
const fallback = exported.vehicles.find(v => v.plate === 'TEST0')!;
|
||||
assert.equal(fallback.totalKm,100);
|
||||
assert.equal(fallback.totalKmFallbackDate,'2026-10-10');
|
||||
assert.equal(exported.queriedAt,first.queriedAt);assert.ok(!exported.vehicles[0].dailyMileage);
|
||||
assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1);
|
||||
user={...user,permissionLevel:'department'};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fetchOneOsDailyMileage, type OneOsDailyMileage } from './oneos-api.js';
|
||||
import { previousMileageDate } from './cache-model.js';
|
||||
import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js';
|
||||
import { mileageDatesBetween, previousMileageDate } from './cache-model.js';
|
||||
import { fetchMileageSummary, type MileageSummary } from './summary-api.js';
|
||||
import type { OneOsProtocol } from './source-policy.js';
|
||||
|
||||
@@ -10,6 +10,7 @@ const MAX_ENTRIES = 4;
|
||||
export interface RangeMileageSource {
|
||||
summary: MileageSummary;
|
||||
yesterdayRows: OneOsDailyMileage[];
|
||||
priorOdometers?: Map<string, { date: string; km: number }>;
|
||||
}
|
||||
|
||||
type Entry = { result: RangeMileageSource; queriedAt: string; expiresAt: number };
|
||||
@@ -19,7 +20,23 @@ async function loadSource(start: string, end: string, protocols: OneOsProtocol[]
|
||||
fetchMileageSummary(start, end, protocols, plates),
|
||||
plates ? Promise.resolve([]) : fetchOneOsDailyMileage(previousMileageDate(start), undefined, protocols, refresh),
|
||||
]);
|
||||
return { summary, yesterdayRows };
|
||||
// Plate-scoped summary requests are used only for filtered trends.
|
||||
if (plates || start === end) return { summary, yesterdayRows };
|
||||
const missing = summary.vehicles.filter(v => v.endDateTotalMileageKm === null && v.validDays + v.anomalyDays > 0);
|
||||
if (!missing.length) return { summary, yesterdayRows };
|
||||
const dates = mileageDatesBetween(start, previousMileageDate(end));
|
||||
const history = await fetchOneOsMileageDates(dates, missing.map(v => v.plateNumber), protocols);
|
||||
const priorOdometers = new Map<string, { date: string; km: number }>();
|
||||
for (const date of dates.reverse()) {
|
||||
for (const row of history.get(date) || []) {
|
||||
const plate = row.plateNumber.toUpperCase();
|
||||
const trusted = row.status === 'NORMAL' || row.dataQuality === 'BASELINE_MISSING' || row.dataQuality === 'ODOMETER_SOURCE_CHANGED';
|
||||
if (!priorOdometers.has(plate) && trusted && row.dataQuality !== 'CARRIED_FORWARD' && row.totalMileageKm != null) {
|
||||
priorOdometers.set(plate, { date, km: row.totalMileageKm });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { summary, yesterdayRows, priorOdometers };
|
||||
}
|
||||
|
||||
export function createMonitoringQueryCache(
|
||||
|
||||
@@ -4,15 +4,17 @@ import { buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, typ
|
||||
import type { CachedVehicle, VehicleInfoRow } from './types.js';
|
||||
import { sourceCategoryFromProtocol } from './source-policy.js';
|
||||
|
||||
export function buildSummaryMileage(summary: MileageSummary, yesterdayRows: OneOsDailyMileage[], infoMap: Map<string, VehicleInfoRow>, targets: TargetRow[], regions: Record<string, string>): CachedVehicle[] {
|
||||
export function buildSummaryMileage(summary: MileageSummary, yesterdayRows: OneOsDailyMileage[], infoMap: Map<string, VehicleInfoRow>, targets: TargetRow[], regions: Record<string, string>, priorOdometers?: Map<string, { date: string; km: number }>): CachedVehicle[] {
|
||||
const byPlate = new Map(summary.vehicles.map(v => [v.plateNumber, v]));
|
||||
return mergeMonitoringVehicles([], infoMap, dailyMileageMap(yesterdayRows), buildPlateTargetNamesMap(targets), regions).map(vehicle => {
|
||||
const row = byPlate.get(vehicle.plate.trim().toUpperCase());
|
||||
if (!row) return { ...vehicle, periodMileageKm: null };
|
||||
const prior = priorOdometers?.get(row.plateNumber);
|
||||
const categories = new Set(row.sourceProtocols.map(sourceCategoryFromProtocol));
|
||||
return { ...vehicle, vin: row.vin || vehicle.vin,
|
||||
dailyKm: row.periodMileageKm ?? 0, periodMileageKm: row.periodMileageKm,
|
||||
totalKm: row.endDateTotalMileageKm,
|
||||
totalKm: row.endDateTotalMileageKm ?? prior?.km ?? null,
|
||||
totalKmFallbackDate: prior?.date,
|
||||
validDays: row.validDays, missingDays: row.missingDays, anomalyDays: row.anomalyDays,
|
||||
qualityStatus: row.status, qualityReasons: row.qualityReasons, sourceProtocols: row.sourceProtocols,
|
||||
mileageAnomaly: row.anomalyDays ? row.qualityReasons.join(', ') || 'DATA_ANOMALY' : null,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CachedVehicle {
|
||||
mileageAnomaly?: string | null;
|
||||
dailyMileage?: Record<string, number | null>;
|
||||
totalKm: number | null;
|
||||
totalKmFallbackDate?: string;
|
||||
source: string;
|
||||
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
|
||||
sourceCategory: 'INSTRUMENT' | 'GPS' | 'MIXED' | null;
|
||||
|
||||
Reference in New Issue
Block a user