表格 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 抛异常

用户点"取消"时 confirmrejectawait 会抛。不用写 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>

关键点


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('删除失败')
  }
}

什么时候该用乐观更新

风险:前后端数据偶尔不一致(比如另一个管理员同时删了)。简单做法——每次操作后还是调一次 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>

要不要抽


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 组件

小练习

  1. 在 in4vue 里建 noteApiuseTable,接一个模拟的分页接口(可以用 setTimeout + 假数据
  2. 实现 NoteList.vue 完整页面,包含搜索、分页、新增、编辑、删除
  3. 把搜索改成防抖输入模式(refDebounced
  4. 给"删除"操作加乐观更新
  5. query 同步到 URL,刷新后条件还在

延伸阅读