这篇文章是关于近期【监测用物联网提升智能传感器和系统软件开发】项目中,我在其中摸鱼凑数日子里积攒下来的系列文章一: 关于在一般后台系统中封装通用型大页面【表单筛选项+树状组织架构+表格+分页】及新增、编辑弹窗的实现思路、实现过程。
技术栈: vue3.x
+TS(X)
+Arco Design
需求分析:
一般后台系统通用型页面一般由这几个元素构成: 顶部表单筛选项、一些新增等的操作按钮、左侧树状组织架构、右侧表格数据展示、底部分页;在传统开发中,虽借助了element-ui
等业内优秀组件库,但完整写出一个页面还是需要费一番功夫的;
一般新增、编辑为弹窗形式展现出来, 其中包括:各种表单输入项、表单验证等这些元素, 这些完全复制于组件库及老项目也需要狠狠的费一番功夫,给我们开发人员带来了很不快乐的感觉;
所以我们能不能尽可能的把常见的业务实现过程封装起来,大大提高效率呢?基于这一初衷,结合目前的项目有了大胆尝试的想法。
实现思路:
表单项(页面筛选、编辑弹窗表单)
- 弹窗的控制
- 表单的验证及字段绑定、回显
- 根据需求渲染不同类型的输入项
左侧树状组织架构
展示组织架构(集团、分公司、部门【项目】)
点击哪个级别,在表格中展示哪个级别的数据
给其传入一个获取组织架构的接口地址,组件挂载时自动获取数据并展示数据,在点击交互时候 调用表格获取数据方法,以获取对应的数据。
表格及分页【感谢字节UI已经将表格和分页融合起来,并且表格天然支持jsx语法(render方法)】
这一块基本把
Arco
的表格项搬过来就好,唯一需要进一步操作的是:给表格一个获取数据的接口地址,在组件挂载时自动去请求数据并渲染到表格上。
实现过程:(代码)
编辑、新增弹窗
封装代码:
import {defineComponent, ref} from "vue";
import request from "@/api/request";
import {Message} from "@arco-design/web-vue";
export default defineComponent({
emits: ['done'],
props: {
writerData: {
type: Object,
required: true,
default: () => ({})
}
},
setup({writerData}, context) {
const writerFormRef = ref(null)
const titleName = ref<String>('')
const sourceFormData = ref({})
// 检查数据
// action url is required
if (!writerData.actionUrl) {
throw new Error("请传入新增/编辑得保存接口地址")
}
// form options is required
if (!writerData.form) {
throw new Error("请传入表单配置项")
}
const {form} = writerData;
let _selfForm = Object.assign({}, form)
if (writerData.getUrl || JSON.stringify(writerData.formData) !== '{}') {
titleName.value = writerData.titleObj['edit'] || '编辑'
// 编辑时候 回显数据
if (writerData.getUrl) {
// todo: 获取数据 sourceFormData.value
request.get(writerData.getUrl).then(res => {
console.log(res)
sourceFormData.value = res
})
}
if (writerData.formData) {
sourceFormData.value = writerData.formData
// 将多选得字段给改成数组
form.formItems.forEach(item => {
if (item?.inputOptions?.props?.multiple) {
sourceFormData.value[item.name] = sourceFormData.value[item.name].split(',').map(Number)
}
})
}
} else {
titleName.value = writerData.titleObj['add'] || '新增'
// 组成新增时候的表单数据
_selfForm.formItems.forEach(item => {
sourceFormData.value[item.name] = item.value
})
}
// 生成表单项
const getFormItems = () => {
let formItems = _selfForm.formItems;
let nodes = []
formItems.forEach((item, index) => {
if (!item.hidden) {
let node = (<a-form-item label={item.title + ':'} field={item.name} {...item.props}>
{getInputItem(item)}
</a-form-item>)
nodes.push(node)
}
})
return nodes
}
// 生成表单输入项
const getInputItem = (item) => {
let {type, props, render} = item.inputOptions
switch (type) {
case 'input':
return (
<a-input v-model={sourceFormData.value[item.name]} {...props} allow-clear />
)
case 'select':
return (
<a-select v-model={sourceFormData.value[item.name]} {...props} allow-clear>
{render ? render() : null}
</a-select>
)
case 'inputNumber':
return (
<a-input-number v-model={sourceFormData.value[item.name]} {...props} allow-clear/>
)
}
}
// 点击保存
const handleBeforeOk = (done) => {
// 表单验证
writerFormRef.value.validate((valid) => {
if (valid) {
done(false)
} else {
console.log(sourceFormData.value)
// 遍历表单项,去除空值和将数组转为字符串
let formData = {}
for (let key in sourceFormData.value) {
if (sourceFormData.value[key] instanceof Array) {
formData[key] = sourceFormData.value[key].join(',')
} else if (sourceFormData.value[key]) {
formData[key] = sourceFormData.value[key]
}
}
request.post(writerData.actionUrl, formData).then(res => {
console.log(res)
Message.success('保存成功')
done()
// 执行回调(关闭弹窗,重新加载数据)
context.emit('done', true)
}).catch(error => {
done(false)
})
}
})
}
const handleCancel = () => {
context.emit('done', false)
}
const slots = {
title: () => {
return <span>{titleName.value}</span>
}
}
return () => (
<>
<a-modal title-align="start" width="900px" visible={true} onBeforeOk={handleBeforeOk} onBeforeCancel={handleCancel} v-slots={slots} mask-closable={false} {...writerData.props} >
<a-form model={sourceFormData} layout="inline" {..._selfForm.props} ref={writerFormRef}>
{getFormItems}
</a-form>
</a-modal>
</>
)
}
})
调用代码:
import {defineComponent, reactive, ref} from "vue";
// 1. 引用组件
import writerModel from "@/components/writerModel/writerModel.js";
export default defineComponent({
// 2. 注册组件
components: {writerModel},
setup() {
const writerData = reactive({
visible: false, // 弹框是否显示
titleObj: {add: '新增', edit: '编辑'}, // modal 标题对象
formData: {}, // 表单源数据: {a:1,b:2}
getUrl: '', // 编辑时数据获取地址
actionUrl: '', // 新增/编辑 保存地址
props: {width: '600px'}, // modal props,参考 arco modal props
form: { // 表单
props: {layout: 'horizontal'}, // form props,参考 arco form props
formItems: [ // 表单项配置
{
title: '表单label', // 表单label
name: '表单feild', // 表单项绑定值得key
value: '', // 默认值
props: { // 表单项配置 参考 arco formitem的props
rules: [{required: true, message: '请选择'}], // 表单验证规则
},
inputOptions: { // 输入框配置
type: 'select', // 输入框类型: 【input, select, inputNumber】
props: { placeholder: '请选择'}, // 输入框配置 参考 arco input/select/input-number的props
render: () => { // select下自定义方法,一般用于传入下拉选项
return <>
{
[1,2,3].map((item, index) => {
return <a-option key={index} value={item} label={item}></a-option>
})
}
</>
}
}
},
{
title: '表单label',
name: '表单feild',
value: null,
props: {
rules: [{required: true, message: '请输入'}],
},
inputOptions: {
type: 'input',
props: {placeholder: '请输入值',},
}
},
]
}
})
// 回调
const handleWriterDone = (result:boolean) => {
if (result) {
// 重新获取表格数据
proTableRef.value.getTableData();
}
// 重置弹窗所有选项
writerData.visible = false;
}
// 渲染
return () => (
<>
// writerData.visible来判断是否渲染弹窗,使用完毕后直接销毁
{writerData.visible && <writerModel writer-data={writerData} onDone={handleWriterDone}/>}
</>
)
}
})
通用页面组件
封装代码:
searchForm.tsx
import {defineComponent} from "vue";
export default defineComponent({
emits: ['search'],
props: {
queryForm: {
type: Array,
required: true,
default: () => []
},
actionBtn: {
type: Array,
required: true,
default: () => []
}
},
setup({queryForm, actionBtn}, context) {
const generateInputItem = (item: any) => {
switch (item.itemOption.type) {
case "input":
return (
<a-input v-model={item.value} {...item.itemOption.props} allow-clear />
)
case "select":
return (
<a-select v-model={item.value} {...item.itemOption.props} allow-clear>
{item.itemOption.render()}
</a-select>
)
}
}
const renderInputItem = (): Array<any> => {
let nodes:Array<any> = [];
if (queryForm) {
queryForm.forEach(item => {
if (!item.hidden) {
let node = <a-form-item field={item.name} label={item.title}>
{generateInputItem(item)}
</a-form-item>
nodes.push(node);
}
})
}
return nodes;
}
const renderActionBtnItem = (): Array<any> => {
let nodes: Array<any> = [];
if (actionBtn) {
actionBtn.forEach(item => {
let node = <a-button class="search" onClick={() => item.clickEvent ? item.clickEvent() : console.log("暂时没有点击事件")}>{item.title}</a-button>
nodes.push(node);
})
}
return nodes;
}
const handleFilter = () => {
context.emit("search")
}
const resetQueryForm = () => {
queryForm.forEach(item => {
if (item.hasOwnProperty('defaultValue')) {
item.value = item.defaultValue;
} else {
item.value = '';
}
})
handleFilter();
}
return () => (
<>
<div class="filterContainer">
<a-form model={queryForm} onSubmit={() => handleFilter()} layout="inline">
{renderInputItem()}
<a-form-item hide-label>
<a-button class="search" html-type="submit">搜索</a-button>
</a-form-item>
<a-form-item hide-label>
<a-button class="reset" onClick={() => resetQueryForm()}>重置</a-button>
</a-form-item>
<a-form-item hide-label class="actionBtn">
{renderActionBtnItem()}
</a-form-item>
</a-form>
</div>
</>
);
}
});
table_self.tsx
import {defineComponent} from "vue";
export default defineComponent({
props: {
tableData: {
type: Object,
required: true,
default: () => ({})
},
_tableData: {
type: Object,
required: true,
default: () => ({})
}
},
emits: ['handlePageChange'],
setup({tableData, _tableData}, context) {
const pageChange = (page:number):void => {
context.emit("handlePageChange", page)
}
return () => (
<>
<a-table {..._tableData} {...tableData} onPageChange={pageChange} />
</>
);
}
})
proTable.vue
<template>
<search-form :queryForm="pageData.queryForm" :action-btn="pageData.actionBtn" @search="handleSearch"/>
<a-row class="mainContainer" :gutter="48">
<a-col :span="4" class="tree">
<div class="treeContainer">
<div class="title">组织架构</div>
<a-input-search style="margin-bottom: 8px;" placeholder="请输入搜索内容" v-model="treeData.searchKey"/>
<a-skeleton :loading="!treeData.showTree" animation>
<a-space direction="vertical" :style="{width:'100%'}" size="large">
<a-skeleton-line :rows="3" />
</a-space>
</a-skeleton>
<a-tree :data="treeData.data" @select="handleTreeSelect" v-if="treeData.showTree"
:fieldNames="{children: 'projects', title: 'projectName', key: 'id'}" :selected-keys="[_queryForm.projectId || _queryForm.deptId]">
<template #title="nodeData">
<template v-if="index = getMatchIndex(nodeData?.projectName), index < 0">{{ nodeData?.projectName }}</template>
<span v-else>
{{ nodeData?.projectName?.substr(0, index) }}
<span style="color: var(--color-primary-light-4);">
{{ nodeData?.projectName?.substr(index, treeData.searchKey.length) }}
</span>{{ nodeData?.projectName?.substr(index + treeData.searchKey.length) }}
</span>
</template>
</a-tree>
</div>
</a-col>
<a-col :span="20" class="tableContainer">
<tableSelf :tableData="pageData.tableData" :_tableData="_tableData" @handlePageChange="pageChange"/>
</a-col>
</a-row>
</template>
<script lang="ts" setup>
import SearchForm from "./components/searchForm.tsx";
import {onMounted, reactive, toRefs} from "vue";
import request from "@/api/request";
import tableSelf from "./components/table_self.tsx";
const props = defineProps({
pageData: {
type: Object,
required: true,
},
});
const treeData = reactive({
searchKey: '',
showTree: false,
data: [],
select: '',
})
const _queryForm = reactive({
deptId: '',
projectId: ''
});
interface Pagination {
current: number;
pageSize: number;
total: number;
showJumper: boolean,
showTotal: boolean,
hideOnSinglePage: boolean,
}
interface TableData {
loading: boolean;
data: Array<any>;
bordered: boolean;
pagePosition: string;
pagination: Pagination;
}
const _tableData:TableData = reactive({
loading: false,
data: [],
bordered: false,
pagePosition: 'bottom',
pagination: {
current: 1,
pageSize: 10,
total: 0,
showJumper: true,
showTotal: true,
hideOnSinglePage: true,
},
})
// 根据输入条件获取匹配的数据
const handleSearch = ():void => {
_tableData.pagination.current = 1
getTableData()
}
// 获取表格数据
const getTableData = ():void => {
if (!props.pageData?.tableData?.url) {
throw new Error('请配置tableData.url')
return
}
_tableData.loading = true;
let params = {
..._queryForm,
page: _tableData.pagination.current,
pageSize: _tableData.pagination.pageSize,
}
props.pageData.queryForm.forEach(item => {
params[item['name']] = item['value']
})
// 去除空值
Object.keys(params).forEach(key => {
if (params[key] === '') {
delete params[key]
}
})
request.get(props.pageData.tableData.url, {params}).then(res => {
_tableData.data = res.list;
_tableData.pagination.total = res.total;
_tableData.loading = false;
}).catch(() => {
_tableData.loading = false;
})
}
defineExpose({
treeData,
_queryForm,
getTableData,
_tableData,
})
// 分页
function pageChange(page:number):void {
_tableData.pagination.current = page;
getTreeData()
}
// 筛选组织数据
function getMatchIndex(title:string):number {
if (!treeData.searchKey) return -1;
return title.toLowerCase().indexOf(treeData.searchKey.toLowerCase());
}
// 处理点击树节点
const handleTreeSelect = (selectedKeys?: Array<string | number>, data?: any) :void => {
if(data.node.id) {
treeData.select = data.node;
if (data.node.deptFullName) {
_queryForm.deptId = data.node.id;
_queryForm.projectId = "";
} else {
_queryForm.projectId = data.node.id;
_queryForm.deptId = "";
}
getTableData();
}
};
// 获取树数据
const getTreeData = ():void => {
request.get(props.pageData.tree.url)
.then(res => {
let _list = [
{
projectName: '根目录',
key: '-1', id: null,
projects: serializeTreeData(res)
}
]
treeData.showTree = true;
_queryForm.deptId = res[0].id;
treeData.select = res[0];
treeData.data = _list;
getTableData();
})
}
// 序列化组织架构数据
const serializeTreeData = (data: Array<any>): Array<any> => {
let _list = [];
data.forEach(item => {
_list.push({
...item,
projectName: item.deptName || item.projectName,
projects: item.childContentList.length ? serializeTreeData(item.childContentList) : item.projects
})
})
return _list;
}
onMounted(() => {
// 判断是否有左侧树结构数据需要处理
if (props.pageData.tree) {
// 请求树结构数据
getTreeData()
}
// 暂不考虑没有树结构的情况
})
</script>
调用:
引入
import proTable from "@/components/proTable/index.vue";
实例化, 推荐
defineComponent
中通过JSX来实现export default defineComponent({ components: { proTable }, setup() { return () => ( <> <proTable page-data={pageData} ref={proTableRef} /> </> ); } })
默认传值
{ // 筛选项表单项 queryForm: [ { title: "表单名 - label", name: "表单 - field", defaultValue: "默认值,重置之后的默认值" value: "输入框内容 - 双向绑定的值", itemOption: { // 输入框配置项 type: "输入框类型,可选:input/ select", props: {}, // aroc [type]input 的 props 配置项 render: () => { // select的option } } }, // deptId & projectId已经和树结构内置 ], // 筛选框右侧的按钮 actionBtn: [ { title: '按钮名称', clickEvent: () => { // 按钮点击事件,写在调用页面【本页面】 } } ], // 左侧树结构, 必传, 无此值页面将无数据 tree: { url: "树结构的url", }, // 右侧表格数据, tableData: { url: "获取表格数据的url", columns: [], // 表格配置项,详见arco 表格配置 } }
默认暴露的方法与数据
treeData: 右侧树结构 相关数据
_queryForm: deptId & projectId
getTableData: 获取表格数据的方法
_tableData: 表格内置配置项: 分页,加载状态, 数据等配置
4.1. 获取暴露的方法与数据
const proTableRef = ref(null);
...
<>
<proTable page-data={pageData} ref={proTableRef} />
</>
...
4.2. 使用
proTableRef.value._tableData.pagination.current = 1;
proTableRef.value.getTableData();
初稿完成于:2022.8.20 10:42
经验有限,烦请多多提出意见, 联系邮箱:zhanghaoran@qq.com