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:
@@ -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>
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
Vendored
+80
@@ -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')
|
||||
}
|
||||
Vendored
+13
@@ -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']
|
||||
}
|
||||
}
|
||||
Vendored
+17
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user