后臺管理系統(tǒng)可拖拽式組件的設計思路
在后臺管理系統(tǒng)的項目中,因為是數(shù)據(jù)管理,大部分都是 CURD 的頁面。比如:

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

需要處理的問題
數(shù)據(jù)結構的組裝 組件列表的選擇 組件的拖拽處理 組件的配置信息配置 請求的處理 下拉選項數(shù)據(jù)的處理 table 組件的設計 按鈕與彈窗的處理 彈窗與表格數(shù)據(jù)的聯(lián)動 自定義插槽
下面的內(nèi)容只是做具體的設計思路分析,不做詳細的代碼展示,內(nèi)容太多了,沒法一一展示
數(shù)據(jù)結構的組裝
由于這種都是組件的組裝,所以我們要先定義具體組件的數(shù)據(jù)結構:
class Component {
type: string = 'componentName'
properties: Record<string, any> = {}
children: Record<string, any>[] = []
}
復制代碼
type:組件的名字 properties:組件的屬性 children:當前組件下的子組件,用于嵌套
因為這種設計,整個頁面就是一個大組件,按照同樣的結構,所以我們最終的數(shù)據(jù)結構應該是這樣的:
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 組件是固定位置的,所以這里就直接定死了,如果想直接拖拽定位,直接在數(shù)據(jù)頂層加 children 字段即可,然后可以進行拖拽排序位置。對于內(nèi)部兄弟組件的排序功能,因為 vue 框架已經(jīng)提供了 transition-group 組件,直接使用即可。而 table 下面的 buttons 數(shù)組,是由于在一般的 table 組件的上方會有一排按鈕,用于新增,或者批量操作等。
組件列表的選擇
對于數(shù)據(jù)管理頁面,能夠用上的組件無外乎就是 input,select,date,checkbox,button 等常用的 form 組件,還有我們要在配置頁面重新封裝 search,table 等業(yè)務組件,梳理出所有要用的組件后,我們需要用一個文件來匯總所有組件的屬性:
// 頁面結構
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[1],首先是左側的組件列表的每一個組件都是可以拖拽的,在拖動到中間展示區(qū)域的時候,我們需要獲取 drop 事件的目標元素,然后結合 dragstart 事件的信息,確定當前拖動組件的父級是誰,然后進行數(shù)據(jù)組裝,這里所有的數(shù)據(jù)組裝都由 drop 事件來完成,數(shù)據(jù)組裝完成之后,更新中間的渲染區(qū)域。
組件的配置信息配置
每一個組件的配置信息其實都是不一樣的,這些具體的屬性,除了像 prop,id 這樣通用的信息,都需要根據(jù)自己的情況來定,但是這些屬性是與組件的 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'
}
]
}
// ...
]
復制代碼
定義完基本信息之后,我們還需要處理兩種特殊情況:
當組件中的一個屬性其實是依賴另一些屬性的具體值的處理 組件處于不同的父級組件下,應用不同的屬性
第一種情況,當一個屬性依賴另一個或者幾個的屬性的時候,我們可以設置一個規(guī)則數(shù)組,比如:
[
{
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'
}
]
復制代碼
以上的規(guī)則,我們可以去解析屬性中的 rules 字段,當 type 的值為 url 時,我們就顯示屬性2,否則就不顯示。
還有一種是同一個組件在不同的父級顯示不同的可操作屬性,比如,input 組件在 search 組件下不需要校驗字段,而在 form 表單是需要的,所以我們可以增加一個字段 use:
const formItem = [
{
use: ['search', 'dialog'],
label: '標簽',
value: 'label',
type: 'input'
}
// ...
]
復制代碼
以上信息表示,formItem 組件的標簽屬性是在 search 和 dialog 組件中使用的,其它的父級組件下不會顯示。
當所有組件的配置信息配置完成后,我們在聚焦預覽區(qū)域的具體組件時,用程序篩選出可操作屬性即可。
// 處理右側可操作屬性
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
}
復制代碼
最后使用循環(huán)渲染 getShowProperties 數(shù)據(jù)就可以完成。
請求的處理
在完全封裝的頁面內(nèi)部,大部分的動作都是配置出來的,請求的觸發(fā)除了初始化的,一般都是由點擊按鈕觸發(fā)請求,或者是組件的 change 事件中等,但是頁面內(nèi)部的請求依賴于項目的請求封裝,所以在內(nèi)部組件的屬性上面需要增加請求的相關信息。主要包括:url,type,params,在點擊按鈕觸發(fā)的請求的時候,去 properties 內(nèi)部拿到請求信息,由于請求方法依賴于項目,所以這個組件內(nèi)部不做請求封裝,由外部把封裝好的請求方法傳遞進去,組件內(nèi)外只做規(guī)范約定:
// 外部通用的請求方法
import HTTP from '@/http'
export const commonRequest = (
url: string,
params: Record<string, any> = {},
type: 'post' | 'get' = 'get'
) => {
return HTTP[`$${type}`](url, params)
}
復制代碼
在遇到請求的 url 和 params,需要用到變量的情況下,我們可以約定變量格式,在內(nèi)部去解析且替換,如下:
// 屬性
const properties = {
api: '/{type}/get-data',
type: 'get',
params: 'id={id}'
}
/**
* 解析方法
* url 需要解析的請求的路徑
* params 需要解析的參數(shù)
* parent 解析依賴的父級數(shù)據(jù)
*/
const parseApiInfo = (url: string, params: string, parent: Record<string, any>) => {
const depData = {
// ...globalData // 全局數(shù)據(jù)
..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 去執(zhí)行請求, 這樣基本完成對請求的處理。
下拉選項數(shù)據(jù)的處理
對于下拉選項數(shù)據(jù)的處理,可以大致分為兩種情況:
靜態(tài)數(shù)據(jù) 動態(tài)數(shù)據(jù)
靜態(tài)數(shù)據(jù)
靜態(tài)數(shù)據(jù)比較好處理,因為是不變的,所以我們可以直接在前端配置好,比如:
const options = {
optionsId: [
{
label: '標簽',
value: 'val'
}
// ...
]
}
復制代碼
動態(tài)數(shù)據(jù)
動態(tài)數(shù)據(jù)會相對麻煩一點,因為需要后端配合,給出一個固定的接口,讓我們能一次性直接拿到整個頁面需要的所有的下拉數(shù)據(jù),格式如上。
table 組件的設計
table 組件是頁面內(nèi)主要的數(shù)據(jù)展示組件,因此功能上要考慮的較完善。
table 組件相關的按鈕:
table 上方的按鈕,主要是上傳、新增、批量刪除、批量編輯等,這里的按鈕依賴的數(shù)據(jù)主要有搜索欄組件內(nèi)的數(shù)據(jù)和 table 多選框選中的數(shù)據(jù) table 內(nèi) column 組件內(nèi)部的按鈕,因為是行內(nèi)按鈕,所以依賴的數(shù)據(jù)要把上方按鈕的選中的數(shù)據(jù)換成當前行的數(shù)據(jù)
column 組件的設計:
column 組件的類型主要分為三種:selection(多選列)、default(默認)、operate(可操作列) selection 是用于 table 第一列的多選列 default 為默認,不做其它配置 operate 為可操作列,該類型的列內(nèi)部可放子組件,比如 button,switch 等 自定義文本,分為兩種情況: 比較普通的狀態(tài)轉換文本,比如 0 -> 開啟;1 -> 關閉 下拉選項的取值,這里我們需要一個具體下拉數(shù)據(jù)的 id,就是上方下拉數(shù)據(jù)的處理,然后用一個腳本程序去解析替換。
按鈕與彈窗的處理
在這種頁面內(nèi)部,按鈕組件應該是用的最多的組件,比如:彈窗、table、column、search等,都需要用上,并且按鈕在不同的位置,能處理的功能也不一樣,按鈕的功能主要分為以下幾種:
確認提示框 彈窗 請求 跳轉 下載
除了彈窗,其余的功能都可以通過自身的屬性字段來完成任務,但是彈窗是一個比較特殊且十分重要的功能,管理類系統(tǒng)的彈窗一般是需要新增或者編輯、查看等,所以彈窗組件的內(nèi)部需要將 form 組件的功能考慮進去。
因為彈窗的內(nèi)容是自定義且內(nèi)容十分多,比如:彈窗內(nèi)部有 table,table 內(nèi)部有按鈕,按鈕還能打開彈窗等情況,所以我們需要將彈窗的內(nèi)容數(shù)據(jù)打平,否則會造成結構嵌套太深導致不好解析。
const pageConfig = {
type: 'page',
properties: {},
search: {},
table: {},
// 彈窗數(shù)據(jù)
dialogTemplates: [
{
id: 'xxx',
type: 'dialog',
properties: {},
// 彈窗內(nèi)部 form 表單組件
children: [
{
type: 'input',
id: 'xxx',
properties: {}
}
// ...
],
// 彈窗底部按鈕
buttons: [
{
type: 'button',
id: 'xxx',
properties: {}
}
// ...
]
}
// ...
]
}
復制代碼
使用的話,我們在 button 組件上添加一個 dialogId 的字段,用來指向 dialogTemplates 數(shù)組內(nèi) id 為 dialogId 的彈窗數(shù)據(jù)即可。
頁面的彈窗數(shù)量是不能做限制的,所以在彈窗的設計上,不能用普通的標簽去實現(xiàn),我們需要用服務方式去調(diào)用彈窗,如不了解 vue 服務方式的請看:使用服務方式來調(diào)用 vue 組件[2],這樣我們就實現(xiàn)了彈窗功能。
彈窗與表格數(shù)據(jù)的聯(lián)動
彈窗內(nèi)的新增和編輯大部分都會影響 table 列表數(shù)據(jù),還有就是在行內(nèi)的按鈕彈窗會默認攜帶行內(nèi)數(shù)據(jù)作為彈窗表單內(nèi)的初始數(shù)據(jù),所以我們在彈窗操作完成之后,要能刷新 table 數(shù)據(jù),所以我們要將頁面內(nèi)的按鈕功能統(tǒng)一的封裝起來,統(tǒng)一管理。如下:
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) {
// 調(diào)用彈窗
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 的方法放入回調(diào)函數(shù) callback 中, 在彈窗確認接口成功后,再執(zhí)行回調(diào)函數(shù)來刷新 table,對于依賴彈窗的功能都可以通過該方法去實現(xiàn)。
自定義插槽
對于有些特殊的表單功能通過配置無法實現(xiàn),我們需要開放兩個插槽,由開發(fā)者介入進行手動開發(fā)。
第一個位置是 table 上方的按鈕位置區(qū)域 第二個位置是 column 操作列的按鈕位置區(qū)域
最后
后臺管理系統(tǒng)可拖拽式組件,大體的設計思路就這樣。主要分為兩大塊:頁面配置和頁面渲染兩個組件。
頁面配置組件:分為三個模塊(子組件列表、預覽區(qū)域、屬性配置區(qū)域)。配置組件思路比較容易,就是配置好各個組件之間的關系。
頁面渲染組件:該組件就是拿到配置組件配置好的數(shù)據(jù)進行渲染,及業(yè)務邏輯的實現(xiàn)。
整體功能不難,就是細節(jié)比較多,需要在各個組件、各個位置上都要想的要比較全面。如果想做好,最好還是得到后端的支持,該組件至少可以覆蓋管理系統(tǒng) 80% - 90% 的場景。
寫的比較粗糙,有什么疑問或者更好的想法,歡迎留言指出
關于本文:
來自:對半
https://juejin.cn/post/7073131582176886815
