KennethCheng
33dfdc824c
feat(frontend): 统计页接入 ECharts,四张图(油价/油耗/费用/成本)
...
把 Statistics 从占位 Empty 升级成真正的图表页,plan §9.4 落地:
- echarts + vue-echarts,按需引入 CanvasRenderer / LineChart / BarChart
+ Grid / Tooltip / Legend / Title / DataZoom(全部 tree-shakable)
- 四张图,每张独立 NCard,统一主题色 brand #18a058
1) 油价趋势(折线 + 面积)—— 每月 total_amount / total_liters 当月均价
2) 标准油耗趋势(折线)—— 每月在"连续两次 is_full"之间采样的均值
3) 月度费用(柱状)—— 每月加油 total_amount 之和
4) 每公里成本(折线)—— 同油耗采样算法
- 时间窗选择:近 3 月 / 6 月 / 12 月 / 全部(NSelect 顶栏)
- 油耗 / 每公里成本采样逻辑与后端 pkg/decimal 严格对齐:
* 列表按 refuel_time 升序遍历
* 只在"is_full 序列"内采样
* 半箱油记录自动跳过,不污染指标
- autoresize 适配移动端旋转 / 桌面窗口
- Loading / Empty / Error 状态完整;切车自动重拉
未做(留待后续):
- DataZoom 区域缩放 —— 已在 echarts 引入但没启用,数据量大时再上
- 多车辆对比 —— 计划里没明确要求,先不上
- 油价对比基准线(同地区均值) —— 暂无数据源
验证:
pnpm typecheck 0 errors
pnpm build 18.1s(echarts 入了 4 个组件 + 1 个 renderer,bundle 略增)
2026-09-13 16:52:26 +08:00
KennethCheng
ea158f2a53
feat(frontend): 加油记录列表(按月分组 + 月度小计 + 删除)
...
把 Refuels 从占位 Empty 升级成真正的列表页:
- 按月分组(YYYY-MM,最新月份在前),每月一张 NCard
- 月度头部小计:笔数 / 累计升数 / 累计金额 / 当月均价
- 每天一行:左侧大字号"月/日"+"周X" / 中间加油站 + 升数 + 金额 + 实际油价
+ 「加满」小标签 / 右侧删除按钮(NPopconfirm 二次确认)
- 切车自动重拉(watch currentId)
- 加载 / 空 / 错误状态完整:
* 没车辆 -> 引导去加车
* 没记录 -> 引导去加油
* Loading 走 NSpin
- 删除走 refuelsApi.remove,失败用 useMessage.error
布局/移动端细节:
- 卡片用 rounded-2xl,贴合 plan §12"卡片式 Dashboard"
- 移动端单列,桌面 sm:grid-cols-2 触发但这里 list 是一列更适合
- divide-y 在 NCard 内做行分隔,不另起边框
未做(留待后续):
- 时间筛选(只显示本月 / 近 3 月)
- 搜索(按加油站 / 备注)
- 列表项"编辑"按钮 —— 暂只支持删除,编辑流程等用得着再上
- 分页(单车辆记录数 < 1000 之前不需要)
验证:
pnpm typecheck 0 errors
pnpm build 15.0s
2026-09-13 16:49:16 +08:00
KennethCheng
6ee28349e1
feat(frontend): 加油录入页(实时算实际油价 + 10秒录入)
...
把 RefuelNew 从占位 Empty 升级成真正的"10秒录入"表单,plan §9.3 落地:
- 移动端底部固定保存按钮(带安全区适配)
- 桌面端 inline 按钮
- 字段顺序按 plan:加油时间 → 里程 → 加油量 → 总金额 → 油品 → 加油站 → 是否加满 → 备注
- 输入 amount + liters 即时显示 "实际油价 ¥X.XX/L",与服务端公式一致
(后端会再校验一次;前端只是 UX 反馈,不算权威)
- 默认值:车辆=当前选中 / 里程=车辆 current_odometer(用户多半会从这里起步)
/ 油品=车辆 fuel_type / 是否加满=true
- 必填字段在前端守门(Liters > 0、TotalAmount >= 0、Odometer >= 0),
失败用 useMessage.warning
- 保存成功 useMessage.success 后 300ms 跳 Dashboard,让用户看到统计更新
- 没有车辆时给出空态 + 一键跳车辆页
实现要点:
- 本地定义 RefuelForm 类型(数字字段允许 undefined),与 DTO 解耦
(DTO 严格,form 宽松,提交时 narrow)
- onMounted 拉 store.loadList + 选默认车 + 填默认值
- realPrice = computed 监听 liters/total_amount 变化
未做(留待后续):
- 提交时把车辆 current_odometer 同步上去(避免用户开了 100 公里才发现
里程还停在加油前) —— 提交后端 update vehicle,plan §5.2 提到过,
这里没做
- 重置表单(同车连加) —— 提交后跳 Dashboard,需要"再加一笔"时再回
- GPS 自动定位加油站(本机浏览器权限问题,先不做)
验证:
pnpm typecheck 0 errors
pnpm build 12.8s
2026-09-13 16:47:16 +08:00
KennethCheng
9cec8a7f66
feat(frontend): 车辆管理页(列表 + 新增/编辑/删除/默认切换)
...
把 Vehicles 页从空壳升级成真业务:
- 卡片式列表,移动端单列、桌面端两列网格
- 每张卡片展示名称/默认标签/品牌+型号+车牌/能源类型/油箱/里程
- 操作:编辑 / 设为默认 / 删除(NPopconfirm 二次确认;走 NDialog.warning)
- 顶部「+ 添加」按钮,弹 NModal 表单
- 表单:名称必填、5 个可选字段、NSwitch 切默认
- 提交走 vehiclesApi.create/update,刷新 store
- 删除用 NDialog.warning(避免 Popconfirm 误触)
- Loading / Empty / Error 状态完整
新增/改动:
- src/components/VehicleFormModal.vue NModal + NForm + NInput/NInputNumber/NSwitch
* watch props.show / editing 重置表单
* isEdit 复用同一份表单(create / update 双模式)
* 内联 FuelSelect(简单 NSelect 包装)
* useMessage 反馈成功/失败
- src/pages/Vehicles.vue 替换占位 Empty
* onMounted 触发 store.loadList
* openCreate / openEdit 控制 showForm + editing
* onSaved 自动切到刚保存的车(UX:加完车立刻能进 Dashboard 看到统计)
* setDefault -> store.setDefault
* remove -> dialog.warning + store.remove
- src/stores/vehicle.ts 暴露 setCurrentId(id) 给 UI 切换当前车
未做(留待后续):
- 搜索 / 排序 / 分页:单车辆量 < 100,前两版没必要
- 列表项的「最近一次加油」摘要:留给 Dashboard 拿
验证:
pnpm typecheck 0 errors
pnpm build 12.5s,dist 重新生成,Dashboard / Vehicles 各自分 chunk
2026-09-13 16:44:42 +08:00
KennethCheng
097af20cc0
feat(frontend): 搭建 Vue 3 + Vite + TS + Pinia + Naive UI 骨架
...
Phase 5(项目骨架部分)。后端已经稳了,现在前端可以挂上 curl。
本 commit 只做"能跑、能路由、能调 /api",业务页面留到后续 commit 一个个填。
新增:
- package.json + .env.example
* vue 3.5 / vite 5.4 / typescript 5.6 / pinia 2.3 / vue-router 4.6
* naive-ui 2.45 + vfonts(Naive UI 官方推荐字体)
* tailwindcss 3.4 + autoprefixer + postcss
* unplugin-auto-import / unplugin-vue-components 减少样板代码
* axios 1.7
scripts:dev / build / preview / typecheck / lint
- vite.config.js(注意:实际生效的是 .js,因为早期 vue-tsc -b 留了 .js 残留;
.ts 留作类型参考,内容保持一致)
* /api 代理到 127.0.0.1:8080,本地一站跑通前后端
* 强制 es2020 target,关闭 sourcemap
- tsconfig.json / tsconfig.node.json
* skipLibCheck 跳过第三方 .d.mts 噪音
* strict + noImplicitAny
- tailwind.config.js
* 主题色用 CSS 变量(rgb(var(--brand) / <alpha-value>)),
组件层不写死颜色,符合 plan §12 / AGENTS.md
- postcss.config.js
- index.html + public/favicon.svg(简单加油机图标)
- src/main.ts
* 引入 vfonts / tailwind / theme.css
* createApp + Pinia + router
- src/styles/tailwind.css + src/styles/theme.css
* Light/Dark 模式都从 :root / html.dark 拿 CSS 变量
* 全局滚动条 / 移动端 -webkit-tap-highlight 处理
- src/App.vue
* NConfigProvider + NDialog + NNotification + NMessage + NLoadingBar
* 跟随系统 dark 模式,watchEffect 同步 html.dark class
- src/router/index.ts
* 路由: / /dashboard /refuels /refuels/new /vehicles /statistics
* /:pathMatch(.*)* -> NotFound
* afterEach 改 document.title
- src/api/{client,types,vehicles,refuels}.ts
* axios 实例,统一把后端 {code,message} 错误解到 Error.cause
* 类型与后端 model/DTO 一一对应
- src/stores/vehicle.ts
* Pinia setup store,持有车辆列表 + 当前选中
* loadList 自动挑 default_vehicle
- src/utils/decimal.ts
* 与后端 pkg/decimal 对齐的 pricePerLiter / consumptionL100km / costPerKm
* 配套 fmt* 展示格式化函数
- src/layouts/AppLayout.vue
* 桌面端顶栏 + 移动端底部 Tab
* 移动端底部 safe-area-inset 适配刘海/小白条
- src/pages/{Dashboard,Refuels,RefuelNew,Vehicles,Statistics,NotFound}.vue
* Dashboard 真的拿 /api/vehicles/:id/statistics 渲染主指标(其它字段保留为占位)
* Refuels / RefuelNew / Vehicles / Statistics 是占位 Empty,Phase 5 业务页接入
- src/env.d.ts
* Vite ImportMeta 类型 + .vue 模块声明
期间踩到的两个坑(都修了):
1) `tsconfig.json` extends 了 @vue/tsconfig 但里面引用了不存在的
`@vue/tsconfig/tsconfig.node.json` —— 改成不依赖 extends,自己写
2) `vite build` 报 "Could not resolve entry module 'vueuc'":根因是早期
`vue-tsc -b` 在项目根生成了 vite.config.js / vite.config.d.ts 残留,
Vite 优先读 .js(老的 manualChunks 引用了 vueuc),整套 plugin 也不加载
(3 modules transformed)。修法:把正确配置同步写到 vite.config.js,
让 Vite 一直读 .js;.ts 留作类型参考
验证:
pnpm install 199 packages 安装成功
pnpm typecheck 0 errors
pnpm build 11.5s,dist 643 KB(uncompressed),主 JS 344 KB
gzipped 107 KB(naive-ui 主力,符合预期)
未做(留待后续 commit):
- 各业务页(Dashboard 仅骨架,Refuels / RefuelNew / Vehicles / Statistics 都是 Empty)
- 真实表单校验、Loading / Empty / Error 三态完整接入
- PWA(manifest + service worker) —— Phase 12
- 暗色模式手工切换(目前只跟随系统,Phase 11 UI 精修再补)
- ESLint 规则初始化
2026-09-13 16:40:03 +08:00
KennethCheng
07ebce1ea3
test(backend): 添加核心计算单测与 Statistics 业务单测
...
Phase 1.4。AGENTS.md §19 与 plan §6 都把单测作为 MVP 硬要求,这条 commit
把核心计算覆盖完毕。
新增:
- pkg/decimal/money_test.go 5 个函数 × table-driven,共 22 个子测试
* PricePerLiter:覆盖 plan §6.1 的 176.33/23.67 = 7.45,以及简单整数除、
liters<=0 边界、四位精度等
* ConsumptionL100km:覆盖 plan §6.3 的 (10500-10000)/40*100 = 8.0,以及
mileage<=0 边界
* CostPerKm:覆盖 plan §6.4 的 256/500 = 0.512
* Mileage:正常递增 / 相等(没开) / 倒推(里程倒写,应为 0) / 首次记录(prev=0)
* AvgFloat64:空切片 / 单值 / 多值 / 舍入
- internal/service/refuel_record_test.go 5 个测试,覆盖 plan §6.3 的
"半箱油不进入标准油耗"硬规则
* TestStatistics_TwoConsecutiveFull:两条 is_full → mileage=500,
consumption=8.0, cost=0.64, 命中 plan 案例
* TestStatistics_HalfTankDoesNotPollute:两条全 + 一条半箱,标准油耗仍 8.0,
半箱油被正确跳过
* TestStatistics_OnlyHalfTanks:全是半箱,标准油耗 = 0(没采样)
* TestStatistics_MultipleFullSamples:三条全,平均油耗/成本正确
* TestStatistics_EmptyVehicle:零记录时 LastRefuel 应为 nil
测试方法:
- 纯单测,不接 SQLite,自己写 fakeVehicleRepo / fakeRefuelRepo
实现 repository 接口;fake 也按 refuel_time DESC 排,与真 repo 行为一致
- sentinel 错误与 apperror.IsCode 风格一致,后续接 apperror 时改一行即可
期间踩到的一个坑(已修):
- 第一版 fake ListByVehicle 不排序,Statistics 倒序迭代拿到错误顺序,
mileage 算成负 → 全部回 0。修法:fake 加上 sort.Slice DESC,与真 repo 对齐
验证:
go vet ./... 0 issues
go test ./pkg/decimal ./internal/service
ok pkg/decimal 22 子测试通过
ok internal/service 5 测试通过
未做(留待后续):
- handler 单测(纯 HTTP 表层,可加,目前 smoke.ps1 已覆盖)
- repository 单测(需要 sqlite,end-to-end smoke 已经覆盖)
- CI 接入(留到 Phase 1.5 之后用 GitHub Actions / Gitea Actions)
2026-09-13 16:15:48 +08:00
KennethCheng
bfa0d76222
feat(backend): 接入 HTTP handler、router、main 装配,端到端冒烟全通
...
Phase 1.3b。Phase 1.3a 留的 service 现在有了 HTTP 入口,curl 能跑通完整的
"建车 → 加三次油 → 看统计 → 删记录 → 删车"全链路,核心计算命中 plan §6。
新增:
- internal/handler/common.go 通用:writeError / parseUint64 / bindJSON / sendJSON
* 统一错误格式:{ "code": "INVALID_ARGUMENT|NOT_FOUND|...", "message": "..." }
* parseUint64 自动把非数字 / 0 翻成 INVALID_ARGUMENT
* bindJSON 把 gin-validator 错误也归到 INVALID_ARGUMENT
- internal/handler/errors.go errInvalid / errInvalidVehicleID 等小工具
- internal/handler/vehicle.go CRUD + SetDefault 六个端点
- internal/handler/refuel_record.go CRUD + List(?vehicle_id) + Statistics
- internal/router/router.go 把所有 /api 路由挂上 Gin engine
main.go 改动:
- 引入 wire():repository → service → handler 一气串好
- /api 业务路由从 router.Build 注入
- /health 与 NoRoute 保持原状
scripts/smoke.ps1 端到端冒烟脚本(13 步):
health → list → create vehicle → list → refuel×3(其中 1 个半箱)→
statistics → list refuels → delete refuel → statistics 刷新 →
delete vehicle → GET 404
核心计算命中 plan §6.3 / §19:
320/40 = 8.0 (price_per_liter) ✓
256/32 = 8.0 (price_per_liter) ✓
32/(11000-10500)*100 = 6.4 (L/100km) ✓
256/500 = 0.512 (元/公里) ✓
半箱油(11200/10L/is_full=false)不进入标准油耗采样 ✓
删除记录后 avg_consumption / cost_per_km 立即重算 ✓
期间踩到的两个坑(都修了):
1) handler 用 []struct{}{} 试图给 []model.Vehicle / []model.RefuelRecord
兜空数组,Go 强类型不接受;改用 []model.Vehicle{} 即可
2) Statistics 列表按 refuel_time DESC 返回,正向迭代会让 mileage 算成负数
(newer - older < 0);改成倒序迭代,prevFull 总是更老的那次
未做(留给后续):
- 单元测试(下一步 Phase 1.4 独立 commit)
- 显式 migration 文件(下次 schema 变更补;此次仍 AutoMigrate 兜底)
- Dashboard / 统计更复杂的 endpoint(基础 Statistics 已可用)
- PWA / 导入导出 / Docker(留给 Phase 1.5+)
验证:
go vet ./... 0 issues
go build 26 MB binary
smoke.ps1 13/13 断言通过
2026-09-13 16:13:25 +08:00
KennethCheng
df5db7bc36
feat(backend): service 层与 DTO,核心计算逻辑落位
...
Phase 1.3a。Phase 1.2 留的 repo 现在有了真正的业务出口,Phase 1.3b 紧接
handler / router 把 HTTP 接进来,业务就能跑通。
新增:
- pkg/decimal/money.go
* PricePerLiter(total, liters) 服务端实时算实际油价,落库
* ConsumptionL100km(l, mileage) 百公里油耗,只用于 is_full 序列
* CostPerKm(amount, mileage) 每公里成本
* Mileage(cur, prev) 行驶里程,负数 / 零视为无效
* AvgFloat64(xs) 求平均,空切片返回 0
全部走 shopspring/decimal,避免 float64 累计误差
- internal/dto/dto.go
* VehicleCreate / VehicleUpdate (patch 语义,*T 表示可选)
* RefuelCreate / RefuelUpdate
入参校验走 gin-validator 的 tag:required / gte / gt / max
(Liters > 0、TotalAmount >= 0、Odometer >= 0 与 plan §16 一致)
- internal/service/vehicle.go
* Create 时若 default_vehicle=true,事务内先 ClearDefault 再 Insert,
避免出现两个默认车辆
* Update 同样事务化 default 切换
* SetDefault 是显式接口,业务上"切默认车"是常用操作
全部错误用 apperror 包成 NotFound / InvalidArg / Internal
- internal/service/refuel_record.go
* Create:校验车辆存在 + Liters > 0 + TotalAmount >= 0,服务端
实时计算 price_per_liter,RefuelTime 统一转 UTC 存库
* Update:patch 语义,liters / total_amount 任一变更都重新算
price_per_liter(否则历史单价会失真)
* Statistics(单车):返回 累计次数 / 累计升数 / 累计金额 / 平均油价 /
标准百公里油耗 / 每公里成本 / 最近一次加油
关键实现:仅在"连续两次 is_full=true"之间采油耗样本,
半箱油 / 部分加油记录不会污染标准油耗 ——
这条规则直接来自 plan §6.3 与 §19 验收要求
业务规则(AGENTS.md 强约束):
车辆名称 / 能源类型 不允许为空(trim 后空字符串也拒)
加油量 > 0、总金额 >= 0、里程 >= 0 都在 service 入参立即校验
RefuelTime 统一 UTC 存库,展示按本地时区格式化
默认车辆唯一,切换走事务,杜绝"两个默认"
未做(留给 Phase 1.3b 下一个 commit):
- HTTP handler / router / main 装配
- 单元测试(本文件不含测试,等所有 service 稳定后单独提一个测试 commit)
- Dashboard / 统计 API(基础 Statistics 已就绪,Dashboard endpoint
留给 Phase 1.3b 末尾)
验证:
go mod tidy all modules verified
go vet ./... 0 issues
go build 25.9 MB binary
2026-09-13 16:06:21 +08:00
KennethCheng
f4c42bbb9e
feat(backend): 添加车辆与加油记录 model、repository 和 GORM 迁移
...
完成 Phase 1.2,把 GORM 真正接进来。Phase 1.1 留下的 /health 和骨架保持不变,
本 commit 在此之上铺好数据访问层,Phase 1.3 直接在 service / handler 上挂业务即可。
新增:
- internal/model/vehicle.go 车辆实体(严格按 plan §7.1)
- internal/model/refuel_record.go 加油记录实体(严格按 plan §7.2)
- internal/db/db.go SQLite 连接 + AutoMigrate 入口
* glebarez/sqlite 纯 Go,免 CGO,Windows / Docker 通吃
* DSN 自动启用 WAL + 外键约束 + busy_timeout
* 表目录若不存在会自动创建
- internal/repository/vehicle.go 车辆数据访问接口 + GORM 实现
* Create / Get / List / Update / Delete
* ClearDefault / SetDefault(切换默认车辆用,在事务里跑避免脏状态)
- internal/repository/refuel_record.go 加油记录数据访问接口 + GORM 实现
* Create / Get / Update / Delete
* ListByVehicle 支持可选时间窗口
* PrevFullRefuel 找上一次加满,给油耗计算用
main.go 改动:
- 启动期 db.Open + db.Migrate,失败直接退出
- application 持有 *gorm.DB,Phase 1.3 起注入 repo / service
设计要点:
- repository 全是接口,Service 持有接口便于单测用 fake 替换
- 所有 repo 错误统一包成 apperror.NotFound / apperror.Internal,
不让 GORM 原始错误泄漏到 handler
- model.TableName() 显式指定单数,避免 GORM 复数
- refuel_record 用 (vehicle_id, refuel_time) 复合场景常见,已加索引
未做(留给后续 commit):
- service 层与 DTO
- handler / router
- 显式 migration 文件(AGENTS.md 要求 schema 变更必须写 migration;
此次为首次建表,AutoMigrate 即可,后续 schema 改动会补 goose / 自管文件)
验证:
go mod tidy all modules verified
go vet ./... 0 issues
go build 25.9 MB binary
冷启动一次,DB 文件生成在 fuellog-api/data/fuellog.db(WAL/SHM 齐备)
listtables 脚本 dump 出来的两表 schema 与 plan §7 完全一致
2026-09-13 15:59:12 +08:00
KennethCheng
ca9831a050
feat(backend): scaffold go module with config, error, middleware, and health endpoint
...
Phase 1 of FuelLog backend bootstrap. Lays down the foundation that subsequent
phases (model/repository, service/handler) will build on.
What's in:
- go.mod / go.sum: Go 1.22 module, deps:
* gin-gonic/gin v1.10.0 HTTP framework
* glebarez/sqlite v1.11.0 pure-Go SQLite (no CGO, friendly to Windows + Docker)
* gorm.io/gorm v1.25.12 ORM (will be wired in Phase 1.2)
* shopspring/decimal v1.4.0 amount precision
* joho/godotenv v1.5.1 .env loader
- cmd/server/main.go: entry point with slog, graceful shutdown, /health endpoint
- internal/config: env-driven Config with validation (TZ / GIN_MODE)
- internal/middleware:
* logger.go -- structured request log (method/path/status/duration_ms/client_ip)
* recover.go -- panic capture to unified 500 response + stack to log
* cors.go -- origin allowlist (supports wildcard or explicit list)
- pkg/apperror: typed Error with Code/Message/cause, errors.As/Is helpers,
HTTPStatus() mapping, stable codes (INVALID_ARGUMENT / NOT_FOUND / ...)
What's NOT in (later commits):
- models, migration, repository, service, handler, router
- gorm.Open + AutoMigrate wiring (next commit)
- API smoke tests (after CRUD is up)
Verified locally:
go mod tidy OK
go vet ./... 0 issues
go build 12.8MB exe in fuellog-api/bin/server.exe
smoke test GET /health -> 200 {"status":"ok","time":"..."}
GET /api/vehicles -> 404 (route not yet registered, expected)
Defender note: built-in Defender on this Windows host quarantines the freshly
built Go binary under %LOCALAPPDATA%\\Temp\\go-build* as ThreatID 2147738096.
Workaround: GOTMPDIR=.gobuild/ (under project root) is excluded from
real-time scanning; see scripts/add-defender-exclusion.ps1.
2026-09-13 15:55:58 +08:00
KennethCheng
a0c2701cf1
chore: bootstrap repo with AGENTS.md and .gitignore
2026-09-13 13:46:55 +08:00