简介
vue-dynamic-admin 是一个基于【vue vben admin】免费开源的动态后台模版。使用了最新的vue3,vite2,TypeScript等主流技术开发,开箱即用的中后台前端解决方案,也可用于学习参考。
吊炸天效果
点我预览--> guoshao-service.test.upcdn.net/file-path/v…
特性
- 最新技术栈:使用 Vue3/vite2 等前端前沿技术开发
- TypeScript: 应用程序级 JavaScript 的语言
- 主题:可配置的主题
- 国际化:内置完善的国际化方案
- Mock 数据 内置 Mock 数据方案
- 权限 内置完善的动态路由权限生成方案
- 组件 二次封装了多个常用的组件
项目地址
- general-admin-web - 前端代码
- general-admin-server - 后端代码
文档
准备
- node 和 git -项目开发环境
- Vite - 熟悉 vite 特性
- Vue3 - 熟悉 Vue 基础语法
- TypeScript - 熟悉
TypeScript基本语法 - Es6+ - 熟悉 es6 基本语法
- Vue-Router-Next - 熟悉 vue-router 基本使用
- Ant-Design-Vue - ui 基本使用
- Mock.js - mockjs 基本语法
安装使用
- 获取项目代码
git clone https://github.com/guoshaonb/vue-dynamic-admin.git
- 安装依赖
cd vue-dynamic-admin
pnpm install
- 运行
pnpm serve
- 打包
pnpm build
系统动态配置模块的大概逻辑
流程:通过【前端配置字段->存储服务器->服务器解析->根据配置渲染页面】这个流程来实现的。
图示:


动态配置的主要部分代码如下:
<template>
<div style="width: 100%">
<div class="config-header">
<div>
<span class="config-header-titp">操作按钮</span>
<a-button type="success" @click="initConfig">创建配置</a-button>
<a-button type="danger" @click="delConfig">删除配置</a-button>
<a-button type="primary" @click="updConfig">更新配置</a-button>
</div>
<div v-if="configData">
<span class="config-header-titp">配置分类</span>
<dynamicTag
:id="id"
:pageId="menu_id"
:configClassifys="configClassifys"
@saveClassifys="saveClassifys"
/>
</div>
</div>
<a-tabs
v-if="configData"
tab-position="left"
:style="{ height: '800px', overflowY: 'scroll' }"
v-model:activeKey="activeKey"
>
<a-tab-pane
v-for="i in 50"
:key="i"
:tab="`配置-${i}${configData['c_' + i]?.includes('字段') ? '(未用)' : ''}`"
>
<h4 class="config-content-header">
表单配置{{ i }}
<span style="color: red" v-if="configData['c_' + i]?.includes('字段')">(待配置)</span>
</h4>
<commonForm
:ref="sonRefFormObj[i]"
:configOption="configData['c_' + i]"
:configClassifys="configClassifys"
/>
<dynamicOptions :ref="sonRefOptionObj[i]" :configOption="configData['c_' + i]" />
</a-tab-pane>
</a-tabs>
</div>
</template>
<script lang="ts">
// 系统引入项
import { defineComponent, reactive, toRefs, ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Alert, Card, message } from 'ant-design-vue';
// 组件引入项
import commonForm from './components/commonForm.vue';
import dynamicOptions from './components/dynamicOptions.vue';
import dynamicTag from './components/dynamicTag.vue';
// api引入项
import {
getGeneralConfigList,
addGeneralConfig,
editGeneralConfig,
delGeneralConfig,
getConfigclassifyList,
addConfigclassify,
editConfigclassify,
delConfigclassify,
} from '/@/api/demo/system';
import classObj from './classObj';
import { USER_INFO_KEY } from '/@/enums/cacheEnum';
import { Persistent } from '/@/utils/cache/persistent';
import { isObjectValueEqual } from '/@/utils/common';
let sonRefFormObj = {};
let sonRefOptionObj = {};
let configObj = {}
for (let i = 1; i <= 50; i++) {
configObj['c_' + i] = `字段${i},field${i},f,0;`
}
export default defineComponent({
components: {
commonForm,
dynamicOptions,
dynamicTag,
},
setup() {
const route = useRoute();
const router = useRouter();
const userId = ref(null);
const state = reactive({
id: 0,
class_id: 0,
menu_id: 0,
activeKey: 1,
configData: null,
configClassifys: null,
});
// 获取动态字段配置
const getDynamicConfig = async () => {
// 获取配置数据
const dynamicResult = await getGeneralConfigList({
menu_id: route.query.menu_id,
});
const dynamicData = dynamicResult?.[0];
if (dynamicData) {
state.id = dynamicData.id;
state.menu_id = dynamicData.menu_id;
state.configData = dynamicData;
} else {
state.configData = null;
state.configClassifys = null;
}
// 获取配置分类
const classifyResult = await getConfigclassifyList({
menu_id: route.query.menu_id,
});
const classData = classifyResult?.[0];
if (classData) {
let configClassifys = [];
state.class_id = classData.id;
Object.keys(classData).forEach((item) => {
if (item.includes('class') && classData[item] !== '0') {
configClassifys.push(classData[item]);
}
});
state.configClassifys = configClassifys;
}
for (let i = 1; i <= 50; i++) {
sonRefFormObj[i] = ref(null);
sonRefOptionObj[i] = ref(null);
}
userId.value = Persistent.getLocal(USER_INFO_KEY).id;
};
getDynamicConfig();
// 重新加载数据
const reloadContent = (content) => {
message.success(content);
getDynamicConfig();
};
/**
* ------------------------配置按钮事件------------------------
*/
// 创建配置
const initConfig = async () => {
if (!state.configData) {
const dynamicData = {
menu_id: route.query.menu_id,
user_id: userId.value,
...configObj,
};
const classData = {
menu_id: route.query.menu_id,
user_id: userId.value,
...classObj,
class1: '基础配置',
};
await addGeneralConfig(dynamicData);
const result = await addConfigclassify(classData);
if (result) {
reloadContent('创建配置成功!');
}
} else {
message.warning('已包含配置了,请先删除改配置再初始化哦!');
}
};
// 更新配置
const updConfig = () => {
const configData = {};
const configNames: any = [];
for (let i = 1; i <= 50; i++) {
const formData = sonRefFormObj[i].value?.[0]?.getOptionVal();
const optionData = sonRefOptionObj[i].value?.[0]?.getOptionVal();
if (formData && !isObjectValueEqual(state.configData['c_' + i], formData)) {
if (formData.configName.includes('字段')) {
return message.error('字段说明不能包含字段2字哦');
}
if (configNames.includes(formData.configName)) {
return message.error('字段说明不能重复哦');
}
configNames.push(formData.configName);
const options: any = [];
Object.values(optionData).forEach((value) => {
options.push(value);
});
configData['c_' + i] =
formData.configName +
',' +
formData.configField +
',t,' +
formData.configType +
';' +
options.join(',') +
'`' +
formData.selectType;
}
}
const data = {
...state.configData,
...configData,
};
editGeneralConfig({
id: state.id,
user_id: userId.value,
menu_id: state.menu_id,
...data,
}).then(() => {
reloadContent('更新配置成功!');
});
};
// 删除配置
const delConfig = () => {
delGeneralConfig({ id: state.id }).then(() => {
reloadContent('删除配置成功!');
});
};
// 保存分类
const saveClassifys = (row) => {
if (!row) return;
const classObj = {};
JSON.parse(row || '')?.forEach((item, index) => {
classObj['class' + (index + 1)] = item;
});
editConfigclassify({
id: state.class_id,
user_id: userId.value,
menu_id: state.menu_id,
...classObj,
}).then(() => {
reloadContent('配置分类成功!');
});
};
return {
...toRefs(state),
sonRefFormObj,
sonRefOptionObj,
initConfig,
updConfig,
delConfig,
saveClassifys,
};
},
});
</script>
然后是界面部分
<template>
<PageWrapper dense contentFullHeight fixedHeight contentClass="flex">
<BasicTable
ref="tableRef"
:title="route.query.name + '列表'"
:columns="columns"
:api="getGeneralDataList"
:canResize="canResize"
:loading="loading"
:striped="striped"
:bordered="border"
:pagination="{ pageSize: 10 }"
:formConfig="{
labelWidth: 120,
schemas: searchFormSchema,
}"
:beforeFetch="
(row) => {
row.menu_id = route.query.menu_id;
}
"
:useSearchForm="true"
:showTableSetting="true"
:showIndexColumn="false"
:actionColumn="{
width: 120,
title: '操作',
dataIndex: 'action',
slots: { customRender: 'action' },
fixed: undefined,
}"
rowKey="id"
:rowSelection="{ type: 'checkbox' }"
>
<template #toolbar>
<a-button type="danger" @click="handleDeletes"> 批量删除 </a-button>
<a-button type="primary" @click="handleCreate"> 新增 </a-button>
<a-button type="primary" @click="deriveExcelData"> 导出 </a-button>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{
icon: 'clarity:note-edit-line',
onClick: handleEdit.bind(null, record),
},
{
icon: 'ant-design:delete-outlined',
color: 'error',
popConfirm: {
title: '是否确认删除',
confirm: handleDelete.bind(null, record),
},
},
]"
/>
</template>
</BasicTable>
<pageModal
:configObj="configObj"
:classifys="classifys"
@register="registerModal"
@success="handleSuccess"
/>
</PageWrapper>
</template>
<script lang="ts">
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import { createVNode, defineComponent, ref, reactive, toRefs } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { BasicTable, useTable, TableAction } from '/@/components/Table';
import { PageWrapper } from '/@/components/Page';
import {
getGeneralConfigList,
getGeneralDataList,
addGeneralData,
editGeneralData,
delGeneralData,
delsGeneralData,
getConfigclassifyList,
} from '/@/api/demo/system';
import { operationApi } from '/@/utils/event/operation';
import { useModal } from '/@/components/Modal';
import pageModal from './pageModal.vue';
import { columns, searchFormSchema } from './data';
import { exportExcel } from '/@/utils/excel/exportExcel';
import { message, Modal } from 'ant-design-vue';
export default defineComponent({
components: { TableAction, BasicTable, PageWrapper, pageModal },
setup() {
const tableRef = ref<Nullable<TableActionType>>(null);
const [registerModal, { openModal }] = useModal();
const route = useRoute();
const state: any = reactive({
searchFormSchema: [],
columns: [],
configObj: {},
classifys: '',
});
// 获取动态字段配置
const getDynamicConfig = async () => {
// 获取配置数据
const dynamicResult = await getGeneralConfigList({
menu_id: route.query.menu_id,
});
if (dynamicResult) {
const configObj = {};
const configNapeData = dynamicResult?.[0] || [];
Object.keys(configNapeData).forEach((key, index) => {
if (key.includes('c_')) {
const configNapeDatakey = configNapeData[key].toString();
const configNapeDatake = configNapeData[key].toString();
const typeCommonArray =
[
'Input',
'InputNumber',
'InputTextArea',
'Select',
'RadioGroup',
'CheckboxGroup',
'Switch',
'DatePicker',
'RangePicker',
];
const leftOpions = configNapeDatakey?.split(';')?.[0];
const optionsArray =
configNapeDatakey?.split('`')?.[0]?.split(';')?.[1]?.split(',') || [];
configObj['c_' + key?.split('c_')[1]] = {
name: configNapeDatakey?.split(',')?.[0],
isExist: leftOpions?.split(',')?.[2],
field: leftOpions?.split(',')?.[1],
type: typeCommonArray[leftOpions?.split(',')?.[3] || 0],
options: [],
class: configNapeDatakey.split('`')?.[1] || '基础配置',
};
optionsArray?.forEach((element) => {
configObj['c_' + key?.split('c_')[1]]['options'].push({
label: element,
value: element,
});
});
}
});
// 设置表格、表单
state.configObj = configObj;
state.columns = columns(configObj);
state.searchFormSchema = searchFormSchema(configObj);
}
// 获取配置分类
const classifyResult = await getConfigclassifyList({
menu_id: route.query.menu_id,
})
if(classifyResult) {
let configClassifys = [];
const classifyData = classifyResult?.[0] || [];
Object.keys(classifyData).forEach((item) => {
if (item.includes('class') && classifyData[item] !== '0') {
configClassifys.push(classifyData[item]);
}
});
state.classifys = configClassifys;
}
};
getDynamicConfig()
function handleCreate() {
openModal(true, {
isUpdate: false,
});
}
function handleEdit(record: Recordable) {
openModal(true, {
record,
isUpdate: true,
id: record.id,
});
}
function handleDelete(record: Recordable) {
operationApi('del', record, {
delAction: delGeneralData,
});
handleSuccess();
}
const showDeleteConfirm = (callBack) => {
Modal.confirm({
title: '确定批量删除吗?',
icon: createVNode(ExclamationCircleOutlined),
content: '',
okText: '是',
okType: 'danger',
cancelText: '否',
onOk() {
callBack();
},
onCancel() {
message.warning('您取消了删除');
},
});
};
function handleDeletes(record: Recordable) {
const ids = tableRef.value.getRowSelection().selectedRowKeys;
if (ids.length > 0) {
showDeleteConfirm(() => {
const params = {
ids,
is_del: 1,
};
operationApi('del', params, {
delAction: delsGeneralData,
});
handleSuccess();
});
} else {
message.warning('请选择数据后再删除哦');
}
}
function handleSuccess() {
setTimeout(() => {
tableRef.value.reload();
}, 200);
}
// 导出excel
const deriveExcelData = () => {
const tableData = tableRef.value.getDataSource();
exportExcel(state.columns, tableData, route.query.name + '数据');
};
return {
...toRefs(state),
tableRef,
route,
registerModal,
handleCreate,
handleEdit,
handleDelete,
handleDeletes,
handleSuccess,
deriveExcelData,
getGeneralDataList,
};
},
});
</script>
pageModal.vue代码
<template>
<BasicModal
centered
v-bind="$attrs"
@register="registerModal"
:title="getTitle"
@ok="handleSubmit"
>
<BasicForm
ref="BasicForm"
:labelWidth="100"
:schemas="schemas"
:showActionButtonGroup="false"
@submit="handleSubmitForm"
/>
</BasicModal>
</template>
<script lang="ts">
import { defineComponent, ref, reactive, toRefs, computed, unref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import {
getGeneralConfigList,
getGeneralDataList,
addGeneralData,
editGeneralData,
delGeneralData,
} from '/@/api/demo/system';
import { columns, formSchema } from './data';
import { getDeptList } from '/@/api/demo/system';
import { operationApi } from '/@/utils/event/operation';
import { message } from 'ant-design-vue';
import { json } from 'stream/consumers';
import { USER_INFO_KEY } from '/@/enums/cacheEnum';
import { Persistent } from '/@/utils/cache/persistent';
export default defineComponent({
name: 'DeptModal',
components: { BasicModal, BasicForm },
emits: ['success', 'register'],
props: {
configObj: {
type: Object,
default: {},
},
classifys: {
type: Object,
default: {},
},
},
setup(props, { emit }) {
const BasicForm = ref(null);
const isUpdate = ref(true);
const route = useRoute();
const state = reactive({
schemas: [],
});
const userId = ref('');
const pageId = ref('');
(() => {
userId.value = Persistent.getLocal(USER_INFO_KEY)?.id;
setTimeout(() => {
state.schemas = formSchema(props.configObj, props.classifys);
}, 1000);
})();
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
BasicForm.value.resetFields();
setModalProps({ confirmLoading: false });
pageId.value = data?.id;
isUpdate.value = !!data?.isUpdate;
if (unref(isUpdate)) {
state.schemas = state.schemas.map((item) => {
switch (item.component) {
case 'Switch':
item.defaultValue = data.record[item.field] === '1';
break;
case 'RangePicker':
item.defaultValue =
data.record[item.field] != '' ? data.record[item.field].split(',') : '';
break;
default:
item.defaultValue = data.record[item.field];
break;
}
return item;
});
} else {
state.schemas = state.schemas.map((item) => {
switch (item.component) {
case 'Switch':
item.defaultValue = item.componentProps?.options?.[0]?.value === '1';
break;
case 'RangePicker':
item.defaultValue = '';
break;
default:
item.defaultValue = item.componentProps?.options?.[0]?.value;
break;
}
return item;
});
}
});
const getTitle = computed(() => (!unref(isUpdate) ? '新增' : '编辑'));
async function handleSubmit() {
BasicForm.value.submit();
}
async function handleSubmitForm(values) {
try {
Object.keys(values).forEach((item) => {
if (values[item] instanceof Array) {
values[item] = values[item].join(',');
}
});
const params = {
user_id: userId.value,
menu_id: route.query.menu_id,
...values,
};
operationApi('addOredit', params, {
id: pageId.value,
addAction: addGeneralData,
editAction: editGeneralData,
}).then(() => {
closeModal();
emit('success');
});
} finally {
}
}
return {
...toRefs(state),
BasicForm,
registerModal,
getTitle,
handleSubmit,
handleSubmitForm,
};
},
});
</script>
data.ts文件代码
import { h } from 'vue';
import { BasicColumn } from '/@/components/Table';
import { FormSchema } from '/@/components/Table';
import { Switch } from 'ant-design-vue';
import { setRoleStatus } from '/@/api/demo/system';
import { useMessage } from '/@/hooks/web/useMessage';
export const columns = function (data?) {
const startArray = [
{
title: 'ID',
dataIndex: 'id',
width: 150,
}
]
const middleArray: any = []
const afterArray = [{
title: '创建时间',
dataIndex: 'createdAt',
width: 150,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 150,
}]
for (let i = 1; i <= 50; i++) {
middleArray.push({
title: data?.['c_' + i]?.name,
dataIndex: 'data' + i,
width: 150,
isExist: data?.['c_' + i]?.isExist,
})
}
return JSON.stringify(data) === '{}' ? [] :
[...startArray, ...middleArray, ...afterArray]?.
filter(item => item?.isExist !== 'f')
}
export const searchFormSchema = function (data?) {
let options: any = []
if (!(JSON.stringify(data) === '{}')) {
for (let i = 1; i <= 50; i++) {
if (!data?.['c_' + i]?.name?.includes("字段")) {
options.push({
label: data?.['c_' + i]?.name,
value: 'data' + i,
key: 'data' + i,
})
}
}
}
return [
{
field: 'selectField',
label: '选择查询条件',
component: 'Select',
colProps: { span: 8 },
componentProps: {
options,
},
},
{
field: 'inputValue',
label: '输入查询内容',
component: 'Input',
colProps: { span: 8 },
}
]
}
export const formSchema = function (data?, classifys?) {
const configArray: any = []
for (const item of classifys) {
if (classifys?.length > 1) {
configArray.push({
field: 'divider-basic',
component: 'Divider',
label: item,
colProps: {
span: 24,
},
isExist: 't'
})
}
for (let i = 1; i <= 50; i++) {
if (data?.['c_' + i]?.class === item) {
configArray.push({
field: 'data' + i,
label: data?.['c_' + i]?.name,
component: data?.['c_' + i]?.type,
componentProps: {
options: data?.['c_' + i]?.options,
},
isExist: data?.['c_' + i]?.isExist
})
}
}
}
return configArray?.filter(item => item?.isExist !== 'f')
}
提示:调接口获取动态配置后,解析出每一项的type,然后存入配置项columns【保存type为component属性的值】,最后再用component的值渲染对应的控件。渲染界面的时候,重点在这一段【核心】:
for (let i = 1; i <= 50; i++) { // 遍历50个字段
configArray.push({
field: 'data' + i, // 字段标识
label: data?.['c_' + i]?.name, // 表单项label
component: data?.['c_' + i]?.type, // 要渲染的组件
componentProps: {
options: data?.['c_' + i]?.options, // 组件的options配置项【复选框、下拉列表的数据】
},
isExist: data?.['c_' + i]?.isExist // 字段是否已使用
})
}