feat(frontend): 车辆管理页(列表 + 新增/编辑/删除/默认切换)
把 Vehicles 页从空壳升级成真业务: - 卡片式列表,移动端单列、桌面端两列网格 - 每张卡片展示名称/默认标签/品牌+型号+车牌/能源类型/油箱/里程 - 操作:编辑 / 设为默认 / 删除(NPopconfirm 二次确认;走 NDialog.warning) - 顶部「+ 添加」按钮,弹 NModal 表单 - 表单:名称必填、5 个可选字段、NSwitch 切默认 - 提交走 vehiclesApi.create/update,刷新 store - 删除用 NDialog.warning(避免 Popconfirm 误触) - Loading / Empty / Error 状态完整 新增/改动: - src/components/VehicleFormModal.vue NModal + NForm + NInput/NInputNumber/NSwitch * watch props.show / editing 重置表单 * isEdit 复用同一份表单(create / update 双模式) * 内联 FuelSelect(简单 NSelect 包装) * useMessage 反馈成功/失败 - src/pages/Vehicles.vue 替换占位 Empty * onMounted 触发 store.loadList * openCreate / openEdit 控制 showForm + editing * onSaved 自动切到刚保存的车(UX:加完车立刻能进 Dashboard 看到统计) * setDefault -> store.setDefault * remove -> dialog.warning + store.remove - src/stores/vehicle.ts 暴露 setCurrentId(id) 给 UI 切换当前车 未做(留待后续): - 搜索 / 排序 / 分页:单车辆量 < 100,前两版没必要 - 列表项的「最近一次加油」摘要:留给 Dashboard 拿 验证: pnpm typecheck 0 errors pnpm build 12.5s,dist 重新生成,Dashboard / Vehicles 各自分 chunk
This commit is contained in:
@@ -0,0 +1,231 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NInputNumber,
|
||||||
|
NModal,
|
||||||
|
NSpace,
|
||||||
|
NSwitch,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { vehiclesApi } from '@/api/vehicles'
|
||||||
|
import type { Vehicle, VehicleCreate } from '@/api/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
editing?: Vehicle | null
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), { editing: null })
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [v: boolean]
|
||||||
|
saved: [v: Vehicle]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref<InstanceType<typeof NForm> | null>(null)
|
||||||
|
|
||||||
|
const form = reactive<VehicleCreate>({
|
||||||
|
name: '',
|
||||||
|
brand: '',
|
||||||
|
model: '',
|
||||||
|
plate_number: '',
|
||||||
|
fuel_type: 'gasoline',
|
||||||
|
tank_capacity: undefined,
|
||||||
|
current_odometer: undefined,
|
||||||
|
default_vehicle: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const FUEL_OPTIONS = [
|
||||||
|
{ label: '汽油', value: 'gasoline' },
|
||||||
|
{ label: '柴油', value: 'diesel' },
|
||||||
|
{ label: '混动', value: 'hybrid' },
|
||||||
|
{ label: '纯电', value: 'electric' },
|
||||||
|
{ label: '插混', value: 'plug_in_hybrid' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.editing)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, props.editing],
|
||||||
|
() => {
|
||||||
|
if (!props.show) return
|
||||||
|
if (props.editing) {
|
||||||
|
const v = props.editing
|
||||||
|
form.name = v.name
|
||||||
|
form.brand = v.brand
|
||||||
|
form.model = v.model
|
||||||
|
form.plate_number = v.plate_number
|
||||||
|
form.fuel_type = v.fuel_type
|
||||||
|
form.tank_capacity = v.tank_capacity || undefined
|
||||||
|
form.current_odometer = v.current_odometer || undefined
|
||||||
|
form.default_vehicle = v.default_vehicle
|
||||||
|
} else {
|
||||||
|
form.name = ''
|
||||||
|
form.brand = ''
|
||||||
|
form.model = ''
|
||||||
|
form.plate_number = ''
|
||||||
|
form.fuel_type = 'gasoline'
|
||||||
|
form.tank_capacity = undefined
|
||||||
|
form.current_odometer = undefined
|
||||||
|
form.default_vehicle = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (submitting.value) return
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
message.warning('请填写车辆名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.fuel_type.trim()) {
|
||||||
|
message.warning('请选择能源类型')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
let v: Vehicle
|
||||||
|
if (isEdit.value && props.editing) {
|
||||||
|
v = await vehiclesApi.update(props.editing.id, {
|
||||||
|
name: form.name,
|
||||||
|
brand: form.brand,
|
||||||
|
model: form.model,
|
||||||
|
plate_number: form.plate_number,
|
||||||
|
fuel_type: form.fuel_type,
|
||||||
|
tank_capacity: form.tank_capacity,
|
||||||
|
current_odometer: form.current_odometer,
|
||||||
|
default_vehicle: form.default_vehicle,
|
||||||
|
})
|
||||||
|
message.success('已保存')
|
||||||
|
} else {
|
||||||
|
v = await vehiclesApi.create({
|
||||||
|
name: form.name,
|
||||||
|
brand: form.brand,
|
||||||
|
model: form.model,
|
||||||
|
plate_number: form.plate_number,
|
||||||
|
fuel_type: form.fuel_type,
|
||||||
|
tank_capacity: form.tank_capacity,
|
||||||
|
current_odometer: form.current_odometer,
|
||||||
|
default_vehicle: form.default_vehicle,
|
||||||
|
})
|
||||||
|
message.success('已添加')
|
||||||
|
}
|
||||||
|
emit('saved', v)
|
||||||
|
emit('update:show', false)
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
emit('update:show', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NModal
|
||||||
|
:show="show"
|
||||||
|
:mask-closable="!submitting"
|
||||||
|
preset="card"
|
||||||
|
:title="isEdit ? '编辑车辆' : '添加车辆'"
|
||||||
|
style="max-width: 520px"
|
||||||
|
@update:show="(v: boolean) => emit('update:show', v)"
|
||||||
|
>
|
||||||
|
<NForm ref="formRef" label-placement="top" :model="form">
|
||||||
|
<NFormItem label="名称" required>
|
||||||
|
<NInput v-model:value="form.name" placeholder="例如:我的夏" maxlength="64" show-count />
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<NFormItem label="品牌">
|
||||||
|
<NInput v-model:value="form.brand" placeholder="比亚迪" maxlength="64" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="型号">
|
||||||
|
<NInput v-model:value="form.model" placeholder="秦 PLUS DM-i" maxlength="64" />
|
||||||
|
</NFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NFormItem label="车牌号">
|
||||||
|
<NInput v-model:value="form.plate_number" placeholder="京A12345" maxlength="32" />
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<NFormItem label="能源类型" required>
|
||||||
|
<FuelSelect v-model="form.fuel_type" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="油箱容量(L)">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.tank_capacity"
|
||||||
|
:min="0"
|
||||||
|
:max="9999"
|
||||||
|
placeholder="48"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NFormItem label="当前里程(km)">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.current_odometer"
|
||||||
|
:min="0"
|
||||||
|
:precision="1"
|
||||||
|
placeholder="10000"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="设为默认车辆">
|
||||||
|
<NSwitch v-model:value="form.default_vehicle" />
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NButton :disabled="submitting" @click="onCancel">取消</NButton>
|
||||||
|
<NButton type="primary" :loading="submitting" @click="onSubmit">
|
||||||
|
{{ isEdit ? '保存' : '添加' }}
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// 能源类型下拉:内联写简单版,避免 NSelect + auto-import 反复来回
|
||||||
|
import { defineComponent, h, type PropType } from 'vue'
|
||||||
|
import { NSelect } from 'naive-ui'
|
||||||
|
|
||||||
|
const FUEL_OPTIONS = [
|
||||||
|
{ label: '汽油', value: 'gasoline' },
|
||||||
|
{ label: '柴油', value: 'diesel' },
|
||||||
|
{ label: '混动', value: 'hybrid' },
|
||||||
|
{ label: '纯电', value: 'electric' },
|
||||||
|
{ label: '插混', value: 'plug_in_hybrid' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FuelSelect = defineComponent({
|
||||||
|
name: 'FuelSelect',
|
||||||
|
props: {
|
||||||
|
modelValue: { type: String, default: 'gasoline' },
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
NSelect,
|
||||||
|
{
|
||||||
|
value: props.modelValue,
|
||||||
|
options: FUEL_OPTIONS,
|
||||||
|
'onUpdate:value': (v: string) => emit('update:modelValue', v),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,13 +1,134 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NEmpty } from 'naive-ui'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NEmpty,
|
||||||
|
NPopconfirm,
|
||||||
|
NSpin,
|
||||||
|
NTag,
|
||||||
|
useDialog,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { vehiclesApi } from '@/api/vehicles'
|
||||||
|
import { useVehicleStore } from '@/stores/vehicle'
|
||||||
|
import type { Vehicle } from '@/api/types'
|
||||||
|
import VehicleFormModal from '@/components/VehicleFormModal.vue'
|
||||||
|
|
||||||
|
const store = useVehicleStore()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editing = ref<Vehicle | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => store.loadList())
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(v: Vehicle) {
|
||||||
|
editing.value = v
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved(v: Vehicle) {
|
||||||
|
await store.loadList()
|
||||||
|
store.setCurrentId(v.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefault(v: Vehicle) {
|
||||||
|
try {
|
||||||
|
await store.setDefault(v.id)
|
||||||
|
message.success('已设为默认车辆')
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message || '设置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(v: Vehicle) {
|
||||||
|
dialog.warning({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `车辆「${v.name}」删除后无法恢复,确定吗?`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await store.remove(v.id)
|
||||||
|
message.success('已删除')
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message || '删除失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
</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/POST/PUT/DELETE /api/vehicles;POST /api/vehicles/:id/default
|
<NButton type="primary" @click="openCreate">+ 添加</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NSpin :show="store.loading">
|
||||||
|
<NEmpty
|
||||||
|
v-if="!store.loading && !store.list.length"
|
||||||
|
description="还没有车辆,点右上角加一辆"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<NCard
|
||||||
|
v-for="v in store.list"
|
||||||
|
:key="v.id"
|
||||||
|
hoverable
|
||||||
|
class="!rounded-2xl"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg font-semibold truncate">{{ v.name }}</span>
|
||||||
|
<NTag v-if="v.default_vehicle" type="success" size="small">默认</NTag>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-ink-soft mt-1 truncate">
|
||||||
|
{{ v.brand || '—' }} {{ v.model }}
|
||||||
|
<span v-if="v.plate_number"> · {{ v.plate_number }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-ink-soft mt-2 flex gap-3 flex-wrap">
|
||||||
|
<span>{{ v.fuel_type }}</span>
|
||||||
|
<span v-if="v.tank_capacity">油箱 {{ v.tank_capacity }} L</span>
|
||||||
|
<span v-if="v.current_odometer">{{ v.current_odometer }} km</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-3 flex-wrap">
|
||||||
|
<NButton size="small" @click="openEdit(v)">编辑</NButton>
|
||||||
|
<NButton
|
||||||
|
v-if="!v.default_vehicle"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
@click="setDefault(v)"
|
||||||
|
>
|
||||||
|
设为默认
|
||||||
|
</NButton>
|
||||||
|
<NPopconfirm
|
||||||
|
@positive-click="remove(v)"
|
||||||
|
positive-text="删除"
|
||||||
|
negative-text="取消"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<NButton size="small" type="error" ghost>删除</NButton>
|
||||||
|
</template>
|
||||||
|
确认删除「{{ v.name }}」?
|
||||||
|
</NPopconfirm>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
</NEmpty>
|
</NSpin>
|
||||||
|
|
||||||
|
<VehicleFormModal v-model:show="showForm" :editing="editing" @saved="onSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -54,12 +54,17 @@ export const useVehicleStore = defineStore('vehicle', () => {
|
|||||||
await loadList()
|
await loadList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCurrentId(id: number | null) {
|
||||||
|
currentId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
currentId,
|
currentId,
|
||||||
loading,
|
loading,
|
||||||
loadList,
|
loadList,
|
||||||
getCurrent,
|
getCurrent,
|
||||||
|
setCurrentId,
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
remove,
|
remove,
|
||||||
|
|||||||
Reference in New Issue
Block a user