什么是巡檢報告
巡檢報告是指對某一個系統或設備進行全面檢查,并把檢查結果及建議整理成報告的程序,
巡檢報告通常用于評估系統或設備的運行狀況與性能,以發現問題、優化系統、提高效率、降低故障率等方面提供參考,
要實作什么功能
自定義布局
- 現報告中的面板可進行拖拽改變布局,
- 在拖拽的程序中限制拖拽區域,只允許在同一父級內進行拖拽,不允許跨目錄移動,不允許改變目錄的級別,比如把一級目錄移動到另一個一級目錄內,變成二級目錄
目錄可收縮展開
- 目錄支持收縮展開,收縮時隱藏所以子面板,展開時顯示所以子面板
- 移動目錄時,子面板跟隨移動
- 改變目錄后,同步更新右側的目錄面板
- 生成目錄編號
右側目錄樹
- 生成目錄編號
- 支持錨點滾動
- 支持展開收縮
- 與左側報告聯動
資料面板
- 根據日期范圍獲取指標資料
- 通過圖表的形式展示指標資訊
- 查看詳情,洗掉
- 各面板的請求設計,支持重繪請求
面板匯入
- 統計目錄下選擇的面板數量
- 匯入新面板時,不能破壞已有布局,新面板只能跟在舊面板后
- 匯入已有面板時,需要進行資料比較,有資料變更需要重新獲取最新的資料
保存
在保存前,所有影響布局相關的操作,都是臨時的,包括匯入面板,只有在點擊保存后,才會把當前資料提交給后端進行保存,
支持 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
在面板布局改變后觸發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 = https://www.cnblogs.com/dtux/p/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 的圖片插入),
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/556431.html
標籤:Html/Css
上一篇:HTML網頁內容適配——標題欄
下一篇:返回列表