feat: integrate OneOS mileage APIs and release v1.1.10
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-07-23 12:19:11 +08:00
parent 4d523cbce0
commit 3f29bed5fa
14 changed files with 863 additions and 325 deletions

View File

@@ -20,6 +20,11 @@ services:
MILEAGE_DB_USER: "bi_reader_02"
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
MILEAGE_DB_NAME: "hydrogen_energy"
# ECS production accesses OneOS over the private network. Configure the key
# as a Portainer stack environment variable; never commit it to this file.
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}"
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"

View File

@@ -0,0 +1,129 @@
# OneOS 车辆里程接口需求
## 开发原则
1. 新增区间查询接口;车辆里程汇总能力在现有
`POST /api/v1/vehicles/mileage/query` 上兼容扩展。
2. 现有接口的路径、鉴权、已有请求参数、已有响应字段、字段语义和默认行为必须完全兼容。
3. 公网 `https://open.d.lnoneos.com` 与 ECS 内网 `http://172.17.111.55:20310` 使用相同路径、鉴权和数据口径。
4. 鉴权继续使用 `Authorization: Bearer APP_KEY`
5. 日期采用 `YYYY-MM-DD`,时区统一为 `Asia/Shanghai`,里程单位统一为 km。
## 1. 车辆区间日里程批量查询
```http
POST /api/v1/vehicles/mileage/range/query
```
请求:
```json
{
"startDate": "2026-07-01",
"endDate": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"],
"cursor": null,
"pageSize": 5000
}
```
规则:
- `startDate``endDate` 必填,最长支持 366 天;
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`
- 每辆车每天返回一条记录;
- 无数据返回 `status: "NO_DATA"``dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"``dailyMileageKm: 0`
响应:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "沪A00001",
"date": "2026-07-01",
"dailyMileageKm": 182.437,
"dataTime": "2026-07-01T23:58:45+08:00",
"updatedAt": "2026-07-02T05:10:00+08:00",
"status": "NORMAL"
}
],
"snapshotId": "mileage-20260723-001",
"nextCursor": null,
"traceId": "..."
}
```
## 2. 兼容扩展现有单日车辆里程接口
用于一次获取指定日期每辆车的日里程、累计里程和数据时间。
```http
POST /api/v1/vehicles/mileage/query
```
请求:
```json
{
"date": "2026-07-23",
"plateNumbers": ["沪A00001", "沪A00002"]
}
```
规则:
- `date``plateNumbers` 沿用现有接口规则;
- `plateNumbers` 省略时返回授权范围内全部车辆;
- 接口默认在现有车辆结果中返回 `totalMileageKm``dataTime``updatedAt`
- 只增加响应字段,不删除或修改任何现有字段及其语义;
- 历史日期返回该自然日最终有效结果;
- 查询当天返回当前最新结果;
- `dataTime` 必须是本次统计实际采用的最后一条车辆源数据时间,不能使用接口请求时间或响应时间代替;
- 无数据时相关里程字段返回 `null`,不能使用 0 代替;
- 接口应支持当前应用授权范围内的全部车辆。
响应:
```json
{
"code": "SUCCESS",
"message": "success",
"data": [
{
"vin": "LMRK...",
"plateNumber": "沪A00001",
"date": "2026-07-23",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.678,
"dataTime": "2026-07-23T10:35:42+08:00",
"updatedAt": "2026-07-23T10:35:46+08:00",
"status": "NORMAL"
}
],
"traceId": "..."
}
```
现有响应外层结构及已有字段保持不变,调用方无需增加任何请求参数。
## 通用要求
- `dataTime``updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
- 每个响应返回 `traceId`
- 接口返回值保留原始精度;
- 负里程不得作为正常数据返回;
- 公网和 ECS 内网使用同一 AppKey 时,授权范围和查询结果保持一致;
- 区间接口及现有接口的汇总扩展不得影响现有调用方和默认行为。
## 验收
1. 现有 `/api/v1/vehicles/mileage/query` 请求方式保持不变,回归测试全部通过。
2. 区间接口不传车牌可完整查询全部授权车辆,分页无重复、无漏行。
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
4. 正确区分无数据与真实零里程。
5. `dataTime` 能反映每辆车实际数据的新鲜度。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ln-bi",
"version": "1.1.7",
"version": "1.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.1.7",
"version": "1.1.10",
"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.9",
"version": "1.1.10",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",

View File

@@ -4,6 +4,7 @@ import {
Truck, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
RefreshCw,
} from 'lucide-react';
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
@@ -25,6 +26,72 @@ function defaultMileageDate(): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function parseOneOsTime(value: string | null | undefined): number | null {
if (!value) return null;
let normalized = value.trim().replace(' ', 'T');
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) normalized += '+08:00';
const timestamp = Date.parse(normalized);
return Number.isFinite(timestamp) ? timestamp : null;
}
function formatAbsoluteTime(value: string | null | undefined): string | null {
const timestamp = parseOneOsTime(value);
if (timestamp === null) return null;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(new Date(timestamp));
}
function vehicleStatisticTime(vehicle: MonitoringVehicle, nowMs: number): {
label: string;
title: string;
color: string;
} {
const primaryTime = vehicle.dataTime || vehicle.updatedAt || vehicle.calculatedAt;
const timestamp = parseOneOsTime(primaryTime);
if (timestamp === null) {
return {
label: vehicle.isDataSynced ? '统计时间待接口' : '无有效数据时间',
title: 'OneOS 当前未返回车辆级数据时间',
color: vehicle.isDataSynced ? 'text-amber-500' : 'text-slate-500',
};
}
const deltaSeconds = Math.floor((nowMs - timestamp) / 1000);
let label: string;
if (deltaSeconds < -60) label = '时间异常';
else if (deltaSeconds < 5) label = '刚刚';
else if (deltaSeconds < 60) label = `${deltaSeconds}秒前`;
else if (deltaSeconds < 3600) label = `${Math.floor(deltaSeconds / 60)}分钟前`;
else if (deltaSeconds < 86400) label = `${Math.floor(deltaSeconds / 3600)}小时前`;
else label = `${Math.floor(deltaSeconds / 86400)}天前`;
const absoluteDataTime = formatAbsoluteTime(vehicle.dataTime);
const absoluteCalculatedAt = formatAbsoluteTime(vehicle.calculatedAt);
const absoluteUpdatedAt = formatAbsoluteTime(vehicle.updatedAt);
const title = [
absoluteDataTime ? `数据时间:${absoluteDataTime}` : null,
absoluteCalculatedAt ? `计算时间:${absoluteCalculatedAt}` : null,
absoluteUpdatedAt ? `更新时间:${absoluteUpdatedAt}` : null,
].filter(Boolean).join('\n');
const ageSeconds = Math.max(0, deltaSeconds);
const color = ageSeconds <= 300
? 'text-emerald-500'
: ageSeconds <= 3600
? 'text-blue-500'
: ageSeconds <= 86400
? 'text-amber-500'
: 'text-rose-500';
return { label, title, color };
}
function normalizeRangeLabel(start: string, end: string): string {
if (!start && !end) return '最新数据';
if (start && end && start === end) return start;
@@ -35,12 +102,15 @@ function fmtDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
type RangePreset = 'today' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
type RangePreset = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15';
function getRangePreset(preset: RangePreset): { start: string; end: string } {
const end = new Date(`${defaultMileageDate()}T00:00:00`);
const start = new Date(end);
if (preset === 'thisWeek') {
if (preset === 'yesterday') {
start.setDate(start.getDate() - 1);
end.setDate(end.getDate() - 1);
} else if (preset === 'thisWeek') {
const day = start.getDay() || 7;
start.setDate(start.getDate() - day + 1);
} else if (preset === 'thisMonth') {
@@ -295,9 +365,17 @@ export default function MonitoringView() {
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [manualRefreshing, setManualRefreshing] = useState(false);
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
const [showBackToTop, setShowBackToTop] = useState(false);
const [relativeNow, setRelativeNow] = useState(() => Date.now());
const PAGE_SIZE = 50;
useEffect(() => {
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
return () => clearInterval(timer);
}, []);
const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates;
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
@@ -305,10 +383,6 @@ export default function MonitoringView() {
const averageDailyKm = rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
const topLoadedVehicle = useMemo(
() => vehicles.reduce<MonitoringVehicle | null>((best, vehicle) => (!best || vehicle.dailyKm > best.dailyKm ? vehicle : best), null),
[vehicles],
);
const applyRangePreset = useCallback((preset: RangePreset) => {
const range = getRangePreset(preset);
setRangeStart(range.start);
@@ -322,9 +396,9 @@ export default function MonitoringView() {
}, [filterTargetNames]);
// 加载首页数据
const loadFirstPage = useCallback(() => {
setPageLoading(true);
fetchMonitoring({
const loadFirstPage = useCallback((showPageLoading = true) => {
if (showPageLoading) setPageLoading(true);
return fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
@@ -353,9 +427,22 @@ export default function MonitoringView() {
setTotal(d.total);
setPage(1);
setHasMore(d.page < d.totalPages);
}).catch(() => {}).finally(() => setPageLoading(false));
setLastRefreshedAt(new Date());
}).catch(() => {}).finally(() => {
if (showPageLoading) setPageLoading(false);
});
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]);
const handleManualRefresh = useCallback(async () => {
if (manualRefreshing) return;
setManualRefreshing(true);
try {
await loadFirstPage(false);
} finally {
setManualRefreshing(false);
}
}, [loadFirstPage, manualRefreshing]);
// 加载更多
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
@@ -437,7 +524,7 @@ export default function MonitoringView() {
// 每分钟自动刷新
useEffect(() => {
const timer = setInterval(loadFirstPage, 60 * 1000);
const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000);
return () => clearInterval(timer);
}, [loadFirstPage]);
@@ -757,12 +844,18 @@ export default function MonitoringView() {
<tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
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 text-xs font-bold text-white"><Blur>{v.plate}</Blur></td>
<td className="px-3 py-2">
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label}
</div>
</td>
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
@@ -788,89 +881,61 @@ export default function MonitoringView() {
)}
</AnimatePresence>
{/* Ultra Compact Header - Two Rows */}
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-3">
{/* Top Row: Title & Sort */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-1 h-8 bg-blue-600 rounded-full"></div>
<div>
<div className="flex flex-col gap-3 rounded-2xl border border-gray-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div className="h-8 w-1 flex-shrink-0 rounded-full bg-blue-600" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-black text-slate-900 leading-none"></h1>
<button
onClick={toggleFullscreen}
className="p-1 text-slate-300 hover:text-blue-600 transition-colors"
title="全屏视图"
>
<h1 className="truncate text-lg font-black leading-none text-slate-900"></h1>
<button onClick={toggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
<Maximize2 size={14} />
</button>
<button
onClick={handleDownload}
disabled={exporting}
className="p-1 text-slate-300 hover:text-blue-600 transition-colors disabled:text-slate-200"
title="下载当前筛选结果"
>
<button onClick={handleDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
</button>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight"> 15</span>
<div className="mt-1 flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<span className="text-[9px] font-bold uppercase tracking-tight text-slate-400"> OneOS </span>
</div>
</div>
</div>
<div className="flex items-center gap-1 bg-slate-100 p-0.5 rounded-lg">
<div className="flex gap-0.5">
<button
onClick={() => setSortBy('today')}
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
>
</button>
<button
onClick={() => setSortBy('total')}
className={`px-2 py-1 text-[9px] font-bold rounded-md transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}
>
<div className="flex flex-shrink-0 items-center gap-1.5">
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
{lastRefreshedAt ? lastRefreshedAt.toLocaleTimeString('zh-CN', { hour12: false }) : '--:--:--'}
</span>
<button
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="仅刷新当前筛选结果"
>
<RefreshCw size={11} className={manualRefreshing ? 'animate-spin' : ''} />
<span className="hidden sm:inline">{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">
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
</button>
</div>
<div className="w-[1px] h-3 bg-slate-200 mx-0.5"></div>
<button
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="p-1 text-blue-600 hover:bg-white rounded-md transition-all"
>
{sortOrder === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
</button>
</div>
</div>
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
<div className="flex items-center gap-2">
<div className="flex-1 grid grid-cols-3 gap-1.5">
<BatchMultiSelect
options={filterOptions.targetNames}
selected={filterTargetNames}
onChange={setFilterTargetNames}
placeholder="批次型号"
/>
<SearchableSelect
options={filterOptions.regions}
value={filterRegion}
onChange={setFilterRegion}
placeholder="运营区域"
/>
<PlateMultiSelect
allPlates={plateNumbers}
selected={filterPlates}
onChange={setFilterPlates}
placeholder="按车牌"
/>
<div className="grid flex-1 grid-cols-3 gap-1.5">
<BatchMultiSelect options={filterOptions.targetNames} selected={filterTargetNames} onChange={setFilterTargetNames} placeholder="批次型号" />
<SearchableSelect options={filterOptions.regions} value={filterRegion} onChange={setFilterRegion} placeholder="运营区域" />
<PlateMultiSelect allPlates={plateNumbers} selected={filterPlates} onChange={setFilterPlates} placeholder="按车牌" />
</div>
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 || filterBrands.length > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
className={`flex-shrink-0 rounded-lg border p-1.5 transition-all ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 || filterBrands.length > 0 ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-transparent bg-slate-50 text-slate-400'}`}
title="高级筛选"
>
<Filter size={16} />
</button>
@@ -879,6 +944,7 @@ export default function MonitoringView() {
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
{([
['today', '今天'],
['yesterday', '昨天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last15', '近15天'],
@@ -890,19 +956,13 @@ export default function MonitoringView() {
key={preset}
type="button"
onClick={() => applyRangePreset(preset)}
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${
active
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'
}`}
className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${active ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm' : 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'}`}
>
{label}
</button>
);
})}
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">
{rangeLabel}
</span>
<span className="ml-auto shrink-0 rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-400">{rangeLabel}</span>
</div>
</div>
@@ -937,6 +997,7 @@ export default function MonitoringView() {
<div className="flex items-center gap-1.5 pt-1">
{([
['today', '今天'],
['yesterday', '昨天'],
['thisWeek', '本周'],
['thisMonth', '本月'],
['last7', '近7天'],
@@ -1150,15 +1211,16 @@ export default function MonitoringView() {
setRangeStart(today); setRangeEnd(today);
};
return (
<div className="flex items-center gap-2 flex-wrap">
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
<span className="mr-1 text-[10px] font-black text-slate-500"></span>
{tags.map((tag, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-bold">
<span key={i} className="inline-flex items-center gap-1 rounded-md bg-blue-50 px-2 py-1 text-[10px] font-bold text-blue-600">
{tag.label}
<button onClick={tag.onClear} className="hover:text-blue-800 ml-0.5">&times;</button>
</span>
))}
<button onClick={clearAll} className="text-[10px] font-bold text-rose-500 hover:text-rose-600 ml-auto">
</button>
</div>
);
@@ -1167,19 +1229,19 @@ export default function MonitoringView() {
{/* Sticky header: KPI + 清单标题 */}
<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="col-span-2 bg-slate-900 p-2.5 rounded-xl text-white relative overflow-hidden">
<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-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></>}
</div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div>
<div className="bg-white p-2.5 rounded-xl border border-gray-100 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-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-[7px] text-slate-400">km/</div>
</div>
<div className="bg-white p-2.5 rounded-xl border border-gray-100 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-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : stats.vehicleCount}</div>
<div className="text-[7px] text-slate-400"></div>
@@ -1223,42 +1285,35 @@ export default function MonitoringView() {
</div>
</div>
) : (
<div className="grid grid-cols-[minmax(0,1fr)_76px_76px] items-center gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_96px_96px]">
<div className="min-w-0">
<div className="grid min-h-[64px] grid-cols-[minmax(0,1fr)_86px_86px] items-stretch gap-2 px-3 py-2 md:grid-cols-[minmax(0,1fr)_110px_110px]">
<div className="flex min-w-0 flex-col justify-center">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-700">
<CalendarDays size={12} className="text-blue-500" />
</div>
<div className="mt-1 truncate text-[9px] font-bold text-slate-400">{rangeLabel} · km</div>
<div className="mt-1.5 rounded-lg bg-slate-50 px-2 py-1 ring-1 ring-slate-100">
<div className="text-[8px] font-black text-slate-400"></div>
{topLoadedVehicle ? (
<div className="mt-0.5 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<span className="whitespace-nowrap text-[10px] font-black text-slate-700">{topLoadedVehicle.plate}</span>
<span className="whitespace-nowrap text-[10px] font-black text-blue-600 tabular-nums">
{topLoadedVehicle.dailyKm.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km
</span>
</div>
) : (
<div className="mt-0.5 text-[10px] font-black text-slate-300">-</div>
)}
</div>
</div>
<div className="rounded-lg bg-blue-50 px-2 py-1.5 text-right">
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-blue-400"></div>
<div className="text-xs font-black text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
</div>
<div className="rounded-lg bg-slate-50 px-2 py-1.5 text-right">
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-slate-400"></div>
<div className="text-xs font-black text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between px-2">
<div className="flex items-center justify-between px-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span>
<span className="text-[9px] font-bold text-slate-300">{total} </span>
</div>
<div className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid">
<span> / </span>
<span> / </span>
<span className="text-right">{isRangeMode ? '区间里程' : '当日里程'}</span>
<span className="text-right"></span>
</div>
</div>
{/* Vehicle List */}
@@ -1287,15 +1342,16 @@ export default function MonitoringView() {
<div className="grid grid-cols-1 gap-1.5">
{filteredVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
key={v.plate}
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 cursor-pointer transition-all"
className="grid cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 shadow-sm transition-all hover:border-blue-200 hover:shadow-md active:bg-slate-50 md:grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] md:px-4"
onClick={() => setDetailVehicle(v)}
>
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="flex min-w-0 items-center gap-3 overflow-hidden">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
@@ -1309,13 +1365,34 @@ export default function MonitoringView() {
{v.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
{statisticTime.label}
</div>
<div className="flex items-center gap-1.5 md:hidden">
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
</div>
</div>
</div>
<div className="text-right flex-shrink-0 ml-2 flex flex-col items-end">
<div className="hidden min-w-0 md:block">
<div className="truncate text-xs font-bold text-slate-700"><Blur>{v.customer || '-'}</Blur></div>
<div className="mt-1 truncate text-[9px] font-medium text-slate-400">
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
</div>
</div>
<div className="hidden text-right md:block">
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
{Math.max(0, v.dailyKm || 0).toLocaleString()}
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
</div>
</div>
<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>
</div>
</div>
<div className="flex flex-shrink-0 flex-col items-end text-right md:hidden">
<div className="flex items-center gap-1 mb-0.5">
{!v.isDataSynced && v.totalKm == null && (
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>

View File

@@ -5,6 +5,9 @@ export interface MonitoringVehicle {
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;

View File

@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -64,6 +64,9 @@ interface MileageRow {
daily_km: string;
total_km: string | null;
source: string;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}
interface DailyMileageRow {
@@ -72,6 +75,49 @@ interface DailyMileageRow {
date: string;
daily_km: string | number | null;
source: string | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}
function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] {
return rows.map(row => ({
plate: row.plateNumber,
vin: row.vin,
daily_km: String(row.dailyMileageKm ?? 0),
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
}));
}
function previousDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
const value = new Date(Date.UTC(year, month - 1, day));
value.setUTCDate(value.getUTCDate() - 1);
return value.toISOString().slice(0, 10);
}
function shanghaiDate(): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
}
function dailyMileageMap(rows: OneOsDailyMileage[]): Map<string, number> {
const map = new Map<string, number>();
for (const row of rows) {
if (row.status !== 'NORMAL') continue;
const km = Math.max(0, Number(row.dailyMileageKm) || 0);
const existing = map.get(row.plateNumber) || 0;
if (km > existing) map.set(row.plateNumber, km);
}
return map;
}
export interface RangeMileageResult {
@@ -116,56 +162,10 @@ function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]
return map;
}
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULLG7 只回传日增量),
// 业务库 lingniu_prod.tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
// 用它兜底保证 totalKm 汇总完整。
const [rows] = await pool.execute(
'SELECT plate_number, vehicle_total_mileage FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.vehicle_total_mileage);
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
}
return map;
}
async function fetchLatestPgTotalMileageMap(asOf?: string): Promise<Map<string, number>> {
// 当日 ln_vehicle_day_total_pg 无记录或 total_mileage 为 NULL 时,
// 回填该车 dates <= asOf 的最近一条非空 total_mileage÷1000 转 km
// 让视图 total_km 为 NULL 的车也能显示历史累计。
// MySQL 5.7 无窗口函数,用 GROUP BY MAX(dates) + JOIN 取每车最近一条。
const sql = `
SELECT t.plate_number, t.total_mileage
FROM ln_vehicle_day_total_pg t
INNER JOIN (
SELECT plate_number, MAX(dates) AS max_dates
FROM ln_vehicle_day_total_pg
WHERE total_mileage IS NOT NULL
${asOf ? 'AND dates <= ?' : ''}
GROUP BY plate_number
) m ON m.plate_number = t.plate_number AND m.max_dates = t.dates
WHERE t.total_mileage IS NOT NULL`;
const params = asOf ? [asOf] : [];
const [rows] = await mileagePool.execute(sql, params) as [
{ plate_number: string; total_mileage: string | number | null }[],
unknown,
];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.total_mileage) / 1000;
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
}
return map;
}
function mergeVehicles(
mileageRows: MileageRow[],
infoMap: Map<string, VehicleInfoRow>,
yesterdayMap: Map<string, number>,
bizTotalMap: Map<string, number>,
latestPgTotalMap: Map<string, number>,
targetNamesByPlate: Map<string, string[]>,
): CachedVehicle[] {
const mileageMap = new Map<string, MileageRow>();
@@ -182,14 +182,17 @@ function mergeVehicles(
const dailyKm = Number(m?.daily_km) || 0;
const source = m?.source || 'NONE';
const gpsTotal = m?.total_km != null ? Number(m.total_km) : null;
const latestPgTotal = latestPgTotalMap.get(plate);
const bizTotal = bizTotalMap.get(plate);
return {
plate,
vin: m?.vin || info.vin || '',
dailyKm,
totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null),
// The OneOS daily mileage API does not currently return cumulative mileage.
// Never backfill it from another mileage source outside the assessment page.
totalKm: gpsTotal,
source,
dataTime: m?.data_time || null,
calculatedAt: m?.calculated_at || null,
updatedAt: m?.updated_at || null,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info.customer || null,
@@ -212,44 +215,23 @@ export async function refreshMonitoringCache(): Promise<void> {
console.log('[mileage] refreshing monitoring cache...');
const start = Date.now();
const [mileageRows, yesterdayMap, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
(async () => {
const [dateRows] = await mileagePool.execute(
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
) as [{ latest: string | null }[], unknown];
const latestDate = dateRows[0]?.latest;
if (!latestDate) return [];
const [rows] = await mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[latestDate]
) as [MileageRow[], unknown];
return rows;
})(),
(async () => {
const [rows] = await mileagePool.execute(
`SELECT plate, daily_km FROM v_vehicle_daily_stats
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
) as [{ plate: string; daily_km: string }[], unknown];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.daily_km) || 0;
const existing = map.get(r.plate) || 0;
if (km > existing) map.set(r.plate, km);
}
return map;
})(),
const date = shanghaiDate();
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsDailyMileage(date),
fetchOneOsDailyMileage(previousDate(date)),
fetchVehicleInfoMap(),
fetchTargetRows(),
fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(),
]);
const mileageRows = toMileageRows(apiRows);
const yesterdayMap = dailyMileageMap(yesterdayRows);
const targetPlatesMap = buildTargetPlatesMap(targetRows);
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
const targetNames = Array.from(targetPlatesMap.keys());
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap, targetNamesByPlate);
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, targetNamesByPlate);
const totalToday = Math.round(vehicles.reduce((sum, v) => sum + v.dailyKm, 0));
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
monitoringCache = {
@@ -267,34 +249,20 @@ export async function refreshMonitoringCache(): Promise<void> {
}
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
const [mileageRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[dateStr]
).then(([r]) => r as MileageRow[]),
mileagePool.execute(
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
[dateStr]
).then(([r]) => r as { plate: string; daily_km: string }[]),
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsDailyMileage(dateStr),
fetchOneOsDailyMileage(previousDate(dateStr)),
fetchVehicleInfoMap(),
fetchTargetRows(),
fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(dateStr),
]);
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
}
const mileageRows = toMileageRows(apiRows);
const yesterdayMap = dailyMileageMap(yesterdayRows);
return mergeVehicles(
mileageRows,
infoMap,
yesterdayMap,
bizTotalMap,
latestPgTotalMap,
buildPlateTargetNamesMap(targetRows),
);
}
@@ -315,31 +283,57 @@ function datesBetween(start: string, end: string): string[] {
}
export async function queryRangeMileage(startDate: string, endDate: string): Promise<RangeMileageResult> {
if (startDate === endDate) {
const vehicles = (await queryDateMileage(startDate)).map(vehicle => ({
...vehicle,
dailyMileage: { [startDate]: vehicle.dailyKm },
}));
return {
vehicles,
dailyTotals: [{
date: startDate,
totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
}],
start: startDate,
end: endDate,
};
}
const days = datesBetween(startDate, endDate);
const [dailyRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
mileagePool.execute(
`SELECT plate,
DATE_FORMAT(stat_date, '%Y-%m-%d') AS date,
vin,
daily_km,
source
FROM v_vehicle_daily_stats
WHERE stat_date >= ? AND stat_date <= ?
ORDER BY stat_date, plate`,
[startDate, endDate]
).then(([r]) => r as DailyMileageRow[]),
mileagePool.execute(
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
[startDate]
).then(([r]) => r as { plate: string; daily_km: string }[]),
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsMileageDates(days),
fetchOneOsDailyMileage(endDate),
fetchOneOsDailyMileage(previousDate(startDate)),
fetchVehicleInfoMap(),
fetchTargetRows(),
fetchBizTotalMileageMap(),
fetchLatestPgTotalMileageMap(endDate),
]);
const dailyRows: DailyMileageRow[] = [];
for (const [date, apiRows] of apiRowsByDate) {
for (const row of apiRows) {
dailyRows.push({
plate: row.plateNumber,
vin: row.vin,
date,
daily_km: row.dailyMileageKm,
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
});
}
}
const perVehicleDaily = new Map<string, Record<string, number>>();
const perVehicleSum = new Map<string, { plate: string; vin: string; daily_km: string; total_km: null; source: string }>();
const perVehicleSum = new Map<string, {
plate: string;
vin: string;
daily_km: string;
total_km: string | null;
source: string;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}>();
const dailyTotals = new Map<string, number>();
const bestDailyRows = new Map<string, DailyMileageRow>();
@@ -371,22 +365,31 @@ 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'),
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,
});
}
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
for (const [plate, aggregate] of perVehicleSum) {
const endDateRow = endDateMap.get(plate);
if (!endDateRow) continue;
perVehicleSum.set(plate, {
...aggregate,
vin: endDateRow.vin || aggregate.vin,
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
data_time: endDateRow.dataTime || aggregate.data_time,
updated_at: endDateRow.updatedAt || aggregate.updated_at,
});
}
const yesterdayMap = dailyMileageMap(yesterdayRows);
const vehicles = mergeVehicles(
Array.from(perVehicleSum.values()),
infoMap,
yesterdayMap,
bizTotalMap,
latestPgTotalMap,
buildPlateTargetNamesMap(targetRows),
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
@@ -397,7 +400,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
return {
vehicles,
dailyTotals: days.map(date => ({ date, totalKm: dailyTotals.get(date) || 0 })),
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
start: startDate,
end: endDate,
};

View File

@@ -28,6 +28,7 @@ function applyFilters(vehicles: CachedVehicle[], params: {
const q = params.search.toLowerCase();
result = result.filter(v =>
v.plate.toLowerCase().includes(q) ||
v.vin.toLowerCase().includes(q) ||
(v.customer || '').toLowerCase().includes(q) ||
(v.project || '').toLowerCase().includes(q)
);
@@ -168,12 +169,12 @@ app.get('/', async (c) => {
if (rangeDailyTotals && filtered.length !== allVehicles.length) {
rangeDailyTotals = rangeDailyTotals.map(item => ({
...item,
totalKm: filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0),
totalKm: Math.round(filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0)),
}));
}
const stats = {
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
totalToday: Math.round(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),

View File

@@ -0,0 +1,321 @@
import dotenv from 'dotenv';
dotenv.config();
const ENDPOINT = '/api/v1/vehicles/mileage/query';
const RANGE_ENDPOINT = '/api/v1/vehicles/mileage/range/query';
const DEFAULT_TIMEOUT_MS = 20_000;
const CURRENT_DAY_TTL_MS = 0;
const HISTORICAL_TTL_MS = 6 * 60 * 60 * 1000;
const MAX_CACHE_DATES = 400;
const RANGE_PAGE_SIZE = 5000;
export interface OneOsDailyMileage {
vin: string;
plateNumber: string;
date: string;
dailyMileageKm: number | null;
totalMileageKm: number | null;
status: 'NORMAL' | 'NO_DATA';
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
}
interface OneOsMileageResponse {
code?: string;
message?: string;
data?: unknown;
traceId?: string;
}
interface OneOsMileageRangeResponse extends OneOsMileageResponse {
snapshotId?: string;
nextCursor?: string | null;
}
interface CacheEntry {
expiresAt: number;
rows: OneOsDailyMileage[];
}
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<OneOsDailyMileage[]>>();
const rangeInflight = new Map<string, Promise<Map<string, OneOsDailyMileage[]>>>();
function apiConfig(): { baseUrl: string; apiKey: string; timeoutMs: number } {
const baseUrl = (process.env.ONEOS_MILEAGE_API_BASE_URL || '').replace(/\/+$/, '');
const apiKey = process.env.ONEOS_MILEAGE_API_KEY || '';
const timeoutMs = Number(process.env.ONEOS_MILEAGE_API_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
if (!baseUrl) throw new Error('ONEOS_MILEAGE_API_BASE_URL is not configured');
if (!apiKey) throw new Error('ONEOS_MILEAGE_API_KEY is not configured');
return { baseUrl, apiKey, timeoutMs };
}
function shanghaiDate(): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
}
function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage[] {
if (!Array.isArray(value)) throw new Error('OneOS mileage API returned a non-array data field');
const best = new Map<string, OneOsDailyMileage>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const vin = typeof row.vin === 'string' ? row.vin.trim() : '';
const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : '';
const date = typeof row.date === 'string' ? row.date : requestedDate;
const status = row.status === 'NORMAL' ? 'NORMAL' : 'NO_DATA';
const rawKm = row.dailyMileageKm;
const numericKm = rawKm === null || rawKm === undefined ? null : Number(rawKm);
const dailyMileageKm = status === 'NORMAL' && numericKm !== null && Number.isFinite(numericKm)
? Math.max(0, numericKm)
: null;
const rawTotalKm = row.totalMileageKm;
const numericTotalKm = rawTotalKm === null || rawTotalKm === undefined ? null : Number(rawTotalKm);
const totalMileageKm = status === 'NORMAL' && numericTotalKm !== null && Number.isFinite(numericTotalKm)
? Math.max(0, numericTotalKm)
: null;
const stringField = (...names: string[]): string | null => {
for (const name of names) {
const field = row[name];
if (typeof field === 'string' && field.trim()) return field.trim();
}
return null;
};
// dataTime is the preferred contract. Aliases keep the BI forward-compatible
// while the Open API rolls out the formal field name.
const dataTime = stringField('dataTime', 'statisticTime', 'recordTime');
const calculatedAt = stringField('calculatedAt', 'calculationTime');
const updatedAt = stringField('updatedAt');
if (!plateNumber || date !== requestedDate) continue;
const normalized: OneOsDailyMileage = {
vin,
plateNumber,
date,
dailyMileageKm,
totalMileageKm,
status,
dataTime,
calculatedAt,
updatedAt,
};
const existing = best.get(plateNumber);
if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) {
best.set(plateNumber, normalized);
}
}
return Array.from(best.values());
}
function normalizeRangeRows(value: unknown, startDate: string, endDate: string): Map<string, OneOsDailyMileage[]> {
if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field');
const rawByDate = new Map<string, unknown[]>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const date = (item as Record<string, unknown>).date;
if (typeof date !== 'string' || date < startDate || date > endDate) continue;
const rows = rawByDate.get(date) || [];
rows.push(item);
rawByDate.set(date, rows);
}
const result = new Map<string, OneOsDailyMileage[]>();
for (const [date, rows] of rawByDate) result.set(date, normalizeRows(rows, date));
return result;
}
async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
const { baseUrl, apiKey, timeoutMs } = apiConfig();
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const response = await fetch(`${baseUrl}${ENDPOINT}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
// Intentionally omit plateNumbers: the API then returns every vehicle
// authorized for this application on the requested natural day.
body: JSON.stringify({ date }),
signal: AbortSignal.timeout(timeoutMs),
});
const payload = await response.json().catch(() => null) as OneOsMileageResponse | null;
if (!response.ok || payload?.code !== 'SUCCESS') {
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
const error = new Error(
`OneOS mileage API failed: HTTP ${response.status}, code=${payload?.code || 'UNKNOWN'}${trace}`,
);
if (response.status < 500 || attempt === 2) throw error;
lastError = error;
} else {
return normalizeRows(payload.data, date);
}
} catch (error) {
lastError = error;
const nonRetryable = error instanceof Error && /HTTP (400|401|403)/.test(error.message);
if (nonRetryable || attempt === 2) throw error;
}
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt));
}
throw lastError instanceof Error ? lastError : new Error('OneOS mileage API request failed');
}
async function requestRange(startDate: string, endDate: string): Promise<Map<string, OneOsDailyMileage[]>> {
const { baseUrl, apiKey, timeoutMs } = apiConfig();
const allRows: unknown[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
let snapshotId: string | null = null;
for (let page = 0; page < 10_000; page += 1) {
let payload: OneOsMileageRangeResponse | null = null;
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const body: Record<string, unknown> = {
startDate,
endDate,
pageSize: RANGE_PAGE_SIZE,
};
if (cursor) body.cursor = cursor;
const response = await fetch(`${baseUrl}${RANGE_ENDPOINT}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
payload = await response.json().catch(() => null) as OneOsMileageRangeResponse | null;
if (!response.ok || payload?.code !== 'SUCCESS') {
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
const error = new Error(
`OneOS mileage range API failed: HTTP ${response.status}, code=${payload?.code || 'UNKNOWN'}${trace}`,
);
if (response.status < 500 || attempt === 2) throw error;
lastError = error;
} else {
break;
}
} catch (error) {
lastError = error;
const nonRetryable = error instanceof Error && /HTTP (400|401|403)/.test(error.message);
if (nonRetryable || attempt === 2) throw error;
}
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt));
}
if (!payload || payload.code !== 'SUCCESS') {
throw lastError instanceof Error ? lastError : new Error('OneOS mileage range API request failed');
}
if (!Array.isArray(payload.data)) throw new Error('OneOS mileage range API returned a non-array data field');
if (snapshotId && payload.snapshotId !== snapshotId) {
throw new Error('OneOS mileage range API snapshot changed during pagination');
}
snapshotId = payload.snapshotId || snapshotId;
allRows.push(...payload.data);
const nextCursor = payload.nextCursor || null;
if (!nextCursor) return normalizeRangeRows(allRows, startDate, endDate);
if (seenCursors.has(nextCursor)) throw new Error('OneOS mileage range API returned a repeated cursor');
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new Error('OneOS mileage range API exceeded the pagination safety limit');
}
function trimCache(): void {
while (cache.size > MAX_CACHE_DATES) {
const oldestKey = cache.keys().next().value as string | undefined;
if (!oldestKey) break;
cache.delete(oldestKey);
}
}
export async function fetchOneOsDailyMileage(
date: string,
plateNumbers?: string[],
): Promise<OneOsDailyMileage[]> {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
const hit = cache.get(date);
let rows: OneOsDailyMileage[];
if (hit && hit.expiresAt > Date.now()) {
rows = hit.rows;
} else {
let pending = inflight.get(date);
if (!pending) {
pending = requestDate(date).then(result => {
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
cache.delete(date);
if (ttl > 0) {
cache.set(date, { rows: result, expiresAt: Date.now() + ttl });
trimCache();
}
return result;
}).finally(() => inflight.delete(date));
inflight.set(date, pending);
}
rows = await pending;
}
if (!plateNumbers?.length) return rows;
const selected = new Set(plateNumbers.map(plate => plate.trim()).filter(Boolean));
return rows.filter(row => selected.has(row.plateNumber));
}
export async function fetchOneOsMileageDates(
dates: string[],
): Promise<Map<string, OneOsDailyMileage[]>> {
const result = new Map<string, OneOsDailyMileage[]>();
const uniqueDates = Array.from(new Set(dates)).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}`);
result.set(date, []);
}
const groups: string[][] = [];
for (const date of uniqueDates) {
const current = groups[groups.length - 1];
if (!current) {
groups.push([date]);
continue;
}
const previous = new Date(`${current[current.length - 1]}T00:00:00Z`);
previous.setUTCDate(previous.getUTCDate() + 1);
if (previous.toISOString().slice(0, 10) === date && current.length < 366) current.push(date);
else groups.push([date]);
}
await Promise.all(groups.map(async group => {
const startDate = group[0];
const endDate = group[group.length - 1];
const key = `${startDate}:${endDate}`;
let pending = rangeInflight.get(key);
if (!pending) {
pending = requestRange(startDate, endDate).finally(() => rangeInflight.delete(key));
rangeInflight.set(key, pending);
}
const rowsByDate = await pending;
for (const date of group) result.set(date, rowsByDate.get(date) || []);
}));
return result;
}
export function clearOneOsMileageCache(): void {
cache.clear();
rangeInflight.clear();
}

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { getCache } from './cache.js';
import { fetchOneOsDailyMileage } from './oneos-api.js';
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
@@ -202,7 +202,7 @@ app.get('/', async (c) => {
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
periods,
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0),
todayTotal: Math.round((targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0)),
cumulativeTotal: Number(s.cumulative_total) || 0,
avgCompletion: (Number(s.avg_completion) || 0) * 100,
qualifiedCount: Number(s.qualified_count) || 0,
@@ -256,20 +256,15 @@ app.get('/:id/vehicles', async (c) => {
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (date && plates.length > 0) {
const [mileageRows] = await mileagePool.execute(
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
[date, ...plates]
) as [any[], unknown];
const mileageRows = await fetchOneOsDailyMileage(date, plates);
for (const m of mileageRows) {
const existing = dateMileageMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
const existing = dateMileageMap.get(m.plateNumber);
const dailyKm = m.status === 'NORMAL' ? (Number(m.dailyMileageKm) || 0) : 0;
if (!existing || dailyKm > existing.dailyKm) {
const source = m.source || 'NONE';
dateMileageMap.set(m.plate, {
dateMileageMap.set(m.plateNumber, {
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
isOnline: source !== 'NONE' && dailyKm > 0,
totalKm: m.totalMileageKm,
isOnline: m.status === 'NORMAL' && dailyKm > 0,
});
}
}

View File

@@ -1,12 +1,12 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchOneOsMileageDates } from './oneos-api.js';
const app = new Hono();
app.get('/', async (c) => {
const targetId = c.req.query('targetId');
const days = Number(c.req.query('days')) || 7;
const days = Math.min(Math.max(Number(c.req.query('days')) || 7, 1), 366);
try {
let plates: string[] = [];
@@ -19,27 +19,23 @@ app.get('/', async (c) => {
if (plates.length === 0) return c.json([]);
}
// 单车日里程负值视为脏数据(里程表回滚 / 换 GPS 设备),不纳入统计
let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date,
SUM(IF(daily_km < 0, 0, daily_km)) as mileage
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
`;
const params: (string | number)[] = [days];
if (plates.length > 0) {
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
params.push(...plates);
const today = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
today.setHours(0, 0, 0, 0);
const dates: string[] = [];
for (let offset = days; offset >= 1; offset -= 1) {
const date = new Date(today);
date.setDate(today.getDate() - offset);
dates.push(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`);
}
sql += ' GROUP BY stat_date ORDER BY stat_date';
const [rows] = await mileagePool.execute(sql, params) as [any[], unknown];
return c.json(rows.map((r: any) => ({
date: r.date,
mileage: Math.round(Number(r.mileage) || 0),
const rowsByDate = await fetchOneOsMileageDates(dates);
const selectedPlates = plates.length > 0 ? new Set(plates) : null;
return c.json(dates.map(date => ({
date: date.slice(5),
mileage: Math.round((rowsByDate.get(date) || []).reduce((sum, row) => {
if (selectedPlates && !selectedPlates.has(row.plateNumber)) return sum;
return sum + (row.status === 'NORMAL' ? (row.dailyMileageKm || 0) : 0);
}, 0)),
})));
} catch (e: unknown) {
console.error('trend error:', e);

View File

@@ -6,6 +6,9 @@ export interface CachedVehicle {
dailyMileage?: Record<string, number>;
totalKm: number | null;
source: string;
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;

View File

@@ -1,14 +1,8 @@
import { Hono } from 'hono';
import mileagePool from '../../mileage-db.js';
import { fetchOneOsMileageDates } from './oneos-api.js';
const app = new Hono();
interface DayRow {
date: string;
daily_km: string | number | null;
source: string | null;
}
function fmt(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
@@ -58,35 +52,24 @@ app.get('/:plate/recent', async (c) => {
}
try {
const [rows] = await mileagePool.execute(
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
FROM v_vehicle_daily_stats
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
ORDER BY stat_date`,
[plate, fmt(start), fmt(end)]
) as [DayRow[], unknown];
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
const map = new Map<string, { dailyKm: number; source: string }>();
for (const r of rows) {
const km = Number(r.daily_km) || 0;
const src = r.source || 'NONE';
const existing = map.get(r.date);
if (!existing || km > existing.dailyKm) {
map.set(r.date, { dailyKm: km, source: src });
}
const dates: string[] = [];
const dateCursor = new Date(start);
while (dateCursor <= end) {
dates.push(fmt(dateCursor));
dateCursor.setDate(dateCursor.getDate() + 1);
}
const rowsByDate = await fetchOneOsMileageDates(dates);
// 补全:从 start 到 end 每天一条
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
const cursor = new Date(start);
while (cursor <= end) {
const key = fmt(cursor);
const hit = map.get(key);
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
result.push({
date: key,
dailyKm: hit?.dailyKm ?? 0,
isDataSynced: !!hit && hit.source !== 'NONE',
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
isDataSynced: hit?.status === 'NORMAL',
});
cursor.setDate(cursor.getDate() + 1);
}

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
import { fetchOneOsMileageDates } from '../mileage/oneos-api.js';
import { mapRegion } from '../vehicles.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { classifyVehicle, generateSuggestions } from './algorithm.js';
@@ -42,6 +42,24 @@ function classifyVehicleType(typeName: string, _modelRaw: string): string {
return t || '其他';
}
function recentCompletedDates(count: number): string[] {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const [year, month, day] = today.split('-').map(Number);
const base = new Date(Date.UTC(year, month - 1, day));
const dates: string[] = [];
for (let offset = count; offset >= 1; offset -= 1) {
const date = new Date(base);
date.setUTCDate(base.getUTCDate() - offset);
dates.push(date.toISOString().slice(0, 10));
}
return dates;
}
// ---------------------------------------------------------------------------
// Route
// ---------------------------------------------------------------------------
@@ -114,29 +132,33 @@ app.get('/', async (c) => {
// ---- Collect all plates for Query 6 ----
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
// ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ----
// ---- Query 6: Customer daily avg (OneOS only) — 30d baseline + 7d recent ----
const customerAvgDailyMap = new Map<string, number>();
const customerAvgDaily7dMap = new Map<string, number>();
if (allPlates.length > 0) {
const placeholders = allPlates.map(() => '?').join(',');
// Single query returning both windows per plate.
const [dailyRows] = await mileagePool.execute(
`SELECT plate,
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND stat_date < CURDATE()
AND plate IN (${placeholders})
GROUP BY plate`,
allPlates,
) as [any[], unknown];
const dates = recentCompletedDates(30);
const sevenDayDates = new Set(dates.slice(-7));
const allowedPlates = new Set(allPlates);
const rowsByDate = await fetchOneOsMileageDates(dates);
const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>();
for (const [date, rows] of rowsByDate) {
for (const row of rows) {
if (!allowedPlates.has(row.plateNumber) || row.status !== 'NORMAL' || row.dailyMileageKm === null) continue;
const current = aggregates.get(row.plateNumber) || { sum30: 0, count30: 0, sum7: 0, count7: 0 };
current.sum30 += row.dailyMileageKm;
current.count30 += 1;
if (sevenDayDates.has(date)) {
current.sum7 += row.dailyMileageKm;
current.count7 += 1;
}
aggregates.set(row.plateNumber, current);
}
}
const plateAvg30Map = new Map<string, number>();
const plateAvg7Map = new Map<string, number>();
for (const row of dailyRows) {
if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
for (const [plate, values] of aggregates) {
if (values.count30 > 0) plateAvg30Map.set(plate, values.sum30 / values.count30);
if (values.count7 > 0) plateAvg7Map.set(plate, values.sum7 / values.count7);
}
const customerPlates30 = new Map<string, number[]>();