Become a sponsor

前端页面视图是用户直接交互的界面。项目使用 Vue3 + NaiveUI + Vite 构建。一个完整的 CRUD 页面由 5 个文件组成:
ui/src/views/tool/example/
├── index.vue # 主页面:搜索 + 表格 + 操作
├── edit.vue # 编辑弹窗:新增/编辑表单
├── detail.vue # 详情弹窗:查看记录详情
├── columns.ts # 表格列定义
└── querySchemas.ts # 搜索表单 Schema主页面是模块的入口页面,包含搜索表单、数据表格和操作按钮。
<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><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>useForm 注册,gridProps 配置响应式栅格@update:checked-row-keys 监听多选renderIcon 包装 @vicons/antd 图标编辑弹窗是独立组件,负责新增和编辑操作的表单展示与提交。使用 basicModal + useModal 模式。
<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><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>useModal 注册弹窗path 指定字段,:rule 定义校验规则:options 传入useModal 控制弹窗确认按钮的 loading 状态useModal 返回的方法控制弹窗显隐详情弹窗用于展示记录的详细信息,使用 NaiveUI 的 n-descriptions 组件。
<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><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 + useModal 注册弹窗定义表格的列配置,包括列名、字段名、宽度和自定义渲染。
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 指定列标题和字段名h() 函数创建虚拟 DOM,record 直接是行数据ellipsis: { tooltip: true } 实现溢出省略定义搜索表单的字段配置,使用项目封装的 FormSchema 类型。
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 },
],
},
},
];NInput / NSelectindex.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' ← 接口请求ui/src/views/{group}/{module}/ 下useModal 注册,openModal() 打开h() 渲染复杂内容:如 NTag 状态标签、NAvatar 图片缩略图title / key:指定列标题和字段名NInput / NSelect 组件名:NaiveUI 表单组件useMessage() Hook:NaiveUI 消息提示useDialog() Hook:NaiveUI 确认对话框前端页面统一使用 n-card + BasicForm + BasicTable + TableAction 组件组合,分页由 BasicTable 内置处理。编辑弹窗使用 basicModal + useModal 模式。每个模块包含 index.vue(列表页)、edit.vue(编辑弹窗)、detail.vue(详情弹窗)、columns.ts(列定义)、querySchemas.ts(搜索配置)五个文件。