vue3开发可视化大屏编辑器

lxf2023-03-12 08:32:01

最近公司有几个大屏项目,就想着在闲暇时间做一个大屏编辑器也顺带学习一下vue3,在这里简单记录一下

效果预览

vue3开发可视化大屏编辑器 vue3开发可视化大屏编辑器

设计思路

技术选型

  • vue3+ts
  • echarts
  • element-plus

思路

  • 采用栅格布局,先通过配置面板添加栅格
vue3开发可视化大屏编辑器
  • 栅格创建完成后,将需要放置的组件拖入栅格内,例如拖入图表数据、文本、图片
vue3开发可视化大屏编辑器
  • 通过配置面板修改样式和图表配置,图表用的是echarts库,可以直接从echarts官网的案例复制过来粘贴或者自行修改
vue3开发可视化大屏编辑器
  • 选中组件,为组件配置动态数据,配置完成后开启,每次进入页面都会请求配置好的地址
vue3开发可视化大屏编辑器
  • 所有工作完成之后就可以点击导出html页面生成压缩包下载了
vue3开发可视化大屏编辑器

核心功能的实现

数据结构的定义

以树形结构为基础来渲染页面,首先完成树形结构内的对象属性定义

//elementType.ts

//元素
export interface ElementType {
  /** 唯一标识 */
  id?: number;
  /** 元素类型 */
  type: string;
  /** 样式 */
  style: StyleType;
  /** 子级 */
  children?: Array<ElementType>;
  /** 组件配置 */
  componentConfig?: ComponentConfigType | undefined;
  /** http配置 */
  request?:RequestType
}

export interface StyleType {
  /** 栅格宽度 */
  spanWidth?: number;
  /** 高度 */
  height?: number;
  /** 高度单位 */
  heightUnit?: string;
  /** 宽度单位 */
  spanUnit?: string;
  /** 字体大小 */
  fontSize?: number;
  /** 字体粗细 */
  fontWeight?: number;
  ...
}
//进度条
export interface ProgressType {
  /** 进度值 */
  percentage?: number;
  /** 标题 */
  label?: number;
  /** 进度条类型 */
  type?: string;
  /** 进度值样式 */
  valueStyle?: object;
  /** 进度标题样式 */
  labelStyle?: object;
  /** 进度宽度 */
  strokeWidth?: number;
  /** 环状大小 */
  width?: number;
  /** 颜色 */
  color?: string;
}
//图片
export interface ImgType {
  /** 宽度 */
  width?: number;
  /** 宽度单位 */
  widthUnit?: string;
  /** 高度 */
  height?: number;
  /** 高度单位 */
  heightUnit?: string;
  /** 图片地址 */
  imgUrl?: string;
}
//表格
export interface TableType {
  /** 列 */
  columns?: Array<any>;
  /** 数据 */
  data?: Array<any>;
}
//地图图表
export interface MapType {
  /** 区域级别 */
  level?: string;
  /** 所属区域 */
  area?: string;
}
//http请求
export interface RequestType {
  /** 开关 */
  open?: boolean;
  /** 请求类型 */
  method?: string;
  /** 参数类型 */
  dataType?: string;
  /** 请求头 */
  headers?: string;
  /** 请求参数 */
  requesetData?: string;
}
//组件配置
export interface ComponentConfigType
  extends TableType,
    MapType,
    ImgType,
    ProgressType {
  /** 组件样式 */
  style?: object;
  /** 图表配置代码 */
  option?: object | string;
}

组件开发

结构定义好了,我们可以根据结构开始写组件了(组件代码都是基于element和echarts封装就不贴出来了)

vue3开发可视化大屏编辑器

然后可以开始写核心渲染组件,我们用的是栅格布局可以沿用element的row、col组件,然后写一个递归组件,初始化我们先前定义的数据和组件,动态引入组件就可以了

//layout.vue
<template>
    <el-row :gutter="row.gutter" :align="row.align"
      :justify="row.justify" :class="{'layout-row':true,'layout-border':pattern==='edit'}"
      v-for="row in rowList">
      <el-col>
        <div class="col-element">
          <Layout v-if="col.children?.length > 0" :pattern="pattern" :rowList="[col]" />
          <Component v-else :key="componentType(col.type)" :is="componentType(col.type)" :col="col"
            :initStyle="initStyle" />
        </div>
      </el-col>
    </el-row>
</template>
<script lang="ts" setup name="Layout">
import { inject, PropType, ref } from 'vue';
import defaultConfig from "./defaultConfig.ts"
import { ElementType } from "../../interface/elementType.ts";
import ViewChart from "./components/viewChart.vue"
import ViewImg from "./components/viewImg.vue"
import ViewProgress from "./components/viewProgress.vue"
import ViewTable from "./components/viewTable.vue"
import ViewText from "./components/viewText.vue"
import ViewTime from "./components/viewTime.vue"

const viewConfig = ref<Array<ElementType>>([
{ type: "row", children: [], id: 0, style: {}, request: { method: "GET" } }
])
const componentType = (type: string) => {
  const fileName = `View${firstToUpper3(type)}`
  return components.value[fileName]
};

拖拽生成

这里用的是H5拖拽api

//菜单页
<template>
  <el-menu :collapse="collapse" default-active="2" class="el-menu-vertical">
    <div class="expand-fold">
      <el-icon :size="20" @click="emit('update:collapse', !collapse)">
        <Expand v-if="collapse" />
        <Fold v-else />
      </el-icon>
    </div>
    <el-menu-item :index="i" v-for="i in menuList" draggable="true" @dragstart="drag($event, i.renderType)">
      <el-icon>
        <component class="icons" :is="i.icon"></component>
      </el-icon>
      <span>{{ i.text }}</span>
    </el-menu-item>
  </el-menu>
</template>
import { ref } from 'vue'
const menuList = ref([
  { text: "折线图", renderType: "line", icon: TrendCharts },
  { text: "柱状图", renderType: "bar", icon: Management }
  ...
])
const drag = (event: any, renderType: string) => {
  event.dataTransfer.setData("renderType", renderType);
}

然后定义各种组件初始化的默认配置

//defaultConfig.ts
export default {
  bar: {
    option: {
    ...
    },
  },
  pie: {
    option: {
    ...
    },
  },
  line: {
    option: {
     ...
    },
  },
  map: {
    option: {
      ...
    },
    level: "countryOption",
    area: "100000", 
  },
  table: {
    style: {},
    columns: [
      {
        id: genFileId(),
        label: "表头1",
        prop: "column1",
        width: 80,
        align: "center",
      },
      ...
    ],
    data: [],
  },
  progress: {
    percentage: 100,
    color: "#409eff",
    strokeWidth: 6,
    type: "line",
    width: 126,
    labelStyle: {},
    valueStyle: {},
  },
  img: { width: 120, height: 100, widthUnit: "px", heightUnit: "px" },
}

拖拽完成后事件,初始化组件

//layout.vue
<template>
    <el-row :gutter="row.gutter" :align="row.align"
      @dragover.prevent @drop.stop="dropEvent($event, row)"
      :justify="row.justify" :class="{'layout-row':true,'layout-border':pattern==='edit'}"
      v-for="row in rowList">
      <el-col @dragover.prevent @drop.stop="dropEvent($event, col)">
        <div class="col-element">
          <Layout v-if="col.children?.length > 0" :pattern="pattern" :rowList="[col]" />
          <Component v-else :key="componentType(col.type)" :is="componentType(col.type)" :col="col"
            :initStyle="initStyle" />
        </div>
      </el-col>
    </el-row>
   </template>
  <script lang="ts" setup name="Layout">
  import defaultConfig from "./defaultConfig.ts"
  import { ElementType } from "../../interface/elementType.ts";

  const dropEvent = (event: any, item: ElementType) => {
  event.preventDefault();
  item.type = ""
  if (props.pattern === 'readonly' || process.env.NODE_ENV === "production") {
    return
  }
  if (item.id !== 0) {
    const renderType = event.dataTransfer.getData("renderType");
    if (['text', 'time'].includes(renderType)) {
      item.style = Object.assign(item.style, { color: "white", fontSize: 16, fontWeight: 400 })
    }
    item.componentConfig = defaultConfig[renderType];
    item.type = (["pie", "line", "bar", "map"].includes(renderType) ? 'chart' : renderType)
    item.request = { method: "GET" };
    item.children = []
    setElementData(item)
  }
}
</script>

到这里已经完成了组件的拖拽渲染

配置面板设计

配置面板区域分为两个模块,一个模块是用来配置组件的样式或者属性的,像长度边距之类的还有图表的属性配置,另一个模块就是数据的配置,主要配置静态数据或者动态数据http请求,这两块主要都是表单配置和请求的封装,这里就不贴代码了。

页面的生成

先把我们的项目配置两个入口文件,一个是大屏编辑页面的入口,另一个是大屏预览页面的入口,这样就可以各自打包减少体积,这里用到了vite-plugin-html插件,配置完成后就可以根据环境打不同的包了。

//vite.config.ts
import { defineConfig, loadEnv } from "vite";
import { createHtmlPlugin } from "vite-plugin-html";
const getOption = (env) => {
  const option: any = {};
  switch (env) {
    case "development":
      option.entry = "/src/main.ts";
      option.dir = "dist/main";
      option.publicDir = "public"
      option.outputName = `大屏编辑器${timeFormat()}`;
      break
    default:
      option.entry = "/src/mainView.ts";
      option.dir = "dist/view";
      option.publicDir = "publicView"
      option.outputName = "大屏预览页";
      break
  }
  return option;
};
export default ({ mode }) => {
  const env = loadEnv(mode, __dirname);
  const option = getOption(env.VITE_NODE_ENV);
  return defineConfig({
    envDir: "env",
    plugins: [
      createHtmlPlugin({
        minify: true,
        entry: option.entry
      }),
      alias(),
      VueSetupExtend(),
      vue(),
      ...
    ],
    publicDir: option.publicDir, // 静态资源服务的文件夹
    ...
  });
};

然后就可以build预览页面的包了,build后把包打成zip放在后台服务上面(我这里用的是node),当大屏编辑页发送导出HTML请求的时候,会把渲染数据发送到后台,后台就可以开始根据渲染数据打包处理,最后再返回包地址到页面上下载,到这里就结束了。

//导出HTML
async createViewZip(body) {
    const { jsonList, mapArea } = body;
    const folderName = uuid.v4();
    const AdmZip = require('adm-zip');
    const initZipName = join(__dirname, `../../../../public/viewZip/${folderName}.zip`);
    //把zip包copy一遍
    await fsCopyFile(join(__dirname, `../../../../public/view.zip`), initZipName);
    try {
      // 加载并解析copy的zip
      const zip = await new AdmZip(initZipName);
      // 为zip添加文件,文件名为xView.json,内容为渲染数据
      await zip.addFile('xView.json', jsonList);
      // 如果包含地图组件还需要把地区json写入zip内
      if (mapArea) {
        await zip.addLocalFile(join(__dirname, `../../../../public/areaJSON/${mapArea}.json`), './static/area');
      }
      // 生成zip文件返回
      await zip.writeZip(initZipName);
      return {
        code: 200,
        data: `/public/viewZip/${folderName}.zip`,
        msg: '操作成功',
      };
    } catch (err) {
      console.log(err);
      return {
        code: 500,
        msg: err,
      };
    }
  }

后续功能的完善

未完成(待补充)

  1. 优先解决bug
  2. 丰富组件库
  3. 图表配置的动态化(echarts 的配置选项实在是太多了,可能还是保持现状)
  4. 丰富样式
  5. 组件的事件和联动
  6. 自适应相关的问题

总结

关于大屏编辑器要考虑的东西太多了,功能要做到完善还是比较难的,现在开发这个阶段也只能满足 一些简单的大屏页面。希望能给看到这篇文章的小伙伴一些帮助,也希望能给到我一些建议。

测试地址