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