打造一款適合本身的快速開發框架-前端篇之選擇樹組件設計與實現

前言

任何業務系統均可能會涉及到對樹型類數據的管理,如菜單管理、組織機構管理等。而在對樹型類數據進行管理的時候通常都須要選擇父節點,雖然elementui也有樹型組件,可是若是直接使用,要完成該功能,須要編寫的代碼量也仍是很多,因此咱們要想更方便的時候,就得須要在其基礎上進行進一步的封裝。前端

選擇樹組件設計

數據結構

數型組件通常都須要必定規範的數據結構。vue

以下效果:java

├──	節點1
	├── 節點11
		└── 節點111
	└── 節點12
├──	節點2
	├── 節點21
	└── 節點22
複製代碼

其標準的數據結構:node

[
    {
        "id": 1,
        "name": "節點1",
        "children": [
            {
                "id": 11,
                "name": "節點11",
                "children": [
                    {
                        "id": 111,
                		"name": "節點111",
                    }
                ]
            },
            {
                "id": 12,
                "name": "節點12"
            }
        ]
    },
    {
        "id": 2,
        "name": "節點2",
        "children": [
            {
                "id": 21,
                "name": "節點21"
            },
            {
                "id": 22,
                "name": "節點22"
            }
        ]
    }
]
複製代碼

數據庫的存儲通常結構爲:git

[
    { "id": 1, "parentId": 0, "name": "節點1" },
    { "id": 11, "parentId": 1, "name": "節點11" },
    { "id": 111, "parentId": 11, "name": "節點111" },
    { "id": 12, "parentId": 1, "name": "節點12" },
    { "id": 2, "parentId": 0, "name": "節點2" },
    { "id": 21, "parentId": 2, "name": "節點21" },
    { "id": 22, "parentId": 2, "name": "節點22" }
]
複製代碼

數據結構處理

elementui的樹型組件是不支持id/parentId模式的,須要組裝成children模式,因此直接使用數據庫的列表數據是不能直接展現成樹狀結構的。這就須要對原始的數據進行轉換,常見的轉換方式有兩種,其實就是由哪一端處理。數據庫

  1. 後端按照前端須要的數據結構返回
  2. 後端只返回原始數據,由前端自行轉換成標準的樹型結構

本文爲了方便,採用的是前端轉換的方式,其實無論是哪一端,均可以寫成通用的方法,只是java這邊寫成通用方法沒有js方便,因此本框架選擇在前端進行該轉換動做。json

接口說明

接口仍是通用的查詢接口,區別在於入參須要把pageSize調大一點,以菜單爲例後端

請求地址api

{{api_base_url}}/sys/menu/listbash

數據類型

application/json

請求示例:

{
    "pageNum": 1,
    "pageSize": 10000
}
複製代碼

響應示例:

{
	"code": 0,
	"msg": "查詢菜單成功",
	"data": {
		"pageNum": 1,
		"pageSize": 10000,
		"recordCount": 16,
		"totalPage": 1,
		"rows": [{
			"id": 1,
			"parentId": 0,
			"name": "系統設置",
			"sort": 10.0,
			"routeName": "sys",
			"icon": "sys",
			"isShow": 2,
			"createTime": "2020-06-25 21:05:01",
			"updateTime": "2020-06-25 21:05:03",
			"isDeleted": 1
		}, {
			"id": 2,
			"parentId": 1,
			"name": "菜單管理",
			"sort": 1.0,
			"routeName": "sys:menu:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:06:34",
			"updateTime": "2020-06-25 21:06:36",
			"isDeleted": 1
		}, {
			"id": 3,
			"parentId": 1,
			"name": "用戶管理",
			"sort": 2.0,
			"routeName": "sys:user:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:07:05",
			"updateTime": "2020-06-25 21:07:09",
			"isDeleted": 1
		}, {
			"id": 4,
			"parentId": 1,
			"name": "角色管理",
			"sort": 3.0,
			"routeName": "sys:role:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:07:37",
			"updateTime": "2020-06-25 21:07:41",
			"isDeleted": 1
		}, {
			"id": 5,
			"parentId": 1,
			"name": "字典管理",
			"sort": 4.0,
			"routeName": "sys:dict:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:08:08",
			"updateTime": "2020-06-25 21:08:11",
			"isDeleted": 1
		}, {
			"id": 6,
			"parentId": 0,
			"name": "內容管理",
			"sort": 11.0,
			"routeName": "cms",
			"icon": "cms",
			"isShow": 2,
			"createTime": "2020-06-25 21:09:05",
			"updateTime": "2020-06-25 21:09:07",
			"isDeleted": 1
		}, {
			"id": 7,
			"parentId": 6,
			"name": "欄目管理",
			"sort": 1.0,
			"routeName": "sys:category:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:09:36",
			"updateTime": "2020-06-25 21:09:39",
			"isDeleted": 1
		}, {
			"id": 8,
			"parentId": 6,
			"name": "模型管理",
			"sort": 2.0,
			"routeName": "sys:model:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:10:23",
			"updateTime": "2020-06-25 21:10:25",
			"isDeleted": 1
		}, {
			"id": 9,
			"parentId": 6,
			"name": "文章管理",
			"sort": 3.0,
			"routeName": "sys:article:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:10:50",
			"updateTime": "2020-06-25 21:10:53",
			"isDeleted": 1
		}, {
			"id": 10,
			"parentId": 0,
			"name": "訂單管理",
			"sort": 12.0,
			"routeName": "oms",
			"isShow": 2,
			"createTime": "2020-06-25 21:11:29",
			"updateTime": "2020-06-25 21:11:31",
			"isDeleted": 1
		}, {
			"id": 11,
			"parentId": 10,
			"name": "訂單列表",
			"sort": 1.0,
			"routeName": "oms:order:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:11:55",
			"updateTime": "2020-06-25 21:11:57",
			"isDeleted": 1
		}, {
			"id": 12,
			"parentId": 10,
			"name": "訂單設置",
			"sort": 2.0,
			"routeName": "oms:orderSetting:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:12:15",
			"updateTime": "2020-06-25 21:12:19",
			"isDeleted": 1
		}, {
			"id": 13,
			"parentId": 0,
			"name": "商品管理",
			"sort": 13.0,
			"routeName": "pms",
			"icon": "pms",
			"isShow": 2,
			"createTime": "2020-06-25 21:14:02",
			"updateTime": "2020-06-25 21:14:05",
			"isDeleted": 1
		}, {
			"id": 14,
			"parentId": 13,
			"name": "商品分類",
			"sort": 1.0,
			"routeName": "pms:productCategory:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:16:05",
			"updateTime": "2020-06-25 21:16:07",
			"isDeleted": 1
		}, {
			"id": 15,
			"parentId": 13,
			"name": "商品列表",
			"sort": 2.0,
			"routeName": "pms:product:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:16:36",
			"updateTime": "2020-06-25 21:16:39",
			"isDeleted": 1
		}, {
			"id": 16,
			"parentId": 13,
			"name": "品牌管理",
			"sort": 3.0,
			"routeName": "pms:brand:index",
			"isShow": 2,
			"createTime": "2020-06-25 21:16:57",
			"updateTime": "2020-06-25 21:17:01",
			"isDeleted": 1
		}]
	}
}
複製代碼

組件參數說明

暫時定幾個經常使用的參數,後續可能還會有追加

參數名 類型 默認值 說明
url String undefined 接口地址
isEdit Boolean false 是否編輯模式
value String, Number,Array undefined 綁定的值
multiple Boolean false 是否多選(預留)
size String medium 組件大小medium/small/mini
placeholder String 請選擇 佔位符
dialogTitle String 請選擇 彈窗標題
dialogWidth String 30% 彈窗寬度
defaultExpandAll Boolean false 是否默認展開全部節點

開始編碼

目錄結構

├── src
	├──	components/m
		├──	SelectTree
			└── index.vue
	├── utils
		└── util.js
	├── views
		├──	dashboard
			└── index.vue
	└── main.js
複製代碼

文件詳解

  • src/components/m/Select/index.vue

選擇樹組件

<template>
  <div class="m-select-tree">
    <el-input readonly :size="size" :placeholder="placeholder" v-model="mValue">
      <el-button slot="append" icon="el-icon-search" @click="openDialog"></el-button>
    </el-input>
    <el-dialog :title="dialogTitle" :visible.sync="isOpenDialog" :width="dialogWidth" append-to-body @close="handleCancel">
      <el-tree
        :props="defaultProps"
        :data="treeData"
        node-key="id"
        highlight-current
        :default-expand-all="defaultExpandAll"
        @current-change="handleCurrentChange"
        ref="tree">
      </el-tree>
      <div slot="footer" class="dialog-footer">
        <el-button type="primary" @click="handleSubmit">確 定</el-button>
        <el-button @click="handleCancel">取 消</el-button>
      </div>
    </el-dialog>
  </div>
</template>
<script>
import request from '@/utils/request'
export default {
  name: 'MSelectTree',
  props: {
    url: { // 接口地址
      type: String,
      default: undefined
    },
    isEdit: { // 是否編輯模式
      type: Boolean,
      default: false
    },
    // 綁定的值
    value: {
      type: [String, Number, Array],
      default: undefined
    },
    multiple: { // 是否多選
      type: Boolean,
      default: false
    },
    size: { // medium/small/mini
      type: String,
      default: 'medium'
    },
    placeholder: { //  佔位符
      type: String,
      default: '請選擇'
    },
    dialogTitle: { //  彈窗標題
      type: String,
      default: '請選擇'
    },
    dialogWidth: { // 彈窗寬度
      type: String,
      default: '30%'
    },
    defaultExpandAll: { // 是否默認展開全部節點
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      mValue: '根節點', // 顯示的文本值
      isOpenDialog: false, // 是否打開彈窗
      treeData: [], // 樹型結構
      defaultProps: { // elementui樹型組件默認屬性配置
        children: 'children',
        label: 'name'
      }
    }
  },
  watch: {
    value(n, o) { // 監聽父組件值變更,子組件也要變更
      if (o === undefined || o === 0) {
        this.refreshView()
      }
    }
  },
  created() {
    if (this.isEdit) {
      this.requestData()
    }
  },
  methods: {
    requestData() {
      if (this.treeData.length) {
        this.$nextTick(() => {
          // dom更新完成再設置當前選中項
          this.refreshView()
        })
        return
      }
      if (this.url) {
        request({
          url: this.url,
          method: 'post',
          data: {
            pageNum: 1,
            pageSize: 10000
          }
        }).then(res => {
          if (res.code === 0) {
            this.treeData = [
              {
                id: 0,
                name: '根節點',
                children: []
              },
              // 這裏使用工具方法將id/parentId數據結構轉成children結構
              ...this.$util.getTree(res.data.rows)
            ]
            this.$nextTick(() => {
              // dom更新完成再設置當前選中項
              this.refreshView()
            })
          }
        })
      }
    },
    openDialog() { // 打開彈出框
      this.isOpenDialog = true
      this.requestData()
    },
    handleSubmit() {
      this.isOpenDialog = false
    },
    handleCancel() {
      this.isOpenDialog = false
    },
    // 處理當前選中節點變化時觸發的事件
    handleCurrentChange(data) {
      // 修改顯示
      this.mValue = data.name
      // 子組件值變化要經過父組件
      this.$emit('input', data.id)
    },
    // 刷新頁面元素
    refreshView() {
      if (this.$refs.tree) {
        if (this.value === undefined) {
          this.$refs.tree.setCurrentKey(0)
        } else {
          this.$refs.tree.setCurrentKey(this.value)
        }
      }
      if (this.isEdit) {
        var nodes = this.treeData.filter(item => {
          return item.id === this.value
        })
        if (nodes.length) {
          this.mValue = nodes[0].name
        }
      }
    }
  }
}
</script>
複製代碼
  • src/utils/util.js

工具類,樹型結構處理。好久以前寫的了,使用的仍是遞歸,還沒進行優化。

/** * 根據key複製對象 * @param {}} src * @param {*} dest */
export const copy = function(src, dest) {
  const res = {}
  Object.keys(dest).forEach(key => {
    res[key] = src[key]
  })
  return res
}
/** * 獲取菜單樹 * @param {} nodes id/parentId格式數據 */
export const getTree = (nodes) => {
  var root = []
  for (var i = 0; i < nodes.length; i++) {
    if (Number(nodes[i]['parentId']) <= 0) {
      root.push(nodes[i])
    }
  }
  return buildTree(nodes, root)
}
/** * 構建菜單樹 * @param {*} nodes id/parentId格式數據 * @param {*} root 樹節點 */
export const buildTree = (nodes, root) => {
  for (var i = 0; i < root.length; i++) {
    root[i].title = root[i].name
    var children = []
    for (var k = 0; k < nodes.length; k++) {
      if (nodes[k]['parentId'] === root[i]['id']) {
        children.push(nodes[k])
      }
    }
    if (children.length !== 0) {
      root[i]['children'] = children
      buildTree(nodes, children)
    }
  }
  return root
}
/** * 先序遍歷樹 * @param {*} tree 標準樹結構 * @param {*} level 層級 */
export const preorder = (tree, level) => {
  var array = []
  for (var i = 0; i < tree.length; i++) {
    tree[i].level = level
    if (level === 1) {
      // tree[i].expand = true
    }
    if (tree[i]['children'] != null) {
      tree[i].leaf = false
      array.push(tree[i])
      array = array.concat(preorder(tree[i]['children'], level + 1))
    } else {
      tree[i].leaf = true
      array.push(tree[i])
    }
    tree[i]['children'] = []
  }
  return array
}
/** * 樹型結構先序遍歷轉列表 * @param {*} datas 標準樹結構數據 */
export const tranDataTreeToTable = (datas) => {
  return preorder(getTree(datas), 1)
}
export const getNode = (datas, id) => {
  const res = datas.filter(item => {
    return item.id === id
  })
  if (res.length) {
    return res[0]
  } else {
    return 0
  }
}
/** * 獲取全部父級 * @param {} datas * @param {*} id */
export const getParents = (datas, id) => {
  const res = []
  const node = getNode(datas, id)
  if (node) {
    res.push(node)
  }
  for (let i = 0, len = datas.length; i < len; i++) {
    const item = datas[i]
    if (item.id === node.parentId) {
      res.push(item)
      res.push(...getParents(datas, item.id))
      break
    }
  }
  return res
}
/** * 獲取全部子元素 * @param {*} datas * @param {*} id * @param {*} containParent 是否包含父id */
export const getChildren = (datas, id, containParent) => {
  const res = []
  if (containParent === undefined) {
    containParent = true
  }
  const node = getNode(datas, id)
  if (node) {
    if (containParent) {
      res.push(node)
    }
  } else {
    return res
  }
  for (let i = 0, len = datas.length; i < len; i++) {
    const item = datas[i]
    if (item.parentId === id) {
      res.push(item)
      res.push(...getChildren(datas, item.id, false))
    }
  }
  return res
}
複製代碼
  • src/main.js

主入口全局註冊自定義組件,這裏也用了require.context,代碼片斷,這裏簡單的對駝峯進行了-轉換

// 處理自定義組件全局註冊
const files = require.context('./components/m', true, /\.vue$/)
files.keys().forEach((routerPath) => {
  const componentName = routerPath.replace(/^\.\/(.*)\/index\.\w+$/, '$1')
  const value = files(routerPath)
  Vue.component('m' + componentName.replace(/([A-Z])/g, '-$1').toLowerCase(), value.default)
}, {})
複製代碼
  • src/views/dashboard/index.vue

這裏提供了使用樣例:

選擇單個

<m-select-tree dialog-title="請選擇父菜單" v-model="parentId" url="sys/menu/list" value-key="id" label-key="name"></m-select-tree>
複製代碼

選擇單個-修改模式

<m-select-tree dialog-title="請選擇父菜單" v-model="form.parentId" is-edit url="sys/menu/list" value-key="id" label-key="name"></m-select-tree>
複製代碼

js片斷

export default {
  name: 'Dashboard',
  data() {
    return {
      form: {
        parentId: undefined
      },
      parentId: undefined
    }
  },
  created() {
    // 模擬修改異步更新
    setTimeout(() => {
      this.$set(this.form, 'parentId', 1)
    }, 2000)
  }
}
複製代碼

效果圖

小結

本文的選擇樹組件使用了elementui的三個組件(Input/Dialog/Tree)進行組裝,目前只作了單選的,如後續場景須要再考慮支持多選。

項目源碼地址

  • 後端

gitee.com/mldong/mldo…

  • 前端

gitee.com/mldong/mldo…

相關文章

打造一款適合本身的快速開發框架-先導篇

打造一款適合本身的快速開發框架-前端腳手架搭建

打造一款適合本身的快速開發框架-前端篇之登陸與路由模塊化

打造一款適合本身的快速開發框架-前端篇之框架分層及CURD樣例

打造一款適合本身的快速開發框架-前端篇之字典組件設計與實現

打造一款適合本身的快速開發框架-前端篇之下拉組件設計與實現

相關文章
相關標籤/搜索