7 Commits

Author SHA1 Message Date
kkfluous
17c839a35e feat(mileage): add statistic-time sorting and refresh control
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-23 15:44:01 +08:00
kkfluous
99b4efda6d chore: release v1.1.13
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-23 15:12:36 +08:00
kkfluous
6b72f0ce3c feat(mileage): default to instrument-only source 2026-07-23 15:12:36 +08:00
kkfluous
b0c8d6a5b8 chore: release v1.1.12
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-23 14:11:57 +08:00
kkfluous
d8773ff0a0 feat(mileage): add OneOS source priority controls 2026-07-23 14:11:52 +08:00
kkfluous
becacecc67 chore: release v1.1.11
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-07-23 12:29:29 +08:00
kkfluous
67f3ec7306 perf(mileage): filter single-vehicle range queries 2026-07-23 12:27:23 +08:00
15 changed files with 701 additions and 66 deletions

View File

@@ -1,5 +1,8 @@
# OneOS 车辆里程接口需求
当前协议选源联调阻塞项和 OneOS 修改清单见:
[OneOS 车辆里程数据源协议改造清单](./oneos-mileage-source-protocol-gaps.md)。
## 开发原则
1. 新增区间查询接口;车辆里程汇总能力在现有
@@ -22,6 +25,7 @@ POST /api/v1/vehicles/mileage/range/query
"startDate": "2026-07-01",
"endDate": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"],
"protocolPriority": ["GB32960", "MQTT", "JT808"],
"cursor": null,
"pageSize": 5000
}
@@ -31,6 +35,16 @@ POST /api/v1/vehicles/mileage/range/query
- `startDate``endDate` 必填,最长支持 366 天;
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
- `protocolPriority` 可省略;提供时只允许 `GB32960``MQTT``JT808`
数组顺序表示逐车逐日的数据源优先级,未列出的协议视为禁用;
- BI 只会使用以下四种组合:
- 仪表数据优先:`["GB32960", "MQTT", "JT808"]`
- GPS 数据优先:`["JT808", "GB32960", "MQTT"]`
- 仅仪表数据:`["GB32960", "MQTT"]`
- 仅 GPS 数据:`["JT808"]`
- BI 页面默认只启用“仪表数据”,即默认发送
`["GB32960", "MQTT"]`;用户主动启用 GPS 后才发送包含 `JT808` 的组合;
- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换;
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`
- 每辆车每天返回一条记录;
- 无数据返回 `status: "NO_DATA"``dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"``dailyMileageKm: 0`
@@ -47,6 +61,7 @@ POST /api/v1/vehicles/mileage/range/query
"plateNumber": "沪A00001",
"date": "2026-07-01",
"dailyMileageKm": 182.437,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-01T23:58:45+08:00",
"updatedAt": "2026-07-02T05:10:00+08:00",
"status": "NORMAL"
@@ -71,13 +86,15 @@ POST /api/v1/vehicles/mileage/query
```json
{
"date": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"]
"plateNumbers": ["沪A00001", "沪A00002"],
"protocolPriority": ["GB32960", "MQTT", "JT808"]
}
```
规则:
- `date``plateNumbers` 沿用现有接口规则;
- `protocolPriority` 的枚举、顺序和禁用规则与区间接口完全一致;
- `plateNumbers` 省略时返回授权范围内全部车辆;
- 接口默认在现有车辆结果中返回 `totalMileageKm``dataTime``updatedAt`
- 只增加响应字段,不删除或修改任何现有字段及其语义;
@@ -100,6 +117,7 @@ POST /api/v1/vehicles/mileage/query
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
@@ -114,6 +132,11 @@ POST /api/v1/vehicles/mileage/query
## 通用要求
- `dataTime``updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
- `sourceProtocol` 必须返回本条结果实际采用的协议:
`GB32960``MQTT``JT808`;无数据时返回 `null`
- OneOS 必须按 `protocolPriority` 选择第一份有效数据,不得返回已禁用协议的数据;
- `sourceProtocol=GB32960/MQTT` 在 BI 显示为“仪表数据”,
`sourceProtocol=JT808` 显示为“GPS数据”
- 每个响应返回 `traceId`
- 接口返回值保留原始精度;
- 负里程不得作为正常数据返回;
@@ -127,3 +150,6 @@ POST /api/v1/vehicles/mileage/query
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
4. 正确区分无数据与真实零里程。
5. `dataTime` 能反映每辆车实际数据的新鲜度。
6. 四种 `protocolPriority` 组合均能按顺序选源,且响应中的
`sourceProtocol` 与实际采用协议一致。
7. 禁用某类协议后,响应不得回退到该协议。

View File

@@ -0,0 +1,183 @@
# OneOS 车辆里程数据源协议改造清单
## 0. 发布后复测结论
复测日期2026-07-23
开放平台发布后BI 已重新连接 `https://open.d.lnoneos.com` 并完成复测:
- 单日接口和区间接口均接受 `protocolPriority`,不再返回
`INVALID_REQUEST`
- 成功响应已返回实际使用的 `sourceProtocol`
- 仪表优先、GPS 优先、仅仪表、仅 GPS 四种模式均返回 HTTP 200
- “仅仪表数据”结果未出现 `JT808`
- “仅 GPS 数据”结果未出现 `GB32960``MQTT`
- 同一车辆四天区间查询能够随优先级在 `GB32960``JT808`
之间正确切换;
- BI 页面已显示真实“仪表数据”或“GPS数据”不再显示“来源待接口”。
复测时的单日协议分布如下,数据量会随当天车辆上报变化:
| BI 模式 | GB32960 | MQTT | JT808 | 无数据 | 禁用协议混入 |
| --- | ---: | ---: | ---: | ---: | --- |
| 仪表数据优先 | 387 | 49 | 134 | 454 | 无 |
| GPS 数据优先 | 69 | 30 | 471 | 454 | 无 |
| 仅仪表数据 | 387 | 49 | 0 | 588 | 无 |
| 仅 GPS 数据 | 0 | 0 | 471 | 553 | 无 |
结论:本清单中的协议选源阻塞项已关闭,当前接口满足 BI 对接要求。
以下内容保留为接口契约和后续回归验收依据。
## 1. 历史联调结论(发布前)
联调地址:`https://open.d.lnoneos.com`
涉及接口:
- `POST /api/v1/vehicles/mileage/query`
- `POST /api/v1/vehicles/mileage/range/query`
发布前接口不满足 BI 对车辆里程数据源切换和来源标注的要求,阻塞项如下:
| 序号 | 当前表现 | 期望表现 | 影响 |
| --- | --- | --- | --- |
| 1 | 请求增加 `protocolPriority` 后返回 HTTP 400业务码为 `INVALID_REQUEST` | 两个里程接口均接受可选字段 `protocolPriority` | BI 无法切换仪表数据和 GPS 数据的优先级,也无法单独禁用某类数据 |
| 2 | 成功响应中没有 `sourceProtocol` | 每辆车、每天的结果返回实际采用的协议 | BI 无法准确标注“仪表数据”或“GPS数据” |
| 3 | 无法验证接口是否排除了已禁用协议 | 未列入 `protocolPriority` 的协议不得参与选源 | “仅仪表数据”和“仅 GPS 数据”无法生效 |
| 4 | 无法验证仪表数据内部的降级顺序 | 仪表数据固定按 `GB32960 > MQTT` 选源 | 32960 缺失时无法确认是否正确降级到 MQTT |
BI 保留旧请求格式的兼容回退,用于开放平台异常或版本回滚时保证现有里程查询可用。回退结果不会伪造数据来源,页面会显示“来源待接口”。正常情况下新接口已不触发该回退。
## 2. OneOS 必须修改的请求字段
两个接口都需要增加以下可选字段:
```json
{
"protocolPriority": ["GB32960", "MQTT", "JT808"]
}
```
字段规则:
1. 类型为非空字符串数组。
2. 仅允许 `GB32960``MQTT``JT808`,协议名区分以此处定义为准。
3. 数组顺序表示逐车、逐自然日的数据源优先级。
4. 未出现在数组中的协议视为禁用,不得参与选源或作为兜底数据返回。
5. `protocolPriority` 省略时保持现有接口的默认行为,确保旧调用方兼容。
6. 非法枚举、空数组或重复协议应返回 HTTP 400并在响应中给出明确的错误信息和 `traceId`
BI 只会发送以下四种组合:
| BI 模式 | `protocolPriority` | 选源规则 |
| --- | --- | --- |
| 仪表数据优先 | `["GB32960", "MQTT", "JT808"]` | 先查 32960再查 MQTT最后查 JT808 |
| GPS 数据优先 | `["JT808", "GB32960", "MQTT"]` | 先查 JT808再查 32960最后查 MQTT |
| 仅仪表数据 | `["GB32960", "MQTT"]` | 只允许 32960 和 MQTT禁止使用 JT808 |
| 仅 GPS 数据 | `["JT808"]` | 只允许 JT808禁止使用 32960 和 MQTT |
BI 页面默认只启用“仪表数据”,即默认使用
`["GB32960", "MQTT"]`GPS 数据需要用户主动启用。
仪表数据内部优先级固定为 `GB32960 > MQTT`BI 不会发送 `MQTT > GB32960`
## 3. OneOS 必须增加的响应字段
两个接口的每条车辆日里程结果都需要增加:
```json
{
"sourceProtocol": "GB32960"
}
```
字段规则:
1. 有有效里程数据时,只能返回 `GB32960``MQTT``JT808`
2. 返回值必须是本条结果实际采用的协议,不能返回车辆支持的协议列表,也不能返回请求中的第一项作为固定值。
3. 无有效数据、`status="NO_DATA"` 时返回 `null`
4. `sourceProtocol` 对应关系:
- `GB32960``MQTT`BI 显示为“仪表数据”;
- `JT808`BI 显示为“GPS数据”。
5. 如果 `sourceProtocol` 不在本次请求的 `protocolPriority` 中,应视为接口错误。
响应示例:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "粤A00001F",
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"sourceProtocol": "GB32960",
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
}
],
"traceId": "..."
}
```
## 4. 逐车逐日选源规则
OneOS 需要针对每辆车、每个自然日独立执行以下逻辑:
1.`protocolPriority` 从前到后检查数据源。
2. 找到第一份有效数据后立即使用,不再混用低优先级协议的数据。
3. 有效数据必须满足现有里程质量规则,包括里程非空、非负且数据状态有效。
4. 高优先级协议无有效数据时,才能降级到下一个已启用协议。
5. 所有已启用协议均无有效数据时,返回:
```json
{
"dailyMileageKm": null,
"sourceProtocol": null,
"status": "NO_DATA"
}
```
6. 真实零里程必须返回:
```json
{
"dailyMileageKm": 0,
"sourceProtocol": "实际采用的协议",
"status": "NORMAL"
}
```
区间查询必须逐日选源,不能先按一个协议汇总整个区间后再切换协议。
## 5. 验收用例
OneOS 修改完成后,至少提供一辆在同一天同时存在 32960、MQTT、JT808 数据的测试车辆,以及一辆只有 MQTT 数据的测试车辆。
| 用例 | 请求优先级 | 必须满足的结果 |
| --- | --- | --- |
| 仪表优先 | `GB32960, MQTT, JT808` | 同时有三种数据时采用 GB32960`sourceProtocol="GB32960"` |
| GPS 优先 | `JT808, GB32960, MQTT` | 同时有三种数据时采用 JT808`sourceProtocol="JT808"` |
| 仅仪表 | `GB32960, MQTT` | 不得返回 JT80832960 缺失时允许降级到 MQTT |
| 仅 GPS | `JT808` | 不得返回 GB32960 或 MQTT |
| 32960 降级 | `GB32960, MQTT` | 只有 MQTT 有效时返回 MQTT`sourceProtocol="MQTT"` |
| 禁用验证 | `JT808` | 即使仪表数据存在,也不能回退到仪表数据 |
| 无数据 | 任一合法组合 | 返回 `dailyMileageKm=null``sourceProtocol=null``status="NO_DATA"` |
| 真实零里程 | 任一合法组合 | 返回 `dailyMileageKm=0`、实际 `sourceProtocol``status="NORMAL"` |
| 单日接口 | 四种组合分别测试 | 请求成功且选源、来源字段符合规则 |
| 区间接口 | 四种组合分别测试 | 每辆车每天独立选源,分页无重复、无漏行 |
| 兼容性 | 不传 `protocolPriority` | 原有调用方式和响应语义保持不变 |
## 6. 交付和联调要求
1. 公网 `https://open.d.lnoneos.com` 和 ECS 内网地址同时发布相同能力。
2. 发布后提供接口版本或发布日期、测试车辆、测试日期及对应协议数据情况。
3. 提供四种合法组合的请求和响应样例。
4. 提供一组禁用协议的验证结果,证明未启用协议不会被回退使用。
5. 每个成功和失败响应均返回 `traceId`,便于双方定位问题。
完成标准:两个接口均支持 `protocolPriority`,每条结果准确返回 `sourceProtocol`,四种 BI 模式全部通过上述验收用例。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ln-bi",
"version": "1.1.10",
"version": "1.1.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.1.10",
"version": "1.1.13",
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",

View File

@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.10",
"version": "1.1.13",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",

View File

@@ -4,10 +4,15 @@ import {
Truck, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
RefreshCw,
RefreshCw, ArrowLeftRight, Power,
} from 'lucide-react';
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import type {
MileageSourceGroup,
MonitoringVehicle,
MonitoringStats,
MonitoringFilters,
} from './types';
import { fetchMonitoring } from './api';
import Blur from '../../components/Blur';
import PlateMultiSelect from './PlateMultiSelect';
@@ -19,6 +24,118 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([
'交投190辆4.5T冷链车',
]);
const HIGH_MILEAGE_ALERT_KM = 800;
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
const MILEAGE_SOURCE_META = {
instrument: { label: '仪表数据', protocol: null },
gps: { label: 'GPS数据', protocol: 'JT808' },
} as const;
function vehicleSourceDisplay(vehicle: MonitoringVehicle): {
label: string;
title: string;
className: string;
} {
if (vehicle.sourceCategory === 'INSTRUMENT') {
return {
label: '仪表数据',
title: vehicle.sourceProtocol || 'GB32960 / MQTT',
className: 'bg-violet-50 text-violet-600',
};
}
if (vehicle.sourceCategory === 'GPS') {
return {
label: 'GPS数据',
title: vehicle.sourceProtocol || 'JT808',
className: 'bg-emerald-50 text-emerald-600',
};
}
if (vehicle.sourceCategory === 'MIXED') {
return {
label: '仪表+GPS',
title: '所选区间内包含仪表数据和 GPS 数据',
className: 'bg-blue-50 text-blue-600',
};
}
return {
label: vehicle.isDataSynced ? '来源待接口' : '无数据',
title: vehicle.isDataSynced
? 'OneOS 当前响应未返回 sourceProtocol'
: '所选来源没有有效数据',
className: vehicle.isDataSynced
? 'bg-amber-50 text-amber-600'
: 'bg-slate-100 text-slate-400',
};
}
function SourcePriorityControl({
priority,
onChange,
}: {
priority: MileageSourceGroup[];
onChange: (next: MileageSourceGroup[]) => void;
}) {
const ordered = [
...priority,
...ALL_MILEAGE_SOURCE_GROUPS.filter(group => !priority.includes(group)),
];
const toggle = (group: MileageSourceGroup) => {
if (priority.includes(group)) {
if (priority.length === 1) return;
onChange(priority.filter(item => item !== group));
return;
}
onChange([...priority, group]);
};
const swap = () => {
if (priority.length === 2) onChange([priority[1], priority[0]]);
};
return (
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
<span className="shrink-0 text-[9px] font-black text-slate-400"></span>
{ordered.map(group => {
const enabledIndex = priority.indexOf(group);
const enabled = enabledIndex >= 0;
const meta = MILEAGE_SOURCE_META[group];
return (
<button
key={group}
type="button"
onClick={() => toggle(group)}
className={`flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-1 transition-all ${
enabled
? 'border-blue-100 bg-blue-50 text-blue-700'
: 'border-slate-100 bg-slate-50 text-slate-400'
}`}
title={enabled && priority.length === 1 ? '至少保留一种数据来源' : enabled ? '点击停用' : '点击启用'}
>
<span className={`flex h-4 w-4 items-center justify-center rounded text-[8px] font-black ${
enabled ? 'bg-blue-600 text-white' : 'bg-slate-200 text-slate-500'
}`}>
{enabled ? enabledIndex + 1 : <Power size={9} />}
</span>
<span className="text-left leading-tight">
<span className="block text-[9px] font-black">{meta.label}</span>
<span className="block min-h-[9px] text-[7px] font-bold opacity-60">
{enabled ? meta.protocol : '已停用'}
</span>
</span>
</button>
);
})}
<button
type="button"
onClick={swap}
disabled={priority.length !== 2}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-slate-100 bg-white text-slate-400 transition-all hover:border-blue-100 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-30"
title="切换数据来源优先级"
>
<ArrowLeftRight size={12} />
</button>
</div>
);
}
function defaultMileageDate(): string {
const now = new Date();
@@ -329,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);
@@ -354,6 +471,7 @@ export default function MonitoringView() {
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument']);
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
@@ -418,6 +536,7 @@ export default function MonitoringView() {
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}).then(d => {
setVehicles(d.vehicles);
setStats(d.stats);
@@ -431,7 +550,7 @@ export default function MonitoringView() {
}).catch(() => {}).finally(() => {
if (showPageLoading) setPageLoading(false);
});
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]);
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
const handleManualRefresh = useCallback(async () => {
if (manualRefreshing) return;
@@ -468,12 +587,13 @@ export default function MonitoringView() {
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}).then(d => {
setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage);
setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands]);
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]);
// 筛选/排序变化时重新加载
useEffect(() => {
@@ -513,6 +633,7 @@ export default function MonitoringView() {
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
});
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
} catch (err) {
@@ -520,7 +641,7 @@ export default function MonitoringView() {
} finally {
setExporting(false);
}
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]);
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
// 每分钟自动刷新
useEffect(() => {
@@ -592,12 +713,13 @@ export default function MonitoringView() {
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}).then(d => {
setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats);
setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands]);
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]);
// 全屏时禁止背景滚动
useEffect(() => {
@@ -664,7 +786,13 @@ 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">
{sourcePriority.map(group => MILEAGE_SOURCE_META[group].label).join(' > ')}
</span>
</span>
</div>
</div>
<div className="flex items-center gap-2">
@@ -845,13 +973,22 @@ export default function MonitoringView() {
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
<td className="px-3 py-2 text-center">
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
<span
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title}
>
{sourceDisplay.label}
</span>
</div>
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label}
</div>
@@ -910,22 +1047,29 @@ 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>
</div>
</div>
<SourcePriorityControl priority={sourcePriority} onChange={setSourcePriority} />
<div className="flex items-center gap-2">
<div className="grid flex-1 grid-cols-3 gap-1.5">
<BatchMultiSelect options={filterOptions.targetNames} selected={filterTargetNames} onChange={setFilterTargetNames} placeholder="批次型号" />
@@ -1230,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">
@@ -1343,6 +1487,7 @@ export default function MonitoringView() {
{filteredVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
<motion.div
initial={{ opacity: 0 }}
@@ -1359,11 +1504,17 @@ export default function MonitoringView() {
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${v.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} title={v.isOnline ? '在线' : '离线'}></div>
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{v.isOnline ? '在线' : '离线'}
</span>
<span
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title}
>
{sourceDisplay.label}
</span>
</div>
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label}
@@ -1435,7 +1586,11 @@ export default function MonitoringView() {
<div ref={sentinelRef} className="h-1" />
</div>
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
<VehicleDetailModal
vehicle={detailVehicle}
onClose={() => setDetailVehicle(null)}
sourcePriority={sourcePriority}
/>
{/* 回到顶部按钮 */}
<AnimatePresence>

View File

@@ -4,13 +4,14 @@ import { X, Truck } from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
} from 'recharts';
import type { MonitoringVehicle } from './types';
import type { MileageSourceGroup, MonitoringVehicle } from './types';
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
import Blur from '../../components/Blur';
interface Props {
vehicle: MonitoringVehicle | null;
onClose: () => void;
sourcePriority: MileageSourceGroup[];
}
type RangeKey = 'last15' | 'month' | 'quarter';
@@ -56,7 +57,13 @@ function formatLabel(date: string, key: RangeKey): string {
return date.slice(5);
}
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
function daySourceLabel(day: VehicleRecentDay): string {
if (day.sourceCategory === 'INSTRUMENT') return '仪表数据';
if (day.sourceCategory === 'GPS') return 'GPS数据';
return day.isDataSynced ? '来源待接口' : '无数据';
}
export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) {
const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loading, setLoading] = useState(false);
const [range, setRange] = useState<RangeKey>('last15');
@@ -74,12 +81,12 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
setLoading(true);
setDays([]);
let cancelled = false;
fetchVehicleRecent(vehicle.plate, { start, end })
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
.then(d => { if (!cancelled) setDays(d.days); })
.catch(() => { if (!cancelled) setDays([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
}, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动
useEffect(() => {
@@ -280,7 +287,21 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
>
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
<div className="w-[88px] flex-shrink-0">
<div className="text-[11px] font-mono font-bold text-slate-600">{d.date}</div>
<div
className={`text-[8px] font-bold ${
d.sourceCategory === 'INSTRUMENT'
? 'text-violet-500'
: d.sourceCategory === 'GPS'
? 'text-emerald-500'
: 'text-amber-500'
}`}
title={d.sourceProtocol || 'OneOS 当前未返回 sourceProtocol'}
>
{daySourceLabel(d)}
</div>
</div>
<div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<motion.div

View File

@@ -1,11 +1,19 @@
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
import type {
MileageSourceCategory,
MileageSourceGroup,
MileageSourceProtocol,
MonitoringData,
TargetSummary,
TargetVehicle,
TrendPoint,
} from './types';
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;
@@ -25,6 +33,7 @@ export async function fetchMonitoring(params?: {
startDate?: string;
endDate?: string;
brands?: string[];
sourcePriority?: MileageSourceGroup[];
}): Promise<MonitoringData> {
const query = new URLSearchParams();
if (params?.sortBy) query.set('sortBy', params.sortBy);
@@ -56,6 +65,7 @@ export async function fetchMonitoring(params?: {
if (params?.date) query.set('date', params.date);
if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate);
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
const qs = query.toString();
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
}
@@ -82,6 +92,8 @@ export interface VehicleRecentDay {
date: string;
dailyKm: number;
isDataSynced: boolean;
sourceProtocol: MileageSourceProtocol | null;
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
}
export interface VehicleRecentResponse {
@@ -89,16 +101,23 @@ export interface VehicleRecentResponse {
start?: string;
end?: string;
days: VehicleRecentDay[];
sourcePriority?: MileageSourceGroup[];
}
export async function fetchVehicleRecent(
plate: string,
range: { days?: number; start?: string; end?: string } = { days: 15 },
range: {
days?: number;
start?: string;
end?: string;
sourcePriority?: MileageSourceGroup[];
} = { days: 15 },
): Promise<VehicleRecentResponse> {
const params = new URLSearchParams();
if (range.start) params.set('start', range.start);
if (range.end) params.set('end', range.end);
if (range.days != null) params.set('days', String(range.days));
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
return fetchJson<VehicleRecentResponse>(
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
);

View File

@@ -1,3 +1,7 @@
export type MileageSourceGroup = 'instrument' | 'gps';
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS' | 'MIXED';
export type MileageSourceProtocol = 'GB32960' | 'MQTT' | 'JT808';
export interface MonitoringVehicle {
plate: string;
vin: string;
@@ -5,6 +9,9 @@ export interface MonitoringVehicle {
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
sourceProtocol: MileageSourceProtocol | null;
sourceCategory: MileageSourceCategory | null;
dailySourceProtocols?: Record<string, MileageSourceProtocol | null>;
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
@@ -51,6 +58,7 @@ export interface MonitoringData {
page: number;
totalPages: number;
updatedAt: string;
sourcePriority: MileageSourceGroup[];
}
export interface TargetSummary {

View File

@@ -5,14 +5,21 @@ interface ExportContext {
date?: string;
startDate?: string;
endDate?: string;
sortBy: 'today' | 'total';
sortBy: 'today' | 'total' | 'statisticTime';
}
const BASE_HEADERS = [
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
'运营区域',
] as const;
function sourceLabel(v: MonitoringVehicle): string {
if (v.sourceCategory === 'INSTRUMENT') return '仪表数据';
if (v.sourceCategory === 'GPS') return 'GPS数据';
if (v.sourceCategory === 'MIXED') return '仪表数据+GPS数据';
return v.isDataSynced ? '来源待接口' : '无数据';
}
function statusLabel(v: MonitoringVehicle): string {
if (!v.isDataSynced) return '未对接';
return v.isOnline ? '在线' : '离线';
@@ -49,6 +56,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
...vehicles.map(v => {
const baseRow = [
statusLabel(v),
sourceLabel(v),
v.plate,
v.customer || '',
v.department || '',
@@ -71,6 +79,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
const numFixedCols = BASE_HEADERS.length;
const wsCols: { wch: number }[] = [
{ wch: 8 }, // 状态
{ wch: 16 }, // 数据来源
{ wch: 12 }, // 车牌号
{ wch: 28 }, // 客户
{ wch: 14 }, // 业务部门
@@ -111,7 +120,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
// 每日明细 sheet保留原有格式
if (dayKeys.length > 0) {
const detailHeaders = [
'车牌号', '客户', '业务部门', '项目', '租赁状态', '运营区域',
'车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域',
...dayKeys.map(day => `${day}里程(km)`),
'区间合计(km)',
'累计里程(km)',
@@ -120,6 +129,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
detailHeaders,
...vehicles.map(v => [
v.plate,
sourceLabel(v),
v.customer || '',
v.department || '',
v.project || '',
@@ -133,6 +143,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
detailWs['!cols'] = [
{ wch: 12 },
{ wch: 16 },
{ wch: 28 },
{ wch: 14 },
{ wch: 16 },
@@ -142,9 +153,9 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
{ wch: 14 },
{ wch: 14 },
];
detailWs['!freeze'] = { xSplit: 6, ySplit: 1 } as never;
detailWs['!freeze'] = { xSplit: 7, ySplit: 1 } as never;
for (let r = 1; r < detailData.length; r++) {
for (let c = 6; c < detailHeaders.length; c++) {
for (let c = 7; c < detailHeaders.length; c++) {
const ref = XLSX.utils.encode_cell({ r, c });
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########';
}
@@ -161,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

@@ -4,6 +4,11 @@ import { dirname, join } from 'node:path';
import pool from '../../db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js';
import {
sourceCategoryFromProtocol,
type MileageSourceCategory,
type OneOsProtocol,
} from './source-policy.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -64,6 +69,7 @@ interface MileageRow {
daily_km: string;
total_km: string | null;
source: string;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
@@ -75,6 +81,7 @@ interface DailyMileageRow {
date: string;
daily_km: string | number | null;
source: string | null;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
@@ -87,6 +94,7 @@ function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] {
daily_km: String(row.dailyMileageKm ?? 0),
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
@@ -190,6 +198,8 @@ function mergeVehicles(
// Never backfill it from another mileage source outside the assessment page.
totalKm: gpsTotal,
source,
sourceProtocol: m?.source_protocol || null,
sourceCategory: sourceCategoryFromProtocol(m?.source_protocol || null),
dataTime: m?.data_time || null,
calculatedAt: m?.calculated_at || null,
updatedAt: m?.updated_at || null,
@@ -248,10 +258,13 @@ export async function refreshMonitoringCache(): Promise<void> {
}
}
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
export async function queryDateMileage(
dateStr: string,
protocolPriority?: OneOsProtocol[],
): Promise<CachedVehicle[]> {
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsDailyMileage(dateStr),
fetchOneOsDailyMileage(previousDate(dateStr)),
fetchOneOsDailyMileage(dateStr, undefined, protocolPriority),
fetchOneOsDailyMileage(previousDate(dateStr), undefined, protocolPriority),
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
@@ -282,11 +295,16 @@ function datesBetween(start: string, end: string): string[] {
return result;
}
export async function queryRangeMileage(startDate: string, endDate: string): Promise<RangeMileageResult> {
export async function queryRangeMileage(
startDate: string,
endDate: string,
protocolPriority?: OneOsProtocol[],
): Promise<RangeMileageResult> {
if (startDate === endDate) {
const vehicles = (await queryDateMileage(startDate)).map(vehicle => ({
const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({
...vehicle,
dailyMileage: { [startDate]: vehicle.dailyKm },
dailySourceProtocols: { [startDate]: vehicle.sourceProtocol },
}));
return {
vehicles,
@@ -301,9 +319,9 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
const days = datesBetween(startDate, endDate);
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsMileageDates(days),
fetchOneOsDailyMileage(endDate),
fetchOneOsDailyMileage(previousDate(startDate)),
fetchOneOsMileageDates(days, undefined, protocolPriority),
fetchOneOsDailyMileage(endDate, undefined, protocolPriority),
fetchOneOsDailyMileage(previousDate(startDate), undefined, protocolPriority),
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
@@ -316,6 +334,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
date,
daily_km: row.dailyMileageKm,
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
@@ -324,12 +343,15 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
}
const perVehicleDaily = new Map<string, Record<string, number>>();
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
const perVehicleSum = new Map<string, {
plate: string;
vin: string;
daily_km: string;
total_km: string | null;
source: string;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
@@ -357,6 +379,15 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
const daily = perVehicleDaily.get(plate) || {};
daily[date] = km;
perVehicleDaily.set(plate, daily);
const dailySources = perVehicleDailySources.get(plate) || {};
dailySources[date] = row.source_protocol;
perVehicleDailySources.set(plate, dailySources);
const category = sourceCategoryFromProtocol(row.source_protocol);
if (category) {
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
categories.add(category);
perVehicleSourceCategories.set(plate, categories);
}
const existing = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
@@ -365,6 +396,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
daily_km: String((Number(existing?.daily_km) || 0) + km),
total_km: null,
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
source_protocol: row.source_protocol || existing?.source_protocol || null,
data_time: row.data_time || existing?.data_time || null,
calculated_at: row.calculated_at || existing?.calculated_at || null,
updated_at: row.updated_at || existing?.updated_at || null,
@@ -379,6 +411,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
...aggregate,
vin: endDateRow.vin || aggregate.vin,
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol,
data_time: endDateRow.dataTime || aggregate.data_time,
updated_at: endDateRow.updatedAt || aggregate.updated_at,
});
@@ -393,9 +426,20 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
buildPlateTargetNamesMap(targetRows),
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
const categories = perVehicleSourceCategories.get(vehicle.plate);
const completedDailyMileage: Record<string, number> = {};
const completedDailySources: Record<string, OneOsProtocol | null> = {};
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
return { ...vehicle, dailyMileage: completedDailyMileage };
for (const day of days) completedDailySources[day] = dailySources[day] || null;
return {
...vehicle,
dailyMileage: completedDailyMileage,
dailySourceProtocols: completedDailySources,
sourceCategory: categories && categories.size > 1
? 'MIXED' as const
: categories?.values().next().value || vehicle.sourceCategory,
};
});
return {

View File

@@ -3,6 +3,11 @@ import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import type { AuthUser } from '../../auth/types.js';
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
import {
DEFAULT_MILEAGE_SOURCE_PRIORITY,
parseMileageSourcePriority,
protocolsForSourcePriority,
} from './source-policy.js';
const app = new Hono();
@@ -14,6 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
page: 1,
totalPages: 1,
updatedAt: new Date().toISOString(),
sourcePriority: [...DEFAULT_MILEAGE_SOURCE_PRIORITY],
};
function applyFilters(vehicles: CachedVehicle[], params: {
@@ -98,12 +104,17 @@ 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') || '';
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
const protocolPriority = protocolsForSourcePriority(sourcePriority);
const filterParams = {
search: c.req.query('search') || '',
@@ -128,7 +139,7 @@ app.get('/', async (c) => {
if (range) {
try {
const result = await queryRangeMileage(range.start, range.end);
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals;
dateRange = { start: result.start, end: result.end };
@@ -139,7 +150,7 @@ app.get('/', async (c) => {
}
} else if (date) {
try {
allVehicles = await queryDateMileage(date);
allVehicles = await queryDateMileage(date, protocolPriority);
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring date query error:', e);
@@ -181,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;
@@ -200,6 +228,7 @@ app.get('/', async (c) => {
page,
totalPages: Math.ceil(total / limit),
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
sourcePriority,
});
});

View File

@@ -1,4 +1,9 @@
import dotenv from 'dotenv';
import {
DEFAULT_ONEOS_PROTOCOL_PRIORITY,
normalizeOneOsProtocol,
type OneOsProtocol,
} from './source-policy.js';
dotenv.config();
@@ -20,6 +25,7 @@ export interface OneOsDailyMileage {
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
sourceProtocol: OneOsProtocol | null;
}
interface OneOsMileageResponse {
@@ -42,6 +48,17 @@ interface CacheEntry {
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<OneOsDailyMileage[]>>();
const rangeInflight = new Map<string, Promise<Map<string, OneOsDailyMileage[]>>>();
let protocolPrioritySupported: boolean | null = null;
let protocolFallbackWarned = false;
function markProtocolPriorityUnsupported(): void {
protocolPrioritySupported = false;
if (protocolFallbackWarned) return;
protocolFallbackWarned = true;
console.warn(
'[mileage] OneOS does not support protocolPriority/sourceProtocol yet; using the legacy request contract',
);
}
function apiConfig(): { baseUrl: string; apiKey: string; timeoutMs: number } {
const baseUrl = (process.env.ONEOS_MILEAGE_API_BASE_URL || '').replace(/\/+$/, '');
@@ -94,6 +111,9 @@ function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage
const dataTime = stringField('dataTime', 'statisticTime', 'recordTime');
const calculatedAt = stringField('calculatedAt', 'calculationTime');
const updatedAt = stringField('updatedAt');
const sourceProtocol = normalizeOneOsProtocol(
stringField('sourceProtocol', 'protocol', 'dataSource'),
);
if (!plateNumber || date !== requestedDate) continue;
const normalized: OneOsDailyMileage = {
@@ -106,6 +126,7 @@ function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage
dataTime,
calculatedAt,
updatedAt,
sourceProtocol,
};
const existing = best.get(plateNumber);
if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) {
@@ -131,12 +152,18 @@ function normalizeRangeRows(value: unknown, startDate: string, endDate: string):
return result;
}
async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
async function requestDate(
date: string,
protocolPriority: OneOsProtocol[],
): Promise<OneOsDailyMileage[]> {
const { baseUrl, apiKey, timeoutMs } = apiConfig();
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const includeProtocolPriority = protocolPrioritySupported !== false;
const body: Record<string, unknown> = { date };
if (includeProtocolPriority) body.protocolPriority = protocolPriority;
const response = await fetch(`${baseUrl}${ENDPOINT}`, {
method: 'POST',
headers: {
@@ -145,10 +172,18 @@ async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
},
// Intentionally omit plateNumbers: the API then returns every vehicle
// authorized for this application on the requested natural day.
body: JSON.stringify({ date }),
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const payload = await response.json().catch(() => null) as OneOsMileageResponse | null;
if (
includeProtocolPriority &&
response.status === 400 &&
payload?.code === 'INVALID_REQUEST'
) {
markProtocolPriorityUnsupported();
continue;
}
if (!response.ok || payload?.code !== 'SUCCESS') {
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
const error = new Error(
@@ -157,6 +192,7 @@ async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
if (response.status < 500 || attempt === 2) throw error;
lastError = error;
} else {
if (includeProtocolPriority) protocolPrioritySupported = true;
return normalizeRows(payload.data, date);
}
} catch (error) {
@@ -171,7 +207,12 @@ async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
throw lastError instanceof Error ? lastError : new Error('OneOS mileage API request failed');
}
async function requestRange(startDate: string, endDate: string): Promise<Map<string, OneOsDailyMileage[]>> {
async function requestRange(
startDate: string,
endDate: string,
plateNumbers: string[],
protocolPriority: OneOsProtocol[],
): Promise<Map<string, OneOsDailyMileage[]>> {
const { baseUrl, apiKey, timeoutMs } = apiConfig();
const allRows: unknown[] = [];
const seenCursors = new Set<string>();
@@ -188,6 +229,9 @@ async function requestRange(startDate: string, endDate: string): Promise<Map<str
endDate,
pageSize: RANGE_PAGE_SIZE,
};
const includeProtocolPriority = protocolPrioritySupported !== false;
if (includeProtocolPriority) body.protocolPriority = protocolPriority;
if (plateNumbers.length > 0) body.plateNumbers = plateNumbers;
if (cursor) body.cursor = cursor;
const response = await fetch(`${baseUrl}${RANGE_ENDPOINT}`, {
method: 'POST',
@@ -199,6 +243,14 @@ async function requestRange(startDate: string, endDate: string): Promise<Map<str
signal: AbortSignal.timeout(timeoutMs),
});
payload = await response.json().catch(() => null) as OneOsMileageRangeResponse | null;
if (
includeProtocolPriority &&
response.status === 400 &&
payload?.code === 'INVALID_REQUEST'
) {
markProtocolPriorityUnsupported();
continue;
}
if (!response.ok || payload?.code !== 'SUCCESS') {
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
const error = new Error(
@@ -207,6 +259,7 @@ async function requestRange(startDate: string, endDate: string): Promise<Map<str
if (response.status < 500 || attempt === 2) throw error;
lastError = error;
} else {
if (includeProtocolPriority) protocolPrioritySupported = true;
break;
}
} catch (error) {
@@ -247,26 +300,29 @@ function trimCache(): void {
export async function fetchOneOsDailyMileage(
date: string,
plateNumbers?: string[],
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
): Promise<OneOsDailyMileage[]> {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
const hit = cache.get(date);
const policyKey = protocolPriority.join('>');
const cacheKey = `${date}:${policyKey}`;
const hit = cache.get(cacheKey);
let rows: OneOsDailyMileage[];
if (hit && hit.expiresAt > Date.now()) {
rows = hit.rows;
} else {
let pending = inflight.get(date);
let pending = inflight.get(cacheKey);
if (!pending) {
pending = requestDate(date).then(result => {
pending = requestDate(date, protocolPriority).then(result => {
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
cache.delete(date);
cache.delete(cacheKey);
if (ttl > 0) {
cache.set(date, { rows: result, expiresAt: Date.now() + ttl });
cache.set(cacheKey, { rows: result, expiresAt: Date.now() + ttl });
trimCache();
}
return result;
}).finally(() => inflight.delete(date));
inflight.set(date, pending);
}).finally(() => inflight.delete(cacheKey));
inflight.set(cacheKey, pending);
}
rows = await pending;
}
@@ -278,9 +334,14 @@ export async function fetchOneOsDailyMileage(
export async function fetchOneOsMileageDates(
dates: string[],
plateNumbers?: string[],
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
): Promise<Map<string, OneOsDailyMileage[]>> {
const result = new Map<string, OneOsDailyMileage[]>();
const uniqueDates = Array.from(new Set(dates)).sort();
const selectedPlates = Array.from(new Set(
(plateNumbers || []).map(plate => plate.trim()).filter(Boolean),
)).sort();
if (uniqueDates.length === 0) return result;
for (const date of uniqueDates) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
@@ -303,10 +364,20 @@ export async function fetchOneOsMileageDates(
await Promise.all(groups.map(async group => {
const startDate = group[0];
const endDate = group[group.length - 1];
const key = `${startDate}:${endDate}`;
const key = [
startDate,
endDate,
selectedPlates.length > 0 ? selectedPlates.join('\u0000') : '*',
protocolPriority.join('>'),
].join(':');
let pending = rangeInflight.get(key);
if (!pending) {
pending = requestRange(startDate, endDate).finally(() => rangeInflight.delete(key));
pending = requestRange(
startDate,
endDate,
selectedPlates,
protocolPriority,
).finally(() => rangeInflight.delete(key));
rangeInflight.set(key, pending);
}
const rowsByDate = await pending;

View File

@@ -0,0 +1,38 @@
export type MileageSourceGroup = 'instrument' | 'gps';
export type OneOsProtocol = 'GB32960' | 'MQTT' | 'JT808';
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS';
export const DEFAULT_MILEAGE_SOURCE_PRIORITY: MileageSourceGroup[] = ['instrument'];
export const DEFAULT_ONEOS_PROTOCOL_PRIORITY: OneOsProtocol[] = ['GB32960', 'MQTT'];
export function parseMileageSourcePriority(value?: string): MileageSourceGroup[] {
if (!value) return [...DEFAULT_MILEAGE_SOURCE_PRIORITY];
const groups = value.split(',')
.map(item => item.trim().toLowerCase())
.filter((item): item is MileageSourceGroup => item === 'instrument' || item === 'gps');
const unique = Array.from(new Set(groups));
return unique.length > 0 ? unique : [...DEFAULT_MILEAGE_SOURCE_PRIORITY];
}
export function protocolsForSourcePriority(groups: MileageSourceGroup[]): OneOsProtocol[] {
return groups.flatMap(group => group === 'instrument'
? ['GB32960', 'MQTT'] as OneOsProtocol[]
: ['JT808'] as OneOsProtocol[]);
}
export function normalizeOneOsProtocol(value: unknown): OneOsProtocol | null {
if (typeof value !== 'string') return null;
const normalized = value.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
if (normalized === 'GB32960' || normalized === '32960') return 'GB32960';
if (normalized === 'MQTT') return 'MQTT';
if (normalized === 'JT808' || normalized === '808') return 'JT808';
return null;
}
export function sourceCategoryFromProtocol(
protocol: OneOsProtocol | null,
): MileageSourceCategory | null {
if (protocol === 'GB32960' || protocol === 'MQTT') return 'INSTRUMENT';
if (protocol === 'JT808') return 'GPS';
return null;
}

View File

@@ -6,6 +6,9 @@ export interface CachedVehicle {
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
sourceCategory: 'INSTRUMENT' | 'GPS' | 'MIXED' | null;
dailySourceProtocols?: Record<string, 'GB32960' | 'MQTT' | 'JT808' | null>;
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
@@ -72,6 +75,7 @@ export interface MonitoringResponse {
page: number;
totalPages: number;
updatedAt: string;
sourcePriority: ('instrument' | 'gps')[];
}
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */

View File

@@ -1,5 +1,10 @@
import { Hono } from 'hono';
import { fetchOneOsMileageDates } from './oneos-api.js';
import {
parseMileageSourcePriority,
protocolsForSourcePriority,
sourceCategoryFromProtocol,
} from './source-policy.js';
const app = new Hono();
@@ -23,6 +28,8 @@ const MAX_DAYS = 366;
app.get('/:plate/recent', async (c) => {
const plate = c.req.param('plate');
if (!plate) return c.json({ plate: '', days: [] }, 400);
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
const protocolPriority = protocolsForSourcePriority(sourcePriority);
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -58,10 +65,16 @@ app.get('/:plate/recent', async (c) => {
dates.push(fmt(dateCursor));
dateCursor.setDate(dateCursor.getDate() + 1);
}
const rowsByDate = await fetchOneOsMileageDates(dates);
const rowsByDate = await fetchOneOsMileageDates(dates, [plate], protocolPriority);
// 补全:从 start 到 end 每天一条
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
const result: {
date: string;
dailyKm: number;
isDataSynced: boolean;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
sourceCategory: 'INSTRUMENT' | 'GPS' | null;
}[] = [];
const cursor = new Date(start);
while (cursor <= end) {
const key = fmt(cursor);
@@ -70,11 +83,19 @@ app.get('/:plate/recent', async (c) => {
date: key,
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
isDataSynced: hit?.status === 'NORMAL',
sourceProtocol: hit?.sourceProtocol || null,
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
});
cursor.setDate(cursor.getDate() + 1);
}
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
return c.json({
plate,
start: fmt(start),
end: fmt(end),
sourcePriority,
days: result,
});
} catch (e: unknown) {
console.error('vehicle recent error:', e);
return c.json({ plate, days: [] }, 500);