外观
页面开发范式
概述
本章以「岗位管理」为例,讲解管理后台最典型的页面形态:列表 + 搜索 + 新增/编辑弹窗 + 删除/导出。掌握这一范式后,绝大多数后台模块(用户、角色、字典、日志……)的开发流程都可以照搬。
下面的章节默认你已经了解「组件与 UI-Kit」中提到的 useVbenForm、useVbenModal、useVbenDrawer 与 VbenFormSchema 等基础能力,本章聚焦它们如何组合成一个完整页面。
一个标准页面的组成
一个 CRUD 页面通常在 views/<模块>/<功能> 下由三到四部分构成:
| 文件 | 职责 |
|---|---|
index.vue | 页面入口:组装表格、搜索表单、工具栏按钮、弹窗 |
data.ts | 声明式描述:搜索表单 schema、表格列、新增/编辑表单 schema |
modules/form.vue | 新增/编辑弹窗内容(表单 + Modal 生命周期) |
api/<模块>/index.ts | 接口请求与类型定义(见「接口层」章节) |
简单功能(如岗位)只需上述三件套;复杂功能会进一步拆出
modules/下的多个子组件或抽屉。
接口层
页面所有数据请求都收敛在 api/<模块>/index.ts,与后端 REST 风格接口一一对应。约定:用 namespace 暴露数据类型,用 requestClient 发起请求,分页统一使用 @vben/request 的 PageParam / PageResult。
ts
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace SystemPostApi {
export interface Post {
id?: number;
name: string;
code: string;
sort: number;
status: number;
remark: string;
createTime?: Date;
}
}
/** 分页查询 */
export function getPostPage(params: PageParam) {
return requestClient.get<PageResult<SystemPostApi.Post>>(
'/system/post/page',
{ params },
);
}
export function createPost(data: SystemPostApi.Post) {
return requestClient.post('/system/post/create', data);
}
export function updatePost(data: SystemPostApi.Post) {
return requestClient.put('/system/post/update', data);
}
export function deletePost(id: number) {
return requestClient.delete(`/system/post/delete?id=${id}`);
}接口层只负责「请求 + 类型」,不掺杂任何 UI 逻辑。页面通过 import { getPostPage, createPost } from '#/api/system/post' 调用,响应结构由 requestClient 自动归一化(详见「接口请求与适配层」)。
列表页
列表页通过 useVbenVxeGrid 拿到表格与它的命令式 api。表格同时承载「搜索表单」与「数据表格」两件事物:搜索表单走 formOptions,数据走 gridOptions.proxyConfig.ajax.query。
ts
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: { schema: useGridFormSchema() },
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPostPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues, // 自动合并搜索表单的值
});
},
},
},
rowConfig: { keyField: 'id', isHover: true },
toolbarConfig: { refresh: true, search: true },
} as VxeTableGridOptions<SystemPostApi.Post>,
});要点:
proxyConfig.ajax.query返回的分页对象会被框架自动解析(items+total),无需手写赋值。gridApi.query()用于手动刷新(如弹窗保存成功后);gridApi.formApi.getValues()可取当前搜索条件,用于导出。- 全局已默认开启分页、刷新、缩放、列自定义,无需每个页面重复配置。
模板中表格与弹窗并列放置,弹窗通过 @success 触发刷新:
html
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="岗位列表">
<!-- 工具栏、行操作插槽见下 -->
</Grid>
</Page>
</template>搜索表单
搜索表单在 data.ts 中用 useGridFormSchema() 声明,与新增/编辑表单共用 VbenFormSchema 写法,但字段只描述查询条件,一般不带 rules。
ts
export function useGridFormSchema(): VbenFormSchema[] {
return [
{ fieldName: 'name', label: '岗位名称', component: 'Input',
componentProps: { placeholder: '请输入岗位名称', clearable: true } },
{ fieldName: 'code', label: '岗位编码', component: 'Input',
componentProps: { placeholder: '请输入岗位编码', clearable: true } },
{ fieldName: 'status', label: '岗位状态', component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择岗位状态', clearable: true,
} },
];
}表格内置的搜索按钮会自动收集这些字段的值并传给 query。下拉选项来自数据字典时,优先用 getDictOptions(DICT_TYPE.xxx),而不是手写选项数组。
表格列
列在 useGridColumns() 中声明,是标准 vxe-table 列配置。项目额外注册了一批单元格渲染器与格式化器,避免手写模板。
ts
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{ field: 'name', title: '岗位名称', minWidth: 200 },
{ field: 'status', title: '岗位状态', minWidth: 100,
cellRender: { name: 'CellDict', props: { type: DICT_TYPE.COMMON_STATUS } } },
{ field: 'createTime', title: '创建时间', minWidth: 180, formatter: 'formatDateTime' },
{ title: '操作', width: 130, fixed: 'right', slots: { default: 'actions' } },
];
}常用内置能力:
| 能力 | 用法 | 说明 |
|---|---|---|
CellDict | cellRender: { name: 'CellDict', props: { type } } | 字典值渲染为彩色标签 |
CellSwitch | cellRender: { name: 'CellSwitch', props: { beforeChange } } | 行内开关,支持异步切换前校验 |
CellImage / CellTag / CellTags | cellRender: { name: 'CellImage' } 等 | 图片、单标签、多标签渲染 |
formatDateTime / formatPast2 | formatter: 'formatDateTime' | 时间、相对时间格式化 |
formatAmount2/3、formatFileSize、formatFenToYuanAmount | formatter: 'formatAmount2' | 金额、文件大小等格式化 |
工具栏与行操作
工具栏按钮、行内操作都用 TableAction 组件,通过 actions 数组声明。auth 字段声明权限编码,无权限时按钮自动隐藏(与「权限集成」章节呼应);popConfirm 用于删除等二次确认。
html
<Grid table-title="岗位列表">
<template #toolbar-tools>
<TableAction
:actions="[
{ label: $t('ui.actionTitle.create', ['岗位']), type: 'primary',
icon: ACTION_ICON.ADD, auth: ['system:post:create'], onClick: handleCreate },
{ label: $t('ui.actionTitle.export'), type: 'primary',
icon: ACTION_ICON.DOWNLOAD, auth: ['system:post:export'], onClick: handleExport },
{ label: $t('ui.actionTitle.deleteBatch'), type: 'danger',
icon: ACTION_ICON.DELETE, auth: ['system:post:delete'],
disabled: isEmpty(checkedIds), onClick: handleDeleteBatch },
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{ label: $t('common.edit'), type: 'primary', link: true,
icon: ACTION_ICON.EDIT, auth: ['system:post:update'], onClick: handleEdit.bind(null, row) },
{ label: $t('common.delete'), type: 'danger', link: true,
icon: ACTION_ICON.DELETE, auth: ['system:post:delete'],
popConfirm: { title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row) } },
]"
/>
</template>
</Grid>批量操作通过 gridEvents 监听行勾选,收集主键后调用批量删除接口:
ts
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({ records }: { records: SystemPostApi.Post[] }) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
/* ... */
gridEvents: { checkboxAll: handleRowCheckboxChange, checkboxChange: handleRowCheckboxChange },
});删除、批量删除等危险操作统一用 ElLoading 包裹请求并显示动作文案,结束后提示并刷新:
ts
async function handleDelete(row: SystemPostApi.Post) {
const loading = ElLoading.service({ text: $t('ui.actionMessage.deleting', [row.name]) });
try {
await deletePost(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loading.close();
}
}独立的二次确认(如批量删除、清空)使用 confirm(...) 工具函数。
新增与编辑弹窗
新增/编辑放在 modules/form.vue,通过 useVbenModal 管理生命周期,内部用 useVbenForm 渲染表单。列表页用 connectedComponent 把「弹窗外壳」与「弹窗内容」解耦。
列表页:
ts
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form, // 弹窗内容组件
destroyOnClose: true,
});
function handleCreate() { formModalApi.setData(null).open(); }
function handleEdit(row: SystemPostApi.Post) { formModalApi.setData(row).open(); }弹窗内容组件:
ts
const [Form, formApi] = useVbenForm({
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false, // 提交交由 Modal 确认按钮
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen) {
if (!isOpen) return;
const data = modalApi.getData<SystemPostApi.Post>();
if (data?.id) {
modalApi.lock();
try {
formData.value = await getPost(data.id); // 编辑时拉详情
await formApi.setValues(formData.value); // 回填
} finally { modalApi.unlock(); }
}
},
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
modalApi.lock();
try {
const data = (await formApi.getValues()) as SystemPostApi.Post;
await (formData.value?.id ? updatePost(data) : createPost(data));
await modalApi.close();
emit('success'); // 通知列表页刷新
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally { modalApi.unlock(); }
},
});新增与编辑共用同一套表单,靠「是否带 id」区分:编辑时外部传入行数据,onOpenChange 拉取详情并回填;新增时数据为空。提交后用 emit('success') 通知列表页 handleRefresh。
表单 schema(useFormSchema)与搜索表单写法一致,但业务字段需要 rules 校验,隐藏主键等不需要用户编辑的字段可用 dependencies 隐藏:
ts
{ component: 'Input', fieldName: 'id',
dependencies: { triggerFields: [''], show: () => false } },
{ component: 'Input', fieldName: 'name', label: '岗位名称',
componentProps: { placeholder: '请输入岗位名称' }, rules: 'required' },抽屉
当内容较宽或需要并排展示(如详情、仓库选择)时,用 useVbenDrawer 替代 Modal,API 与 Modal 完全一致。
ts
const [Drawer, drawerApi] = useVbenDrawer({ connectedComponent: DetailPanel });
function openDetail() { drawerApi.open(); }抽屉同样支持 onOpenChange / onConfirm / lock / unlock 以及 getData / setData。它适合「从列表或侧栏打开、展示/选择较多内容」的场景,例如 AI 对话页的角色仓库。
导出
导出复用当前搜索条件,调用后端导出接口拿到 Blob 后下载:
ts
async function handleExport() {
const data = await exportPost(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '岗位.xls', source: data });
}后端导出接口通过 requestClient.download 返回二进制流,前端无需关心解析。
完整数据流
最佳实践
- 页面一律拆成
index.vue+data.ts+modules/,把 schema 与渲染分离,index.vue只编排表格和按钮,data.ts只描述字段,modules/只处理弹窗逻辑。 - 列表与弹窗通过
connectedComponent解耦,弹窗用emit('success')通知刷新,而不是在弹窗内部直接操作表格。 - 提交用
modalApi.lock()/unlock()包裹,避免重复提交与提前关闭;编辑时优先后端拉详情再回填,保证数据最新。 - 下拉、标签、时间等一律使用内置渲染器(
CellDict、CellSwitch)和格式化器(formatDateTime),不要在列里手写模板。 - 危险操作(删除、批量删除)必须
popConfirm或confirm二次确认,并用ElLoading兜底。 - 按钮统一带
auth权限编码,前端隐藏只是体验优化,真正鉴权在后端。 - 文案统一走
$t(...),动作提示、标题用ui.actionMessage/ui.actionTitle命名空间,保持多语言一致。