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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user