612 lines
17 KiB
Markdown
612 lines
17 KiB
Markdown
# 9.12 新增页面开发步骤
|
||
|
||
:::tip 概述
|
||
以 example 模块为例,演示从 API 到视图的完整开发步骤。一个完整模块涉及 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) |
|
||
|
||
## 步骤二:创建表格列配置
|
||
|
||
```typescript
|
||
// ui/src/views/tool/example/columns.ts
|
||
import { h } from 'vue';
|
||
import { Tag, Avatar } from 'ant-design-vue';
|
||
|
||
export const columns = [
|
||
{
|
||
title: 'ID',
|
||
dataIndex: 'id',
|
||
fixed: 'left',
|
||
width: 50,
|
||
},
|
||
{
|
||
title: '名称',
|
||
dataIndex: 'name',
|
||
minWidth: 120,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'statusText',
|
||
minWidth: 100,
|
||
// 选项标签渲染:按值映射文字与颜色
|
||
customRender({ record }) {
|
||
const statusMap = {
|
||
2: { text: '停用', color: 'error' },
|
||
1: { text: '在用', color: 'success' },
|
||
};
|
||
const status = statusMap[record.status];
|
||
return h(Tag, { color: status?.color || 'default' }, {
|
||
default: () => status?.text || record.statusText,
|
||
});
|
||
},
|
||
},
|
||
{
|
||
title: '头像',
|
||
dataIndex: 'avatar',
|
||
minWidth: 80,
|
||
// 图片渲染:以方形头像形式展示
|
||
customRender({ record }) {
|
||
return h(Avatar, {
|
||
size: 48,
|
||
src: record.avatar,
|
||
shape: 'square',
|
||
});
|
||
},
|
||
},
|
||
{
|
||
title: '创建人',
|
||
dataIndex: 'createUser',
|
||
minWidth: 100,
|
||
},
|
||
{
|
||
title: '创建时间',
|
||
dataIndex: 'createTime',
|
||
width: 180,
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
fixed: 'right',
|
||
width: 180,
|
||
},
|
||
];
|
||
```
|
||
|
||
:::tip 多选列
|
||
AntDesign 表格的多选框列不在 `columns` 中定义,而是通过表格的 `:row-selection` 属性开启(见步骤四)。
|
||
:::
|
||
|
||
### 列配置字段说明
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `title` | string | 表头标题 |
|
||
| `dataIndex` | string | 数据字段名(camelCase,与后端响应一致) |
|
||
| `key` | string | 列标识(`#bodyCell` 插槽按 `column.key` 匹配) |
|
||
| `fixed` | string | 固定列:`left` / `right` |
|
||
| `width` | number | 固定宽度 |
|
||
| `minWidth` | number | 最小宽度(自适应) |
|
||
| `customRender` | function | 自定义渲染函数(参数 `{ record }`) |
|
||
|
||
:::tip 状态值对齐
|
||
`statusMap` 的值域必须与数据库/字典一致(如 example:1=在用 2=停用)。推荐直接使用后端补全的 `statusText` 字段,仅颜色按值映射。
|
||
:::
|
||
|
||
## 步骤三:创建查询表单配置
|
||
|
||
```typescript
|
||
// ui/src/views/tool/example/querySchemas.ts
|
||
import { FormSchema } from '@/components/Form/index';
|
||
|
||
export const schemas: FormSchema[] = [
|
||
{
|
||
name: 'name',
|
||
component: 'Input',
|
||
label: '名称',
|
||
componentProps: {
|
||
placeholder: '请输入名称',
|
||
allowClear: true,
|
||
},
|
||
},
|
||
{
|
||
name: 'status',
|
||
component: 'Select',
|
||
label: '状态',
|
||
componentProps: {
|
||
placeholder: '请选择状态',
|
||
allowClear: true,
|
||
options: [
|
||
{ label: '在用', value: 1 },
|
||
{ label: '停用', value: 2 },
|
||
],
|
||
},
|
||
},
|
||
];
|
||
```
|
||
|
||
### 表单组件类型
|
||
|
||
| component | 适用场景 | 说明 |
|
||
|-----------|---------|------|
|
||
| `Input` | 字符串字段 | 文本输入框 |
|
||
| `Select` | 枚举/选项字段 | 下拉选择框 |
|
||
| `InputNumber` | 数字字段 | 数字输入框 |
|
||
| `DatePicker` | 日期字段 | 日期选择器 |
|
||
|
||
:::tip 查询字段须与后端配置对应
|
||
`name` 必须是后端 Service `pageLikeFields`(模糊)或 `pageEqFields`(精确)中声明的字段,否则查询条件不生效(见 [8.3 分页查询规范](/zh/api-spec/pagination.html))。下拉选项也可用 `getDictItemByCode(code)` 从字典动态加载。
|
||
:::
|
||
|
||
## 步骤四:创建列表页
|
||
|
||
```vue
|
||
<!-- ui/src/views/tool/example/index.vue -->
|
||
<template>
|
||
<div>
|
||
<!-- 查询表单 -->
|
||
<SearchForm :schemas="querySchemas" @search="handleSearch" @reset="handleReset" />
|
||
|
||
<!-- 操作栏 -->
|
||
<a-space style="margin-bottom: 10px;">
|
||
<a-button v-perm="['sys:example:add']" type="primary" @click="handleAdd">新增</a-button>
|
||
<a-button v-perm="['sys:example:batchDelete']" danger :disabled="!selectedIds.length" @click="handleBatchDelete">
|
||
批量删除
|
||
</a-button>
|
||
</a-space>
|
||
|
||
<!-- 数据表格 -->
|
||
<a-spin :spinning="loading">
|
||
<a-table
|
||
:data-source="tableData"
|
||
:columns="columns"
|
||
:row-selection="{ selectedRowKeys: selectedIds, onChange: handleSelectionChange }"
|
||
:pagination="false"
|
||
row-key="id"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'action'">
|
||
<a-button v-perm="['sys:example:update']" type="link" @click="handleEdit(record)">编辑</a-button>
|
||
<a-button v-perm="['sys:example:detail']" type="link" @click="handleDetail(record)">详情</a-button>
|
||
<a-button v-perm="['sys:example:delete']" type="link" danger @click="handleDelete(record.id)">删除</a-button>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</a-spin>
|
||
|
||
<!-- 分页 -->
|
||
<a-pagination
|
||
v-model:current="pageNo"
|
||
v-model:page-size="pageSize"
|
||
:total="total"
|
||
:show-size-changer="true"
|
||
:page-size-options="[10, 20, 50, 100]"
|
||
:show-total="(t) => `共 ${t} 条`"
|
||
style="margin-top: 10px; text-align: right;"
|
||
@change="fetchData"
|
||
/>
|
||
|
||
<!-- 编辑弹窗 -->
|
||
<EditDialog ref="editRef" @success="fetchData" />
|
||
|
||
<!-- 详情弹窗 -->
|
||
<DetailDialog v-model:visible="detailVisible" :id="detailId" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
|
||
import { columns } from './columns';
|
||
import { schemas as querySchemas } from './querySchemas';
|
||
import { message, Modal } from 'ant-design-vue';
|
||
import EditDialog from './edit.vue';
|
||
import DetailDialog from './detail.vue';
|
||
|
||
const editRef = ref();
|
||
const loading = ref(false);
|
||
const tableData = ref([]);
|
||
const pageNo = ref(1);
|
||
const pageSize = ref(20);
|
||
const total = ref(0);
|
||
const selectedIds = ref<number[]>([]);
|
||
const detailVisible = ref(false);
|
||
const detailId = ref(0);
|
||
|
||
// 加载数据(分页结构:records / total / current / size / pages)
|
||
const fetchData = async () => {
|
||
loading.value = true;
|
||
try {
|
||
const res = await getExamplePage({
|
||
pageNo: pageNo.value,
|
||
pageSize: pageSize.value,
|
||
});
|
||
tableData.value = res.records;
|
||
total.value = res.total;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
// 搜索
|
||
const handleSearch = (params) => {
|
||
pageNo.value = 1;
|
||
fetchData();
|
||
};
|
||
|
||
// 重置
|
||
const handleReset = () => {
|
||
pageNo.value = 1;
|
||
fetchData();
|
||
};
|
||
|
||
// 新增
|
||
const handleAdd = () => {
|
||
editRef.value.open();
|
||
};
|
||
|
||
// 编辑
|
||
const handleEdit = (row) => {
|
||
editRef.value.open(row);
|
||
};
|
||
|
||
// 详情
|
||
const handleDetail = (row) => {
|
||
detailId.value = row.id;
|
||
detailVisible.value = true;
|
||
};
|
||
|
||
// 删除
|
||
const handleDelete = (id) => {
|
||
Modal.confirm({
|
||
title: '提示',
|
||
content: '确认删除该记录?',
|
||
onOk: async () => {
|
||
await exampleDelete(id);
|
||
message.success('删除成功');
|
||
fetchData();
|
||
},
|
||
});
|
||
};
|
||
|
||
// 批量删除(DELETE,请求体 { ids: [...] })
|
||
const handleBatchDelete = () => {
|
||
Modal.confirm({
|
||
title: '提示',
|
||
content: `确认删除选中的 ${selectedIds.value.length} 条记录?`,
|
||
onOk: async () => {
|
||
await exampleBatchDelete({ ids: selectedIds.value });
|
||
message.success('删除成功');
|
||
fetchData();
|
||
},
|
||
});
|
||
};
|
||
|
||
// 多选变化(row-selection 的 onChange 返回选中行的 row-key 数组)
|
||
const handleSelectionChange = (keys) => {
|
||
selectedIds.value = keys;
|
||
};
|
||
|
||
onMounted(() => fetchData());
|
||
</script>
|
||
```
|
||
|
||
## 步骤五:创建编辑弹窗
|
||
|
||
```vue
|
||
<!-- ui/src/views/tool/example/edit.vue -->
|
||
<template>
|
||
<a-modal v-model:visible="visible" :title="formData.id ? '编辑' : '新增'" width="500px">
|
||
<a-form ref="formRef" :model="formData" :rules="rules" :label-col="{ style: { width: '80px' } }">
|
||
<a-form-item label="名称" name="name">
|
||
<a-input v-model:value="formData.name" placeholder="请输入名称" allow-clear />
|
||
</a-form-item>
|
||
<a-form-item label="类型" name="type">
|
||
<a-select v-model:value="formData.type" placeholder="请选择类型" style="width: 100%">
|
||
<a-select-option v-for="item in typeOptions" :key="item.value" :value="item.value">
|
||
{{ item.name }}
|
||
</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="状态" name="status">
|
||
<a-radio-group v-model:value="formData.status">
|
||
<a-radio :value="1">在用</a-radio>
|
||
<a-radio :value="2">停用</a-radio>
|
||
</a-radio-group>
|
||
</a-form-item>
|
||
<a-form-item label="排序" name="sort">
|
||
<a-input-number v-model:value="formData.sort" :min="0" :max="9999" />
|
||
</a-form-item>
|
||
</a-form>
|
||
<template #footer>
|
||
<a-button @click="visible = false">取消</a-button>
|
||
<a-button type="primary" :loading="submitting" @click="handleSubmit">确定</a-button>
|
||
</template>
|
||
</a-modal>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { exampleAdd, exampleUpdate, getExampleDetail } from '@/api/tool/example';
|
||
import { getDictItemByCode } from '@/api/common/index';
|
||
import { message } from 'ant-design-vue';
|
||
|
||
const emit = defineEmits(['success']);
|
||
|
||
const visible = ref(false);
|
||
const submitting = ref(false);
|
||
const formRef = ref();
|
||
const typeOptions = ref([]);
|
||
|
||
const formData = ref({
|
||
id: 0,
|
||
name: '',
|
||
type: '',
|
||
status: 1,
|
||
sort: 0,
|
||
});
|
||
|
||
const rules = {
|
||
name: { required: true, message: '请输入名称', trigger: 'blur' },
|
||
type: { required: true, message: '请选择类型', trigger: 'change' },
|
||
};
|
||
|
||
// 打开弹窗
|
||
const open = async (row?) => {
|
||
visible.value = true;
|
||
formData.value = { id: 0, name: '', type: '', status: 1, sort: 0 };
|
||
|
||
// 加载字典选项(与后端字典编码一致)
|
||
typeOptions.value = await getDictItemByCode('example_type');
|
||
|
||
// 编辑时加载详情
|
||
if (row?.id) {
|
||
const detail = await getExampleDetail(row.id);
|
||
Object.assign(formData.value, detail);
|
||
}
|
||
};
|
||
|
||
// 提交
|
||
const handleSubmit = async () => {
|
||
await formRef.value.validate();
|
||
submitting.value = true;
|
||
try {
|
||
if (formData.value.id) {
|
||
await exampleUpdate(formData.value);
|
||
message.success('修改成功');
|
||
} else {
|
||
await exampleAdd(formData.value);
|
||
message.success('新增成功');
|
||
}
|
||
visible.value = false;
|
||
emit('success');
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
};
|
||
|
||
defineExpose({ open });
|
||
</script>
|
||
```
|
||
|
||
:::tip 表单规则与后端对齐
|
||
前端 `rules`(必填/长度/枚举)应与后端 `ExampleRequest::rules()` 一致——前端是体验提示,后端是最终防线(见 [3.4 FormRequest 验证器](/zh/guide/validator.html))。
|
||
:::
|
||
|
||
## 步骤六:创建详情弹窗
|
||
|
||
```vue
|
||
<!-- ui/src/views/tool/example/detail.vue -->
|
||
<template>
|
||
<a-modal v-model:visible="props.visible" title="案例详情" width="750px" :mask-closable="false">
|
||
<a-descriptions :column="2" bordered>
|
||
<a-descriptions-item label="名称">
|
||
{{ formData.name || '-' }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="类型">
|
||
{{ formData.typeText || '-' }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="状态">
|
||
<a-tag :color="formData.status === 1 ? 'success' : 'error'">
|
||
{{ formData.statusText || '-' }}
|
||
</a-tag>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="排序">
|
||
{{ formData.sort ?? '-' }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="头像">
|
||
<a-image
|
||
v-if="formData.avatar"
|
||
:src="formData.avatar"
|
||
:width="80"
|
||
:height="80"
|
||
style="object-fit: cover"
|
||
/>
|
||
<span v-else>-</span>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="创建人">
|
||
{{ formData.createUser || '-' }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="创建时间">
|
||
{{ formData.createTime || '-' }}
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
<template #footer>
|
||
<a-button @click="emit('update:visible', false)">关闭</a-button>
|
||
</template>
|
||
</a-modal>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
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);
|
||
}
|
||
};
|
||
|
||
// 监听弹窗显示
|
||
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)。
|