feat(frontend): Dashboard 精修(切车器 + 累计升数 + 错误态)

把 Dashboard 从基本骨架升级到 MVP 可用状态:
- 切车:多车辆时显示 NSelect,直接切;单车时只显示名字(不让选)
  * 切车 watch currentId 自动重拉 statistics,无需手动刷新
- 主指标卡:把"本月加油"标题改成"累计加油",因为目前后端没给"本月"
  过滤,只是总量;数字含义与 Backend RefuelStatistics.total_amount 一致
- 4 项指标:平均油价 / 平均油耗 / 每公里成本 / 累计升数 —— 之前只有 3 项,
  补上总升数
- Empty / Loading / Error 三态完整:
  * Loading -> NSpin
  * 没记录但有车 -> "还没有加油记录"
  * API 失败 -> useMessage.error + stats 清空
- 保留已有:大字号主指标、"记录加油"主按钮、"最近加油"卡片

未做(留待后续):
- "本月"维度过滤(后端可加 query?period=month,Dashboard 切 month/all)
  —— plan §9.1 提了"本月加油",MVP 留总量已经够
- 离线缓存 / PWA install prompt

验证:
  pnpm typecheck   0 errors
  pnpm build       通过
This commit is contained in:
2026-09-13 16:54:19 +08:00
parent 33dfdc824c
commit 34777bbf1b
+63 -9
View File
@@ -1,20 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { NButton, NCard, NSpin, NTag, NEmpty, NText } from 'naive-ui' import {
NButton,
NCard,
NEmpty,
NSpin,
NTag,
NText,
NSelect,
useMessage,
} from 'naive-ui'
import { useVehicleStore } from '@/stores/vehicle' import { useVehicleStore } from '@/stores/vehicle'
import { refuelsApi } from '@/api/refuels' import { refuelsApi } from '@/api/refuels'
import type { RefuelStatistics } from '@/api/types' import type { RefuelStatistics } from '@/api/types'
import { fmtAmount, fmtConsumption, fmtCostPerKm, fmtPrice } from '@/utils/decimal' import { fmtAmount, fmtConsumption, fmtCostPerKm, fmtPrice } from '@/utils/decimal'
const vehicleStore = useVehicleStore() const vehicleStore = useVehicleStore()
const message = useMessage()
const stats = ref<RefuelStatistics | null>(null) const stats = ref<RefuelStatistics | null>(null)
const loading = ref(false) const loading = ref(false)
async function loadStats() { async function loadStats() {
if (!vehicleStore.currentId) return if (!vehicleStore.currentId) {
stats.value = null
return
}
loading.value = true loading.value = true
try { try {
stats.value = await refuelsApi.statistics(vehicleStore.currentId) stats.value = await refuelsApi.statistics(vehicleStore.currentId)
} catch (err) {
message.error((err as Error).message || '加载统计失败')
stats.value = null
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -24,34 +40,65 @@ onMounted(async () => {
await vehicleStore.loadList() await vehicleStore.loadList()
await loadStats() await loadStats()
}) })
watch(
() => vehicleStore.currentId,
() => loadStats(),
)
const currentVehicle = computed(() => vehicleStore.getCurrent())
const vehicleOptions = computed(() =>
vehicleStore.list.map((v) => ({
label: `${v.name}${v.default_vehicle ? ' (默认)' : ''}`,
value: v.id,
})),
)
function onVehicleChange(id: number | null) {
if (id != null) vehicleStore.setCurrentId(id)
}
</script> </script>
<template> <template>
<div class="space-y-4 max-w-screen-md mx-auto"> <div class="space-y-4 max-w-screen-md mx-auto">
<NCard v-if="!vehicleStore.list.length" class="text-center py-12"> <NCard v-if="!vehicleStore.list.length" class="text-center py-12">
<NEmpty description="还没有车辆,先去添加一辆吧"> <NEmpty description="还没有车辆,先去添加一辆吧">
<NButton type="primary" @click="$router.push({ name: 'vehicles' })">添加车辆</NButton> <NButton type="primary" @click="$router.push({ name: 'vehicles' })">
添加车辆
</NButton>
</NEmpty> </NEmpty>
</NCard> </NCard>
<template v-else> <template v-else>
<div> <!-- 切车器:多车时显示,单车时只显示名字(不让选) -->
<div v-if="vehicleStore.list.length > 1" class="flex items-center gap-2">
<NText depth="3" class="text-sm">当前车辆</NText>
<NSelect
:value="vehicleStore.currentId"
:options="vehicleOptions"
size="small"
style="min-width: 160px"
@update:value="onVehicleChange"
/>
</div>
<div v-else>
<NText depth="3" class="text-sm">当前车辆</NText> <NText depth="3" class="text-sm">当前车辆</NText>
<div class="text-2xl font-semibold mt-1"> <div class="text-2xl font-semibold mt-1">
{{ vehicleStore.getCurrent()?.name || '—' }} {{ currentVehicle?.name || '—' }}
</div> </div>
</div> </div>
<NSpin :show="loading"> <NSpin :show="loading">
<NCard v-if="stats" class="text-center py-6"> <NCard v-if="stats" class="text-center py-6">
<div class="text-xs text-ink-soft">本月加油</div> <div class="text-xs text-ink-soft">累计加油</div>
<div class="text-5xl font-bold mt-1 tracking-tight"> <div class="text-5xl font-bold mt-1 tracking-tight">
{{ fmtAmount(stats.total_amount) }} {{ fmtAmount(stats.total_amount) }}
</div> </div>
<div class="text-sm text-ink-soft mt-1"> <div class="text-sm text-ink-soft mt-1">
共 {{ stats.total_records }} 笔 共 {{ stats.total_records }} 笔
</div> </div>
<div class="grid grid-cols-2 gap-4 mt-6"> <div class="grid grid-cols-2 gap-4 mt-6 text-left">
<div> <div>
<div class="text-xs text-ink-soft">平均油价</div> <div class="text-xs text-ink-soft">平均油价</div>
<div class="text-lg font-semibold mt-0.5"> <div class="text-lg font-semibold mt-0.5">
@@ -64,14 +111,21 @@ onMounted(async () => {
{{ fmtConsumption(stats.avg_consumption) }} {{ fmtConsumption(stats.avg_consumption) }}
</div> </div>
</div> </div>
<div class="col-span-2"> <div>
<div class="text-xs text-ink-soft">每公里成本</div> <div class="text-xs text-ink-soft">每公里成本</div>
<div class="text-lg font-semibold mt-0.5"> <div class="text-lg font-semibold mt-0.5">
{{ fmtCostPerKm(stats.cost_per_km) }} {{ fmtCostPerKm(stats.cost_per_km) }}
</div> </div>
</div> </div>
<div>
<div class="text-xs text-ink-soft">累计升数</div>
<div class="text-lg font-semibold mt-0.5">
{{ stats.total_liters.toFixed(2) }} L
</div>
</div>
</div> </div>
</NCard> </NCard>
<NEmpty v-else-if="!loading" description="还没有加油记录" />
</NSpin> </NSpin>
<NButton <NButton