P18 | Element Plus 通用 CRUD 页面模板:一个模板覆盖 80% 管理页面
💰 付费文章 | 第三阶段:Web 管理台
管理台页面的共性
几乎所有管理台页面都是这个模式:
┌─────────────────────────────────────┐
│ 搜索区域(表单筛选) │
│ [关键词] [城市▼] [搜索] [重置] [新增] │
├─────────────────────────────────────┤
│ 数据表格 │
│ ☐ | 名称 | 城市 | 状态 | 创建时间 | 操作 │
│ ☐ | 青岛海底世界 | 青岛 | 有效 | ... | 编辑 删除 │
│ ☐ | 金沙滩 | 青岛 | 有效 | ... | 编辑 删除 │
├─────────────────────────────────────┤
│ 分页器 │
│ ← 1 2 3 ... 10 → 共100条 │
└─────────────────────────────────────┘
点击「新增」→ 弹出对话框 → 填写表单 → 提交保存
点击「编辑」→ 弹出对话框 → 回显数据 → 提交保存
80% 的页面都可以用同一个模板!
通用 CRUD 页面组件
<template>
<div class="crud-page">
<!-- 搜索区域 -->
<el-card class="search-card" shadow="never">
<el-form :model="searchForm" inline>
<slot name="search">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="请输入" clearable />
</el-form-item>
</slot>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button v-permission="addPermission" type="success" :icon="Plus" @click="handleAdd">
新增
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card shadow="never" class="table-card">
<el-table :data="tableData" stripe v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<slot name="columns">
<el-table-column prop="id" label="ID" width="200" />
<el-table-column prop="name" label="名称" />
</slot>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-permission="editPermission" type="primary" link @click="handleEdit(row)">
编辑
</el-button>
<el-popconfirm title="确定删除?" @confirm="handleDelete(row)">
<template #reference>
<el-button v-permission="deletePermission" type="danger" link>删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
class="mt-4 justify-end"
@size-change="loadData"
@current-change="loadData"
/>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" @close="resetForm">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<slot name="form" :formData="formData">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入" />
</el-form-item>
</slot>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Search, Plus } from '@element-plus/icons-vue'
import { post } from '@/api/pikachuNetwork'
const props = defineProps({
listApi: { type: String, required: true }, // 列表接口路径
saveApi: { type: String, required: true }, // 保存接口路径
removeApi: { type: String, required: true }, // 删除接口路径
addPermission: { type: String, default: '' },
editPermission: { type: String, default: '' },
deletePermission: { type: String, default: '' },
})
const emit = defineEmits(['afterSave', 'beforeLoad'])
const searchForm = reactive({ keyword: '' })
const tableData = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增')
const formData = reactive({})
const formRules = reactive({})
const formRef = ref(null)
const submitting = ref(false)
async function loadData() {
loading.value = true
try {
const params = {
page: page.value,
pageSize: pageSize.value,
...searchForm
}
emit('beforeLoad', params)
const result = await post(props.listApi, params)
tableData.value = result.records || []
total.value = result.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
loadData()
}
function handleReset() {
Object.keys(searchForm).forEach(key => searchForm[key] = '')
page.value = 1
loadData()
}
function handleAdd() {
dialogTitle.value = '新增'
Object.keys(formData).forEach(key => formData[key] = null)
dialogVisible.value = true
}
function handleEdit(row) {
dialogTitle.value = '编辑'
Object.assign(formData, { ...row })
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value?.validate()
submitting.value = true
try {
await post(props.saveApi, { ...formData })
ElMessage.success('保存成功')
dialogVisible.value = false
loadData()
emit('afterSave', formData)
} finally {
submitting.value = false
}
}
async function handleDelete(row) {
await post(props.removeApi, { id: row.id })
ElMessage.success('删除成功')
loadData()
}
onMounted(loadData)
defineExpose({ loadData, searchForm })
</script>
使用模板:景点管理页面
<!-- views/town/AttractionList.vue -->
<template>
<CrudPage
listApi="/plat/attraction/page"
saveApi="/plat/attraction/save"
removeApi="/plat/attraction/remove"
addPermission="attraction:add"
editPermission="attraction:edit"
deletePermission="attraction:delete"
@beforeLoad="addSearchParams"
>
<!-- 自定义搜索字段 -->
<template #search>
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="景点名称" clearable />
</el-form-item>
<el-form-item label="城市">
<el-select v-model="searchForm.cityId" placeholder="全部" clearable>
<el-option v-for="city in cities" :key="city.id" :label="city.name" :value="city.id" />
</el-select>
</el-form-item>
</template>
<!-- 自定义表格列 -->
<template #columns>
<el-table-column prop="attName" label="景点名称" min-width="200" />
<el-table-column prop="cityName" label="城市" width="120" />
<el-table-column label="封面" width="100">
<template #default="{ row }">
<el-image :src="row.coverImage" :preview-src-list="[row.coverImage]" style="width:60px;height:40px" fit="cover" />
</template>
</el-table-column>
<el-table-column prop="ticketInfo" label="票价" width="120" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.flag === 1 ? 'success' : 'danger'">
{{ row.flag === 1 ? '有效' : '已删' }}
</el-tag>
</template>
</el-table-column>
</template>
</CrudPage>
</template>