最近公司有几个大屏项目,就想着在闲暇时间做一个大屏编辑器也顺带学习一下vue3,在这里简单记录一下
效果预览
设计思路
技术选型
- vue3+ts
- echarts
- element-plus
思路
- 采用栅格布局,先通过配置面板添加栅格
- 栅格创建完成后,将需要放置的组件拖入栅格内,例如拖入图表数据、文本、图片
- 通过配置面板修改样式和图表配置,图表用的是echarts库,可以直接从echarts官网的案例复制过来粘贴或者自行修改
- 选中组件,为组件配置动态数据,配置完成后开启,每次进入页面都会请求配置好的地址
- 所有工作完成之后就可以点击导出html页面生成压缩包下载了
核心功能的实现
数据结构的定义
以树形结构为基础来渲染页面,首先完成树形结构内的对象属性定义
//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封装就不贴出来了)
然后可以开始写核心渲染组件,我们用的是栅格布局可以沿用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,
};
}
}
后续功能的完善
未完成(待补充)
- 优先解决bug
- 丰富组件库
- 图表配置的动态化(echarts 的配置选项实在是太多了,可能还是保持现状)
- 丰富样式
- 组件的事件和联动
- 自适应相关的问题
总结
关于大屏编辑器要考虑的东西太多了,功能要做到完善还是比较难的,现在开发这个阶段也只能满足 一些简单的大屏页面。希望能给看到这篇文章的小伙伴一些帮助,也希望能给到我一些建议。
测试地址