Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_NaiveVue/zh/frontend/new-page.md
T
2026-10-11 19:06:33 +08:00

676 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 9.12 新增页面开发步骤
:::tip 概述
以 example 模块为例,演示从 API 到视图的完整开发步骤(代码来自项目实际的 `tool/example` 模块)。一个完整模块涉及 6 个文件:API 接口、表格列配置、查询表单配置、列表页、编辑弹窗、详情弹窗。
:::
## 开发流程
```text
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 章 代码生成器](/zh/generator/overview.html)。
:::
## 完整文件结构
```text
ui/src/
├── api/
│ └── tool/
│ └── example.ts # API 接口
└── views/
└── tool/
└── example/
├── index.vue # 列表页(主页面)
├── columns.ts # 表格列配置
├── querySchemas.ts # 查询表单配置
├── edit.vue # 编辑弹窗(新增/修改)
└── detail.vue # 详情弹窗
```
## 步骤一:创建 API 接口
```typescript
// 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` 函数参数为当前行对象:
```typescript
// 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` 等):
```typescript
// 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 分页查询规范](/zh/api-spec/pagination.html))。下拉选项也可用 `getDictItemByCode(code)` 从字典动态加载。
:::
## 步骤四:创建列表页
列表页使用 `BasicForm`(搜索)+ `BasicTable`(表格,内置分页与操作列)组合:
```vue
<!-- 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`:
```vue
<!-- 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 验证器](/zh/guide/validator.html))。
:::
## 步骤六:创建详情弹窗
详情弹窗使用 `n-modal`(preset="card")+ `n-descriptions`:
```vue
<!-- 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 权限节点配置](/zh/guide/permission.html),菜单类型语义见 [6.3 菜单管理](/zh/modules/menu.html)。)
## 步骤八:测试
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 章 代码生成器](/zh/generator/overview.html)。