巡檢報(bào)告是指對某一個系統(tǒng)或設(shè)備進(jìn)行全面檢查,并把檢查結(jié)果及建議整理成報(bào)告的過程。
巡檢報(bào)告通常用于評估系統(tǒng)或設(shè)備的運(yùn)行狀況與性能,以發(fā)現(xiàn)問題、優(yōu)化系統(tǒng)、提高效率、降低故障率等方面提供參考。
(資料圖片僅供參考)
要實(shí)現(xiàn)什么功能自定義布局現(xiàn)報(bào)告中的面板可進(jìn)行拖拽改變布局。在拖拽的過程中限制拖拽區(qū)域,只允許在同一父級內(nèi)進(jìn)行拖拽,不允許跨目錄移動,不允許改變目錄的級別,比如把一級目錄移動到另一個一級目錄內(nèi),變成二級目錄目錄可收縮展開目錄支持收縮展開,收縮時隱藏所以子面板,展開時顯示所以子面板移動目錄時,子面板跟隨移動改變目錄后,同步更新右側(cè)的目錄面板生成目錄編號右側(cè)目錄樹生成目錄編號支持錨點(diǎn)滾動支持展開收縮與左側(cè)報(bào)告聯(lián)動數(shù)據(jù)面板根據(jù)日期范圍獲取指標(biāo)數(shù)據(jù)通過圖表的形式展示指標(biāo)信息查看詳情,刪除各面板的請求設(shè)計(jì),支持刷新請求面板導(dǎo)入統(tǒng)計(jì)目錄下選擇的面板數(shù)量導(dǎo)入新面板時,不能破壞已有布局,新面板只能跟在舊面板后導(dǎo)入已有面板時,需要進(jìn)行數(shù)據(jù)比較,有數(shù)據(jù)變更需要重新獲取最新的數(shù)據(jù)保存在保存前,所有影響布局相關(guān)的操作,都是臨時的,包括導(dǎo)入面板。只有在點(diǎn)擊保存后,才會把當(dāng)前數(shù)據(jù)提交給后端進(jìn)行保存。
支持 pdf 和 word 導(dǎo)出巡檢報(bào)告實(shí)現(xiàn)方案數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)先看看使用扁平結(jié)構(gòu)下的
在扁平結(jié)構(gòu)下,確定子項(xiàng)只需要找到下一個 row 面板,對于多級目錄下也是同理,只是對一級目錄需要額外處理。這種結(jié)構(gòu)上實(shí)現(xiàn)簡單,但是需求要求我們限制目錄的拖拽,限制目錄需要一個比較清晰的面板層級關(guān)系,很顯然,用樹能夠很清晰的描述一個數(shù)據(jù)的層級結(jié)構(gòu)
組件設(shè)計(jì)與傳統(tǒng)組件編程有所區(qū)別。
在實(shí)現(xiàn)上對渲染和數(shù)據(jù)處理進(jìn)行了分離,分為兩塊:
react 組件:主要負(fù)責(zé)頁面渲染class : 負(fù)責(zé)數(shù)據(jù)的處理DashboardModel
class DashboardModel { id: string | number; panels: PanelModel[]; // 各個面板// ...}
PanelModel
class PanelModel { key?: string; id!: number; gridPos!: GridPos; // 位置信息 title?: string; type: string; panels: PanelModel[]; // 目錄面板需要維護(hù)當(dāng)前目錄下的面板信息 // ...}
每一個 Dashboard 組件對應(yīng)一個 DashboardModel,每一個 Panel 組件對應(yīng)一個 PanelModel。
react 組建根據(jù)類實(shí)例中的數(shù)據(jù)進(jìn)行渲染。
實(shí)例生產(chǎn)后,不會輕易的銷毀,或者改變引用地址,這讓依賴實(shí)例數(shù)據(jù)進(jìn)行渲染的 React 組件無法觸發(fā)更新渲染。
需要一個方式,在實(shí)例內(nèi)數(shù)據(jù)發(fā)生改變后,由我們手動觸發(fā)組件的更新渲染。
組件渲染控制由于我們采用的是 hooks
組件,不像 class
組件有 forceUpdate
方法觸發(fā)組件的方法。
而在 react18
中有一個新特性 useSyncExternalStore
,可以讓我們訂閱外部的數(shù)據(jù),如果數(shù)據(jù)發(fā)生改變了,會觸發(fā)組件的渲染。
實(shí)際上 useSyncExternalStore
觸發(fā)組件渲染的原理就是在內(nèi)部維護(hù)了一個 state
,當(dāng)更改了 state
值,引起了外部組件的渲染。
基于這個思路簡單的實(shí)現(xiàn)了一個能夠觸發(fā)組件渲染的 useForceUpdate
方法。
export function useForceUpdate() { const [_, setValue] = useState(0); return debounce(() => setValue((prevState) => prevState + 1), 0);}
雖說實(shí)現(xiàn)了 useForceUpdate
,但是在實(shí)際使用的過程中,還需要在組件銷毀時移除事件。
而 useSyncExternalStore
已經(jīng)內(nèi)部已經(jīng)實(shí)現(xiàn)了,直接使用即可。
useSyncExternalStore(dashboard?.subscribe ?? (() => {}), dashboard?.getSnapshot ?? (() => 0));useSyncExternalStore(panel?.subscribe ?? (() => {}), panel?.getSnapshot ?? (() => 0));
根據(jù)useSyncExternalStore
使用,分別添加了 subscribe 和 getSnapshot 方法。
class DashboardModel {// PanelModel 一樣 count = 0; forceUpdate() { this.count += 1; eventEmitter.emit(this.key); } /** * useSyncExternalStore 的第一個入?yún)?,?zhí)行 listener 可以觸發(fā)組件的重渲染 * @param listener * @returns */ subscribe = (listener: () => void) => { eventEmitter.on(this.key, listener); return () => { eventEmitter.off(this.key, listener); }; }; /** * useSyncExternalStore 的第二個入?yún)?,count 在這里改變后觸發(fā)diff的通過。 * @param listener * @returns */ getSnapshot = () => { return this.count; };}
當(dāng)改變數(shù)據(jù)后,需要觸發(fā)組件的渲染,只需要執(zhí)行forceUpdate
即可。
市面上比較大眾的拖拽插件有以下幾個:
react-beautiful-dndreact-dndreact-grid-layout經(jīng)過比較后,發(fā)現(xiàn) react-grid-layout
非常適合用來做面板的拖拽功能,react-grid-layout
本身使用簡單,基本無上手門檻,最終決定使用 react-grid-layout
詳細(xì)說明可以查看以下鏈接:
react-grid-layout
在面板布局改變后觸發(fā)react-grid-layout
的onLayoutChange
方法,可以拿到布局后的所有面板最新的位置數(shù)據(jù)。
const onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => { for (const newPos of newLayout) { panelMap[newPos.i!].updateGridPos(newPos); } dashboard!.sortPanelsByGridPos();};
panelMap 是一個 map,key 為 Panel.key, value 為面板。是在我們組件渲染時就已經(jīng)準(zhǔn)備好了。
const panelMap: Record = {};
可以通過 panelMap 找到對應(yīng)的面板,執(zhí)行面板的 updateGridPos
方法進(jìn)行更新面板的布局?jǐn)?shù)據(jù)。到這,我們只是完成了面板本身數(shù)據(jù)更新,還需要執(zhí)行儀表盤的 sortPanelsByGridPos
方法,對所有的面板進(jìn)行排序。
class DashboardModel { sortPanelsByGridPos() { this.panels.sort((panelA, panelB) => { if (panelA.gridPos.y === panelB.gridPos.y) { return panelA.gridPos.x - panelB.gridPos.x; } else { return panelA.gridPos.y - panelB.gridPos.y; } }); } // ...}
面板拖動范圍目前的拖動范圍是整個儀表盤,可隨意拖動,如下:
綠色是儀表盤可拖拽區(qū)域,灰色為面板。
如果需要限制就需要改成如下的結(jié)構(gòu):
在原本的基礎(chǔ)上,以目錄為單位區(qū)分,綠色為整體的可移動區(qū)域,黃色為一級目錄塊,可在綠色區(qū)域拖動,拖動時以整個黃色塊進(jìn)行拖動,紫色為二級目錄塊,可在當(dāng)前黃色區(qū)域內(nèi)拖動,不可脫離當(dāng)前黃色塊,灰色的面板只能在當(dāng)前目錄下拖動。
在原先數(shù)據(jù)結(jié)構(gòu)基礎(chǔ)上進(jìn)行改造:
class PanelModel { dashboard?: DashboardModel; // 當(dāng)前目錄下的 dashboard // ...}
目錄目錄收縮展開為目錄面板維護(hù)一個 collapsed
屬性用來控制面板的隱藏顯示
class PanelModel { collapsed?: boolean; // type = row // ...}// 組件渲染{!collapsed && }
對目錄進(jìn)行收縮展開時,會改變自身的高度,現(xiàn)在還需要把這個改變的高度同步給上一級的儀表盤。上一級需要做的就是類似我們控制目錄的處理。如下,控制第一個二級目錄收縮:
當(dāng)面板發(fā)生變更時,需要通知上級面板,進(jìn)行對應(yīng)的操作。
增加一個 top 用來獲取到父級實(shí)例。
class DashboardModel { top?: null | PanelModel; // 最近的 panel 面板 /** * 面板高度變更,同步修改其他面板進(jìn)行對應(yīng)高度 Y 軸的變更 * @param row 變更高度的 row 面板 * @param h 變更高度 */ togglePanelHeight(row: PanelModel, h: number) { const rowIndex = this.getIndexById(row.id); for (let panelIndex = rowIndex + 1; panelIndex < this.panels.length; panelIndex++) { this.panels[panelIndex].gridPos.y += h; } this.panels = [...this.panels]; // 頂級 dashBoard 容器沒有 top this.top?.changeHeight(h); this.forceUpdate(); } // ...}class PanelModel { top: DashboardModel; // 最近的 dashboard 面板 /** * @returns h 展開收起影響的高度 */ toggleRow() { this.collapsed = !this.collapsed; let h = this.dashboard?.getHeight(); h = this.collapsed ? -h : h; this.changeHeight(h); } /** * * @param h 變更的高度 */ changeHeight(h: number) { this.updateGridPos({ ...this.gridPos, h: this.gridPos.h + h }); // 更改自身面板的高度 this.top.togglePanelHeight(this, h); // 觸發(fā)父級變更 this.forceUpdate(); } // ...}
整理流程與冒泡類型,一直到最頂級的 Dashboard。
展開收縮同理。
面板的刪除對于面板的刪除,我們只需要在對應(yīng)的 Dashboard 下進(jìn)行移除,刪除后會改變當(dāng)前 Dashboard 高度,這塊的處理與上面的目錄收縮一致。
class DashboardModel { /** * @param panel 刪除的面板 */ removePanel(panel: PanelModel) { this.panels = this.filterPanelsByPanels([panel]); // 冒泡父容器,減少的高度 const h = -panel.gridPos.h; this.top?.changeHeight(h); this.forceUpdate(); } /** * 根據(jù)傳入的面板進(jìn)行過濾 * @param panels 需要過濾的面板數(shù)組 * @returns 過濾后的面板 */ filterPanelsByPanels(panels: PanelModel[]) { return this.panels.filter((panel) => !panels.includes(panel)); } // ...}
面板的保存PS:與后端溝通后,當(dāng)前巡檢報(bào)告數(shù)據(jù)結(jié)構(gòu)由前端自主維護(hù),最終給后端一個字符串就好。獲取到目前的面板數(shù)據(jù),用 JSON 進(jìn)行轉(zhuǎn)換即可。
面板的信息獲取過程,先從根節(jié)點(diǎn)出發(fā),遍歷至葉子結(jié)點(diǎn),再從葉子結(jié)點(diǎn)開始,一層層向上進(jìn)行返回,也就是回溯的過程。
class DashboardModel { /** * 獲取所有面板數(shù)據(jù) * @returns */ getSaveModel() { const panels: PanelData[] = this.panels.map((panel) => panel.getSaveModel()); return panels; } // ...}// 最終保存時所需要的屬性,其他的都不需要const persistedProperties: { [str: string]: boolean } = { id: true, title: true, type: true, gridPos: true, collapsed: true, target: true,};class PanelModel { /** * 獲取所有面板數(shù)據(jù) * @returns */ getSaveModel() { const model: any = {}; for (const property in this) { if (persistedProperties[property] && this.hasOwnProperty(property)) { model[property] = cloneDeep(this[property]); } } model.panels = this.dashboard?.getSaveModel() ?? []; return model; } // ...}
面板面板的導(dǎo)入設(shè)計(jì)后端返回的數(shù)據(jù)是一顆有著三級層級的樹,我們拿到后,在數(shù)據(jù)上維護(hù)成 moduleMap
, dashboardMap
和 panelMap
3個Map。
import { createContext } from "react";export interface Module { // 一級目錄 key: string; label: string; dashboards?: string[]; sub_module?: Dashboard[];}export interface Dashboard { // 二級目錄 key: string; dashboard_key: string; label: string; panels?: number[]; selectPanels?: number[]; metrics?: Panel[];}export interface Panel { expr: Expr[]; // 數(shù)據(jù)源語句信息 label: string; panel_id: number;}type Expr = { expr: string; legendFormat: string;};export const DashboardContext = createContext({ moduleMap: new Map(), dashboardMap: new Map(), panelMap: new Map(),});
我們在渲染模塊時,遍歷 moduleMap
,并通過 Module
內(nèi)的dashboards
信息找到二級目錄。
在交互上設(shè)置一級目錄不可選中,當(dāng)選中二級目錄時,通過二級目錄 Dashboard
的 panels
找到相關(guān)的面板渲染到右側(cè)區(qū)域。對于這3個Map
的操作,維護(hù)在 useHandleData
中,導(dǎo)出:
{ ...map, // moduleMap、dashboardMap、panelMap getData, // 生成巡檢報(bào)告的數(shù)據(jù)結(jié)構(gòu) init: initData, // 初始化 Map}
面板選中回填在進(jìn)入面板管理時,需要回填已選中的面板。我們可以通過 getSaveModel
獲取到當(dāng)前巡檢報(bào)告的信息。把對應(yīng)的選中信息存放到 selectPanels
中。
現(xiàn)在我們只需要改變 selectPanels
中的值,就可以做到對應(yīng)面板的選中。
直接遍歷 dashboardMap
,并把每個selectPanels
重置。
dashboardMap.forEach((dashboard) => { dashboard.selectPanels = [];});
面板插入在我們選中面板后,對選中面板進(jìn)行插入時,有幾種情況:
巡檢報(bào)告原本存在的面板,這次也選中,在插入時會比較數(shù)據(jù),如果數(shù)據(jù)發(fā)生改變,需要根據(jù)最新的數(shù)據(jù)源信息進(jìn)行請求,并渲染。巡檢報(bào)告原本存在的面板,這次未選中,在插入時,需要刪除掉未選中的面板。新選中的面板,在插入時,在對應(yīng)目錄的末尾進(jìn)行插入。添加新面板需要,與目錄收縮類似,不同的是:
目錄收縮針對只有一個目錄,而插入在針對的是整體。目錄收縮是直接從子節(jié)點(diǎn)開始向上冒泡,而插入是先從根節(jié)點(diǎn)開始向下插入,插入完成后在根據(jù)最新的目錄數(shù)據(jù),更新一遍布局。class DashboardModel { update(panels: PanelData[]) { this.updatePanels(panels); // 更新面板 this.resetDashboardGridPos(); // 重新布局 this.forceUpdate(); } /** * 以當(dāng)前與傳入的進(jìn)行對比,以傳入的數(shù)據(jù)為準(zhǔn),并在當(dāng)前的順序上進(jìn)行修改 * @param panels */ updatePanels(panels: PanelData[]) { const panelMap = new Map(); panels.forEach((panel) => panelMap.set(panel.id, panel)); this.panels = this.panels.filter((panel) => { if (panelMap.has(panel.id)) { panel.update(panelMap.get(panel.id)); panelMap.delete(panel.id); return true; } return false; }); panelMap.forEach((panel) => { this.addPanel(panel); }); } addPanel(panelData: any) { this.panels = [...this.panels, new PanelModel({ ...panelData, top: this })]; } resetDashboardGridPos(panels: PanelModel[] = this.panels) { let sumH = 0; panels?.forEach((panel: any | PanelModel) => { let h = ROW_HEIGHT; if (isRowPanel(panel)) { h += this.resetDashboardGridPos(panel.dashboard.panels); } else { h = panel.getHeight(); } const gridPos = { ...panel.gridPos, y: sumH, h, }; panel.updateGridPos({ ...gridPos }); sumH += h; }); return sumH; }}class PanelModel { /** * 更新 * @param panel */ update(panel: PanelData) { // 數(shù)據(jù)源語句發(fā)生變化需要重新獲取數(shù)據(jù) if (this.target !== panel.target) { this.needRequest = true; } this.restoreModel(panel); if (this.dashboard) { this.dashboard.updatePanels(panel.panels ?? []); } this.needRequest && this.forceUpdate(); }}
面板請求needRequest
控制面板是否需要進(jìn)行請求,如果為 true
在面板下一次進(jìn)行渲染時,會進(jìn)行請求。請求的處理也放在了 PanelModel 中。(是否單獨(dú)維護(hù)請求的邏輯?)
import { Params, params as fetchParams } from "../../components/useParams";class PanelModel { target: string; // 數(shù)據(jù)源信息 getParams() { return { targets: this.target, ...fetchParams, } as Params; } request = () => { if (!this.needRequest) return; this.fetchData(this.getParams()); }; fetchData = async (params: Params) => { const data = await this.fetch(params); this.data = data; this.needRequest = false; this.forceUpdate(); }; fetch = async (params: Params) => { /* ... */ }}
我們數(shù)據(jù)渲染組件一般層級較深,而請求時會需要時間區(qū)間等外部參數(shù)。對于這部分參數(shù)采用全局變量的方式,用 useParams
進(jìn)行維護(hù)。上層組件使用 change 修改參數(shù),數(shù)據(jù)渲染組件根據(jù)拋出的 params
進(jìn)行請求。
export let params: Params = { decimal: 1, unit: null,};function useParams() { const change = (next: (() => Params) | Params) => { if (typeof next === "function") params = next(); params = { ...params, ...next } as Params; }; return { params, change };}export default useParams;
面板刷新從根節(jié)點(diǎn)向下查找,找到葉子節(jié)點(diǎn),在觸發(fā)對應(yīng)的請求。
class DashboardModel { /** * 刷新子面板 */ reloadPanels() { this.panels.forEach((panel) => { panel.reload(); }); }}class PanelModel { /** * 刷新 */ reload() { if (isRowPanel(this)) { this.dashboard.reloadPanels(); } else { this.reRequest(); } } reRequest() { this.needRequest = true; this.request(); }}
右側(cè)目錄渲染錨點(diǎn)/序號錨點(diǎn)采用 Anchor + id 選中組件。
序號根據(jù)每次渲染進(jìn)行生成。
采用發(fā)布訂閱管理渲染每當(dāng)儀表盤改變布局的動作時,右側(cè)目錄就需要進(jìn)行同步更新。而任意一個面板都有可能需要觸發(fā)右側(cè)目錄的更新。如果我們采用實(shí)例內(nèi)維護(hù)對應(yīng)組件的渲染事件,有幾個問題:
需要進(jìn)行區(qū)分,比如刷新面板時,不需要觸發(fā)右側(cè)目錄的渲染。每個面板如何訂閱右側(cè)目錄的渲染事件?最終采用了發(fā)布訂閱者模式,對事件進(jìn)行管理。
class EventEmitter { list: Record = {}; /** * 訂閱 * @param event 訂閱事件 * @param fn 訂閱事件回調(diào) * @returns */ on(event: string, fn: () => void) {} /** * 取消訂閱 * @param event 訂閱事件 * @param fn 訂閱事件回調(diào) * @returns */ off(event: string, fn: () => void) {} /** * 發(fā)布 * @param event 訂閱事件 * @param arg 額外參數(shù) * @returns */ emit(event: string, ...arg: any[]) {}
eventEmitter.emit(this.key); // 觸發(fā)面板的訂閱事件eventEmitter.emit(GLOBAL); // 觸發(fā)頂級訂閱事件,就包括右側(cè)目錄的更新
面板詳情展示對面板進(jìn)行查看時,可修改時間等,這些操作會影響到實(shí)例中的數(shù)據(jù),需要對原數(shù)據(jù)與詳情中的數(shù)據(jù)進(jìn)行區(qū)分。
通過對原面板數(shù)據(jù)的重新生成一個 PanelModel
實(shí)例,對這個實(shí)例進(jìn)行任意操作,都不會影響到原數(shù)據(jù)。
const model = panel.getSaveModel();const newPanel = new PanelModel({ ...model, top: panel.top }); // 創(chuàng)建一個新的實(shí)例setEditPanel(newPanel); // 設(shè)置為詳情
在dom
上,詳情頁面是采用絕對定位,覆蓋著巡檢報(bào)告。
pdf 導(dǎo)出由 html2Canvas + jsPDF 實(shí)現(xiàn)。需要注意的是,當(dāng)圖片過長pdf會對圖片進(jìn)行切分,有可能出現(xiàn)切分的時內(nèi)容區(qū)域。
需要手動計(jì)算面板的高度,是否超出當(dāng)前文檔,如果超出需要我們提前進(jìn)行分割,添加到下一頁中。盡可能把目錄面板和數(shù)據(jù)面板一塊切分。
word 導(dǎo)出由 html-docx-js 實(shí)現(xiàn), 需要保留目錄的結(jié)構(gòu),并可以在面板下添加總結(jié),這就需要我們分別對每一個面板進(jìn)行圖片的轉(zhuǎn)換。
實(shí)現(xiàn)的思路是根據(jù) panels 遍歷,找到目錄面板就是用 h1、h2
標(biāo)簽插入,如果是數(shù)據(jù)面板,在數(shù)據(jù)面板中維護(hù)一個 ref
的屬性,能讓我們拿到當(dāng)前面板的 dom
信息,根據(jù)這個進(jìn)行圖片轉(zhuǎn)換,并為 base64 的格式(word 只支持 base64 的圖片插入)。