feat(frontend): 加油录入页(实时算实际油价 + 10秒录入)

把 RefuelNew 从占位 Empty 升级成真正的"10秒录入"表单,plan §9.3 落地:
- 移动端底部固定保存按钮(带安全区适配)
- 桌面端 inline 按钮
- 字段顺序按 plan:加油时间 → 里程 → 加油量 → 总金额 → 油品 → 加油站 → 是否加满 → 备注
- 输入 amount + liters 即时显示 "实际油价 ¥X.XX/L",与服务端公式一致
  (后端会再校验一次;前端只是 UX 反馈,不算权威)
- 默认值:车辆=当前选中 / 里程=车辆 current_odometer(用户多半会从这里起步)
       / 油品=车辆 fuel_type / 是否加满=true
- 必填字段在前端守门(Liters > 0、TotalAmount >= 0、Odometer >= 0),
  失败用 useMessage.warning
- 保存成功 useMessage.success 后 300ms 跳 Dashboard,让用户看到统计更新
- 没有车辆时给出空态 + 一键跳车辆页

实现要点:
- 本地定义 RefuelForm 类型(数字字段允许 undefined),与 DTO 解耦
  (DTO 严格,form 宽松,提交时 narrow)
- onMounted 拉 store.loadList + 选默认车 + 填默认值
- realPrice = computed 监听 liters/total_amount 变化

未做(留待后续):
- 提交时把车辆 current_odometer 同步上去(避免用户开了 100 公里才发现
  里程还停在加油前) —— 提交后端 update vehicle,plan §5.2 提到过,
  这里没做
- 重置表单(同车连加) —— 提交后跳 Dashboard,需要"再加一笔"时再回
- GPS 自动定位加油站(本机浏览器权限问题,先不做)

验证:
  pnpm typecheck   0 errors
  pnpm build       12.8s
This commit is contained in:
2026-09-13 16:47:16 +08:00
parent 9cec8a7f66
commit 6ee28349e1
+232 -8
View File
@@ -1,16 +1,240 @@
<script setup lang="ts">
import { NEmpty } from 'naive-ui'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
NButton,
NCard,
NForm,
NFormItem,
NInput,
NInputNumber,
NSpace,
NSwitch,
NText,
useMessage,
} from 'naive-ui'
import { refuelsApi } from '@/api/refuels'
import { useVehicleStore } from '@/stores/vehicle'
import { FuelSelect } from '@/components/VehicleFormModal.vue'
import { pricePerLiter, fmtPrice } from '@/utils/decimal'
import type { RefuelCreate } from '@/api/types'
const router = useRouter()
const route = useRoute()
const message = useMessage()
const store = useVehicleStore()
const submitting = ref(false)
const form = reactive<RefuelForm>({
vehicle_id: 0,
refuel_time: new Date().toISOString(),
odometer: undefined,
fuel_type: 'gasoline',
liters: undefined,
total_amount: undefined,
station_name: '',
is_full: true,
remark: '',
})
// 表单态比 DTO 宽松:数字字段允许 undefined(用户还没填)
type RefuelForm = {
vehicle_id: number
refuel_time: string
odometer: number | undefined
fuel_type: string
liters: number | undefined
total_amount: number | undefined
station_name: string
is_full: boolean
remark: string
}
onMounted(async () => {
await store.loadList()
// 优先用 query 上的 vehicle_id,其次 store 的当前车,再次第一辆
const qv = Number(route.query.vehicle_id)
if (qv && store.list.find((v) => v.id === qv)) {
form.vehicle_id = qv
} else if (store.currentId) {
form.vehicle_id = store.currentId
} else if (store.list.length) {
form.vehicle_id = store.list[0].id
}
// 默认里程:沿用车辆 current_odometer(用户多半会从这里开始加)
const cur = store.getCurrent()
if (cur && cur.current_odometer) {
form.odometer = cur.current_odometer
}
if (cur) {
form.fuel_type = cur.fuel_type
}
})
const realPrice = computed(() => {
if (!form.liters || form.liters <= 0 || !form.total_amount) return 0
return pricePerLiter(form.total_amount, form.liters)
})
async function onSubmit() {
if (submitting.value) return
if (!form.vehicle_id) {
message.warning('请先选择车辆')
return
}
if (!form.odometer || form.odometer < 0) {
message.warning('请填写当前里程')
return
}
if (!form.liters || form.liters <= 0) {
message.warning('加油量必须大于 0')
return
}
if (form.total_amount === undefined || form.total_amount < 0) {
message.warning('请填写总金额')
return
}
submitting.value = true
try {
const rec = await refuelsApi.create({
vehicle_id: form.vehicle_id,
refuel_time: new Date(form.refuel_time).toISOString(),
odometer: form.odometer,
fuel_type: form.fuel_type,
liters: form.liters,
total_amount: form.total_amount,
station_name: form.station_name,
is_full: form.is_full,
remark: form.remark,
})
message.success(`已记录,实际油价 ${fmtPrice(rec.price_per_liter)}`)
// 跳回首页(让用户看到统计更新)
setTimeout(() => router.replace({ name: 'dashboard' }), 300)
} catch (err) {
message.error((err as Error).message || '保存失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="max-w-screen-md mx-auto">
<NEmpty description="新增加油页(骨架占位,Phase 4 接入)">
<div class="text-sm text-ink-soft mt-2">
字段:加油时间 / 里程 / 加油量 / 总金额 / 油品 / 加油站 / 是否加满 / 备注
<div class="max-w-screen-md mx-auto pb-24 md:pb-6">
<h2 class="text-xl font-semibold mb-3">记录加油</h2>
<NCard v-if="!store.list.length">
<NText depth="3">还没有车辆,先到「车辆」页加一辆。</NText>
<div class="mt-3">
<NButton type="primary" @click="router.push({ name: 'vehicles' })">
前往添加
</NButton>
</div>
<div class="text-sm text-ink-soft mt-1">
实际油价(price_per_liter)由后端实时计算,前端只入 amount + liters
</NCard>
<NForm v-else label-placement="top">
<NFormItem label="车辆" required>
<select
v-model="form.vehicle_id"
class="w-full px-3 py-2 rounded-md border border-black/10 dark:border-white/15 bg-transparent"
>
<option v-for="v in store.list" :key="v.id" :value="v.id">
{{ v.name }}{{ v.default_vehicle ? ' (默认)' : '' }}
</option>
</select>
</NFormItem>
<NFormItem label="加油时间" required>
<input
v-model="form.refuel_time"
type="datetime-local"
class="w-full px-3 py-2 rounded-md border border-black/10 dark:border-white/15 bg-transparent"
/>
</NFormItem>
<div class="grid grid-cols-2 gap-3">
<NFormItem label="当前里程(km)" required>
<NInputNumber
v-model:value="form.odometer"
:min="0"
:precision="1"
placeholder="10500"
class="w-full"
/>
</NFormItem>
<NFormItem label="加油量(L)" required>
<NInputNumber
v-model:value="form.liters"
:min="0"
:precision="2"
placeholder="23.67"
class="w-full"
/>
</NFormItem>
</div>
<NFormItem label="总金额(元)" required>
<NInputNumber
v-model:value="form.total_amount"
:min="0"
:precision="2"
placeholder="176.33"
class="w-full"
/>
</NFormItem>
<!-- 实际油价实时显示 -->
<div
v-if="realPrice > 0"
class="rounded-xl bg-brand-soft dark:bg-brand-soft px-4 py-3 mb-3 text-center"
>
<div class="text-xs text-ink-soft">实际油价(后端会再校验)</div>
<div class="text-2xl font-bold text-brand mt-0.5">
{{ fmtPrice(realPrice) }}
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<NFormItem label="油品" required>
<FuelSelect v-model="form.fuel_type" />
</NFormItem>
<NFormItem label="是否加满">
<NSwitch v-model:value="form.is_full" />
</NFormItem>
</div>
<NFormItem label="加油站">
<NInput v-model:value="form.station_name" placeholder="中石化 望京" maxlength="128" />
</NFormItem>
<NFormItem label="备注">
<NInput
v-model:value="form.remark"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="选填"
maxlength="512"
/>
</NFormItem>
</NForm>
<!-- 移动端底部固定保存按钮,desktop 普通按钮 -->
<div
class="fixed md:static bottom-0 inset-x-0 p-3 md:p-0 bg-surface md:bg-transparent border-t md:border-0 border-black/5 dark:border-white/10 pb-[env(safe-area-inset-bottom)]"
>
<NSpace vertical>
<NButton
type="primary"
size="large"
block
:loading="submitting"
:disabled="!store.list.length"
@click="onSubmit"
>
保存
</NButton>
<NButton block @click="router.back">取消</NButton>
</NSpace>
</div>
</NEmpty>
</div>
</template>