Skip to content

页面开发范式 ​

概述 ​

本章以「岗位管理」为例,讲解管理后台最典型的页面形态:列表 + 搜索 + 新增/编辑弹窗 + 删除/导出。掌握这一范式后,绝大多数后台模块(用户、角色、字典、日志……)的开发流程都可以照搬。

下面的章节默认你已经了解「组件与 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' } },
  ];
}

常用内置能力:

能力用法说明
CellDictcellRender: { name: 'CellDict', props: { type } }字典值渲染为彩色标签
CellSwitchcellRender: { name: 'CellSwitch', props: { beforeChange } }行内开关,支持异步切换前校验
CellImage / CellTag / CellTagscellRender: { name: 'CellImage' } 等图片、单标签、多标签渲染
formatDateTime / formatPast2formatter: 'formatDateTime'时间、相对时间格式化
formatAmount2/3、formatFileSize、formatFenToYuanAmountformatter: '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 命名空间,保持多语言一致。