11. 标准增删改查页面开发(从 0 到 1)
前端手册增删改查大约 9 分钟约 2575 字
一句话定位
照着本篇的骨架复制改名,就能产出一个符合项目规范的列表页:查询卡 → 表格卡 → 弹窗,含权限点、菜单配置与自测清单。
整体链路
后端写接口(Swagger) 前端生成契约 前端写页面 系统配置
───────────────── ────────────── ────────────── ─────────────
SysDemoController ──► pnpm buildApi ──► views/system/demo/ ──► 菜单管理配菜单
/page /add /update api-services/… ├─ index.vue (path+component)
/delete ──► SysDemoApi ──► └─ component/
editDemo.vue
│
后端菜单「按钮权限」 ──► userInfo.apis ──► v-auth / auth() 控制按钮显隐 ──► 角色授权一、目录约定
src/views/system/demo/
├── index.vue # 列表页:<script setup name="sysDemo">
└── component/
├── editDemo.vue # 新增/编辑弹窗:<script setup name="sysEditDemo">
└── (可选)detailDemo.vue # 详情抽屉| 约定 | 说明 |
|---|---|
| 目录名 | 与后端控制器同名小写(/api/sysDemo/** → views/system/demo) |
name | 列表页 name="sysDemo",弹窗 name="sysEditDemo";name 由 vite-plugin-vue-setup-extend 注入,keep-alive 依赖它 |
菜单 component | 填 system/demo/index(不带 views/、不带 .vue) |
| 弹窗文件 | 放 component/ 子目录,避免被 import.meta.glob('../views/**/*.{vue,tsx}') 与列表页路径冲突(前缀匹配唯一命中) |
二、查询卡(第一个 el-card)
<template>
<div class="sys-demo-container">
<el-card shadow="hover" :body-style="{ padding: '5px 5px 0 5px', display: 'flex', width: '100%', height: '100%', alignItems: 'start' }">
<el-form :model="state.queryParams" ref="queryForm" :show-message="false" :inlineMessage="true" label-width="auto" style="flex: 1 1 0%">
<el-row :gutter="10">
<el-col class="mb5" :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="名称" prop="name">
<el-input v-model="state.queryParams.name" placeholder="名称" clearable @keyup.enter.native="handleQuery(true)" />
</el-form-item>
</el-col>
<el-col class="mb5" :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="状态" prop="status">
<g-sys-dict v-model="state.queryParams.status" code="sys_status" render-as="select" clearable placeholder="状态" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider style="height: calc(100% - 5px); margin: 0 10px" direction="vertical" />
<el-row>
<el-col>
<el-button-group>
<el-button type="primary" icon="ele-Search" @click="handleQuery(true)" v-auth="'sysDemo/page'" :loading="options.loading"> 查询 </el-button>
<el-button icon="ele-Refresh" @click="resetQuery" :loading="options.loading"> 重置 </el-button>
</el-button-group>
</el-col>
</el-row>
</el-card>要点:
label-width="auto",el-row :gutter="10"+el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6"(4 列响应式)。- 每个输入都加
clearable+@keyup.enter.native="handleQuery(true)"。 - 查询按钮挂
v-auth="'sysDemo/page'"与:loading="options.loading"。 - 查询区控件宽度由
App.vue全局变量--next-search-control-width统一,不要写死 171px。
三、表格卡(第二个 el-card)
<el-card class="full-table" shadow="hover" style="margin-top: 5px">
<vxe-grid ref="xGrid" class="xGrid-style" v-bind="options" v-on="gridEvents">
<template #toolbar_buttons>
<el-button type="primary" icon="ele-Plus" @click="handleAdd" v-auth="'sysDemo/add'"> 新增 </el-button>
</template>
<template #toolbar_tools></template>
<template #empty><el-empty :image-size="200" /></template>
<template #row_status="{ row }">
<el-tag v-if="row.status === 1" type="success">启用</el-tag>
<el-tag v-else type="danger">禁用</el-tag>
</template>
<template #row_record="{ row }"><ModifyRecord :data="row" /></template>
<template #row_buttons="{ row }">
<el-tooltip content="编辑" placement="top">
<el-button icon="ele-Edit" text type="primary" v-auth="'sysDemo/update'" @click="handleEdit(row)"></el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button icon="ele-Delete" text type="danger" v-auth="'sysDemo/delete'" @click="handleDelete(row)"></el-button>
</el-tooltip>
</template>
</vxe-grid>
</el-card>
<EditDemo ref="editDemoRef" :title="state.title" @updateData="updateData" />
</div>
</template>要点:class="full-table" 必须有(否则表格撑不满、滚动条跑到页面上);表格细节见「vxe-grid 表格开发全解」。
四、弹窗组件(component/editDemo.vue)
<template>
<div>
<el-dialog v-model="state.isShowDialog" draggable overflow destroy-on-close width="700px">
<template #header>
<div style="color: #fff">
<el-icon size="16" style="margin-right: 3px; display: inline; vertical-align: middle"> <ele-Edit /> </el-icon>
<span> {{ props.title }} </span>
</div>
</template>
<el-form :model="state.ruleForm" ref="ruleFormRef" label-width="auto">
<el-row :gutter="10">
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="名称" prop="name" :rules="[{ required: true, message: '名称不能为空', trigger: 'blur' }]">
<el-input v-model="state.ruleForm.name" placeholder="名称" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="状态" prop="status" :rules="[{ required: true, message: '状态不能为空', trigger: 'blur' }]">
<g-sys-dict v-model="state.ruleForm.status" code="sys_status" render-as="radio" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="排序">
<el-input-number v-model="state.ruleForm.orderNo" placeholder="排序" class="w100" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button icon="ele-CircleCloseFilled" @click="cancel">取消</el-button>
<el-button type="primary" icon="ele-CircleCheckFilled" @click="submit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup name="sysEditDemo">
import { reactive, ref } from 'vue';
import { getAPI } from '/@/utils/axios-utils';
import { SysDemoApi } from '/@/api-services/system/api';
import { UpdateDemoInput } from '/@/api-services/system/models';
const props = defineProps({ title: String });
const emits = defineEmits(['updateData']);
const ruleFormRef = ref();
const state = reactive({ isShowDialog: false, ruleForm: {} as UpdateDemoInput });
// 打开弹窗:深拷贝一行数据,避免直接改到表格行
const openDialog = (row: any) => {
state.ruleForm = JSON.parse(JSON.stringify(row));
state.isShowDialog = true;
ruleFormRef.value?.resetFields();
};
const closeDialog = () => {
emits('updateData');
state.isShowDialog = false;
};
const cancel = () => { state.isShowDialog = false; };
const submit = () => {
ruleFormRef.value.validate(async (valid: boolean) => {
if (!valid) return;
if (state.ruleForm.id != undefined && state.ruleForm.id > 0) {
await getAPI(SysDemoApi).apiSysDemoUpdatePost(state.ruleForm);
} else {
await getAPI(SysDemoApi).apiSysDemoAddPost(state.ruleForm);
}
closeDialog();
});
};
defineExpose({ openDialog });
</script>五、script 完整骨架(index.vue)
<script lang="ts" setup name="sysDemo">
import { reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { VxeGridInstance, VxeGridListeners, VxeGridPropTypes } from 'vxe-table';
import { useVxeTable, useGridPageParam, gridPageEvents, useGridQuery } from '/@/hooks/useVxeTableOptionsHook';
import { auth } from '/@/utils/authFunction';
import EditDemo from '/@/views/system/demo/component/editDemo.vue';
import ModifyRecord from '/@/components/table/modifyRecord.vue';
import { getAPI } from '/@/utils/axios-utils';
import { SysDemoApi } from '/@/api-services/system/api';
import { SysDemo, PageDemoInput } from '/@/api-services/system/models';
// ① 表格实例 / 弹窗实例
const xGrid = ref<VxeGridInstance>();
const editDemoRef = ref<InstanceType<typeof EditDemo>>();
// ② 本地分页偏好(必须在 state 与 options 之前)
const { key: localPageParamKey, param: localPageParam } = useGridPageParam('sysDemo', {
pageSize: 50 as number,
defaultSort: { field: 'orderNo', order: 'asc', descStr: 'desc' },
});
// ③ 页面状态
const state = reactive({
queryParams: { name: undefined, status: undefined },
localPageParam,
title: '',
});
// ④ 表格参数
const options = useVxeTable<SysDemo>(
{
id: 'sysDemo',
name: '示例管理',
columns: [
{ field: 'seq', type: 'seq', title: '序号', width: 60, fixed: 'left' },
{ field: 'name', title: '名称', minWidth: 200, headerAlign: 'center', align: 'left', showOverflow: 'tooltip', sortable: true },
{ field: 'status', title: '状态', width: 80, showOverflow: 'tooltip', sortable: true, slots: { default: 'row_status' } },
{ field: 'orderNo', title: '排序', width: 80, showOverflow: 'tooltip', sortable: true },
{ field: 'record', title: '修改记录', width: 100, showOverflow: 'tooltip', slots: { default: 'row_record' } },
{ field: 'buttons', title: '操作', fixed: 'right', width: 'auto', showOverflow: true, slots: { default: 'row_buttons' } },
],
},
{
proxyConfig: { autoLoad: true, ajax: { query: ({ page, sort }) => handleQueryApi(page, sort) } },
sortConfig: { defaultSort: localPageParam.defaultSort },
pagerConfig: { pageSize: localPageParam.pageSize },
toolbarConfig: { export: true },
}
);
// ⑤ 查询 API
const handleQueryApi = async (page: VxeGridPropTypes.ProxyAjaxQueryPageParams, sort: VxeGridPropTypes.ProxyAjaxQuerySortCheckedParams) => {
const params = Object.assign(state.queryParams, {
page: page.currentPage,
pageSize: page.pageSize,
field: sort.field,
order: sort.order,
descStr: 'desc',
}) as PageDemoInput;
return getAPI(SysDemoApi).apiSysDemoPagePost(params);
};
// ⑥ 查询 / 重置
const handleQuery = useGridQuery(xGrid, options);
const resetQuery = async () => {
state.queryParams.name = undefined;
state.queryParams.status = undefined;
await xGrid.value?.commitProxy('reload');
};
// ⑦ 增 / 改 / 删
const handleAdd = () => { state.title = '添加示例'; editDemoRef.value?.openDialog({ status: 1, orderNo: 100 }); };
const handleEdit = (row: any) => { state.title = '编辑示例'; editDemoRef.value?.openDialog(row); };
const handleDelete = (row: any) => {
ElMessageBox.confirm(`确定删除:${row.name}?`, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
.then(async () => {
await getAPI(SysDemoApi).apiSysDemoDeletePost({ id: row.id });
handleQuery();
ElMessage.success('删除成功');
})
.catch(() => {});
};
// ⑧ 表格事件
const gridEvents: VxeGridListeners<SysDemo> = {
...gridPageEvents(localPageParamKey, localPageParam),
async cellDblclick({ row }) { if (auth('sysDemo/update')) await handleEdit(row); },
};
// ⑨ 弹窗回调:刷新列表
const updateData = async () => { await handleQuery(); };
</script>六、权限点同步
权限点字符串来自后端接口 /api/sysAuth/userInfo 返回的 apis,落到 useUserInfo().userInfos.authApiList。
| 用法 | 场景 | 示例 |
|---|---|---|
v-auth="'sysDemo/add'" | 模板中按钮显隐(mounted 时直接移除 DOM) | <el-button v-auth="'sysDemo/add'"> |
v-auths="['a','b']" | 满足其一即显示 | <el-button v-auths="['sysDemo/add','sysDemo/import']"> |
v-auth-all="['a','b']" | 全部满足才显示 | 同上 |
auth('sysDemo/update') | 脚本中判断(不移除 DOM,只做逻辑分支) | if (auth('sysDemo/update')) handleEdit(row) |
auths([...]) / authAll([...]) | 脚本中多权限判断 | - |
命名约定:模块名/动作,与后端接口路由对应 —— sysDemo/page、sysDemo/add、sysDemo/update、sysDemo/delete。
⚠️
v-auth是在mounted时el.parentNode.removeChild(el),它不会响应式更新。权限是登录时一次性拉全的,所以够用;但如果你在运行时动态改了authApiList,已渲染的按钮不会重新出现。
七、菜单配置与授权
在系统「菜单管理」中新增菜单(后端控制路由):
| 字段 | 填写 |
|---|---|
| 菜单类型 | 目录 / 菜单 / 按钮(按钮类型 type == 3 不会进快捷入口候选池) |
path | /system/demo,浏览器地址栏 hash 路由 |
component | system/demo/index(不带 views/、不带 .vue) |
name | sysDemo,必须与页面 <script setup name="..."> 一致,keep-alive 依赖它 |
meta.title | 菜单名(可填国际化 key,如 message.router.sysDemo) |
meta.icon | iconfont icon-xxx 或 ele-xxx |
meta.isHide | 是否隐藏(隐藏仍可跳转) |
meta.isKeepAlive | 是否缓存 |
meta.isAffix | 是否固定在 tagsView |
meta.isLink / isIframe | 外链 / 内嵌 iframe |
| 权限标识 | 按钮级菜单填 sysDemo/add 这类权限点 |
授权:角色管理 → 勾选该菜单与按钮权限 → 用户绑定角色 → 重新登录(权限在 initBackEndControlRoutes() 时随 setUserInfos() 拉取)。
⚠️ component 与 views/ 下的实际文件必须前缀唯一匹配:dynamicImport() 过滤 import.meta.glob 的 key,命中 0 个或 >1 个都会失败(返回 undefined / false)导致白屏。
八、联调与自测清单
打通前:
功能:
权限:
体验:
⚠️ 注意事项 / 常见坑
openDialog(row)一定要深拷贝(JSON.parse(JSON.stringify(row))),否则弹窗里改值会实时改到表格行。id判断要注意 0:if (state.ruleForm.id != undefined && state.ruleForm.id > 0)才是编辑,别只写if (id)。- 删除后调用
handleQuery()不带参(当前页刷新),点查询按钮才传true。 - 弹窗必须
destroy-on-close:否则表单残留校验态与旧数据。 defineExpose({ openDialog })不能漏,父组件靠editDemoRef.value?.openDialog()打开。- 不要在
views/下放与列表页同前缀的无关文件,会破坏import.meta.glob唯一命中。 v-auth只在模板层生效;脚本里的分支判断用auth()。- 接口默认失败不抛异常,删除/提交这类需要中断后续流程的调用请加
{ customCatch: true }或用feature()。
小结
- 页面 = 查询卡 + 表格卡 + 弹窗,三段结构是项目硬规范。
- 骨架里只需替换三处:Api 类名、模型类型名、
id/权限点前缀。 - 权限靠
v-auth+auth(),权限点命名模块/动作,与后端接口路由对齐。 - 菜单
component=views/之后的路径(不带扩展名),且必须唯一命中。