Files
djangoadmin-wiki/DjangoAdmin_Django_NaiveVue/zh/volume2/fe-view.md
T
2026-09-13 18:12:07 +08:00

792 lines
22 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.
# 前端页面视图
前端页面视图是用户直接交互的界面。项目使用 Vue3 + NaiveUI + Vite 构建。一个完整的 CRUD 页面由 5 个文件组成:
```
ui/src/views/tool/example/
├── index.vue # 主页面:搜索 + 表格 + 操作
├── edit.vue # 编辑弹窗:新增/编辑表单
├── detail.vue # 详情弹窗:查看记录详情
├── columns.ts # 表格列定义
└── querySchemas.ts # 搜索表单 Schema
```
## 1. 主页面 index.vue
主页面是模块的入口页面,包含搜索表单、数据表格和操作按钮。
### 核心结构
```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"
:auto-scroll-x="true"
>
<template #tableTitle>
<n-space>
<n-button type="primary" @click="handleAdd" v-perm="['sys:example:add']">
<template #icon>
<n-icon>
<PlusOutlined />
</n-icon>
</template>
添加案例
</n-button>
<n-button
type="error"
@click="handleDelete()"
:disabled="!rowKeys.length"
v-perm="['sys:example:batchDelete']"
>
<template #icon>
<n-icon>
<DeleteOutlined />
</n-icon>
</template>
批量删除
</n-button>
</n-space>
</template>
</BasicTable>
</n-card>
<!-- 编辑对话框组件 -->
<editDialog
ref="editModalRef"
v-if="editVisible"
:id="currentId"
v-model:visible="editVisible"
@success="reloadTable('noRefresh')"
/>
<!-- 查看详情对话框组件 -->
<detailDialog
ref="detailModalRef"
v-if="detailVisible"
:id="currentId"
v-model:visible="detailVisible"
@success="reloadTable('noRefresh')"
/>
</div>
</template>
```
### 核心逻辑
```vue
<script lang="ts" setup>
import { h, nextTick, reactive, ref } from 'vue';
import { useMessage, useDialog } from 'naive-ui';
import { PlusOutlined, DeleteOutlined, EditOutlined, EyeOutlined } from '@vicons/antd';
import { TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
import { renderIcon } from '@/utils';
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 editModalRef = ref();
const detailModalRef = ref();
// 响应式状态定义
const currentId = ref(0); // 当前操作的ID
const editVisible = ref(false); // 编辑对话框显示状态
const detailVisible = ref(false); // 详情对话框显示状态
const rowKeys = ref([]); // 表格选中行Key
// 查询参数
const formParams = reactive({ name: '', type: '', status: '' });
// 操作栏配置
const actionColumn = reactive({
width: 280,
title: '操作',
align: 'center',
key: 'action',
fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
icon: renderIcon(EditOutlined),
type: 'info',
onClick: handleEdit.bind(null, record),
auth: ['sys:example:update'],
},
{
label: '详情',
icon: renderIcon(EyeOutlined),
type: 'default',
onClick: handleDetail.bind(null, record),
auth: ['sys:example:detail'],
},
{
label: '删除',
icon: renderIcon(DeleteOutlined),
type: 'error',
onClick: handleDelete.bind(null, record),
auth: ['sys:example:delete'],
},
],
});
},
});
// 加载数据
const loadDataTable = async (params: any) => {
rowKeys.value = [];
const result = await getExamplePage({ ...formParams, ...params });
return result;
};
// 注册搜索表单
const [register, {}] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 80,
schemas,
});
// 提交搜索
function handleSubmit(values: Recordable) {
for (const key in formParams) { formParams[key] = ''; }
for (const key in values) { formParams[key] = values[key]; }
reloadTable();
}
// 重置搜索
function handleReset(values: Recordable) {
for (const key in formParams) { formParams[key] = ''; }
for (const key in values) { formParams[key] = values[key]; }
reloadTable();
}
// 表格选择项变化
function onCheckedRow(keys: (string | number)[]) {
rowKeys.value = keys;
}
// 刷新表格
function reloadTable(noRefresh = '') {
basicTableRef.value.reload(noRefresh ? {} : { pageNo: 1 });
}
// 新增
const handleAdd = async () => {
currentId.value = 0;
editVisible.value = true;
await nextTick();
editModalRef.value?.openModal();
};
// 编辑
const handleEdit = async (record: Recordable) => {
currentId.value = record.id;
editVisible.value = true;
await nextTick();
editModalRef.value?.openModal();
};
// 详情
const handleDetail = async (record: Recordable) => {
currentId.value = record.id;
detailVisible.value = true;
await nextTick();
detailModalRef.value?.openModal();
};
// 删除(支持单个删除和批量删除)
async function handleDelete(record?: Recordable) {
let ids = [];
if (!record) {
ids = rowKeys.value;
if (!ids.length) {
message.warning('请选择要删除的数据');
return;
}
}
dialog.warning({
title: '提示',
content: '确定要删除选中的案例吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
record ? await exampleDelete(record.id) : await exampleBatchDelete(ids);
message.success('删除成功');
reloadTable();
},
});
}
</script>
```
### 关键点
- **n-card**:NaiveUI 卡片容器,提供统一的页面布局
- **BasicForm**:搜索表单组件,通过 `useForm` 注册,`gridProps` 配置响应式栅格
- **BasicTable**:数据表格组件,内置分页功能,使用 `@update:checked-row-keys` 监听多选
- **TableAction**:操作栏组件,图标通过 `renderIcon` 包装 `@vicons/antd` 图标
- **useMessage / useDialog**:NaiveUI 的消息和对话框 Hook
- **v-perm**:权限指令,控制按钮显示
## 2. 编辑弹窗 edit.vue
编辑弹窗是独立组件,负责新增和编辑操作的表单展示与提交。使用 `basicModal` + `useModal` 模式。
### 核心结构
```vue
<template>
<basicModal
@register="modalRegister"
ref="modalRef"
class="basicModal basicFormModal"
@on-ok="handleSubmit"
@on-close="dialogClose"
>
<template #default>
<n-form
class="ls-form"
ref="formRef"
:model="formData"
label-placement="left"
label-width="80px"
>
<!-- 案例名称 -->
<n-form-item
label="案例名称"
path="name"
:rule="{ required: true, message: '请输入案例名称', trigger: 'blur' }"
>
<n-input
class="ls-input"
v-model:value="formData.name"
placeholder="请输入案例名称"
clearable
/>
</n-form-item>
<!-- 案例图片(图片上传) -->
<n-form-item label="案例图片" path="avatar">
<UploadImg
@change-file-name="(name) => (formData.avatarImgName = name)"
:fileType="['image/jpeg', 'image/png', 'image/jpg', 'image/gif', 'image/webp']"
name="example"
:fileSize="20"
v-model:image-url="formData.avatar"
>
<template #tip>支持扩展名: jpg png jpeg gif webp; 文件大小不超过20M</template>
</UploadImg>
</n-form-item>
<!-- 案例类型(下拉选择) -->
<n-form-item
label="案例类型"
path="type"
:rule="{
type: 'number',
required: true,
message: '请选择案例类型',
trigger: 'change',
}"
>
<n-select
v-model:value="formData.type"
placeholder="请选择案例类型"
clearable
filterable
:options="optionsData.typeList"
/>
</n-form-item>
<!-- 案例状态(单选) -->
<n-form-item
label="案例状态"
path="status"
:rule="{
type: 'number',
required: true,
message: '请选择案例状态',
trigger: 'change',
}"
>
<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"
:rule="{ type: 'number', required: true, message: '请输入排序', trigger: 'blur' }"
>
<n-input-number v-model:value="formData.sort" :min="0" :max="99999" />
</n-form-item>
</n-form>
</template>
</basicModal>
</template>
```
### 核心逻辑
```vue
<script lang="ts" setup>
import { getExampleDetail, exampleAdd, exampleUpdate } from '@/api/tool/example';
import { onMounted, reactive, ref, watch } from 'vue';
import { useMessage } from 'naive-ui';
import { useModal } from '@/components/Modal';
import UploadImg from '@/components/Upload/Image.vue';
// 定义事件
const emit = defineEmits(['success', 'update:visible']);
// 消息提示
const message = useMessage();
// 表单引用
const formRef = ref();
// 选项数据
const optionsData = reactive({
typeList: [
{ label: '类型1', value: 1 },
{ label: '类型2', value: 2 },
{ label: '类型3', value: 3 },
{ label: '类型4', value: 4 },
],
});
// 表单数据
const formData = reactive({
id: '',
name: '',
avatar: '',
type: 1,
status: 1,
sort: 0,
avatarImgName: '',
});
// 接收的参数
const props = defineProps({
visible: { type: Boolean, required: true, default: false },
id: { type: Number, required: true, default: 0 },
});
// 定义模态框
const [modalRegister, { openModal, setSubLoading, closeModal }] = useModal({
title: props.id ? '编辑案例' : '新增案例',
subBtuText: '确定',
width: 500,
});
// 提交表单
const handleSubmit = async () => {
formRef.value
.validate()
.then(async () => {
props.id ? await exampleUpdate(formData) : await exampleAdd(formData);
message.success('操作成功');
setSubLoading(false);
emit('update:visible', false);
emit('success');
})
.catch(() => {
setSubLoading(false);
});
};
// 关闭窗体
const dialogClose = () => {
emit('update:visible', false);
};
// 编辑时加载数据
const setFormData = async () => {
const data = await getExampleDetail(props.id);
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
// @ts-ignore
formData[key] = data[key];
}
}
};
// 重置表单
const resetForm = () => {
if (formRef.value) {
formRef.value.restoreValidation();
}
};
// 监听visible变化
watch(
() => props.visible,
(newVal) => {
if (newVal && props.id) {
setFormData();
openModal();
} else if (newVal && !props.id) {
resetForm();
openModal();
}
},
);
onMounted(() => {
if (props.visible) {
if (props.id) { setFormData(); } else { resetForm(); }
openModal();
}
});
defineExpose({ openModal });
</script>
```
### 关键点
- **basicModal + useModal**:NaiveUI 弹窗模式,通过 `useModal` 注册弹窗
- **n-form + n-form-item**:NaiveUI 表单组件,使用 `path` 指定字段,`:rule` 定义校验规则
- **n-select + :options**:NaiveUI 下拉选择,选项通过 `:options` 传入
- **n-radio-group + n-radio**:NaiveUI 单选组件
- **n-input-number**:NaiveUI 数字输入组件
- **useMessage**:NaiveUI 消息 Hook
- **setSubLoading**:通过 `useModal` 控制弹窗确认按钮的 loading 状态
- **openModal / closeModal**:通过 `useModal` 返回的方法控制弹窗显隐
## 3. 详情弹窗 detail.vue
详情弹窗用于展示记录的详细信息,使用 NaiveUI 的 `n-descriptions` 组件。
### 核心结构
```vue
<template>
<basicModal @register="modalRegister" ref="modalRef" class="basicModal basicFormModal">
<template #default>
<n-descriptions
class="margin-top"
:column="2"
bordered
:label-style="{ width: '120px' }"
label-placement="left"
>
<n-descriptions-item label="案例名称:">
<span>{{ formData.name || '-' }}</span>
</n-descriptions-item>
<n-descriptions-item label="案例图片:">
<n-image
v-if="formData.avatar"
:src="formData.avatar"
width="80"
height="80"
fit="cover"
:preview-disabled="false"
/>
<span v-else>-</span>
</n-descriptions-item>
<n-descriptions-item label="案例类型:">
<span>{{ getChoiceLabel('type', formData.type) }}</span>
</n-descriptions-item>
<n-descriptions-item label="案例状态:">
<span>{{ getChoiceLabel('status', formData.status) }}</span>
</n-descriptions-item>
<n-descriptions-item label="排序:">
<span>{{ formData.sort || '-' }}</span>
</n-descriptions-item>
</n-descriptions>
</template>
<template #action>
<n-button @click="dialogClose">关闭</n-button>
</template>
</basicModal>
</template>
```
### 核心逻辑
```vue
<script lang="ts" setup>
import { getExampleDetail } from '@/api/tool/example';
import { onMounted, reactive, watch } from 'vue';
import { useModal } from '@/components/Modal';
const emit = defineEmits(['update:visible']);
const formData = reactive({
id: '', name: '', avatar: '', type: 0, status: 0, sort: 0,
});
const props = defineProps({
visible: { type: Boolean, required: true, default: false },
id: { type: Number, required: true, default: 0 },
});
const [modalRegister, { openModal, closeModal }] = useModal({
title: '案例详情',
width: 750,
});
// 获取选项字段的标签
const getChoiceLabel = (fieldName: string, value: any) => {
const choiceMap: Record<string, Record<string, string>> = {
type: { 1: '类型1', 2: '类型2', 3: '类型3', 4: '类型4' },
status: { 1: '正常', 2: '禁用' },
};
return choiceMap[fieldName]?.[value] || value || '-';
};
const dialogClose = () => {
closeModal();
emit('update:visible', false);
};
const setFormData = async () => {
const data = await getExampleDetail(props.id);
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
formData[key] = data[key];
}
}
};
watch(
() => props.visible,
(newVal) => {
if (newVal && props.id) {
setFormData();
openModal();
}
},
);
onMounted(() => {
if (props.visible && props.id) {
setFormData();
openModal();
}
});
defineExpose({ openModal });
</script>
```
### 关键点
- **basicModal**:使用 `basicModal` + `useModal` 注册弹窗
- **n-descriptions**:NaiveUI 的描述列表组件,用于展示详情
- **n-image**:NaiveUI 的图片组件,支持预览功能
- **getChoiceLabel**:将枚举值转换为中文标签
## 4. 表格列定义 columns.ts
定义表格的列配置,包括列名、字段名、宽度和自定义渲染。
### 完整代码
```typescript
import { h } from 'vue';
import { NTag, NAvatar, NImage } from 'naive-ui';
export const columns = [
{
type: 'selection', // 多选框列
width: 50,
fixed: 'left',
},
{
title: 'ID',
key: 'id',
fixed: 'left',
width: 50,
},
{
title: '案例名称',
key: 'name',
width: 150,
ellipsis: { tooltip: true },
},
{
title: '案例图片',
key: 'avatar',
width: 150,
render(record) {
return record.avatar
? h(NAvatar, {
src: record.avatar,
size: 48,
shape: 'square',
fit: 'cover',
style: { borderRadius: '4px' },
})
: h('span', '-');
},
},
{
title: '案例类型',
key: 'type',
width: 100,
render(record) {
const statusMap = {
1: { text: '类型1', type: 'success' },
2: { text: '类型2', type: 'error' },
3: { text: '类型3', type: 'warning' },
4: { text: '类型4', type: 'info' },
};
const status = statusMap[record.type];
return h(
NTag,
{ bordered: false, type: status?.type || 'info' },
{ default: () => status?.text || record.type },
);
},
},
{
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: 'sort', width: 100, ellipsis: { tooltip: true } },
{ title: '创建人', key: 'createUser', width: 100 },
{ title: '创建时间', key: 'createTime', width: 180 },
];
```
### 关键点
- **title / key**:NaiveUI 列定义使用 `title` 和 `key` 指定列标题和字段名
- **type: 'selection'**:自动添加复选框列
- **render 函数**:使用 Vue 的 `h()` 函数创建虚拟 DOM,`record` 直接是行数据
- **NTag 组件**:将状态值渲染为彩色标签
- **NAvatar 组件**:将图片 URL 渲染为缩略图
- **ellipsis**:NaiveUI 使用 `ellipsis: { tooltip: true }` 实现溢出省略
## 5. 搜索表单 querySchemas.ts
定义搜索表单的字段配置,使用项目封装的 `FormSchema` 类型。
### 完整代码
```typescript
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,
filterable: 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,
filterable: true,
options: [
{ label: '正常', value: 1 },
{ label: '禁用', value: 2 },
],
},
},
];
```
### 关键点
- **field**:字段名,与后端查询参数对应
- **component**:表单组件类型使用 NaiveUI 组件名:`NInput` / `NSelect`
- **componentProps**:组件属性,透传给 NaiveUI 组件
- **options**:NSelect 组件的下拉选项
## 文件关系图
```
index.vue(主页面)
├── import schemas from './querySchemas' ← 搜索表单配置
├── import columns from './columns' ← 表格列配置
├── import editDialog from './edit.vue' ← 编辑弹窗组件
├── import detailDialog from './detail.vue' ← 详情弹窗组件
└── import API from '@/api/tool/example' ← 接口请求
edit.vue(编辑弹窗)
└── import API from '@/api/tool/example' ← 接口请求
detail.vue(详情弹窗)
└── import API from '@/api/tool/example' ← 接口请求
```
## 开发要点
1. **一个模块一个目录**:放在 `ui/src/views/{group}/{module}/` 下
2. **5 个文件各司其职**:主页面、编辑弹窗、详情弹窗、列定义、搜索 Schema
3. **弹窗使用 basicModal + useModal**:通过 `useModal` 注册,`openModal()` 打开
4. **列定义使用 `h()` 渲染复杂内容**:如 NTag 状态标签、NAvatar 图片缩略图
5. **列定义使用 `title` / `key`**:指定列标题和字段名
6. **搜索 Schema 使用 `NInput` / `NSelect` 组件名**:NaiveUI 表单组件
7. **消息使用 `useMessage()` Hook**:NaiveUI 消息提示
8. **对话框使用 `useDialog()` Hook**:NaiveUI 确认对话框
## 总结
前端页面统一使用 `n-card` + `BasicForm` + `BasicTable` + `TableAction` 组件组合,分页由 `BasicTable` 内置处理。编辑弹窗使用 `basicModal` + `useModal` 模式。每个模块包含 `index.vue`(列表页)、`edit.vue`(编辑弹窗)、`detail.vue`(详情弹窗)、`columns.ts`(列定义)、`querySchemas.ts`(搜索配置)五个文件。