Files
2026-10-11 19:06:33 +08:00

20 KiB
Raw Permalink Blame History

9.12 新增页面开发步骤

:::tip 概述 以 example 模块为例,演示从 API 到视图的完整开发步骤(代码来自项目实际的 tool/example 模块)。一个完整模块涉及 6 个文件:API 接口、表格列配置、查询表单配置、列表页、编辑弹窗、详情弹窗。 :::

开发流程

1. 创建 API 接口        src/api/{module}/{name}.ts
2. 创建表格列配置        src/views/{module}/{name}/columns.ts
3. 创建查询表单配置      src/views/{module}/{name}/querySchemas.ts
4. 创建列表页           src/views/{module}/{name}/index.vue
5. 创建编辑弹窗         src/views/{module}/{name}/edit.vue
6. 创建详情弹窗         src/views/{module}/{name}/detail.vue
7. 配置菜单             后端菜单管理中设置组件路径 + 权限节点
8. 测试                 刷新前端页面,点击菜单测试

:::tip 更快的方式 以上 6 个前端文件可由后端代码生成器一键生成(POST /api/generator/generate),生成后从第 7 步开始即可。手动编写适合需要深度定制的页面。见 第 10 章 代码生成器。 :::

完整文件结构

ui/src/
├── api/
│   └── tool/
│       └── example.ts              # API 接口
└── views/
    └── tool/
        └── example/
            ├── index.vue           # 列表页(主页面)
            ├── columns.ts          # 表格列配置
            ├── querySchemas.ts     # 查询表单配置
            ├── edit.vue            # 编辑弹窗(新增/修改)
            └── detail.vue          # 详情弹窗

步骤一:创建 API 接口

// ui/src/api/tool/example.ts
import { http } from '@/utils/http/axios';

// 分页查询
export function getExamplePage(params?) {
  return http.request({ url: '/example/page', method: 'GET', params });
}

// 全量列表(下拉框数据源)
export function getExampleList(params?) {
  return http.request({ url: '/example/list', method: 'GET', params });
}

// 详情
export function getExampleDetail(id) {
  return http.request({ url: '/example/detail/' + id, method: 'GET' });
}

// 新增
export function exampleAdd(data: any) {
  return http.request({ url: '/example/add', method: 'POST', data });
}

// 修改
export function exampleUpdate(data: any) {
  return http.request({ url: '/example/update', method: 'PUT', data });
}

// 删除
export function exampleDelete(id) {
  return http.request({ url: '/example/delete/' + id, method: 'DELETE' });
}

// 批量删除(本项目为 DELETE,请求体 { ids: [] })
export function exampleBatchDelete(data: any) {
  return http.request({ url: '/example/batchDelete', method: 'DELETE', data });
}

API 命名规范

操作 函数名 HTTP 方法
分页查询 get{Module}Page GET
全量列表 get{Module}List GET
详情 get{Module}Detail GET
新增 {module}Add POST
修改 {module}Update PUT
删除 {module}Delete DELETE
批量删除 {module}BatchDelete DELETE(带 body)

步骤二:创建表格列配置

表格列使用 title / key 字段声明表头与数据字段,render 函数参数为当前行对象:

// ui/src/views/tool/example/columns.ts(来自项目实际代码)
import { h } from 'vue';
import { NTag, NAvatar } from 'naive-ui';

export const columns = [
  {
    title: '案例名称',
    key: 'name',
    width: 120,
    // 普通文本渲染(默认按 key 取值)
  },
  {
    title: '案例图片',
    key: 'avatar',
    width: 120,
    // 图片渲染:以方形头像形式展示
    render(record) {
      return h(NAvatar, {
        size: 48,
        src: record.avatar,
        round: false,
        objectFit: 'fill',
      });
    },
  },
  {
    title: '案例状态',
    key: 'status',
    width: 100,
    // 选项标签渲染:按选项值映射文字与标签颜色
    render(record) {
      const statusMap = {
        1: { text: '在用', type: 'success' },
        2: { text: '停用', type: 'error' },
      };
      const status = statusMap[record.status];
      return h(
        NTag,
        { bordered: false, type: status?.type || 'info' },
        { default: () => status?.text || record.status },
      );
    },
  },
  {
    title: '创建人',
    key: 'createUser',
    width: 100,
  },
  {
    title: '创建时间',
    key: 'createTime',
    width: 180,
  },
];

列配置字段说明

字段 类型 说明
type string 列类型:selection(多选框)/ index(序号)
title string 表头标题
key string 数据字段名(camelCase,与后端响应一致)
fixed string 固定列:left / right
width number 固定宽度
minWidth number 最小宽度(自适应)
render function 自定义渲染函数,参数为当前行对象
auth string[] 按权限控制列显示(可选)

:::tip 状态值对齐 statusMap 的值域必须与数据库/字典一致(如 example:1=在用 2=停用),type 取 NaiveUI 标签类型(success / error / warning / info)。 :::

步骤三:创建查询表单配置

component 取值为对应的组件名(NInput/NSelect/NInputNumber/NDatePicker 等):

// ui/src/views/tool/example/querySchemas.ts(来自项目实际代码)
import { FormSchema } from '@/components/Form/index';

export const schemas: FormSchema[] = [
  {
    field: 'name',
    component: 'NInput',
    label: '名称',
    componentProps: {
      placeholder: '请输入名称',
      clearable: true,
    },
  },
  {
    field: 'type',
    component: 'NSelect',
    label: '类型',
    componentProps: {
      placeholder: '请选择类型',
      clearable: true,
      // 选项列表:值用于查询,标签用于展示
      options: [
        { label: '类型1', value: 1 },
        { label: '类型2', value: 2 },
        { label: '类型3', value: 3 },
        { label: '类型4', value: 4 },
      ],
    },
  },
  {
    field: 'status',
    component: 'NSelect',
    label: '状态',
    componentProps: {
      placeholder: '请选择状态',
      clearable: true,
      options: [
        { label: '在用', value: 1 },
        { label: '停用', value: 2 },
      ],
    },
  },
];

表单组件类型

component 适用场景 说明
NInput 字符串字段 文本输入框
NSelect 枚举/选项字段 下拉选择框(选项通过 componentProps.options 传入)
NInputNumber 数字字段 数字输入框
NDatePicker 日期字段 日期选择器
NRadioGroup 少量选项(≤3 个) 单选组

:::tip 查询字段须与后端配置对应 field 必须是后端 Service pageLikeFields(模糊)或 pageEqFields(精确)中声明的字段,否则查询条件不生效(见 8.3 分页查询规范)。下拉选项也可用 getDictItemByCode(code) 从字典动态加载。 :::

步骤四:创建列表页

列表页使用 BasicForm(搜索)+ BasicTable(表格,内置分页与操作列)组合:

<!-- ui/src/views/tool/example/index.vue(节选,来自项目实际代码) -->
<template>
  <div>
    <!-- 查询表单 -->
    <n-card :bordered="false" class="pt-3 mb-3 proCard">
      <BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
    </n-card>

    <!-- 数据表格 -->
    <n-card :bordered="false" class="proCard">
      <BasicTable
        :columns="columns"
        :request="loadDataTable"
        :row-key="(row) => row.id"
        ref="basicTableRef"
        :actionColumn="actionColumn"
        @update:checked-row-keys="onCheckedRow"
      >
        <template #tableTitle>
          <n-space>
            <n-button type="primary" @click="handleAdd" v-perm="['sys:example:add']">
              新增
            </n-button>
            <n-button
              type="error"
              :disabled="!rowKeys.length"
              @click="handleDelete()"
              v-perm="['sys:example:batchDelete']"
            >
              批量删除
            </n-button>
          </n-space>
        </template>
      </BasicTable>
    </n-card>

    <!-- 编辑弹窗,主键为 0 时表示新增 -->
    <EditDialog
      ref="editRef"
      v-if="editVisible"
      v-model:visible="editVisible"
      :id="currentId"
      @success="reloadTable('noRefresh')"
    />

    <!-- 详情弹窗 -->
    <DetailDialog v-model:visible="detailVisible" :id="currentId" />
  </div>
</template>

<script setup lang="ts">
import { h, nextTick, reactive, ref } from 'vue';
import { useMessage, useDialog } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form';
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
import { columns } from './columns';
import { schemas } from './querySchemas';
import EditDialog from './edit.vue';
import DetailDialog from './detail.vue';

const message = useMessage();
const dialog = useDialog();
const basicTableRef = ref();
const editRef = ref();
const editVisible = ref(false);
const detailVisible = ref(false);
const currentId = ref(0);
const rowKeys = ref([]);

// 查询参数(与搜索表单字段一一对应,提交后合并到分页请求中)
const formParams = reactive({ name: '', type: '', status: '' });

// 操作列(render 返回 TableAction,auth 控制按钮权限,与后端权限码一致)
const actionColumn = reactive({
  width: 280,
  title: '操作',
  key: 'action',
  fixed: 'right',
  render(record) {
    return h(TableAction as any, {
      actions: [
        { label: '编辑', type: 'info', onClick: handleEdit.bind(null, record), auth: ['sys:example:update'] },
        { label: '详情', type: 'info', onClick: handleDetail.bind(null, record), auth: ['sys:example:detail'] },
        { label: '删除', type: 'error', onClick: handleDelete.bind(null, record), auth: ['sys:example:delete'] },
      ],
    });
  },
});

// 分页数据请求(BasicTable 内置分页,后端固定返回 records / total / current / size / pages)
const loadDataTable = async (params) => {
  rowKeys.value = [];
  return await getExamplePage({ ...formParams, ...params });
};

// 多选变化
function onCheckedRow(keys) {
  rowKeys.value = keys;
}

// 刷新表格(不传参回第一页)
function reloadTable(noRefresh = '') {
  basicTableRef.value.reload(noRefresh ? {} : { pageNo: 1 });
}

// 注册搜索表单
const [register] = useForm({
  gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
  labelWidth: 80,
  schemas,
});

// 搜索 / 重置
function handleSubmit(values) {
  for (const key in formParams) formParams[key] = '';
  Object.assign(formParams, values);
  reloadTable();
}
function handleReset() {
  for (const key in formParams) formParams[key] = '';
  reloadTable();
}

// 新增 / 编辑 / 详情
const handleAdd = async () => {
  currentId.value = 0;
  editVisible.value = true;
  await nextTick();
  editRef.value.openModal();
};
async function handleEdit(record) {
  currentId.value = record.id;
  editVisible.value = true;
  await nextTick();
  editRef.value.openModal();
}
function handleDetail(record) {
  currentId.value = record.id;
  detailVisible.value = true;
}

// 删除(单条 / 批量共用,先弹确认框;批量为 DELETE + 请求体 { ids: [] })
function handleDelete(record?) {
  const ids = record ? [record.id] : rowKeys.value;
  if (!ids.length) {
    message.warning('请选择要删除的数据');
    return;
  }
  dialog.warning({
    title: '提示',
    content: record ? '确定删除该记录?' : `确定删除选中的 ${ids.length} 条记录?`,
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: async () => {
      record ? await exampleDelete(record.id) : await exampleBatchDelete({ ids });
      message.success('删除成功');
      reloadTable();
    },
  });
}
</script>

步骤五:创建编辑弹窗

使用项目封装的 basicModal(useModal)+ n-form:

<!-- ui/src/views/tool/example/edit.vue(节选,来自项目实际代码) -->
<template>
  <basicModal
    @register="modalRegister"
    ref="modalRef"
    @on-ok="handleSubmit"
    @on-close="dialogClose"
  >
    <template #default>
      <n-form class="ls-form" ref="formRef" :model="formData" label-placement="left" label-width="85px">
        <n-form-item
          label="案例名称"
          path="name"
          :rule="{ required: true, message: '请输入案例名称', trigger: 'blur' }"
        >
          <n-input v-model:value="formData.name" placeholder="请输入案例名称" clearable />
        </n-form-item>

        <n-form-item label="案例类型" path="type">
          <n-select
            v-model:value="formData.type"
            placeholder="请选择案例类型"
            clearable
            filterable
            :options="typeOptions"
          />
        </n-form-item>

        <n-form-item label="案例状态" path="status">
          <n-radio-group v-model:value="formData.status">
            <n-radio :value="1">在用</n-radio>
            <n-radio :value="2">停用</n-radio>
          </n-radio-group>
        </n-form-item>

        <n-form-item label="案例排序" path="sort">
          <n-input-number v-model:value="formData.sort" :min="0" :max="99999" />
        </n-form-item>
      </n-form>
    </template>
  </basicModal>
</template>

<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { useModal } from '@/components/Modal';
import { useLockFn } from '@/utils/useLockFn';
import { getExampleDetail, exampleAdd, exampleUpdate } from '@/api/tool/example';

const emit = defineEmits(['success', 'update:visible']);
const message = useMessage();
const formRef = ref();

const props = defineProps({
  visible: { type: Boolean, required: true, default: false },
  id: { type: Number, required: true, default: 0 },  // 0 表示新增
});

const typeOptions = [
  { label: '类型1', value: 1 },
  { label: '类型2', value: 2 },
  { label: '类型3', value: 3 },
  { label: '类型4', value: 4 },
];

const formData = reactive({
  id: 0,
  name: '',
  type: 0,
  status: 1,
  sort: 0,
});

// 弹窗配置
const [modalRegister, { openModal, setSubLoading }] = useModal({
  title: props.id ? '编辑案例演示' : '新增案例演示',
  subBtuText: '确定',
  width: 600,
});

// 加载详情(编辑时回填)
const setFormData = async () => {
  try {
    const data = await getExampleDetail(props.id);
    for (const key in formData) {
      if (data[key] != null) formData[key] = data[key];
    }
  } catch (error) {
    message.error('获取数据失败');
  }
};

// 提交:校验 → 新增/修改 → 提示 → 关闭并刷新
const handleSubmit = useLockFn(async () => {
  await formRef.value?.validate();
  props.id ? await exampleUpdate(formData) : await exampleAdd(formData);
  message.success('操作成功');
  setSubLoading(false);
  emit('update:visible', false);
  emit('success');
});

const dialogClose = () => {
  emit('update:visible', false);
};

defineExpose({ openModal, setFormData });
</script>

:::tip 表单规则与后端对齐 前端 n-form 的 :rule(必填/长度/枚举)应与后端 ExampleRequest::rules() 一致——前端是体验提示,后端是最终防线(见 3.4 FormRequest 验证器)。 :::

步骤六:创建详情弹窗

详情弹窗使用 n-modal(preset="card")+ n-descriptions:

<!-- ui/src/views/tool/example/detail.vue(节选,来自项目实际代码) -->
<template>
  <n-modal
    v-model:show="props.visible"
    preset="card"
    title="案例演示详情"
    :style="{ width: '750px' }"
    :close-on-click-modal="false"
    @close="dialogClose"
  >
    <n-descriptions :column="2" bordered label-placement="left">
      <n-descriptions-item label="名称:">
        {{ formData.name || '-' }}
      </n-descriptions-item>
      <n-descriptions-item label="类型:">
        {{ formData.typeText || '-' }}
      </n-descriptions-item>
      <n-descriptions-item label="状态:">
        <n-tag :type="formData.status === 1 ? 'success' : 'error'" bordered>
          {{ formData.statusText || '-' }}
        </n-tag>
      </n-descriptions-item>
      <n-descriptions-item label="排序:">
        {{ formData.sort ?? '-' }}
      </n-descriptions-item>
      <n-descriptions-item label="头像:">
        <n-image
          v-if="formData.avatar"
          :src="formData.avatar"
          :preview-src="formData.avatar"
          width="80"
          height="80"
          object-fit="cover"
        />
        <span v-else>-</span>
      </n-descriptions-item>
      <n-descriptions-item label="创建人:">
        {{ formData.createUser || '-' }}
      </n-descriptions-item>
      <n-descriptions-item label="创建时间:">
        {{ formData.createTime || '-' }}
      </n-descriptions-item>
    </n-descriptions>
    <template #footer>
      <div style="text-align: right">
        <n-button @click="dialogClose">关闭</n-button>
      </div>
    </template>
  </n-modal>
</template>

<script lang="ts" setup>
import { reactive, watch } from 'vue';
import { NImage } from 'naive-ui';
import { getExampleDetail } from '@/api/tool/example';

const emit = defineEmits(['update:visible']);

const props = defineProps({
  visible: { type: Boolean, required: true, default: false },
  id: { type: Number, required: true, default: 0 },
});

const formData = reactive({
  name: '',
  type: '',
  typeText: '',
  status: 0,
  statusText: '',
  sort: 0,
  avatar: '',
  createUser: '',
  createTime: '',
});

// 加载详情(typeText/statusText 由后端 serializeMaps 补全)
const loadDetail = async () => {
  try {
    const data = await getExampleDetail(props.id);
    for (const key in formData) {
      if (data[key] != null) {
        formData[key] = data[key];
      }
    }
  } catch (error) {
    console.error('获取详情失败', error);
  }
};

const dialogClose = () => {
  emit('update:visible', false);
};

// 监听弹窗显示
watch(() => props.visible, (newVal) => {
  if (newVal && props.id) {
    loadDetail();
  }
}, { immediate: true });
</script>

步骤七:配置菜单

在后端菜单管理中添加菜单项:

字段 值
菜单名称 案例管理
组件路径 tool/example/index
路由路径 /tool/example
菜单类型 菜单/页面(type=0)

添加权限节点(type=1,参与接口鉴权):

节点名称 权限标识
查看 sys:example:page
新增 sys:example:add
修改 sys:example:update
删除 sys:example:delete
批量删除 sys:example:batchDelete
详情 sys:example:detail

(详见 3.8 权限节点配置,菜单类型语义见 6.3 菜单管理。)

步骤八:测试

  1. 为测试角色分配新菜单权限
  2. 刷新前端页面(或重新登录使动态路由生效)
  3. 点击菜单"案例管理"
  4. 测试搜索、新增、编辑、详情、删除、批量删除功能
  5. 用无权限账号登录,确认 v-perm 按钮隐藏、直接调接口返回 403

代码生成器(快速创建)

如果模块结构简单,可使用代码生成器一键生成前后端代码:

  1. 建好数据库表
  2. GET /api/generator/page 查看可生成的表
  3. GET /api/generator/preview 预览代码
  4. POST /api/generator/generate 生成代码(batchGenerate 批量)
  5. 在菜单管理中配置菜单

详见 第 10 章 代码生成器。