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