後臺管理系統可拖拽式組件的設計思路

後臺管理系統可拖拽式組件的設計思路

在後臺管理系統的項目中,因爲是數據管理,大部分都是 CURD 的頁面。比如:

對於這類的頁面,我們完全可以設計一個組件,使用拖拽的方式,將組件一個個拖到指定區域,進行結構組裝,然後再寫一個對組裝數據的渲染組件,渲染成頁面即可。如下:

需要處理的問題

下面的內容只是做具體的設計思路分析,不做詳細的代碼展示,內容太多了,沒法一一展示

數據結構的組裝

由於這種都是組件的組裝,所以我們要先定義具體組件的數據結構:

class Component {
    type: string = 'componentName'
    properties: Record<string, any> = {}
    children: Record<string, any>[] = []
}
複製代碼

因爲這種設計,整個頁面就是一個大組件,按照同樣的結構,所以我們最終的數據結構應該是這樣的:

const pageConfig = {
    type: 'page',
    properties: {},
    search: {
        type: 'search',
        id: 'xxx',
        properties: {},
        children: [
            {
                type: 'input',
                id: 'xxx',
                properties: {}
            }
            // ...
        ]
    },
    table: {
        type: 'table'
        id: 'xxx',
        properties: {},
        children: [
            {
                type: 'column',
                id: 'xxx',
                properties: {}
            },
            {
                type: 'column',
                id: 'xxx',
                properties: {},
                children: [
                    {
                        type: 'button',
                        id: 'xxx',
                        properties: {}
                    }
                ]
            }
            // ...
        ],
        buttons: [
            {
                type: 'button',
                id: 'xxx',
                properties: {}
            }
            // ...
        ]
    }
}
複製代碼

上面的結構,對於第一層來說,因爲場景的限制,search 組件和 table 組件是固定位置的,所以這裏就直接定死了,如果想直接拖拽定位,直接在數據頂層加 children 字段即可,然後可以進行拖拽排序位置。對於內部兄弟組件的排序功能,因爲 vue 框架已經提供了 transition-group 組件,直接使用即可。而 table 下面的 buttons 數組,是由於在一般的 table 組件的上方會有一排按鈕,用於新增,或者批量操作等。

組件列表的選擇

對於數據管理頁面,能夠用上的組件無外乎就是 input,select,date,checkbox,button 等常用的 form 組件,還有我們要在配置頁面重新封裝 search,table 等業務組件,梳理出所有要用的組件後,我們需要用一個文件來彙總所有組件的屬性:

// 頁面結構
class CommonType {
  title?: string
  code?: string
  filter?: string
  readVariable?: string
  writeVariable?: string
}
export class Common {
  hide = true
  type = 'common'
  properties = new CommonType
  dialogTemplate = []
}

// search
class SearchType extends FormType {
  gutter = 20
  searchBtnText = '搜索'
  searchIcon = 'Search'
  resetBtnText = '重置'
  resetIcon = 'Refresh'
  round?: boolean
}
export class Search {
  bui = true
  type = 'search'
  properties = new SearchType
  children = []
}
// ...
複製代碼

組件的拖拽處理

對於組件的拖拽處理,我們可以直接使用 H5 的 draggable,首先是左側的組件列表的每一個組件都是可以拖拽的,在拖動到中間展示區域的時候,我們需要獲取 drop 事件的目標元素,然後結合 dragstart 事件的信息,確定當前拖動組件的父級是誰,然後進行數據組裝,這裏所有的數據組裝都由 drop 事件來完成,數據組裝完成之後,更新中間的渲染區域。

組件的配置信息配置

每一個組件的配置信息其實都是不一樣的,這些具體的屬性,除了像 prop,id 這樣通用的信息,都需要根據自己的情況來定,但是這些屬性是與組件的 properties 一一對應。由於組件的每一個屬性,有不同的類型,有的是輸入框,有的是下拉選擇,還有的是開關等,所以我們要對每一個屬性進行詳細的描述:

const componentName = [
    {
        label: '佔位提示文本',
        value: 'placeholder',
        type: 'input'
    },
    {
        label: '可清除',
        value: 'clearable',
        type: 'switch'
    },
    {
        label: '標籤位置',
        value: 'labelPosition',
        type: 'select',
        children: [
            {
                label: 'left',
                value: 'left'
            },
            {
                label: 'right',
                value: 'right'
            },
            {
                label: 'top',
                value: 'top'
            }
       ]
    }
    // ...
]
複製代碼

定義完基本信息之後,我們還需要處理兩種特殊情況:

第一種情況,當一個屬性依賴另一個或者幾個的屬性的時候,我們可以設置一個規則數組,比如:

[
    {
        label: '屬性1',
        value: 'type',
        type: 'select',
        children: [
            {
                label: 'url',
                value: 'url'
            },
            {
                label: 'other',
                value: 'other'
            }
       ]
    },
    {
        rules: [
            {
                originValue: 'type',
                destValue: ['url']
            }
        ],
        label: '屬性2',
        value: 'prop2',
        type: 'input'
    }
]
複製代碼

以上的規則,我們可以去解析屬性中的 rules 字段,當 type 的值爲 url 時,我們就顯示屬性 2,否則就不顯示。

還有一種是同一個組件在不同的父級顯示不同的可操作屬性,比如,input 組件在 search 組件下不需要校驗字段,而在 form 表單是需要的,所以我們可以增加一個字段 use:

const formItem = [
    {
        use: ['search''dialog'],
        label: '標籤',
        value: 'label',
        type: 'input'
    }
    // ...
]
複製代碼

以上信息表示,formItem 組件的標籤屬性是在 search 和 dialog 組件中使用的,其它的父級組件下不會顯示。

當所有組件的配置信息配置完成後,我們在聚焦預覽區域的具體組件時,用程序篩選出可操作屬性即可。

// 處理右側可操作屬性
const getShowProperties = computed(() ={
  const properties = propertyTemplate[activeComponents.value.type]
  if (!properties) {
    return []
  }
  let props: Record<string, any> = []
  properties.forEach((item: Record<string, any>) ={
    if (
      (!item.use || item.use.includes(activeParent.value)) && 
      getConditionResult(item.rules)
    ) {
      props.push(item)
    }
  })
  return props
})
// 計算是否可操作屬性
const getConditionResult = (rules: { originValue: string, destValue: string[] }[]) ={
  if (!rules) {
    return true
  }
  for (let i = 0; i < rules.length; i++) {
    const item = rules[i]
    if (
      item.destValue &&
      item.originValue &&
      !item.destValue.includes(activeComponents.value.properties[item.originValue])
    ) {
      return false
    }
  }
  return true
}
複製代碼

最後使用循環渲染 getShowProperties 數據就可以完成。

請求的處理

在完全封裝的頁面內部,大部分的動作都是配置出來的,請求的觸發除了初始化的,一般都是由點擊按鈕觸發請求,或者是組件的 change 事件中等,但是頁面內部的請求依賴於項目的請求封裝,所以在內部組件的屬性上面需要增加請求的相關信息。主要包括:url,type,params,在點擊按鈕觸發的請求的時候,去 properties 內部拿到請求信息,由於請求方法依賴於項目,所以這個組件內部不做請求封裝,由外部把封裝好的請求方法傳遞進去,組件內外只做規範約定:

// 外部通用的請求方法
import HTTP from '@/http'

export const commonRequest = (
  url: string, 
  params: Record<string, any> = {}, 
  type: 'post' | 'get' = 'get'
) ={
  return HTTP[`$${type}`](url, params)
}
複製代碼

在遇到請求的 url 和 params,需要用到變量的情況下,我們可以約定變量格式,在內部去解析且替換,如下:

// 屬性
const properties = {
    api: '/{type}/get-data',
    type: 'get',
    params: 'id={id}'
}

/**
 * 解析方法
 * url    需要解析的請求的路徑
 * params 需要解析的參數
 * parent 解析依賴的父級數據
 */
const parseApiInfo = (url: string, params: string, parent: Record<string, any>) ={
  const depData = {
    // ...globalData // 全局數據 
    ..parant
  }
  const newUrl = url.replace(/\{(.*?)\}/g, (a: string, b: string) ={
    return depData[b] || a
  })
  const newParams = params.replace(/\{(.*?)\}/g, (a: string, b: string) ={
    return depData[b] || a
  })
  const obj: Record<string, string> = {}
  newParams.replace(/([a-zA-Z0-9]+?)=(.+?)(&|$)/g, (a: string, b: string, c: string) ={
    obj[b] = c
    return a
  })

  return {
    url: newUrl,
    params: obj
  }
}
複製代碼

解析完 url 和 params 後,用 commonRequest 去執行請求, 這樣基本完成對請求的處理。

下拉選項數據的處理

對於下拉選項數據的處理,可以大致分爲兩種情況:

靜態數據

靜態數據比較好處理,因爲是不變的,所以我們可以直接在前端配置好,比如:

const options = {
    optionsId: [
        {
            label: '標籤',
            value: 'val'
        }
        // ...
    ]
}
複製代碼

動態數據

動態數據會相對麻煩一點,因爲需要後端配合,給出一個固定的接口,讓我們能一次性直接拿到整個頁面需要的所有的下拉數據,格式如上。

table 組件的設計

table 組件是頁面內主要的數據展示組件,因此功能上要考慮的較完善。

table 組件相關的按鈕:

column 組件的設計:

按鈕與彈窗的處理

在這種頁面內部,按鈕組件應該是用的最多的組件,比如:彈窗、table、column、search 等,都需要用上,並且按鈕在不同的位置,能處理的功能也不一樣,按鈕的功能主要分爲以下幾種:

除了彈窗,其餘的功能都可以通過自身的屬性字段來完成任務,但是彈窗是一個比較特殊且十分重要的功能,管理類系統的彈窗一般是需要新增或者編輯、查看等,所以彈窗組件的內部需要將 form 組件的功能考慮進去。

因爲彈窗的內容是自定義且內容十分多,比如:彈窗內部有 table,table 內部有按鈕,按鈕還能打開彈窗等情況,所以我們需要將彈窗的內容數據打平,否則會造成結構嵌套太深導致不好解析。

const pageConfig = {
    type: 'page',
    properties: {},
    search: {},
    table: {},
    // 彈窗數據
    dialogTemplates: [
        {
            id: 'xxx',
            type: 'dialog',
            properties: {},
            // 彈窗內部 form 表單組件
            children: [
                {
                    type: 'input',
                    id: 'xxx',
                    properties: {}
                }
                // ...
            ],
            // 彈窗底部按鈕
            buttons: [
                {
                    type: 'button',
                    id: 'xxx',
                    properties: {}
                }
                // ...
            ]
        }
        // ...
    ]
}
複製代碼

使用的話,我們在 button 組件上添加一個 dialogId 的字段,用來指向 dialogTemplates 數組內 id 爲 dialogId 的彈窗數據即可。

頁面的彈窗數量是不能做限制的,所以在彈窗的設計上,不能用普通的標籤去實現,我們需要用服務方式去調用彈窗,如不瞭解 vue 服務方式的請看:使用服務方式來調用 vue 組件,這樣我們就實現了彈窗功能。

彈窗與表格數據的聯動

彈窗內的新增和編輯大部分都會影響 table 列表數據,還有就是在行內的按鈕彈窗會默認攜帶行內數據作爲彈窗表單內的初始數據,所以我們在彈窗操作完成之後,要能刷新 table 數據,所以我們要將頁面內的按鈕功能統一的封裝起來,統一管理。如下:

interface ButtonParams {
  params?: Record<string, any>
  callback?: () => void
}

export const btnClick = (btn: Record<string, any>, data: ButtonParams, pageId: string) ={
  if (!commonRequest) {
    commonRequest = globalMap.get('request')
  }
  return new Promise((res: (_v?: any) => void) ={
    if (btn.type === 'dialog') {  // dialog
      const dialogMap = globalMap.get(pageId).dialogMap
      if (dialogMap) {
        // 調用彈窗
        DpDialogService({ ...dialogMap.get(btn.dialogTemplateId), params: data.params, pageId, callback: data.callback })
      }
      res()
      return
    }
    const row = data.params && data.params._row
    if (data.params) {
      delete data.params._row
    }
    if (btn.type === 'confirm') { // confirm
      ElMessageBox.confirm(btn.message, btn.title, {
        type: 'warning',
        draggable: true
      }).then(() ={
        const { url, params } = parseApiInfo(btn.api, btn.requestParams, row, pageId)
        if (url) {
          commonRequest(url, { ...data.params, ...params }, btn.requestType).then((ret: any = {}) ={
            data.callback && data.callback()
            res(ret.result)
          })
        }
      })
    } else if (btn.type === 'link') { // link
      const route = parseApiInfo(btn.url, '', row, pageId)
      if (btn.api) {
        const { url, params } = parseApiInfo(btn.api, btn.requestParams, row, pageId)
        if (url) {
          commonRequest(url, { ...data.params, ...params }, btn.requestType).then((ret: any = {}) ={
            res(ret.result)
            if (route.url) {
              if (btn.externalLink) {
                // 新窗口跳轉
                openNewTab(route.url)
              } else {
                // 當前窗口跳轉
                router.push(route.url)
              }
            }
          })
        }
      } else {
        if (route.url) {
          if (btn.externalLink) {
            // 新窗口跳轉
            openNewTab(route.url)
          } else {
            // 當前窗口跳轉
            router.push(route.url)
          }
        }
        res()
      }
    } else if (btn.type === 'none') { // none
      const { url, params } = parseApiInfo(btn.api, btn.requestParams, row, pageId)
      if (url) {
        commonRequest(url, { ...data.params, ...params }, btn.requestType).then((ret: any = {}) ={
          data.callback && data.callback()
          res(ret.result)
        })
      }
    } else if (btn.type === 'download') {
      const { url } = parseApiInfo(btn.api, '', row, pageId)
      if (url) {
        window.open(url)
      }
    }
  })
}
複製代碼

上面按鈕的封裝,比如點擊彈窗,然後更新 table,我們就需要將更新 table 的方法放入回調函數 callback 中, 在彈窗確認接口成功後,再執行回調函數來刷新 table,對於依賴彈窗的功能都可以通過該方法去實現。

自定義插槽

對於有些特殊的表單功能通過配置無法實現,我們需要開放兩個插槽,由開發者介入進行手動開發。

最後

後臺管理系統可拖拽式組件,大體的設計思路就這樣。主要分爲兩大塊:頁面配置和頁面渲染兩個組件。

頁面配置組件:分爲三個模塊(子組件列表、預覽區域、屬性配置區域)。配置組件思路比較容易,就是配置好各個組件之間的關係。

頁面渲染組件:該組件就是拿到配置組件配置好的數據進行渲染,及業務邏輯的實現。

整體功能不難,就是細節比較多,需要在各個組件、各個位置上都要想的要比較全面。如果想做好,最好還是得到後端的支持,該組件至少可以覆蓋管理系統 80% - 90% 的場景。

寫的比較粗糙,有什麼疑問或者更好的想法,歡迎留言指出。

相關閱讀

後臺管理系統可拖拽式組件的設計思路 (補充,內附源碼)(https://juejin.cn/post/7082551831074717726)

來源: https://juejin.cn/post/7073131582176886815

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/_pMO6j2qP1qKaEzdEAQZ4w