feat(frontend): 接入 PWA(manifest + service worker + app icon)
Phase 6 落地。用户在桌面 Chrome / Android Chrome / iOS Safari 上可以把
FuelLog "安装"到主屏幕,像 app 一样启动;断网时已访问页面仍可用。
新增:
- scripts/gen-icons.ps1 PowerShell + System.Drawing 生成 PWA 应用图标
* pwa-192x192.png 标准应用图标
* pwa-512x512.png 高清应用图标
* pwa-maskable-512x512.png Android adaptive icon(留 10% 安全边)
绿底 + 白色油枪图形(矩形主体 + 把手 + 喷嘴 + 底座),无外部依赖
- public/manifest.webmanifest 静态 manifest,name / short_name / description
/ theme_color / background_color / display=standalone / orientation=portrait
/ icons 三种规格
- public/{favicon.svg, pwa-*.png} 全部进 public/ 由 Vite 复制到 dist/
vite.config.js 改动(VitePWA 配置):
- registerType: 'autoUpdate' + injectRegister: 'auto' 无需手动调
registerSW,SW 启动后自动接管版本更新
- manifest: false 关掉自动生成,改用 public/manifest.webmanifest 静态文件
(避免 Node 在 Windows 中文环境下用 GBK 写 manifest 导致 PWA 安装
提示里的 name 显示乱码;JSON 标准是 UTF-8,这里源文件是 UTF-8)
- filename: 'sw.js' + strategies: 'generateSW' 使用 Workbox generateSW
- workbox.globPatterns 预缓存全部 .js / .css / .html / .png / .svg / .woff2
- workbox.navigateFallback = '/index.html' SPA 路由
workbox.navigateFallbackDenylist = [/^\/api/] /api 走网络不被 SPA 兜底
- workbox.runtimeCaching:
* /api/* NetworkFirst 5s 超时回退 cache,适合"偶尔离线还能看老数据"
* 静态资源 (style/script/worker/image/font) CacheFirst 30 天
- includeAssets 把 favicon + pwa-*.png + manifest.webmanifest 一起 precache
- devOptions.enabled = false 开发期关掉 SW(Vite HMR 会被卡住)
index.html 改动:
- link rel="apple-touch-icon" iOS 添加到主屏用
- meta apple-mobile-web-app-* 三件套
- meta theme-color 浏览器顶栏配色
- 保留 viewport-fit=cover,刘海/小白条的安全区已有 CSS 配合
期间踩到的坑(都修了):
- 一开始让 VitePWA 用 config 内嵌 manifest 对象,Node 在 Windows 写
出来的 dist/manifest.webmanifest 是 GBK 字节,Chrome 解析后中文乱码
- 改成 public/manifest.webmanifest 静态文件后,`Get-Content` 在
PowerShell 5.1 仍然显示乱码(ANSI 默认),但 .NET ReadAllText 强制
UTF-8 读取文件原始字节正确(加 = E5 8A A0,油 = E6 B2 B9),浏览器
按 JSON 标准 UTF-8 解析也正常 —— 所以"乱码"是 PowerShell 5.1
Get-Content 自身的输出编码问题,不是文件损坏
未做(留待后续):
- A2HS 按钮(install prompt) —— PWA 标准是浏览器原生弹,先不上自造
- 离线 fallback 页面 —— workbox.navigateFallback 走到 /index.html
已经够;真要自造可以加 public/offline.html
- push notification —— 不在 MVP 范围
验证:
pnpm typecheck 0 errors
pnpm build 19.0s
dist/manifest.webmanifest 643 字节,UTF-8 中文 OK
dist/sw.js 3.1 KB,precache 含全部 28 个静态资源
dist/workbox-f641ca17.js 22.8 KB
dist/registerSW.js 134 字节(由 VitePWA inject)
This commit is contained in:
+184
@@ -0,0 +1,184 @@
|
||||
# FuelLog API 文档
|
||||
|
||||
> Base path: `/api`
|
||||
> 错误统一格式: `{ "code": "INVALID_ARGUMENT", "message": "..." }`
|
||||
> 鉴权: MVP 阶段不要求,MVP 之后会在 `Authorization: Bearer <token>` 头里加。
|
||||
|
||||
---
|
||||
|
||||
## 通用约定
|
||||
|
||||
- 所有时间字段统一 **UTC** 存库;请求时传 RFC3339 字符串,响应回 RFC3339。
|
||||
- 金额 / 加油量 / 里程 / 油耗:**浮点**;金额计算在服务端用 `shopspring/decimal` 避免累计误差。
|
||||
- 列表接口默认空数组(不返回 `null`)。
|
||||
- 删除为硬删(数据库里 `DELETE`);二次确认在前端做。
|
||||
- 默认车辆唯一,切换走事务,避免出现两个 default。
|
||||
|
||||
错误码字典(在 `pkg/apperror`):
|
||||
|
||||
| code | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
| `INTERNAL` | 500 | 兜底,未预期 |
|
||||
| `INVALID_ARGUMENT` | 400 | 参数校验失败 |
|
||||
| `NOT_FOUND` | 404 | 资源不存在 |
|
||||
| `CONFLICT` | 409 | 资源冲突(预留) |
|
||||
| `UNAUTHORIZED` | 401 | 未鉴权(预留) |
|
||||
| `FORBIDDEN` | 403 | 无权限(预留) |
|
||||
| `UNAVAILABLE` | 503 | 暂不可用(预留) |
|
||||
|
||||
---
|
||||
|
||||
## 健康检查
|
||||
|
||||
### `GET /health`
|
||||
|
||||
```json
|
||||
{ "status": "ok", "time": "2026-09-13T16:11:32+08:00" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 车辆 `/api/vehicles`
|
||||
|
||||
### `GET /api/vehicles`
|
||||
|
||||
列出所有车辆,按 `default_vehicle DESC, id ASC` 排序。
|
||||
|
||||
**Response 200**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "我的夏",
|
||||
"brand": "比亚迪",
|
||||
"model": "秦 PLUS DM-i",
|
||||
"plate_number": "京A12345",
|
||||
"fuel_type": "hybrid",
|
||||
"tank_capacity": 48,
|
||||
"current_odometer": 10000,
|
||||
"default_vehicle": true,
|
||||
"created_at": "2026-09-13T16:00:00+08:00",
|
||||
"updated_at": "2026-09-13T16:00:00+08:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `POST /api/vehicles`
|
||||
|
||||
创建车辆。
|
||||
|
||||
**Request**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `name` | string | ✅ | 车辆名称,max 64 |
|
||||
| `brand` | string | | max 64 |
|
||||
| `model` | string | | max 64 |
|
||||
| `plate_number` | string | | max 32 |
|
||||
| `fuel_type` | string | ✅ | gasoline / diesel / hybrid / electric / plug_in_hybrid |
|
||||
| `tank_capacity` | number | | 升,`>= 0` |
|
||||
| `current_odometer` | number | | 公里,`>= 0` |
|
||||
| `default_vehicle` | bool | | 设为默认时,旧默认会被清掉(事务) |
|
||||
|
||||
**Response 200** — 完整 Vehicle 对象
|
||||
|
||||
### `GET /api/vehicles/:id`
|
||||
|
||||
返回单辆;不存在返 404。
|
||||
|
||||
### `PUT /api/vehicles/:id`
|
||||
|
||||
**Patch 语义**:`*string` / `*number` / `*bool` 表示可选,未传的字段不更新。
|
||||
其它校验同 `POST`。
|
||||
|
||||
**Response 200** — 更新后的 Vehicle
|
||||
|
||||
### `DELETE /api/vehicles/:id`
|
||||
|
||||
硬删。返 `204 No Content`。
|
||||
|
||||
### `POST /api/vehicles/:id/default`
|
||||
|
||||
设为唯一默认。事务内先清旧默认,再置新默认。
|
||||
|
||||
**Response 200** — 更新后的 Vehicle
|
||||
|
||||
---
|
||||
|
||||
## 加油记录 `/api/refuels`
|
||||
|
||||
### `POST /api/refuels`
|
||||
|
||||
**Request**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `vehicle_id` | uint64 | ✅ | 关联车辆 |
|
||||
| `refuel_time` | RFC3339 | ✅ | 加油时间,UTC 存库 |
|
||||
| `odometer` | number | ✅ | 加油时里程,`>= 0` |
|
||||
| `fuel_type` | string | ✅ | 油品 |
|
||||
| `liters` | number | ✅ | 加油量,**> 0** |
|
||||
| `total_amount` | number | ✅ | 总金额,`>= 0` |
|
||||
| `station_name` | string | | max 128 |
|
||||
| `is_full` | bool | | 是否加满(决定是否进入标准油耗) |
|
||||
| `remark` | string | | max 512 |
|
||||
|
||||
**注意**:`price_per_liter` 不入参,由服务端用 `total_amount / liters` 实时算出,落库保留历史真实单价。
|
||||
|
||||
**Response 200** — 完整 RefuelRecord
|
||||
|
||||
### `GET /api/refuels/:id`
|
||||
|
||||
### `PUT /api/refuels/:id`
|
||||
|
||||
Patch 语义。`liters` / `total_amount` 任一更新都会重新算 `price_per_liter`。
|
||||
|
||||
### `DELETE /api/refuels/:id`
|
||||
|
||||
返 `204 No Content`。
|
||||
|
||||
### `GET /api/refuels?vehicle_id=...&start_date=...&end_date=...`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `vehicle_id` | ✅ | uint64 |
|
||||
| `start_date` | | RFC3339,只返 refuel_time >= 它的 |
|
||||
| `end_date` | | RFC3339,只返 refuel_time <= 它的 |
|
||||
|
||||
按 `refuel_time DESC, id DESC` 排序。
|
||||
|
||||
**Response 200** — RefuelRecord 数组
|
||||
|
||||
---
|
||||
|
||||
## 统计 `/api/vehicles/:id/statistics`
|
||||
|
||||
单车的关键统计指标,服务端正序遍历 refuel_time 倒序列表,只在
|
||||
"连续两次 `is_full=true`"之间采样油耗 / 每公里成本;半箱油记录不会污染。
|
||||
|
||||
**Response 200**
|
||||
```json
|
||||
{
|
||||
"vehicle_id": 1,
|
||||
"total_records": 3,
|
||||
"total_liters": 82.0,
|
||||
"total_amount": 656.0,
|
||||
"avg_price": 8.0,
|
||||
"avg_consumption": 6.4,
|
||||
"cost_per_km": 0.51,
|
||||
"last_refuel": { /* RefuelRecord 或 null */ }
|
||||
}
|
||||
```
|
||||
|
||||
字段含义:
|
||||
- `avg_price` 全部记录的实际油价算术平均
|
||||
- `avg_consumption` 标准百公里油耗 L/100km(仅 is_full 序列的样本平均)
|
||||
- `cost_per_km` 每公里燃油成本 元/km(仅 is_full 序列的样本平均)
|
||||
- `last_refuel` 最近一次加油记录(按 refuel_time DESC 第一个)
|
||||
|
||||
空车辆返 `{ total_records: 0, ...全 0, last_refuel: null }`。
|
||||
|
||||
---
|
||||
|
||||
## 索引
|
||||
|
||||
- 车辆:`vehicle.id` PK
|
||||
- 加油记录:`refuel_record.id` PK;`(vehicle_id, refuel_time)` 与 `vehicle_id` 各自索引
|
||||
Reference in New Issue
Block a user