Commit Graph
11 Commits
Author SHA1 Message Date
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