All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端 vehicle/:plate/recent 支持 start/end 任意区间,最长 366 天 - 前端弹窗加 segmented control: 近 15 天 / 本月 / 本季度,切换重新加载 - 加载时柱状图与每日明细均显示骨架,区间合计/日均/有数据天 KPI 同步骨架 - 数据回来后柱条与每行进度条带渐入动画 - 顶部加 iOS 风格 drag handle(小白条),按住下滑超过 100px 或大速度触发关闭 - 保留点击背景与 X 按钮两种关闭方式 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
3.0 KiB
TypeScript
102 lines
3.0 KiB
TypeScript
import { Hono } from 'hono';
|
||
import mileagePool from '../../mileage-db.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');
|
||
const dd = String(d.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${dd}`;
|
||
}
|
||
|
||
function parseYmd(s: string): Date | null {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||
if (!m) return null;
|
||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||
d.setHours(0, 0, 0, 0);
|
||
return Number.isFinite(d.getTime()) ? d : null;
|
||
}
|
||
|
||
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 today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
|
||
// 区间参数:优先 start/end;否则回退 days(兼容旧调用)
|
||
const startQ = c.req.query('start');
|
||
const endQ = c.req.query('end');
|
||
let start: Date;
|
||
let end: Date;
|
||
if (startQ) {
|
||
const ps = parseYmd(startQ);
|
||
if (!ps) return c.json({ plate, days: [] }, 400);
|
||
start = ps;
|
||
end = endQ ? (parseYmd(endQ) ?? today) : today;
|
||
} else {
|
||
const days = Math.min(Math.max(Number(c.req.query('days')) || 15, 1), MAX_DAYS);
|
||
end = today;
|
||
start = new Date(today);
|
||
start.setDate(today.getDate() - (days - 1));
|
||
}
|
||
if (start > end) [start, end] = [end, start];
|
||
// 限制区间长度
|
||
const span = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
|
||
if (span > MAX_DAYS) {
|
||
start = new Date(end);
|
||
start.setDate(end.getDate() - (MAX_DAYS - 1));
|
||
}
|
||
|
||
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 });
|
||
}
|
||
}
|
||
|
||
// 补全:从 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);
|
||
result.push({
|
||
date: key,
|
||
dailyKm: hit?.dailyKm ?? 0,
|
||
isDataSynced: !!hit && hit.source !== 'NONE',
|
||
});
|
||
cursor.setDate(cursor.getDate() + 1);
|
||
}
|
||
|
||
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
|
||
} catch (e: unknown) {
|
||
console.error('vehicle recent error:', e);
|
||
return c.json({ plate, days: [] }, 500);
|
||
}
|
||
});
|
||
|
||
export default app;
|