buildAdmin 框架使用半年总结

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 网络请求

网络请求还是做的比较到位

整体的网络请求逻辑如下:

  • 通过axios请求时带上 ba-user-token
  • 当请求数据过期,使用refreshtoken 获取新的token

3.5 路由控制

整体逻辑如下:

  1. 配合src/router/static.ts 路由配置,拦截所有url为 /admin开头请求,把参数转化成to 传递给adminMainLoading路由
  2. 实例化/@/layouts/backend/index.vue 组件, 通过to参数重定向上一次url

注意:

在init实例化方法里必须等待前面的handleAdminRoute方法执行完后才访问路由并跳转。

路由守卫router.beforeEach 只做国际化处理,没做权限控制

结语

由于后台技术是php,buildAdmin可能已经不是什么流行的框架,但是里面的前端一些架构思想还是值得借鉴学习。而且项目一直活跃维护,有兴趣可以试试手。

相关推荐
前期后期3 分钟前
Android Compose是如何使用什么架构,多个Activity?还是Fragment?compose的ui又是如何卸载和挂载的呢?
android·ui·架构·kotlin
银迢迢2 小时前
如何创建一个Vue项目
前端·javascript·vue.js
几何心凉6 小时前
如何解决Vue组件间传递数据的问题?
前端·javascript·vue.js
鱼樱前端8 小时前
Vue3 + TypeScript 整合 MeScroll.js 组件
前端·vue.js
拉不动的猪9 小时前
刷刷题29
前端·vue.js·面试
紫雾凌寒9 小时前
计算机视觉应用|自动驾驶的感知革命:多传感器融合架构的技术演进与落地实践
人工智能·机器学习·计算机视觉·架构·自动驾驶·多传感器融合·waymo
武昌库里写JAVA9 小时前
原生iOS集成react-native (react-native 0.65+)
vue.js·spring boot·毕业设计·layui·课程设计
野生的程序媛9 小时前
重生之我在学Vue--第5天 Vue 3 路由管理(Vue Router)
前端·javascript·vue.js
鱼樱前端9 小时前
Vue 2 与 Vue 3 响应式原理详细对比
javascript·vue.js
阿丽塔~9 小时前
面试题之vue和react的异同
前端·vue.js·react.js·面试