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 规则初始化
This commit is contained in:
2026-09-13 16:40:03 +08:00
parent 07ebce1ea3
commit 097af20cc0
36 changed files with 3287 additions and 0 deletions
View File
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { darkTheme, NConfigProvider, NDialogProvider, NLoadingBarProvider, NMessageProvider, NNotificationProvider, type GlobalThemeOverrides } from 'naive-ui'
import { computed, ref, watchEffect } from 'vue'
// 跟随系统暗色
const isDark = ref(false)
const mql = window.matchMedia('(prefers-color-scheme: dark)')
isDark.value = mql.matches
mql.addEventListener('change', (e) => (isDark.value = e.matches))
const theme = computed<GlobalThemeOverrides>(() => ({
common: {
primaryColor: '#18a058',
primaryColorHover: '#36ad6a',
primaryColorPressed: '#0c7a43',
primaryColorSuppl: '#36ad6a',
},
}))
watchEffect(() => {
document.documentElement.classList.toggle('dark', isDark.value)
})
</script>
<template>
<NConfigProvider :theme="isDark ? darkTheme : null" :theme-overrides="theme">
<NLoadingBarProvider>
<NDialogProvider>
<NNotificationProvider>
<NMessageProvider>
<RouterView />
</NMessageProvider>
</NNotificationProvider>
</NDialogProvider>
</NLoadingBarProvider>
</NConfigProvider>
</template>
+38
View File
@@ -0,0 +1,38 @@
import axios, { type AxiosInstance } from 'axios'
/**
* 统一 HTTP 客户端。
*
* 约定:
* - 基础路径 VITE_API_BASE,默认 '/api'
* - 后端统一错误响应:{ code: '...', message: '...' }
* - 业务侧 catch 时,err 是 Error,err.cause 是结构化错误
*/
const baseURL = (import.meta.env.VITE_API_BASE as string | undefined) || '/api'
export const http: AxiosInstance = axios.create({
baseURL,
timeout: 15_000,
headers: { 'Content-Type': 'application/json' },
})
export interface ApiError {
code: string
message: string
}
http.interceptors.response.use(
(resp) => resp,
(err) => {
if (err.response?.data && typeof err.response.data === 'object') {
const data = err.response.data as Partial<ApiError>
if (data.code && data.message) {
const e = new Error(data.message) as Error & { cause?: ApiError; status?: number }
e.cause = data as ApiError
e.status = err.response.status
return Promise.reject(e)
}
}
return Promise.reject(err)
},
)
+18
View File
@@ -0,0 +1,18 @@
import { http } from './client'
import type { RefuelCreate, RefuelRecord, RefuelStatistics, RefuelUpdate } from './types'
export const refuelsApi = {
list: (vehicleId: number, start?: string, end?: string) => {
const params: Record<string, string> = { vehicle_id: String(vehicleId) }
if (start) params.start_date = start
if (end) params.end_date = end
return http.get<RefuelRecord[]>('/refuels', { params }).then((r) => r.data)
},
get: (id: number) => http.get<RefuelRecord>(`/refuels/${id}`).then((r) => r.data),
create: (body: RefuelCreate) => http.post<RefuelRecord>('/refuels', body).then((r) => r.data),
update: (id: number, body: RefuelUpdate) =>
http.put<RefuelRecord>(`/refuels/${id}`, body).then((r) => r.data),
remove: (id: number) => http.delete(`/refuels/${id}`),
statistics: (vehicleId: number) =>
http.get<RefuelStatistics>(`/vehicles/${vehicleId}/statistics`).then((r) => r.data),
}
+87
View File
@@ -0,0 +1,87 @@
// 与后端 model / DTO 保持一致;后续可用 openapi 自动生成
export interface Vehicle {
id: number
name: string
brand: string
model: string
plate_number: string
fuel_type: string
tank_capacity: number
current_odometer: number
default_vehicle: boolean
created_at: string
updated_at: string
}
export interface VehicleCreate {
name: string
brand?: string
model?: string
plate_number?: string
fuel_type: string
tank_capacity?: number
current_odometer?: number
default_vehicle?: boolean
}
export interface VehicleUpdate {
name?: string
brand?: string
model?: string
plate_number?: string
fuel_type?: string
tank_capacity?: number
current_odometer?: number
default_vehicle?: boolean
}
export interface RefuelRecord {
id: number
vehicle_id: number
refuel_time: string
odometer: number
fuel_type: string
liters: number
price_per_liter: number
total_amount: number
station_name: string
is_full: boolean
remark: string
created_at: string
updated_at: string
}
export interface RefuelCreate {
vehicle_id: number
refuel_time: string
odometer: number
fuel_type: string
liters: number
total_amount: number
station_name?: string
is_full?: boolean
remark?: string
}
export interface RefuelUpdate {
refuel_time?: string
odometer?: number
fuel_type?: string
liters?: number
total_amount?: number
station_name?: string
is_full?: boolean
remark?: string
}
export interface RefuelStatistics {
vehicle_id: number
total_records: number
total_liters: number
total_amount: number
avg_price: number
avg_consumption: number
cost_per_km: number
last_refuel?: RefuelRecord
}
+13
View File
@@ -0,0 +1,13 @@
import { http } from './client'
import type { Vehicle, VehicleCreate, VehicleUpdate } from './types'
export const vehiclesApi = {
list: () => http.get<Vehicle[]>('/vehicles').then((r) => r.data),
get: (id: number) => http.get<Vehicle>(`/vehicles/${id}`).then((r) => r.data),
create: (body: VehicleCreate) => http.post<Vehicle>('/vehicles', body).then((r) => r.data),
update: (id: number, body: VehicleUpdate) =>
http.put<Vehicle>(`/vehicles/${id}`, body).then((r) => r.data),
remove: (id: number) => http.delete(`/vehicles/${id}`),
setDefault: (id: number) =>
http.post<Vehicle>(`/vehicles/${id}/default`).then((r) => r.data),
}
+80
View File
@@ -0,0 +1,80 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+13
View File
@@ -0,0 +1,13 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string
readonly VITE_APP_TITLE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const component: DefineComponent<{}, {}, any>
export default component
}
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NLayout, NLayoutContent, NLayoutHeader } from 'naive-ui'
const router = useRouter()
const route = useRoute()
// 移动端底部导航 + 桌面左侧导航(本 skeleton 共用,后续可视情况拆分)
const navs = [
{ name: 'dashboard', title: '首页', icon: '⌂' },
{ name: 'refuels', title: '加油', icon: '⛽' },
{ name: 'statistics', title: '统计', icon: '📊' },
{ name: 'vehicles', title: '车辆', icon: '🚗' },
]
const activeName = computed(() => route.name as string)
function go(name: string) {
router.push({ name })
}
</script>
<template>
<NLayout class="h-full">
<NLayoutHeader bordered class="hidden md:flex items-center px-4 h-12">
<div class="text-lg font-semibold text-brand">FuelLog 加油记</div>
<nav class="ml-8 flex gap-2">
<button
v-for="n in navs"
:key="n.name"
class="px-3 py-1 rounded-md text-sm transition-colors"
:class="activeName === n.name ? 'bg-brand-soft text-brand' : 'text-ink-soft hover:text-ink'"
@click="go(n.name)"
>
{{ n.title }}
</button>
</nav>
</NLayoutHeader>
<NLayoutContent content-class="p-4 md:p-6 pb-24 md:pb-6">
<RouterView />
</NLayoutContent>
<!-- 移动端底部 Tab -->
<nav
class="md:hidden fixed bottom-0 inset-x-0 bg-surface-soft border-t border-black/5 dark:border-white/10 pb-[env(safe-area-inset-bottom)]"
>
<div class="grid grid-cols-4">
<button
v-for="n in navs"
:key="n.name"
class="flex flex-col items-center justify-center py-2 gap-0.5"
:class="activeName === n.name ? 'text-brand' : 'text-ink-soft'"
@click="go(n.name)"
>
<span class="text-xl leading-none">{{ n.icon }}</span>
<span class="text-xs">{{ n.title }}</span>
</button>
</div>
</nav>
</NLayout>
</template>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import 'vfonts/Lato.css'
import 'vfonts/FiraCode.css'
import './styles/tailwind.css'
import './styles/theme.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { NButton, NCard, NSpin, NTag, NEmpty, NText } from 'naive-ui'
import { useVehicleStore } from '@/stores/vehicle'
import { refuelsApi } from '@/api/refuels'
import type { RefuelStatistics } from '@/api/types'
import { fmtAmount, fmtConsumption, fmtCostPerKm, fmtPrice } from '@/utils/decimal'
const vehicleStore = useVehicleStore()
const stats = ref<RefuelStatistics | null>(null)
const loading = ref(false)
async function loadStats() {
if (!vehicleStore.currentId) return
loading.value = true
try {
stats.value = await refuelsApi.statistics(vehicleStore.currentId)
} finally {
loading.value = false
}
}
onMounted(async () => {
await vehicleStore.loadList()
await loadStats()
})
</script>
<template>
<div class="space-y-4 max-w-screen-md mx-auto">
<NCard v-if="!vehicleStore.list.length" class="text-center py-12">
<NEmpty description="还没有车辆,先去添加一辆吧">
<NButton type="primary" @click="$router.push({ name: 'vehicles' })">添加车辆</NButton>
</NEmpty>
</NCard>
<template v-else>
<div>
<NText depth="3" class="text-sm">当前车辆</NText>
<div class="text-2xl font-semibold mt-1">
{{ vehicleStore.getCurrent()?.name || '—' }}
</div>
</div>
<NSpin :show="loading">
<NCard v-if="stats" class="text-center py-6">
<div class="text-xs text-ink-soft">本月加油</div>
<div class="text-5xl font-bold mt-1 tracking-tight">
{{ fmtAmount(stats.total_amount) }}
</div>
<div class="text-sm text-ink-soft mt-1">
共 {{ stats.total_records }} 笔
</div>
<div class="grid grid-cols-2 gap-4 mt-6">
<div>
<div class="text-xs text-ink-soft">平均油价</div>
<div class="text-lg font-semibold mt-0.5">
{{ fmtPrice(stats.avg_price) }}
</div>
</div>
<div>
<div class="text-xs text-ink-soft">平均油耗</div>
<div class="text-lg font-semibold mt-0.5">
{{ fmtConsumption(stats.avg_consumption) }}
</div>
</div>
<div class="col-span-2">
<div class="text-xs text-ink-soft">每公里成本</div>
<div class="text-lg font-semibold mt-0.5">
{{ fmtCostPerKm(stats.cost_per_km) }}
</div>
</div>
</div>
</NCard>
</NSpin>
<NButton
type="primary"
size="large"
block
@click="$router.push({ name: 'refuel-new' })"
>
+ 记录加油
</NButton>
<NCard v-if="stats?.last_refuel" title="最近加油">
<div class="flex justify-between items-baseline">
<div class="font-medium">
{{ stats.last_refuel.station_name || '未填写加油站' }}
</div>
<NTag size="small" :type="stats.last_refuel.is_full ? 'success' : 'default'">
{{ stats.last_refuel.is_full ? '加满' : '半箱' }}
</NTag>
</div>
<div class="text-sm text-ink-soft mt-1">
{{ stats.last_refuel.liters.toFixed(2) }} L ·
¥{{ stats.last_refuel.total_amount.toFixed(2) }} ·
{{ new Date(stats.last_refuel.refuel_time).toLocaleDateString() }}
</div>
</NCard>
</template>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { NResult, NButton } from 'naive-ui'
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<div class="h-full flex items-center justify-center p-6">
<NResult status="404" title="404" description="页面不存在">
<template #footer>
<NButton type="primary" @click="router.replace({ name: 'dashboard' })">
回首页
</NButton>
</template>
</NResult>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { NEmpty } from 'naive-ui'
</script>
<template>
<div class="max-w-screen-md mx-auto">
<NEmpty description="新增加油页(骨架占位,Phase 4 接入)">
<div class="text-sm text-ink-soft mt-2">
字段:加油时间 / 里程 / 加油量 / 总金额 / 油品 / 加油站 / 是否加满 / 备注
</div>
<div class="text-sm text-ink-soft mt-1">
实际油价(price_per_liter)由后端实时计算,前端只入 amount + liters
</div>
</NEmpty>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { NEmpty } from 'naive-ui'
</script>
<template>
<div class="max-w-screen-md mx-auto">
<NEmpty description="加油记录列表(骨架占位,Phase 4 接入)">
<div class="text-sm text-ink-soft mt-2">
API:GET /api/refuels?vehicle_id=...&start_date=...&end_date=...
</div>
</NEmpty>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { NEmpty } from 'naive-ui'
</script>
<template>
<div class="max-w-screen-md mx-auto">
<NEmpty description="统计图表(骨架占位,Phase 4 接入 ECharts)">
<div class="text-sm text-ink-soft mt-2">
油价趋势 / 油耗趋势 / 月度费用 / 每公里成本
</div>
</NEmpty>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { NEmpty } from 'naive-ui'
</script>
<template>
<div class="max-w-screen-md mx-auto">
<NEmpty description="车辆管理页(骨架占位,Phase 4 接入)">
<div class="text-sm text-ink-soft mt-2">
API:GET/POST/PUT/DELETE /api/vehicles;POST /api/vehicles/:id/default
</div>
</NEmpty>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
children: [
{ path: '', redirect: { name: 'dashboard' } },
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/pages/Dashboard.vue'),
meta: { title: '首页' },
},
{
path: 'refuels',
name: 'refuels',
component: () => import('@/pages/Refuels.vue'),
meta: { title: '加油记录' },
},
{
path: 'refuels/new',
name: 'refuel-new',
component: () => import('@/pages/RefuelNew.vue'),
meta: { title: '记录加油' },
},
{
path: 'vehicles',
name: 'vehicles',
component: () => import('@/pages/Vehicles.vue'),
meta: { title: '车辆' },
},
{
path: 'statistics',
name: 'statistics',
component: () => import('@/pages/Statistics.vue'),
meta: { title: '统计' },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/pages/NotFound.vue'),
},
],
scrollBehavior() {
return { top: 0 }
},
})
router.afterEach((to) => {
const t = (to.meta?.title as string | undefined) ?? ''
document.title = t ? `${t} · FuelLog 加油记` : 'FuelLog 加油记'
})
export default router
+68
View File
@@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { vehiclesApi } from '@/api/vehicles'
import type { Vehicle, VehicleCreate, VehicleUpdate } from '@/api/types'
/**
* 车辆状态:列表 + 当前选中(默认车)。
* 简单 store,业务组件直接 useVehicleStore() 调用。
*/
export const useVehicleStore = defineStore('vehicle', () => {
const list = ref<Vehicle[]>([])
const currentId = ref<number | null>(null)
const loading = ref(false)
async function loadList() {
loading.value = true
try {
list.value = await vehiclesApi.list()
if (currentId.value == null) {
const def = list.value.find((v) => v.default_vehicle) ?? list.value[0]
currentId.value = def?.id ?? null
}
} finally {
loading.value = false
}
}
function getCurrent(): Vehicle | undefined {
return list.value.find((v) => v.id === currentId.value)
}
async function create(body: VehicleCreate) {
const v = await vehiclesApi.create(body)
await loadList()
currentId.value = v.id
return v
}
async function update(id: number, body: VehicleUpdate) {
const v = await vehiclesApi.update(id, body)
await loadList()
return v
}
async function remove(id: number) {
await vehiclesApi.remove(id)
if (currentId.value === id) currentId.value = null
await loadList()
}
async function setDefault(id: number) {
await vehiclesApi.setDefault(id)
currentId.value = id
await loadList()
}
return {
list,
currentId,
loading,
loadList,
getCurrent,
create,
update,
remove,
setDefault,
}
})
+38
View File
@@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 全局基础 */
html,
body,
#app {
height: 100%;
}
body {
font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
background: rgb(var(--surface));
color: rgb(var(--ink));
}
/* 移动端去除按钮点击高亮 */
button,
[role='button'] {
-webkit-tap-highlight-color: transparent;
}
/* 滚动条细节 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: rgb(var(--ink-soft) / 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--ink-soft) / 0.5);
}
+20
View File
@@ -0,0 +1,20 @@
/* 主题色 CSS 变量:Light / Dark 模式都从这里取,组件不写死颜色 */
:root {
/* Light */
--brand: 24 160 88; /* #18a058 绿,加油站 / 油价 */
--brand-soft: 232 255 244; /* 浅绿底 */
--ink: 38 38 38; /* 主文字 */
--ink-soft: 102 102 102; /* 次文字 */
--surface: 250 250 250; /* 页面背景 */
--surface-soft: 245 245 245; /* 卡片 / 区域 */
}
html.dark {
/* Dark */
--brand: 54 173 106;
--brand-soft: 14 79 49;
--ink: 235 235 235;
--ink-soft: 160 160 160;
--surface: 16 16 20;
--surface-soft: 28 28 32;
}
+45
View File
@@ -0,0 +1,45 @@
// 简单数字格式化工具,与后端 pkg/decimal 配套。
// 不引第三方依赖,精度足够 MVP 使用。
/** 实际油价:总金额 / 加油量,保留 2 位小数。 */
export function pricePerLiter(total: number, liters: number): number {
if (!liters || liters <= 0) return 0
return Math.round((total / liters) * 100) / 100
}
/** 百公里油耗:加油量 / 行驶里程 * 100,保留 2 位。 */
export function consumptionL100km(liters: number, mileage: number): number {
if (!mileage || mileage <= 0) return 0
return Math.round((liters / mileage) * 100 * 100) / 100
}
/** 每公里成本:总金额 / 行驶里程,保留 4 位。 */
export function costPerKm(total: number, mileage: number): number {
if (!mileage || mileage <= 0) return 0
return Math.round((total / mileage) * 10000) / 10000
}
/** 金额格式:`176.33` → `176.33 元`。 */
export function fmtAmount(n: number): string {
return `${n.toFixed(2)} 元`
}
/** 升数:`23.67` → `23.67 L`。 */
export function fmtLiters(n: number): string {
return `${n.toFixed(2)} L`
}
/** 油价:`7.45` → `¥7.45/L`。 */
export function fmtPrice(n: number): string {
return `¥${n.toFixed(2)}/L`
}
/** 油耗:`8.0` → `8.0 L/100km`。 */
export function fmtConsumption(n: number): string {
return `${n.toFixed(2)} L/100km`
}
/** 每公里成本:`0.51` → `¥0.51/km`。 */
export function fmtCostPerKm(n: number): string {
return `¥${n.toFixed(2)}/km`
}