1.由何而来
由于早期项目外包缘故,外包商采用的是buildAdmin框架,所以就开始用起来。你可能没听过,但是他的logo你可能不陌生,习惯使用element组件的朋友在官网总能喵的他家了logo,是的,他们是一个element赞助商。
2.buildAdmin架构
早期使用纯php代码实现,后期才成vue3 + think(php)前后分离项目。是一套半低码平台。常用的增删改查都可以框架动态生成
在线演示: demo.buildadmin.com/#/
项目分两个模块
- 前台会员模块
- 后台管理模块
也有自己的模块交易市场
官方也有详细的前端后台文档介绍 doc.buildadmin.com/guide
项目结构
由于早期一锅端 jquery + php ,所以代码是后台前台都在一个git里面,php在根目录,新的vue3代码延续了这个陋习,前端代码在web文件夹里面
3.主要总结内容
主要针对常用列表,新增编辑,网络,路由等做讲解
- 列表库
- baTable实现分析
- 新增/编辑
- FormItem实现分析
- 网络请求
- 路由控制
3.1 列表库
在buildadmin里面,通过配置项可以同步生成列表头的查询字段 和 列表体的显示字段。
如性别的下拉过滤和表格显示,只需要一个json对象描述column的信息即可
js
{
label: t('user.user.Gender'),
prop: 'gender',
align: 'center',
render: 'tag',
custom: { '0': 'info', '1': '', '2': 'success' },
replaceValue: { '0': t('Unknown'), '1': t('user.user.male'), '2': t('user.user.female') },
operator: '='
},
- render 决定表格显示的内容。
- replaceValue 绑定对应的数据源
- custom 控制显示的样式
- operator 查询条件
由于是低码的方式,所以所有的查询条件都是通过拼接sql的参数方式传递(确实有被注入的风险)
这是查询性别女的请求参数,是以多维数组方式传递
踩过的坑:
实际开发中可能查询调试是后台通过别名查询的sql,对应的传参也要调整,如gender可能要改成 ss.gender
实际开发不一定同时需要显示过滤条件和表格显示,或者显示顺序不一致,这时候就要配置多条,同时设置 如operator:false
过滤是否显示 或者show : false
表格是否显示,来实现。
使用方式
html
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.user.User name') + '/' + t('user.user.nickname') })"
/>
<!-- 表格 -->
<Table ref="tableRef" />
<!-- 弹框新增/编辑组件 -->
<PopupForm />
<script setup lang="ts">
const baTable = new baTableClass(
new baTableApi('/admin/user.User/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.user.User name'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.user.Gender'),
prop: 'gender',
align: 'center',
render: 'tag',
custom: { '0': 'info', '1': '', '2': 'success' },
replaceValue: { '0': t('Unknown'), '1': t('user.user.male'), '2': t('user.user.female') },
},
...
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
gender: 0,
money: '0',
score: '0',
status: 'enable',
},
}
)
baTable.mount()
baTable.getIndex()
provide('baTable', baTable)
</script>
通过baTableClass创建操作实例和组件TableHeader和Table 实现的渲染。
- 由于是标准的resful所以接口都是自动根据模块名拼接baTableClass
baTable.mount()
挂载baTable.getIndex()
执行查询
TableHeader
- 头部过滤条件动态渲染
- 中间常用操作配置
Table
实现表格的根据配置渲染,包含分页
数据通信
使用全局的provide('baTable', baTable)
注入,所有的TableHeader和Table和PopupForm共享所有信息 使用const baTable = inject('baTable') as baTableClass
获取。
该设计只适用于单页面模态开发,如果需要同时操作两个页签的业务就很难实现
3.2 baTableClass 实现分析
使用
先看看如何使用
js
const baTable = new baTableClass(
new baTableApi('/admin/user.Group/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
...
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
status: '1',
},
},
{
// 提交前
onSubmit: ({ formEl, operate, items }) => {
let submitCallback = () => {
baTable.form.submitLoading = true
baTable.api
.postData(operate, {
...items,
rules: formRef.value.getCheckeds(),
})
.then((res) => {
baTable.onTableHeaderAction('refresh', {})
baTable.form.submitLoading = false
baTable.form.operateIds?.shift()
if (baTable.form.operateIds!.length > 0) {
baTable.toggleForm('Edit', baTable.form.operateIds)
} else {
baTable.toggleForm()
}
baTable.runAfter('onSubmit', { res })
})
.catch(() => {
baTable.form.submitLoading = false
})
}
if (formEl) {
baTable.form.ref = formEl
formEl.validate((valid) => {
if (valid) {
submitCallback()
}
})
} else {
submitCallback()
}
return false
},
},
{
// 切换表单后
toggleForm({ operate }) {
if (operate == 'Add') {
menuRuleTreeUpdate()
}
},
// 编辑请求完成后
requestEdit() {
menuRuleTreeUpdate()
},
}
)
构造参数
baTableClass 主要接受几个参数
js
constructor(api: baTableApi, table: BaTable, form: BaTableForm = {}, before: BaTableBefore = {}, after: BaTableAfter = {}) {
this.api = api
this.form = Object.assign(this.form, form)
this.table = Object.assign(this.table, table)
this.before = before
this.after = after
}
- api: 接口定义
- table: 定义columns json配置
- form: form 新增表单时候默认值
- before:各种业务事件的前置钩子
- after:各种业务事件的后置钩子
这里重点说说before和after,在日常的开发中,如何设计一个良好的扩展接口,通过注入钩子的方式是一种不错的设计方式,在vue的源码中也应用比较多
钩子
BaTableBefore和BaTableAfter
ts
/**
* BaTable 前置处理函数(前置埋点)
*/
interface BaTableBefore {
// 获取表格数据前
getIndex?: () => boolean | void
// 删除前
postDel?: ({ ids }: { ids: string[] }) => boolean | void
// 编辑请求前
requestEdit?: ({ id }: { id: string }) => boolean | void
// 双击表格具体操作执行前
onTableDblclick?: ({ row, column }: { row: TableRow; column: TableColumn }) => boolean | void
// 表单切换前
toggleForm?: ({ operate, operateIds }: { operate: string; operateIds: string[] }) => boolean | void
// 表单提交前
onSubmit?: ({ formEl, operate, items }: { formEl: FormInstance | undefined; operate: string; items: anyObj }) => boolean | void
// 表格内事件响应前
onTableAction?: ({ event, data }: { event: string; data: anyObj }) => boolean | void
// 表格顶部菜单事件响应前
onTableHeaderAction?: ({ event, data }: { event: string; data: anyObj }) => boolean | void
// 表格初始化前
mount?: () => boolean | void
[key: string]: Function | undefined
}
/**
* BaTable 后置处理函数(后置埋点)
*/
interface BaTableAfter {
// 表格数据请求后
getIndex?: ({ res }: { res: ApiResponse }) => void
// 删除请求后
postDel?: ({ res }: { res: ApiResponse }) => void
// 编辑表单请求后
requestEdit?: ({ res }: { res: ApiResponse }) => void
// 双击单元格操作执行后
onTableDblclick?: ({ row, column }: { row: TableRow; column: TableColumn }) => void
// 表单切换后
toggleForm?: ({ operate, operateIds }: { operate: string; operateIds: string[] }) => void
// 表单提交后
onSubmit?: ({ res }: { res: ApiResponse }) => void
// 表格事件响应后
onTableAction?: ({ event, data }: { event: string; data: anyObj }) => void
// 表格顶部事件菜单响应后
onTableHeaderAction?: ({ event, data }: { event: string; data: anyObj }) => void
[key: string]: Function | undefined
}
可以看到前置和后置方式都是一样对应,再看看具体埋点实现
比如点击编辑时候
ts
// 编辑
requestEdit = (id: string) => {
if (this.runBefore('requestEdit', { id }) === false) return
this.form.loading = true
this.form.items = {}
return this.api
.edit({
[this.table.pk!]: id,
})
.then((res) => {
this.form.items = res.data.row
this.runAfter('requestEdit', { res })
})
.catch((err) => {
this.toggleForm()
this.runAfter('requestEdit', { err })
})
.finally(() => {
this.form.loading = false
})
}
通过this.runBefore('requestEdit', { id })
执行前置钩子, this.runAfter('requestEdit', { res })
执行后置钩子
对外暴露方法
比如表格头的查询事件,表格体的分页事件等
使用
ts
// 通知 baTable 发起通用搜索
baTable.onTableAction('com-search', {})
//发起 分页事件
baTable.onTableAction('selection-change', selection)
源码
onTableAction 和 onTableHeaderAction 通过map配置的方式,控制所有对应的按钮事件。 由于统一管理了,所以再插入上面的钩子函数就变得丝滑
ts
/**
* 表格内的事件统一响应
* @param event 事件:selection-change=选中项改变,page-size-change=每页数量改变,current-page-change=翻页,sort-change=排序,edit=编辑,delete=删除,field-change=单元格值改变,com-search=公共搜索
* @param data 携带数据
*/
onTableAction = (event: string, data: anyObj) => {
if (this.runBefore('onTableAction', { event, data }) === false) return
const actionFun = new Map([
[
'selection-change',
() => {
this.table.selection = data as TableRow[]
},
],
[
'page-size-change',
() => {
this.table.filter!.limit = data.size
this.onTableHeaderAction('refresh', { event: 'page-size-change', ...data })
},
],
[
'current-page-change',
() => {
this.table.filter!.page = data.page
this.onTableHeaderAction('refresh', { event: 'current-page-change', ...data })
},
],
[
'sort-change',
() => {
let newOrder: string | undefined
if (data.prop && data.order) {
newOrder = data.prop + ',' + data.order
}
if (newOrder != this.table.filter!.order) {
this.table.filter!.order = newOrder
this.onTableHeaderAction('refresh', { event: 'sort-change', ...data })
}
},
],
[
'edit',
() => {
this.toggleForm('Edit', [data.row[this.table.pk!]])
},
],
[
'delete',
() => {
this.postDel([data.row[this.table.pk!]])
},
],
['field-change', () => {}],
[
'com-search',
() => {
this.table.filter!.search = this.getComSearchData()
// 刷新表格
this.onTableHeaderAction('refresh', { event: 'com-search', data: this.table.filter!.search })
},
],
[
'default',
() => {
console.warn('No action defined')
},
],
])
const action = actionFun.get(event) || actionFun.get('default')
action!.call(this)
return this.runAfter('onTableAction', { event, data })
}
/**
* 表格顶栏按钮事件统一响应
* @param event 事件:refresh=刷新,edit=编辑,delete=删除,quick-search=快速查询,unfold=折叠/展开,change-show-column=调整列显示状态
* @param data 携带数据
*/
onTableHeaderAction = (event: string, data: anyObj) => {
if (this.runBefore('onTableHeaderAction', { event, data }) === false) return
const actionFun = new Map([
[
'refresh',
() => {
// 刷新表格在大多数情况下无需置空 data,但任需防范表格列组件的 :key 不会被更新的问题,比如关联表的数据列
this.table.data = []
this.getIndex()
},
],
[
'add',
() => {
this.toggleForm('Add')
},
],
[
'edit',
() => {
this.toggleForm('Edit', this.getSelectionIds())
},
],
[
'delete',
() => {
this.postDel(this.getSelectionIds())
},
],
[
'unfold',
() => {
if (!this.table.ref) {
console.warn('Collapse/expand failed because table ref is not defined. Please assign table ref when onMounted')
return
}
this.table.expandAll = data.unfold
this.table.ref.unFoldAll(data.unfold)
},
],
[
'quick-search',
() => {
this.onTableHeaderAction('refresh', { event: 'quick-search', ...data })
},
],
[
'change-show-column',
() => {
const columnKey = getArrayKey(this.table.column, 'prop', data.field)
this.table.column[columnKey].show = data.value
},
],
[
'default',
() => {
console.warn('No action defined')
},
],
])
const action = actionFun.get(event) || actionFun.get('default')
action!.call(this)
return this.runAfter('onTableHeaderAction', { event, data })
}
完整的baTable类
ts
import type { FormInstance, TableColumnCtx } from 'element-plus'
import { ElNotification, dayjs } from 'element-plus'
import { cloneDeep, isArray, isEmpty } from 'lodash-es'
import Sortable from 'sortablejs'
import { reactive } from 'vue'
import { useRoute } from 'vue-router'
import type { baTableApi } from '/@/api/common'
import { findIndexRow } from '/@/components/table'
import { i18n } from '/@/lang/index'
import { auth, getArrayKey } from '/@/utils/common'
export default class baTable {
// API实例
public api
/* 表格状态-s 属性对应含义请查阅 BaTable 的类型定义 */
public table: BaTable = reactive({
ref: undefined,
pk: 'id',
data: [],
remark: null,
loading: false,
selection: [],
column: [],
total: 0,
filter: {},
dragSortLimitField: 'pid',
acceptQuery: true,
showComSearch: false,
dblClickNotEditColumn: [undefined],
expandAll: false,
extend: {},
})
/* 表格状态-e */
/* 表单状态-s 属性对应含义请查阅 BaTableForm 的类型定义 */
public form: BaTableForm = reactive({
ref: undefined,
labelWidth: 160,
operate: '',
operateIds: [],
items: {},
submitLoading: false,
defaultItems: {},
loading: false,
extend: {},
})
/* 表单状态-e */
// BaTable前置处理函数列表(前置埋点)
public before: BaTableBefore
// BaTable后置处理函数列表(后置埋点)
public after: BaTableAfter
// 通用搜索数据
public comSearch: ComSearch = reactive({
form: {},
fieldData: new Map(),
})
constructor(api: baTableApi, table: BaTable, form: BaTableForm = {}, before: BaTableBefore = {}, after: BaTableAfter = {}) {
this.api = api
this.form = Object.assign(this.form, form)
this.table = Object.assign(this.table, table)
this.before = before
this.after = after
}
/**
* 表格内部鉴权方法
* 此方法在表头或表行组件内部自动调用,传递权限节点名,如:add、edit
* 若需自定义表格内部鉴权,重写此方法即可
*/
auth(node: string) {
return auth(node)
}
/**
* 运行前置函数
* @param funName 函数名
* @param args 参数
*/
runBefore(funName: string, args: any = {}) {
if (this.before && this.before[funName] && typeof this.before[funName] == 'function') {
return this.before[funName]!({ ...args }) === false ? false : true
}
return true
}
/**
* 运行后置函数
* @param funName 函数名
* @param args 参数
*/
runAfter(funName: string, args: any = {}) {
if (this.after && this.after[funName] && typeof this.after[funName] == 'function') {
return this.after[funName]!({ ...args }) === false ? false : true
}
return true
}
/* API请求方法-s */
// 查看
getIndex = () => {
if (this.runBefore('getIndex') === false) return
this.table.loading = true
return this.api
.index(this.table.filter)
.then((res) => {
this.table.data = res.data.list
this.table.total = res.data.total
this.table.remark = res.data.remark
this.runAfter('getIndex', { res })
})
.finally(() => {
this.table.loading = false
})
}
// 删除
postDel = (ids: string[]) => {
if (this.runBefore('postDel', { ids }) === false) return
this.api.del(ids).then((res) => {
this.onTableHeaderAction('refresh', {})
this.runAfter('postDel', { res })
})
}
// 编辑
requestEdit = (id: string) => {
if (this.runBefore('requestEdit', { id }) === false) return
this.form.loading = true
this.form.items = {}
return this.api
.edit({
[this.table.pk!]: id,
})
.then((res) => {
this.form.items = res.data.row
this.runAfter('requestEdit', { res })
})
.catch((err) => {
this.toggleForm()
this.runAfter('requestEdit', { err })
})
.finally(() => {
this.form.loading = false
})
}
/* API请求方法-e */
/**
* 双击表格
* @param row 行数据
* @param column 列上下文数据
*/
onTableDblclick = (row: TableRow, column: TableColumnCtx<TableRow>) => {
if (!this.table.dblClickNotEditColumn!.includes('all') && !this.table.dblClickNotEditColumn!.includes(column.property)) {
if (this.runBefore('onTableDblclick', { row, column }) === false) return
this.toggleForm('Edit', [row[this.table.pk!]])
this.runAfter('onTableDblclick', { row, column })
}
}
/**
* 打开表单
* @param operate 操作:Add=添加,Edit=编辑
* @param operateIds 被操作项的数组:Add=[],Edit=[1,2,...]
*/
toggleForm = (operate = '', operateIds: string[] = []) => {
if (this.runBefore('toggleForm', { operate, operateIds }) === false) return
if (operate == 'Edit') {
if (!operateIds.length) {
return false
}
this.requestEdit(operateIds[0])
} else if (operate == 'Add') {
this.form.items = cloneDeep(this.form.defaultItems)
}
this.form.operate = operate
this.form.operateIds = operateIds
this.runAfter('toggleForm', { operate, operateIds })
}
/**
* 提交表单
* @param formEl 表单组件ref
*/
onSubmit = (formEl: FormInstance | undefined = undefined) => {
// 当前操作的首字母小写
const operate = this.form.operate!.replace(this.form.operate![0], this.form.operate![0].toLowerCase())
if (this.runBefore('onSubmit', { formEl: formEl, operate: operate, items: this.form.items! }) === false) return
// 表单验证通过后执行的api请求操作
const submitCallback = () => {
this.form.submitLoading = true
this.api
.postData(operate, this.form.items!)
.then((res) => {
this.onTableHeaderAction('refresh', {})
this.form.operateIds?.shift()
if (this.form.operateIds!.length > 0) {
this.toggleForm('Edit', this.form.operateIds)
} else {
this.toggleForm()
}
this.runAfter('onSubmit', { res })
})
.finally(() => {
this.form.submitLoading = false
})
}
if (formEl) {
this.form.ref = formEl
formEl.validate((valid: boolean) => {
if (valid) {
submitCallback()
}
})
} else {
submitCallback()
}
}
/**
* 获取表格选择项的id数组
*/
getSelectionIds() {
const ids: string[] = []
this.table.selection?.forEach((item) => {
ids.push(item[this.table.pk!])
})
return ids
}
/**
* 表格内的事件统一响应
* @param event 事件:selection-change=选中项改变,page-size-change=每页数量改变,current-page-change=翻页,sort-change=排序,edit=编辑,delete=删除,field-change=单元格值改变,com-search=公共搜索
* @param data 携带数据
*/
onTableAction = (event: string, data: anyObj) => {
if (this.runBefore('onTableAction', { event, data }) === false) return
const actionFun = new Map([
[
'selection-change',
() => {
this.table.selection = data as TableRow[]
},
],
[
'page-size-change',
() => {
this.table.filter!.limit = data.size
this.onTableHeaderAction('refresh', { event: 'page-size-change', ...data })
},
],
[
'current-page-change',
() => {
this.table.filter!.page = data.page
this.onTableHeaderAction('refresh', { event: 'current-page-change', ...data })
},
],
[
'sort-change',
() => {
let newOrder: string | undefined
if (data.prop && data.order) {
newOrder = data.prop + ',' + data.order
}
if (newOrder != this.table.filter!.order) {
this.table.filter!.order = newOrder
this.onTableHeaderAction('refresh', { event: 'sort-change', ...data })
}
},
],
[
'edit',
() => {
this.toggleForm('Edit', [data.row[this.table.pk!]])
},
],
[
'delete',
() => {
this.postDel([data.row[this.table.pk!]])
},
],
['field-change', () => {}],
[
'com-search',
() => {
this.table.filter!.search = this.getComSearchData()
// 刷新表格
this.onTableHeaderAction('refresh', { event: 'com-search', data: this.table.filter!.search })
},
],
[
'default',
() => {
console.warn('No action defined')
},
],
])
const action = actionFun.get(event) || actionFun.get('default')
action!.call(this)
return this.runAfter('onTableAction', { event, data })
}
/**
* 表格顶栏按钮事件统一响应
* @param event 事件:refresh=刷新,edit=编辑,delete=删除,quick-search=快速查询,unfold=折叠/展开,change-show-column=调整列显示状态
* @param data 携带数据
*/
onTableHeaderAction = (event: string, data: anyObj) => {
if (this.runBefore('onTableHeaderAction', { event, data }) === false) return
const actionFun = new Map([
[
'refresh',
() => {
// 刷新表格在大多数情况下无需置空 data,但任需防范表格列组件的 :key 不会被更新的问题,比如关联表的数据列
this.table.data = []
this.getIndex()
},
],
[
'add',
() => {
this.toggleForm('Add')
},
],
[
'edit',
() => {
this.toggleForm('Edit', this.getSelectionIds())
},
],
[
'delete',
() => {
this.postDel(this.getSelectionIds())
},
],
[
'unfold',
() => {
if (!this.table.ref) {
console.warn('Collapse/expand failed because table ref is not defined. Please assign table ref when onMounted')
return
}
this.table.expandAll = data.unfold
this.table.ref.unFoldAll(data.unfold)
},
],
[
'quick-search',
() => {
this.onTableHeaderAction('refresh', { event: 'quick-search', ...data })
},
],
[
'change-show-column',
() => {
const columnKey = getArrayKey(this.table.column, 'prop', data.field)
this.table.column[columnKey].show = data.value
},
],
[
'default',
() => {
console.warn('No action defined')
},
],
])
const action = actionFun.get(event) || actionFun.get('default')
action!.call(this)
return this.runAfter('onTableHeaderAction', { event, data })
}
/**
* 初始化默认排序
* el表格的`default-sort`在自定义排序时无效
* 此方法只有在表格数据请求结束后执行有效
*/
initSort = () => {
}
/**
* 初始化表格拖动排序
*/
dragSort = () => {
}
/**
* 表格初始化
*/
mount = () => {
if (this.runBefore('mount') === false) return
// 记录表格的路由路径
const route = useRoute()
this.table.routePath = route.fullPath
// 初始化通用搜索表单数据和字段 Map
this.initComSearch()
if (this.table.acceptQuery && !isEmpty(route.query)) {
// 根据当前 URL 的 query 初始化通用搜索默认值
this.setComSearchData(route.query)
// 获取通用搜索数据合并至表格筛选条件
this.table.filter!.search = this.getComSearchData().concat(this.table.filter?.search ?? [])
}
}
/**
* 通用搜索初始化
*/
initComSearch = () => {
const form: anyObj = {}
this.comSearch.form = Object.assign(this.comSearch.form, form)
}
/**
* 设置通用搜索数据
*/
setComSearchData = (query: anyObj) => {
}
/**
* 获取通用搜索数据
*/
getComSearchData = () => {
const comSearchData: comSearchData[] = []
return comSearchData
}
}
3.3 新增/编辑
新增编辑正常用el-from开发即可,当然 框架封装了组件FormItem,对于复杂的操作可通过配置实现
使用方式
js
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
...
<el-form-item prop="nickname" :label="t('user.user.nickname')">
<el-input
v-model="baTable.form.items!.nickname"
type="string"
:placeholder="t('Please input field', { field: t('user.user.nickname') })"
></el-input>
</el-form-item>
<FormItem
type="remoteSelect"
:label="t('user.user.grouping')"
v-model="baTable.form.items!.group_id"
prop="group_id"
:placeholder="t('user.user.grouping')"
:input-attr="{
params: { isTree: true, search: [{ field: 'status', val: '1', operator: 'eq' }] },
field: 'name',
remoteUrl: '/admin/user.Group/index',
}"
/>
<FormItem :label="t('user.user.head portrait')" type="image" v-model="baTable.form.items!.avatar" />
<FormItem
:label="t('user.user.Gender')"
v-model="baTable.form.items!.gender"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Unknown'), 1: t('user.user.male'), 2: t('user.user.female') },
}"
/>
</el-form>
FormItem 组件通过 type实现不同逻辑
- type="remoteSelect"实现远程加载数据
- type="image" 实现文件上传
- type="radio" 实现数据源绑定 radio显示
FormItem实现分析
FormItem要实现动态创建不同的element组件我们以 type="image" 实现文件上传为例子
实际应用的场景:从业务组件到最终创建baUpload组件,中间经历了
业务组件-> formItem组件 -> baInput组件 -> baUpload组件
中间创建的方式又是使用动态代码createVNode创建,所以写法与vue的temlpate有所不同
通过createVNode中使用创建组件,事件需要单独声明为带on前缀(如click 要改成onClick 防止与浏览器原生事件冲突)
我们通过v-model的数据绑定看看是如何被一层一层传递和响应的。具体的原理可以参考 juejin.cn/post/745685...
源码
formItem
web/src/components/formItem/index.vue
js
<script lang="ts">
import { formItemProps } from 'element-plus'
import type { PropType, VNode } from 'vue'
import { computed, createVNode, defineComponent, resolveComponent } from 'vue'
import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput'
import { inputTypes } from '/@/components/baInput'
import BaInput from '/@/components/baInput/index.vue'
import type { FormItemAttr } from '/@/components/formItem'
export default defineComponent({
name: 'formItem',
props: {
// 输入框类型,支持的输入框见 inputTypes
type: {
type: String,
required: true,
validator: (value: string) => {
return inputTypes.includes(value)
},
},
// 双向绑定值
modelValue: {
required: true,
},
// 输入框的附加属性
inputAttr: {
type: Object as PropType<InputAttr>,
default: () => {},
},
blockHelp: {
type: String,
default: '',
},
tip: [String, Object],
// el-form-item 的附加属性(还可以直接通过当前组件的 props 传递)
attr: {
type: Object as PropType<FormItemAttr>,
default: () => {},
},
// 额外数据(已和 props.inputAttr 合并,还可以通过它进行传递)
data: {
type: Object as PropType<InputData>,
default: () => {},
},
// 内部输入框的 placeholder(相当于 props.inputAttr.placeholder 的别名)
placeholder: {
type: String,
default: '',
},
...formItemProps,
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
// 通过 props 和 props.attr 两种方式传递的属性汇总为 attrs
const excludeProps = ['type', 'modelValue', 'inputAttr', 'attr', 'data', 'placeholder']
const attrs = computed(() => {
const newAttrs = props.attr || {}
for (const key in props) {
const propValue: any = props[key as keyof typeof props]
if (!excludeProps.includes(key) && (propValue || propValue === false)) {
newAttrs[key as keyof typeof props.attr] = propValue
}
}
return newAttrs
})
const onValueUpdate = (value: ModelValueTypes) => {
emit('update:modelValue', value)
}
// el-form-item 的插槽
const formItemSlots: { [key: string]: () => VNode | VNode[] } = {}
// default 插槽
formItemSlots.default = () => {
let inputNode = createVNode(
BaInput,
{
type: props.type,
attr: { placeholder: props.placeholder, ...props.inputAttr, ...props.data },
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
if (attrs.value.blockHelp) {
return [
inputNode,
createVNode(
'div',
{
class: 'block-help',
},
attrs.value.blockHelp
),
]
}
return inputNode
}
if (attrs.value.tip) {
const createTipNode = () => {
const tipProps = typeof attrs.value.tip === 'string' ? { content: attrs.value.tip, placement: 'top' } : attrs.value.tip
return createVNode(resolveComponent('el-tooltip'), tipProps, {
default: () => [
createVNode('i', {
class: 'fa fal fa-question-circle',
}),
],
})
}
// label 插槽
formItemSlots.label = () => {
return createVNode(
'span',
{
class: 'ba-form-item-label',
},
[
createVNode('span', null, attrs.value.label),
createVNode(
'span',
{
class: 'ba-form-item-label-tip',
},
[createTipNode()]
),
]
)
}
}
return () =>
createVNode(
resolveComponent('el-form-item'),
{
class: 'ba-input-item-' + props.type,
...attrs.value,
},
formItemSlots
)
},
})
</script>
baInput
web/src/components/baInput/index.vue
js
<script lang="ts">
import { isArray, isString } from 'lodash-es'
import type { PropType, VNode } from 'vue'
import { computed, createVNode, defineComponent, reactive, resolveComponent } from 'vue'
import { getArea } from '/@/api/common'
import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput'
import { inputTypes } from '/@/components/baInput'
import Array from '/@/components/baInput/components/array.vue'
import BaUpload from '/@/components/baInput/components/baUpload.vue'
import Editor from '/@/components/baInput/components/editor.vue'
import IconSelector from '/@/components/baInput/components/iconSelector.vue'
import RemoteSelect from '/@/components/baInput/components/remoteSelect.vue'
export default defineComponent({
name: 'baInput',
props: {
// 输入框类型,支持的输入框见 inputTypes
type: {
type: String,
required: true,
validator: (value: string) => {
return inputTypes.includes(value)
},
},
// 双向绑定值
modelValue: {
type: null,
required: true,
},
// 输入框的附加属性
attr: {
type: Object as PropType<InputAttr>,
default: () => {},
},
// 额外数据,radio、checkbox的选项等数据
data: {
type: Object as PropType<InputData>,
default: () => {},
},
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
// 合并 props.attr 和 props.data
const attrs = computed(() => {
return { ...props.attr, ...props.data }
})
// 通用值更新函数
const onValueUpdate = (value: ModelValueTypes) => {
emit('update:modelValue', value)
}
// 基础用法 string textarea password
const bases = () => {
return () =>
createVNode(
resolveComponent('el-input'),
{
type: props.type == 'string' ? 'text' : props.type,
...attrs.value,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
}
// radio checkbox
const rc = () => {
...
return vNode
})
}
// select selects
const select = () => {
...
}
// datetime
const datetime = () => {
let valueFormat = 'YYYY-MM-DD HH:mm:ss'
switch (props.type) {
case 'date':
valueFormat = 'YYYY-MM-DD'
break
case 'year':
valueFormat = 'YYYY'
break
}
return () =>
createVNode(
resolveComponent('el-date-picker'),
{
class: 'w100',
type: props.type,
'value-format': valueFormat,
...attrs.value,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
}
// upload
const upload = () => {
return () =>
createVNode(
BaUpload,
{
type: props.type,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...attrs.value,
},
slots
)
}
// remoteSelect remoteSelects
const remoteSelect = () => {
return () =>
createVNode(
RemoteSelect,
{
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
multiple: props.type == 'remoteSelect' ? false : true,
...attrs.value,
},
slots
)
}
const buildFun = new Map([
['string', bases],
[
'number',
() => {
return () =>
createVNode(
resolveComponent('el-input-number'),
{
class: 'w100',
'controls-position': 'right',
...attrs.value,
modelValue: isString(props.modelValue) ? Number(props.modelValue) : props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
},
],
['textarea', bases],
['password', bases],
['radio', rc],
['checkbox', rc],
['datetime', datetime],
['date', datetime],
[
'time',
() => {
return () =>
createVNode(
resolveComponent('el-time-picker'),
{
class: 'w100',
clearable: true,
format: 'HH:mm:ss',
valueFormat: 'HH:mm:ss',
...attrs.value,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
},
],
['select', select],
['selects', select],
[
'array',
() => {
return () =>
createVNode(
Array,
{
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...attrs.value,
},
slots
)
},
],
['remoteSelect', remoteSelect],
['remoteSelects', remoteSelect],
['image', upload],
['images', upload],
['file', upload],
['files', upload],
[
'icon',
() => {
return () =>
createVNode(
IconSelector,
{
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...attrs.value,
},
slots
)
},
],
[
'editor',
() => {
return () =>
createVNode(
Editor,
{
class: 'w100',
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...attrs.value,
},
slots
)
},
],
[
'default',
() => {
console.warn('暂不支持' + props.type + '的输入框类型,你可以自行在 BaInput 组件内添加逻辑')
},
],
])
let action = buildFun.get(props.type) || buildFun.get('default')
return action!.call(this)
},
})
</script>
baUpload.vue
src/components/baInput/components/baUpload.vue
js
<template>
<div class="w100">
<el-upload
:key="state.key"
ref="upload"
v-model:file-list="state.fileList"
:auto-upload="false"
:class="type"
class="ba-upload"
v-bind="state.attr"
@change="onElChange"
@exceed="onElExceed"
@preview="onElPreview"
@remove="onElRemove"
>
</el-upload>
</div>
</template>
<script lang="ts" setup>
type Writeable<T> = { -readonly [P in keyof T]: T[P] }
interface Props {
type: 'image' | 'images' | 'file' | 'files'
// 上传请求时的额外携带数据
data?: anyObj
modelValue: string | string[]
// 返回绝对路径
returnFullUrl?: boolean
// 隐藏附件选择器
hideSelectFile?: boolean
topic: string
// 可自定义el-upload的其他属性
attr?: Partial<Writeable<UploadProps>>
// 强制上传到本地存储
forceLocal?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'image',
data: () => {
return {}
},
modelValue: () => [],
returnFullUrl: false,
hideSelectFile: true,
topic: 'default',
attr: () => {
return {}
},
forceLocal: false,
})
const emits = defineEmits<{
(e: 'update:modelValue', value: string | string[]): void
}>()
const slots = useSlots()
const upload = ref<UploadInstance>()
const state: {
key: string
// 返回值类型,通过v-model类型动态计算
defaultReturnType: 'string' | 'array'
// 预览弹窗
preview: {
show: boolean
url: string
}
// 文件列表
fileList: UploadFileExt[]
// el-upload的属性对象
attr: Partial<UploadProps>
// 正在上传的文件数量
uploading: number
// 显示选择文件窗口
selectFile: {
show: boolean
type?: 'image' | 'file'
limit?: number
returnFullUrl: boolean
}
events: anyObj
} = reactive({
key: uuid(),
defaultReturnType: 'string',
preview: {
show: false,
url: '',
},
fileList: [],
attr: {},
uploading: 0,
selectFile: {
show: false,
type: 'file',
returnFullUrl: props.returnFullUrl,
},
events: [],
})
const onElExceed = (files: UploadUserFile[]) => {
const file = files[0] as UploadRawFile
file.uid = genFileId()
upload.value!.handleStart(file)
typeof state.events['onExceed'] == 'function' && state.events['onExceed'](file, files)
}
onMounted(() => {
if (props.type == 'image' || props.type == 'file') {
state.attr = { ...state.attr, limit: 1 }
} else {
state.attr = { ...state.attr, multiple: true }
}
if (props.type == 'image' || props.type == 'images') {
state.selectFile.type = 'image'
state.attr = { ...state.attr, accept: 'image/*', listType: 'picture-card' }
}
const addProps: anyObj = {}
const evtArr = ['onPreview', 'onRemove', 'onSuccess', 'onError', 'onChange', 'onExceed', 'beforeUpload', 'onProgress']
for (const key in props.attr) {
if (evtArr.includes(key)) {
state.events[key] = props.attr[key as keyof typeof props.attr]
} else {
addProps[key] = props.attr[key as keyof typeof props.attr]
}
}
state.attr = { ...state.attr, ...addProps }
if (state.attr.limit) state.selectFile.limit = state.attr.limit
init(props.modelValue)
initSort()
})
watch(
() => props.modelValue,
(newVal) => {
if (state.uploading > 0) return
if (newVal === undefined || newVal === null) {
return init('')
}
let newValArr = arrayFullUrl(stringToArray(cloneDeep(newVal)))
let oldValArr = arrayFullUrl(getAllUrls('array'))
if (newValArr.sort().toString() != oldValArr.sort().toString()) {
init(newVal)
}
}
)
const onChange = (file: string | string[] | UploadFileExt, files: UploadFileExt[]) => {
initSort()
typeof state.events['onChange'] == 'function' && state.events['onChange'](file, files)
}
const getUploadRef = () => {
return upload.value
}
const showSelectFile = () => {
state.selectFile.show = true
}
defineExpose({
getUploadRef,
showSelectFile,
})
</script>
3.4 网络请求
网络请求还是做的比较到位
- 实现重复请求拦截
- 支持refreshtoken 获取新的token , 可以参考juejin.cn/post/745744...
整体的网络请求逻辑如下:
- 通过axios请求时带上 ba-user-token
- 当请求数据过期,使用refreshtoken 获取新的token
3.5 路由控制
整体逻辑如下:
- 配合src/router/static.ts 路由配置,拦截所有url为 /admin开头请求,把参数转化成to 传递给adminMainLoading路由
- 实例化/@/layouts/backend/index.vue 组件, 通过to参数重定向上一次url
注意:
在init实例化方法里必须等待前面的handleAdminRoute方法执行完后才访问路由并跳转。
路由守卫router.beforeEach 只做国际化处理,没做权限控制
结语
由于后台技术是php,buildAdmin可能已经不是什么流行的框架,但是里面的前端一些架构思想还是值得借鉴学习。而且项目一直活跃维护,有兴趣可以试试手。