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
This commit is contained in:
2026-09-13 16:49:16 +08:00
parent 6ee28349e1
commit ea158f2a53
+169 -5
View File
@@ -1,13 +1,177 @@
<script setup lang="ts"> <script setup lang="ts">
import { NEmpty } from 'naive-ui' import { computed, onMounted, ref, watch } from 'vue'
import {
NButton,
NCard,
NEmpty,
NPopconfirm,
NSpin,
NTag,
useMessage,
} from 'naive-ui'
import { refuelsApi } from '@/api/refuels'
import { useVehicleStore } from '@/stores/vehicle'
import type { RefuelRecord } from '@/api/types'
import { fmtAmount, fmtLiters, fmtPrice } from '@/utils/decimal'
const store = useVehicleStore()
const message = useMessage()
const records = ref<RefuelRecord[]>([])
const loading = ref(false)
async function load() {
if (!store.currentId) {
records.value = []
return
}
loading.value = true
try {
records.value = await refuelsApi.list(store.currentId)
} catch (err) {
message.error((err as Error).message || '加载失败')
} finally {
loading.value = false
}
}
onMounted(async () => {
await store.loadList()
await load()
})
// 切车就重拉
watch(
() => store.currentId,
() => load(),
)
// 按月分组(最新月份在前)
const grouped = computed(() => {
const map = new Map<string, RefuelRecord[]>()
for (const r of records.value) {
const d = new Date(r.refuel_time)
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(r)
}
return Array.from(map.entries()).sort((a, b) => (a[0] < b[0] ? 1 : -1))
})
const monthLabel = (k: string) => `${k.slice(0, 4)} 年 ${parseInt(k.slice(5), 10)} 月`
const monthSubtotal = (rs: RefuelRecord[]) => {
const liters = rs.reduce((s, r) => s + r.liters, 0)
const amount = rs.reduce((s, r) => s + r.total_amount, 0)
return { liters, amount }
}
const monthAvgPrice = (rs: RefuelRecord[]) => {
const totalL = rs.reduce((s, r) => s + r.liters, 0)
if (totalL <= 0) return 0
const totalA = rs.reduce((s, r) => s + r.total_amount, 0)
return totalA / totalL
}
async function remove(r: RefuelRecord) {
try {
await refuelsApi.remove(r.id)
message.success('已删除')
await load()
} catch (err) {
message.error((err as Error).message || '删除失败')
}
}
const dayLabel = (iso: string) => {
const d = new Date(iso)
return `${d.getMonth() + 1}/${d.getDate()}`
}
const weekday = (iso: string) => {
const d = new Date(iso)
return ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
}
</script> </script>
<template> <template>
<div class="max-w-screen-md mx-auto"> <div class="max-w-screen-md mx-auto space-y-3">
<NEmpty description="加油记录列表(骨架占位,Phase 4 接入)"> <div class="flex items-center justify-between">
<div class="text-sm text-ink-soft mt-2"> <h2 class="text-xl font-semibold">加油记录</h2>
API:GET /api/refuels?vehicle_id=...&start_date=...&end_date=... <NButton type="primary" @click="$router.push({ name: 'refuel-new' })">
+ 新增
</NButton>
</div> </div>
<NSpin :show="loading">
<NEmpty
v-if="!loading && !records.length && store.currentId"
description="还没有加油记录,点右上角加一笔"
/>
<NEmpty
v-else-if="!loading && !store.currentId"
description="还没有车辆,先去添加"
>
<NButton type="primary" @click="$router.push({ name: 'vehicles' })">
添加车辆
</NButton>
</NEmpty> </NEmpty>
<div v-else class="space-y-4">
<NCard
v-for="[key, items] in grouped"
:key="key"
:title="monthLabel(key)"
class="!rounded-2xl"
size="small"
>
<template #header-extra>
<div class="text-xs text-ink-soft">
共 {{ items.length }} 笔 ·
{{ fmtLiters(monthSubtotal(items).liters) }} ·
{{ fmtAmount(monthSubtotal(items).amount) }} ·
均价 {{ fmtPrice(monthAvgPrice(items)) }}
</div>
</template>
<div class="divide-y divide-black/5 dark:divide-white/10 -mx-2">
<div
v-for="r in items"
:key="r.id"
class="flex items-center gap-3 px-2 py-3"
>
<div class="text-center w-12 shrink-0">
<div class="text-base font-semibold leading-none">
{{ dayLabel(r.refuel_time) }}
</div>
<div class="text-[10px] text-ink-soft mt-0.5">周{{ weekday(r.refuel_time) }}</div>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">
{{ r.station_name || '未填写加油站' }}
</div>
<div class="text-xs text-ink-soft mt-0.5">
{{ fmtLiters(r.liters) }} ·
{{ fmtAmount(r.total_amount) }} ·
{{ fmtPrice(r.price_per_liter) }}
<NTag
v-if="r.is_full"
size="tiny"
type="success"
class="!ml-1 align-middle"
>
加满
</NTag>
</div>
</div>
<NPopconfirm @positive-click="remove(r)">
<template #trigger>
<NButton size="tiny" quaternary type="error">删</NButton>
</template>
删除这笔?
</NPopconfirm>
</div>
</div>
</NCard>
</div>
</NSpin>
</div> </div>
</template> </template>