Files
FuelLog/fuellog-web/src/pages/Refuels.vue
T
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

178 lines
5.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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>
<template>
<div class="max-w-screen-md mx-auto space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">加油记录</h2>
<NButton type="primary" @click="$router.push({ name: 'refuel-new' })">
+ 新增
</NButton>
</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>
<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>
</template>