关于前端:如何实现巡检报告

3次阅读

共计 10324 个字符,预计需要花费 26 分钟才能阅读完成。

什么是巡检报告

巡检报告是指对某一个零碎或设施进行全面查看,并把查看后果及倡议整顿成报告的过程。

巡检报告通常用于评估零碎或设施的运行状况与性能,以发现问题、优化零碎、提高效率、升高故障率等方面提供参考。

要实现什么性能

自定义布局

  1. 现报告中的面板可进行拖拽扭转布局。
  2. 在拖拽的过程中限度拖拽区域,只容许在同一父级内进行拖拽,不容许跨目录挪动,不容许扭转目录的级别,比方把一级目录挪动到另一个一级目录内,变成二级目录

目录可膨胀开展

  1. 目录反对膨胀开展,膨胀时暗藏所以子面板,开展时显示所以子面板
  2. 挪动目录时,子面板追随挪动
  3. 扭转目录后,同步更新右侧的目录面板
  4. 生成目录编号

右侧目录树

  1. 生成目录编号
  2. 反对锚点滚动
  3. 反对开展膨胀
  4. 与左侧报告联动

数据面板

  1. 依据日期范畴获取指标数据
  2. 通过图表的模式展现指标信息
  3. 查看详情,删除
  4. 各面板的申请设计,反对刷新申请

面板导入

  1. 统计目录下抉择的面板数量
  2. 导入新面板时,不能毁坏已有布局,新面板只能跟在旧面板后
  3. 导入已有面板时,须要进行数据比拟,有数据变更须要从新获取最新的数据

保留

在保留前,所有影响布局相干的操作,都是长期的,包含导入面板。只有在点击保留后,才会把以后数据提交给后端进行保留。

反对 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-layoutonLayoutChange 办法,能够拿到布局后的所有面板最新的地位数据。

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;
    }
    // ...
}

面板

面板的导入设计

后端返回的数据是一颗有着三级层级的树,咱们拿到后,在数据上保护成 moduleMapdashboardMappanelMap 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 信息找到二级目录。

在交互上设置一级目录不可选中,入选中二级目录时,通过二级目录 Dashboardpanels 找到相干的面板渲染到右侧区域。
对于这 3 个 Map 的操作,保护在 useHandleData中,导出:

{
    ...map, // moduleMap、dashboardMap、panelMap
    getData, // 生成巡检报告的数据结构
    init: initData, // 初始化 Map
}

面板选中回填

在进入面板治理时,须要回填已选中的面板。咱们能够通过 getSaveModel 获取到以后巡检报告的信息。把对应的选中信息寄存到 selectPanels 中。

当初咱们只须要扭转 selectPanels 中的值,就能够做到对应面板的选中。

面板选中重置

间接遍历 dashboardMap,并把每个 selectPanels 重置。

dashboardMap.forEach((dashboard) => {dashboard.selectPanels = [];
});

面板插入

在咱们选中面板后,对选中面板进行插入时,有几种状况:

  • 巡检报告本来存在的面板,这次也选中,在插入时会比拟数据,如果数据产生扭转,须要依据最新的数据源信息进行申请,并渲染。
  • 巡检报告本来存在的面板,这次未选中,在插入时,须要删除掉未选中的面板。
  • 新选中的面板,在插入时,在对应目录的开端进行插入。

增加新面板须要,与目录膨胀相似,不同的是:

  1. 目录膨胀针对只有一个目录,而插入在针对的是整体。
  2. 目录膨胀是间接从子节点开始向上冒泡,而插入是先从根节点开始向下插入,插入实现后在依据最新的目录数据,更新一遍布局。
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 选中组件。

序号依据每次渲染进行生成。

采纳公布订阅治理渲染

每当仪表盘扭转布局的动作时,右侧目录就须要进行同步更新。而任意一个面板都有可能须要触发右侧目录的更新。
如果咱们采纳实例内保护对应组件的渲染事件,有几个问题:

  1. 须要进行辨别,比方刷新面板时,不须要触发右侧目录的渲染。
  2. 每个面板如何订阅右侧目录的渲染事件?

最终采纳了公布订阅者模式,对事件进行治理。

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 的图片插入)。

正文完
 0