表格 CRUD 页面实战
1. 为什么单独讲 CRUD 页面
后端每天写 Controller:list / get / create / update / delete。前端对应的就是管理后台的 CRUD 页面:
┌────────────────────────────────────┐
│ [搜索框] [筛选] [新增按钮] │
├────────────────────────────────────┤
│ ┌──────────────────────────────┐ │
│ │ ID │ 标题 │ 作者 │ 操作 │ │
│ │ 1 │ ... │ ... │ 编辑/删除 │ │
│ │ 2 │ ... │ ... │ ... │ │
│ └──────────────────────────────┘ │
│ [分页] │
└────────────────────────────────────┘
这张图一个项目能画 20 遍。早做一个能复用的模板,后面每个 CRUD 页面不用重复发明轮子。
Java 对照:类似你写的 BaseService<T>,每个业务 Service 继承就白嫖了一堆 CRUD 方法。前端也可以做到——但要"组件组合 + composable",不是 Java 的"继承 + 泛型"。
2. 接口约定:先和后端对齐
一个典型的笔记管理 CRUD:
// src/api/note.ts
import { request } from './request'
export interface Note {
id: number
title: string
content: string
category: string
status: 'draft' | 'published'
createdAt: string
updatedAt: string
}
export interface NotePageParams {
page: number
pageSize: number
keyword?: string
category?: string
status?: Note['status']
}
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}
export const noteApi = {
page: (params: NotePageParams) => request.get<PageResult<Note>>('/note/page', { params }),
get: (id: number) => request.get<Note>(`/note/${id}`),
create: (data: Omit<Note, 'id' | 'createdAt' | 'updatedAt'>) =>
request.post<Note>('/note/create', data),
update: (id: number, data: Partial<Note>) =>
request.put<Note>(`/note/update/${id}`, data),
remove: (id: number) => request.delete(`/note/delete/${id}`),
batchRemove: (ids: number[]) => request.post('/note/batchDelete', { ids }),
}
命名规范(应剑锋自定义要求):接口路径驼峰命名,/ledgerAdjustment/page,不用 /ledger-adjustment/page。
分页参数约定:page(页码,从 1 开始)+ pageSize(每页条数)。响应用统一的 PageResult<T> 包裹,list 放数据,total 放总数——前端不用到处判断数据结构。
3. 数据层:useTable composable
把"拉列表 + 分页 + 搜索"抽成一个 composable,所有 CRUD 页面复用:
// src/composables/useTable.ts
import { reactive, ref } from 'vue'
export interface PageResult<T> {
list: T[]
total: number
}
export interface UseTableOptions<Q, T> {
/** 拉数据的接口 */
api: (query: Q) => Promise<PageResult<T>>
/** 查询条件的初始值 */
initialQuery: Q
/** 是否挂载时自动加载 */
immediate?: boolean
}
export function useTable<Q extends { page: number; pageSize: number }, T>(
options: UseTableOptions<Q, T>,
) {
const { api, initialQuery, immediate = true } = options
const query = reactive({ ...initialQuery }) as Q
const list = ref<T[]>([])
const total = ref(0)
const loading = ref(false)
const loadData = async () => {
loading.value = true
try {
const { list: data, total: t } = await api(query)
list.value = data as any
total.value = t
} finally {
loading.value = false
}
}
/** 搜索:回到第 1 页重新加载 */
const search = () => {
query.page = 1
return loadData()
}
/** 重置查询条件 */
const reset = () => {
Object.assign(query, initialQuery)
return loadData()
}
/** 分页切换 */
const onPageChange = (page: number) => {
query.page = page
loadData()
}
const onSizeChange = (size: number) => {
query.page = 1
query.pageSize = size
loadData()
}
if (immediate) loadData()
return {
query,
list,
total,
loading,
loadData,
search,
reset,
onPageChange,
onSizeChange,
}
}
Java 对照:这相当于 PageHelper + BaseService 的前端版——关注点分离,业务代码只管"显示什么、怎么交互",分页/loading/查询条件这些脚手架代码复用。
4. 页面组件:搜索 + 表格 + 分页
<!-- src/pages/admin/NoteList.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search, Refresh } from '@element-plus/icons-vue'
import { noteApi, type Note, type NotePageParams } from '@/api/note'
import { useTable } from '@/composables/useTable'
import NoteEditDialog from './NoteEditDialog.vue'
const { query, list, total, loading, loadData, search, reset, onPageChange, onSizeChange } =
useTable<NotePageParams, Note>({
api: noteApi.page,
initialQuery: {
page: 1,
pageSize: 20,
keyword: '',
category: '',
status: undefined,
},
})
// 编辑弹窗
const dialogVisible = ref(false)
const currentNote = ref<Partial<Note> | null>(null)
const onCreate = () => {
currentNote.value = { title: '', content: '', category: '', status: 'draft' }
dialogVisible.value = true
}
const onEdit = (row: Note) => {
currentNote.value = { ...row }
dialogVisible.value = true
}
const onSaved = () => {
dialogVisible.value = false
loadData()
}
// 删除
const onDelete = async (row: Note) => {
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '提示', { type: 'warning' })
await noteApi.remove(row.id)
ElMessage.success('删除成功')
loadData()
}
// 批量操作
const selectedIds = ref<number[]>([])
const onSelectionChange = (rows: Note[]) => {
selectedIds.value = rows.map((r) => r.id)
}
const onBatchDelete = async () => {
if (!selectedIds.value.length) return
await ElMessageBox.confirm(`确定删除选中的 ${selectedIds.value.length} 条?`, '提示', {
type: 'warning',
})
await noteApi.batchRemove(selectedIds.value)
ElMessage.success('批量删除成功')
selectedIds.value = []
loadData()
}
</script>
<template>
<div class="space-y-4">
<!-- 搜索栏 -->
<el-card shadow="never">
<el-form :model="query" inline @submit.prevent="search">
<el-form-item label="关键词">
<el-input v-model="query.keyword" placeholder="标题/内容" clearable @clear="search" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="query.category" placeholder="全部" clearable class="w-40">
<el-option label="前端基础" value="前端基础" />
<el-option label="Vue 核心" value="Vue 核心" />
<el-option label="样式与布局" value="样式与布局" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable class="w-32">
<el-option label="草稿" value="draft" />
<el-option label="已发布" value="published" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格 -->
<el-card shadow="never">
<div class="mb-3 flex gap-2">
<el-button type="primary" :icon="Plus" @click="onCreate">新增</el-button>
<el-button
type="danger"
:icon="Delete"
:disabled="!selectedIds.length"
@click="onBatchDelete"
>
批量删除
</el-button>
</div>
<el-table
v-loading="loading"
:data="list"
border
stripe
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="category" label="分类" width="140" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'published' ? 'success' : 'info'">
{{ row.status === 'published' ? '已发布' : '草稿' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onEdit(row)">编辑</el-button>
<el-button link type="danger" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="mt-4 justify-end"
:current-page="query.page"
:page-size="query.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 编辑弹窗 -->
<NoteEditDialog v-model="dialogVisible" :note="currentNote" @saved="onSaved" />
</div>
</template>
几个细节值得讲:
4.1 @submit.prevent="search"
表单里按回车默认会触发原生 submit(可能刷新页面)。.prevent 阻止默认行为,改为调我们的 search()。
4.2 show-overflow-tooltip
列宽不够时 Element Plus 的表格会自动省略,并显示 tooltip。别用 CSS 的 text-overflow 自己搞,表格列的宽度是动态的。
4.3 fixed="right" 操作列固定
横向滚动时"操作列"始终可见。用户最常点的列优先固定。
4.4 :selection-change
批量操作的基石。选中行变化时触发,拿到选中行数组。
4.5 ElMessageBox.confirm 抛异常
用户点"取消"时 confirm 会 reject,await 会抛。不用写 try-catch——reject 之后 noteApi.remove 自然不会执行,正是我们想要的效果。
5. 防抖搜索:关键字边输入边搜
上面的"搜索按钮"是手动触发。很多场景需要输入时自动搜,但每敲一下字母就发请求会把接口打爆,要加防抖。
用 VueUse 的 useDebouncedRef:
import { watch } from 'vue'
import { refDebounced } from '@vueuse/core'
// 原始输入实时响应
const keyword = ref('')
// 防抖后的值,停止输入 300ms 后才更新
const debouncedKeyword = refDebounced(keyword, 300)
watch(debouncedKeyword, () => {
query.keyword = debouncedKeyword.value
search()
})
模板里绑定 keyword(原始值)即可:
<el-input v-model="keyword" placeholder="搜索..." clearable />
Java 对照:类似 RxJava 的 debounce(300, TimeUnit.MILLISECONDS)——只有流"停下来"时才发出最新值。
6. 编辑弹窗:共用表单组件
新增和编辑复用同一个弹窗,通过"传入的 note 是否有 id"判断模式:
<!-- src/pages/admin/NoteEditDialog.vue -->
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { noteApi, type Note } from '@/api/note'
const props = defineProps<{
modelValue: boolean
note: Partial<Note> | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEdit = computed(() => !!props.note?.id)
const title = computed(() => (isEdit.value ? '编辑笔记' : '新增笔记'))
const formRef = ref<FormInstance>()
const form = reactive<Partial<Note>>({
title: '',
content: '',
category: '',
status: 'draft',
})
const rules: FormRules = {
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ max: 100, message: '标题不超过 100 字', trigger: 'blur' },
],
category: [{ required: true, message: '请选择分类', trigger: 'change' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }],
}
// 打开弹窗时重置表单
watch(
() => props.modelValue,
(v) => {
if (v && props.note) {
Object.assign(form, {
title: '',
content: '',
category: '',
status: 'draft',
...props.note,
})
formRef.value?.clearValidate()
}
},
)
const saving = ref(false)
const onSave = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (isEdit.value) {
await noteApi.update(props.note!.id!, form)
ElMessage.success('更新成功')
} else {
await noteApi.create(form as any)
ElMessage.success('创建成功')
}
emit('saved')
} finally {
saving.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" :title="title" width="640px" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="分类" prop="category">
<el-select v-model="form.category" class="w-full">
<el-option label="前端基础" value="前端基础" />
<el-option label="Vue 核心" value="Vue 核心" />
<el-option label="样式与布局" value="样式与布局" />
</el-select>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="8" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio value="draft">草稿</el-radio>
<el-radio value="published">已发布</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</template>
</el-dialog>
</template>
关键点:
computed+modelValue实现v-model双向绑定(见 Vue 核心笔记)isEdit用!!id判断,不需要 props 再加个mode字段watch(modelValue, ...)重置表单:每次打开弹窗都重新初始化,避免"上次填的数据残留"clearValidate():清上次遗留的错误提示:close-on-click-modal="false":防止误点遮罩关闭,丢失填写的内容
7. 乐观更新:UI 先改,请求后台
删除一条数据,默认流程是"请求成功 → loadData() 重拉列表"。用户感觉有 500ms 延迟。
乐观更新:UI 立刻变,请求在后台发。失败再回滚。
const onDelete = async (row: Note) => {
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '提示', { type: 'warning' })
// 先从本地列表移除
const index = list.value.indexOf(row)
list.value.splice(index, 1)
total.value -= 1
try {
await noteApi.remove(row.id)
ElMessage.success('删除成功')
} catch (err) {
// 失败了恢复
list.value.splice(index, 0, row)
total.value += 1
ElMessage.error('删除失败')
}
}
什么时候该用乐观更新:
- ✅ 单条删除、切换状态、点赞收藏(快速反馈重要)
- ❌ 新增、批量操作、涉及 ID 的操作(ID 要等后端返回)
风险:前后端数据偶尔不一致(比如另一个管理员同时删了)。简单做法——每次操作后还是调一次 loadData(),兼顾手感和一致性。
8. 行内编辑(Inline Edit)
除了弹窗编辑,还有一种"点单元格直接改"的交互:
<el-table-column label="标题">
<template #default="{ row, $index }">
<el-input
v-if="editingRowIndex === $index"
v-model="editingRow.title"
@blur="saveInlineEdit"
@keyup.enter="saveInlineEdit"
/>
<span v-else @dblclick="startInlineEdit($index, row)">{{ row.title }}</span>
</template>
</el-table-column>
<script setup lang="ts">
const editingRowIndex = ref(-1)
const editingRow = ref<Partial<Note>>({})
const startInlineEdit = (index: number, row: Note) => {
editingRowIndex.value = index
editingRow.value = { ...row }
}
const saveInlineEdit = async () => {
if (editingRowIndex.value < 0) return
const row = list.value[editingRowIndex.value] as Note
if (editingRow.value.title === row.title) {
editingRowIndex.value = -1
return
}
await noteApi.update(row.id, { title: editingRow.value.title })
row.title = editingRow.value.title!
editingRowIndex.value = -1
ElMessage.success('保存成功')
}
</script>
适合:改字段少、操作高频的场景(如"调整排序号")。 不适合:字段多、需要校验的复杂表单(用弹窗)。
9. URL 同步:分页参数写入地址栏
用户筛选完条件刷新页面,参数没了要重选,体验差。把 query 同步到 URL:
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// 初始化时从 URL 读取
const initialQuery = {
page: Number(route.query.page) || 1,
pageSize: Number(route.query.pageSize) || 20,
keyword: (route.query.keyword as string) || '',
}
// 查询变化时写回 URL
watch(query, (newQuery) => {
router.replace({ query: { ...newQuery } })
}, { deep: true })
刷新后 URL 还在,分页和搜索自动恢复。同事发 URL 给你直接跳到对应的结果。
10. 拆分可复用:CrudTable 抽象组件(进阶)
如果你的系统里 CRUD 页面超过 5 个,可以把壳抽出来:
<!-- src/components/CrudTable.vue -->
<script setup lang="ts" generic="T">
defineProps<{
data: T[]
total: number
loading: boolean
query: { page: number; pageSize: number }
}>()
defineEmits<{
pageChange: [page: number]
sizeChange: [size: number]
}>()
</script>
<template>
<div class="space-y-4">
<div class="flex justify-between">
<slot name="toolbar" />
<slot name="actions" />
</div>
<el-table v-loading="loading" :data="data" border stripe>
<slot />
</el-table>
<el-pagination
class="justify-end"
:current-page="query.page"
:page-size="query.pageSize"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => $emit('pageChange', p)"
@size-change="(s: number) => $emit('sizeChange', s)"
/>
</div>
</template>
用起来:
<CrudTable :data="list" :total="total" :loading="loading" :query="query">
<template #actions>
<el-button type="primary" @click="onCreate">新增</el-button>
</template>
<el-table-column prop="id" label="ID" />
<el-table-column prop="title" label="标题" />
</CrudTable>
要不要抽:
- 页面超过 5 个 → 抽
- 前 3 个页面 → 先直写,等模式清晰再抽(否则过度设计)
11. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
| 搜索后列表不刷新 | search() 没重置 page=1 |
search 里强制 query.page = 1 |
| 分页切换到第 2 页,搜索后还在第 2 页但数据不对 | 同上 | 同上 |
| 弹窗关闭后再打开还是上次的数据 | 关闭时没重置 form | watch(modelValue, ...) 里 Object.assign(form, initial) |
| 表格列自适应宽度一直抖动 | 多列没设 width 或 min-width | 关键列固定宽度,弹性列用 min-width |
el-table 在 flex 布局里撑爆父容器 |
<el-table> 默认宽度无限 |
父级加 min-width: 0(见 CSS 基础笔记 §14) |
| 批量删除按钮 loading 不转 | 异步操作没 await 或没挂 :loading |
给按钮绑 :loading="batchDeleting" |
12. 小决策表
| 需求 | 方案 |
|---|---|
| 简单 CRUD | useTable + 页面组件 + 弹窗 |
| 搜索框多 | 折叠高级搜索(超过 4 个条件时折起来) |
| 字段多编辑 | 弹窗或详情页 |
| 字段少高频改 | 行内编辑 |
| 改一条后不想重拉整页 | 乐观更新 + 本地 splice |
| 筛选条件重要 | URL 同步 |
| 页面超过 5 个 | 抽 CrudTable 组件 |
小练习
- 在 in4vue 里建
noteApi和useTable,接一个模拟的分页接口(可以用setTimeout + 假数据) - 实现
NoteList.vue完整页面,包含搜索、分页、新增、编辑、删除 - 把搜索改成防抖输入模式(
refDebounced) - 给"删除"操作加乐观更新
- 把
query同步到 URL,刷新后条件还在
延伸阅读
- Element Plus - Table
- Element Plus - Pagination
- VueUse - refDebounced
- TanStack Table(想做更复杂的表格时参考,支持虚拟滚动、列拖拽等)
- vue-element-admin - Table 示例(源码可以借鉴)