基于SpringBoot + Vue 实现拖拽式可编辑大屏(实时渲染版)
作者:ZHANG13HAO
本文主要介绍了基于SpringBoot + Vue 实现拖拽式可编辑大屏(实时渲染版),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
一、方案背景与核心思路
拖拽式可编辑大屏是数据可视化领域的典型需求,核心目标是让用户通过拖拽方式自由编排大屏组件,同时支持组件绑定实时数据源,最终实现 “所见即所得” 的大屏展示效果。本文将详细讲解基于 SpringBoot + Vue 的完整实现方案,核心架构遵循 “前端通用渲染引擎 + 后端配置存储 + 实时数据对接” 的设计思路:
核心设计原则
- 前后端解耦:前端负责大屏可视化拖拽、实时渲染,后端仅负责配置存储和数据接口提供;
- 配置中心化:大屏组件的位置、大小、样式、数据源规则统一以 JSON 格式存储在后端数据库;
- 渲染通用化:前端编译一套通用渲染引擎,所有大屏通过 “固定引擎 + 动态 JSON 配置” 实现渲染,无需为每个大屏生成独立代码;
- 数据实时化:组件支持 API 接口短轮询、WebSocket 实时推送两种数据源,实现数据动态更新。
整体业务流程
graph TD A[用户拖拽编辑大屏] --> B[前端生成组件布局+数据源JSON配置] B --> C[调用后端接口保存JSON到MySQL] C --> D[用户访问大屏固定地址?code=大屏编码] D --> E[前端渲染引擎加载] E --> F[引擎请求后端获取对应JSON配置] F --> G[引擎解析JSON,动态渲染组件布局] G --> H[引擎按JSON中的数据源规则拉取/订阅实时数据] H --> I[数据更新时,前端无刷新更新组件内容]
二、后端实现(SpringBoot)
1. 技术栈与依赖
- 核心框架:SpringBoot 2.7.10
- 数据存储:MySQL + MyBatis-Plus
- 实时通信:WebSocket
- 其他:Lombok、FastJSON、跨域支持
2. 核心依赖(pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.10</version>
<relativePath/>
</parent>
<groupId>com.dashboard</groupId>
<artifactId>screen-editor</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>拖拽式大屏后端</name>
<dependencies>
<!-- SpringBoot核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JSON处理 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</dependency>
<!-- 跨域支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>3. 配置文件(application.yml)
spring:
datasource:
url: jdbc:mysql://localhost:3306/screen_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
jackson:
default-property-inclusion: non_null
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: com.dashboard.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
server:
port: 8080
servlet:
context-path: /api4. 核心实体类
大屏配置实体(ScreenConfig. java)
package com.dashboard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 大屏配置实体:存储拖拽后的布局、组件属性、数据源规则等
*/
@Data
@TableName("screen_config")
public class ScreenConfig {
/** 主键ID */
@TableId(type = IdType.AUTO)
private Long id;
/** 大屏名称 */
private String screenName;
/** 大屏唯一编码 */
private String screenCode;
/** 布局配置(JSON字符串) */
private String layoutConfig;
/** 备注 */
private String remark;
/** 创建时间 */
private LocalDateTime createTime;
/** 更新时间 */
private LocalDateTime updateTime;
}5. 数据访问层(Mapper)
package com.dashboard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.dashboard.entity.ScreenConfig;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ScreenConfigMapper extends BaseMapper<ScreenConfig> {
}6. 业务层(Service)
接口(ScreenConfigService.java)
package com.dashboard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.dashboard.entity.ScreenConfig;
public interface ScreenConfigService extends IService<ScreenConfig> {
}
实现类(ScreenConfigServiceImpl.java)
package com.dashboard.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.dashboard.entity.ScreenConfig;
import com.dashboard.mapper.ScreenConfigMapper;
import com.dashboard.service.ScreenConfigService;
import org.springframework.stereotype.Service;
@Service
public class ScreenConfigServiceImpl extends ServiceImpl<ScreenConfigMapper, ScreenConfig> implements ScreenConfigService {
}
7. 控制器层
大屏配置接口(ScreenConfigController.java)
package com.dashboard.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.dashboard.entity.ScreenConfig;
import com.dashboard.service.ScreenConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
/**
* 大屏配置增删改查接口
*/
@RestController
@RequestMapping("/screen")
@CrossOrigin // 允许跨域
public class ScreenConfigController {
@Autowired
private ScreenConfigService screenConfigService;
/**
* 保存/更新大屏配置
*/
@PostMapping("/save")
public String saveScreenConfig(@RequestBody ScreenConfig screenConfig) {
if (screenConfig.getId() == null) {
screenConfig.setCreateTime(LocalDateTime.now());
}
screenConfig.setUpdateTime(LocalDateTime.now());
screenConfigService.saveOrUpdate(screenConfig);
return "success";
}
/**
* 根据编码查询大屏配置(预览大屏用)
*/
@GetMapping("/getByCode/[code]")
public ScreenConfig getByCode(@PathVariable String code) {
QueryWrapper<ScreenConfig> wrapper = new QueryWrapper<>();
wrapper.eq("screen_code", code);
return screenConfigService.getOne(wrapper);
}
/**
* 分页查询大屏列表
*/
@GetMapping("/list")
public IPage<ScreenConfig> getScreenList(@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
Page<ScreenConfig> page = new Page<>(pageNum, pageSize);
return screenConfigService.page(page);
}
/**
* 删除大屏配置
*/
@DeleteMapping("/delete/{id}")
public String deleteScreen(@PathVariable Long id) {
screenConfigService.removeById(id);
return "success";
}
}
大屏 数据接口(Screen DataController. java)
package com.dashboard.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 大屏组件实时数据接口
*/
@RestController
@RequestMapping("/data")
@CrossOrigin
public class ScreenDataController {
/**
* 销售额趋势接口(模拟业务数据)
*/
@GetMapping("/sales/trend")
public Map<String, Object> getSalesTrend(
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
// 模拟数据库查询结果
List<Map<String, Object>> dataList = new ArrayList<>();
String[] dates = {"2026-03-01", "2026-03-02", "2026-03-03", "2026-03-04", "2026-03-05"};
double[] amounts = {12000, 15000, 13500, 18000, 21000};
for (int i = 0; i < dates.length; i++) {
Map<String, Object> item = new HashMap<>();
item.put("date", dates[i]);
item.put("amount", amounts[i]);
dataList.add(item);
}
// 标准返回格式
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "success");
result.put("data", dataList);
return result;
}
/**
* 实时在线人数接口
*/
@GetMapping("/real-time/online-count")
public Map<String, Object> getOnlineCount() {
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "success");
// 模拟实时变化的数值
result.put("data", Math.round(Math.random() * 1000 + 5000));
return result;
}
}WebSocket 实时推送(ScreenWebSocket. java)
package com.dashboard.controller;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArraySet;
@ServerEndpoint("/websocket/screen-data")
@Component
public class ScreenWebSocket {
// 存储所有连接的客户端
private static CopyOnWriteArraySet<ScreenWebSocket> webSocketSet = new CopyOnWriteArraySet<>();
private Session session;
@OnOpen
public void onOpen(Session session) {
this.session = session;
webSocketSet.add(this);
System.out.println("新客户端连接:" + session.getId());
}
@OnClose
public void onClose() {
webSocketSet.remove(this);
System.out.println("客户端断开连接:" + session.getId());
}
@OnMessage
public void onMessage(String message, Session session) {
System.out.println("收到客户端消息:" + message);
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
// 群发消息
public static void sendAllMessage(String message) {
for (ScreenWebSocket item : webSocketSet) {
try {
item.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 模拟实时数据推送(实际业务替换为真实数据源)
public static void mockRealTimeData() {
new Thread(() -> {
while (true) {
try {
Thread.sleep(1000); // 1秒推送一次
int onlineCount = (int) (Math.random() * 1000 + 5000);
String json = "{\"type\":\"onlineCount\",\"value\":" + onlineCount + "}";
sendAllMessage(json);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}8. 启动类
package com.dashboard;
import com.dashboard.controller.ScreenWebSocket;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.dashboard.mapper")
public class ScreenEditorApplication implements CommandLineRunner {
public static void main(String[] args) {
SpringApplication.run(ScreenEditorApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
// 启动WebSocket模拟数据推送
ScreenWebSocket.mockRealTimeData();
}
}
三、前端实现(Vue3)
1. 技术栈与依赖
- 核心框架:Vue3
- UI 组件:Element Plus
- 拖拽缩放:vue3-draggable-resizable
- 图表:ECharts
- 网络请求:Axios
2. 项目初始化与依赖安装
# 创建Vue项目 vue create screen-editor-front # 进入项目目录 cd screen-editor-front # 安装核心依赖 npm install element-plus vue3-draggable-resizable axios echarts
3. 全局配置(main.js)
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import axios from 'axios'
import router from './router'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
// 配置全局Axios
app.config.globalProperties.$axios = axios.create({
baseURL: 'http://localhost:8080/api',
timeout: 5000
})
app.mount('#app')4. 路由配置(router/index.js)
import { createRouter, createWebHistory } from 'vue-router'
import ScreenEditor from '../views/ScreenEditor.vue'
import ScreenRenderer from '../views/ScreenRenderer.vue'
const routes = [
{
path: '/editor',
name: 'ScreenEditor',
component: ScreenEditor
},
{
path: '/screen',
name: 'ScreenRenderer',
component: ScreenRenderer
}
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router
5. 大屏编辑页面(ScreenEditor.vue)
<template>
<div class="screen-editor">
<!-- 顶部操作栏 -->
<div class="editor-header">
<el-input v-model="screenName" placeholder="请输入大屏名称" style="width: 300px; margin-right: 10px;"></el-input>
<el-input v-model="screenCode" placeholder="请输入大屏编码(唯一)" style="width: 300px; margin-right: 10px;"></el-input>
<el-button type="primary" @click="saveConfig">保存配置</el-button>
<el-button type="success" @click="previewScreen">预览大屏</el-button>
</div>
<div class="editor-content">
<!-- 左侧组件库 -->
<div class="component-library">
<div class="component-item" v-for="item in componentList" :key="item.type" @dragstart="handleDragStart(item)">
{{ item.name }}
</div>
</div>
<!-- 中间画布 -->
<div class="screen-canvas" @drop="handleDrop" @dragover="handleDragOver">
<vue3-draggable-resizable
v-for="(component, index) in canvasComponents"
:key="component.id"
:x="component.x"
:y="component.y"
:width="component.width"
:height="component.height"
@resize="(e) => handleResize(e, index)"
@drag="(e) => handleDrag(e, index)"
>
<div class="component-content">
{{ component.name }}
<el-button size="mini" @click="openConfigDialog(index)">配置</el-button>
</div>
</vue3-draggable-resizable>
</div>
<!-- 右侧属性配置面板 -->
<div class="config-panel" v-if="activeComponent">
<h3>{{ activeComponent.name }} 属性配置</h3>
<!-- 基础样式配置 -->
<el-form :model="activeComponent.props" label-width="80px">
<el-form-item label="背景色">
<el-color-picker v-model="activeComponent.props.bgColor"></el-color-picker>
</el-form-item>
<el-form-item label="字体大小">
<el-input-number v-model="activeComponent.props.fontSize" :min="12" :max="32"></el-input-number>
</el-form-item>
</el-form>
<!-- 数据配置 -->
<el-divider content-position="left">数据配置</el-divider>
<el-form :model="activeComponent.props.dataConfig" label-width="80px">
<el-form-item label="数据源类型">
<el-select v-model="activeComponent.props.dataConfig.dataSourceType">
<el-option label="API接口" value="api"></el-option>
<el-option label="WebSocket" value="websocket"></el-option>
</el-select>
</el-form-item>
<el-form-item label="接口地址" v-if="activeComponent.props.dataConfig.dataSourceType === 'api'">
<el-input v-model="activeComponent.props.dataConfig.apiUrl" placeholder="如:/data/sales/trend"></el-input>
</el-form-item>
<el-form-item label="刷新策略" v-if="activeComponent.props.dataConfig.dataSourceType === 'api'">
<el-select v-model="activeComponent.props.dataConfig.refreshType">
<el-option label="仅一次" value="once"></el-option>
<el-option label="定时刷新" value="interval"></el-option>
</el-select>
</el-form-item>
<el-form-item label="刷新间隔(ms)" v-if="activeComponent.props.dataConfig.refreshType === 'interval'">
<el-input-number v-model="activeComponent.props.dataConfig.refreshInterval" :min="1000" :max="30000"></el-input-number>
</el-form-item>
<el-form-item label="字段映射(X轴)" v-if="activeComponent.type === 'chart'">
<el-input v-model="activeComponent.props.dataConfig.fieldMap.xAxis"></el-input>
</el-form-item>
<el-form-item label="字段映射(Y轴)" v-if="activeComponent.type === 'chart'">
<el-input v-model="activeComponent.props.dataConfig.fieldMap.yAxis"></el-input>
</el-form-item>
<el-form-item label="数据主题" v-if="activeComponent.props.dataConfig.dataSourceType === 'websocket'">
<el-input v-model="activeComponent.props.dataConfig.topic" placeholder="如:onlineCount"></el-input>
</el-form-item>
</el-form>
</div>
</div>
<!-- 组件配置弹窗 -->
<el-dialog title="组件配置" v-model="configDialogVisible" width="500px">
<el-form :model="activeComponent.props" label-width="80px">
<el-form-item label="组件名称">
<el-input v-model="activeComponent.name"></el-input>
</el-form-item>
<el-form-item label="宽度">
<el-input-number v-model="activeComponent.width" :min="100" :max="1000"></el-input-number>
</el-form-item>
<el-form-item label="高度">
<el-input-number v-model="activeComponent.height" :min="100" :max="800"></el-input-number>
</el-form-item>
</el-form>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import Vue3DraggableResizable from 'vue3-draggable-resizable'
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
import { ElMessage } from 'element-plus'
import axios from 'axios'
// 大屏基础信息
const screenName = ref('默认大屏')
const screenCode = ref('DEFAULT_SCREEN')
// 组件库列表
const componentList = ref([
{ type: 'text', name: '文本组件' },
{ type: 'chart', name: '图表组件' },
{ type: 'table', name: '表格组件' }
])
// 画布组件列表
const canvasComponents = reactive([])
// 激活的组件
const activeComponent = ref(null)
// 配置弹窗状态
const configDialogVisible = ref(false)
// 拖拽开始
const handleDragStart = (component) => {
event.dataTransfer.setData('component', JSON.stringify(component))
}
// 拖拽悬浮
const handleDragOver = (event) => {
event.preventDefault()
}
// 拖拽放下
const handleDrop = (event) => {
event.preventDefault()
const component = JSON.parse(event.dataTransfer.getData('component'))
const canvas = event.target.closest('.screen-canvas')
const rect = canvas.getBoundingClientRect()
const x = event.clientX - rect.left - 100
const y = event.clientY - rect.top - 50
canvasComponents.push({
id: Date.now(),
type: component.type,
name: component.name,
x,
y,
width: 200,
height: 150,
props: {
bgColor: '#ffffff',
fontSize: 14,
dataConfig: {
dataSourceType: 'api',
apiUrl: '',
refreshType: 'once',
refreshInterval: 5000,
params: {},
fieldMap: {
xAxis: 'date',
yAxis: 'amount',
seriesName: '数据'
},
topic: ''
}
}
})
}
// 组件缩放
const handleResize = (e, index) => {
canvasComponents[index].width = e.width
canvasComponents[index].height = e.height
}
// 组件拖拽
const handleDrag = (e, index) => {
canvasComponents[index].x = e.x
canvasComponents[index].y = e.y
}
// 打开配置弹窗
const openConfigDialog = (index) => {
activeComponent.value = canvasComponents[index]
configDialogVisible.value = true
}
// 保存配置
const saveConfig = () => {
if (!screenCode.value) {
ElMessage.error('请输入大屏编码!')
return
}
const params = {
screenName: screenName.value,
screenCode: screenCode.value,
layoutConfig: JSON.stringify(canvasComponents),
remark: '拖拽编辑的大屏'
}
axios.post('/screen/save', params).then(res => {
if (res.data === 'success') {
ElMessage.success('配置保存成功!')
} else {
ElMessage.error('配置保存失败!')
}
}).catch(err => {
ElMessage.error('接口调用失败:' + err.message)
})
}
// 预览大屏
const previewScreen = () => {
if (!screenCode.value) {
ElMessage.error('请输入大屏编码!')
return
}
window.open(`/screen?code=${screenCode.value}`, '_blank')
}
</script>
<style scoped>
.screen-editor {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
.editor-header {
height: 60px;
line-height: 60px;
padding: 0 20px;
border-bottom: 1px solid #e6e6e6;
display: flex;
align-items: center;
}
.editor-content {
flex: 1;
display: flex;
}
.component-library {
width: 200px;
border-right: 1px solid #e6e6e6;
padding: 20px;
}
.component-item {
height: 40px;
line-height: 40px;
text-align: center;
border: 1px solid #e6e6e6;
border-radius: 4px;
margin-bottom: 10px;
cursor: move;
}
.screen-canvas {
flex: 1;
background-color: #f5f5f5;
position: relative;
overflow: auto;
}
.config-panel {
width: 300px;
border-left: 1px solid #e6e6e6;
padding: 20px;
}
.component-content {
width: 100%;
height: 100%;
border: 1px solid #409eff;
border-radius: 4px;
padding: 10px;
background-color: #fff;
}
</style>6. 通用大屏渲染引擎(ScreenRenderer.vue)
<template>
<div class="screen-renderer" ref="canvasRef">
<!-- 动态渲染组件 -->
<component
v-for="(item, index) in componentList"
:key="item.id"
:is="getComponentType(item.type)"
:component-config="item"
:index="index"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import axios from 'axios'
// 导入子组件
import TextComponent from '../components/TextComponent.vue'
import ChartComponent from '../components/ChartComponent.vue'
import TableComponent from '../components/TableComponent.vue'
// 画布DOM
const canvasRef = ref(null)
// 组件列表
const componentList = reactive([])
// 定时器池
const timerPool = reactive([])
// WebSocket实例
let wsInstance = null
// 初始化
const init = async () => {
// 获取URL中的大屏编码
const screenCode = new URLSearchParams(window.location.search).get('code')
if (!screenCode) {
alert('请传入大屏编码!')
return
}
// 加载大屏配置
try {
const res = await axios.get(`/screen/getByCode/${screenCode}`)
const layoutConfig = JSON.parse(res.data.layoutConfig)
componentList.splice(0, componentList.length, ...layoutConfig)
// 初始化组件数据
initComponentData()
} catch (err) {
console.error('加载配置失败:', err)
}
}
// 匹配组件类型
const getComponentType = (type) => {
const componentMap = {
text: TextComponent,
chart: ChartComponent,
table: TableComponent
}
return componentMap[type] || TextComponent
}
// 初始化组件数据
const initComponentData = () => {
componentList.forEach((component, index) => {
const { dataConfig } = component.props || {}
if (!dataConfig) return
// API接口数据源
if (dataConfig.dataSourceType === 'api') {
const fetchData = async () => {
try {
const res = await axios.get(dataConfig.apiUrl, { params: dataConfig.params })
component.realData = res.data.data
} catch (err) {
component.realData = '加载失败'
}
}
// 立即请求
fetchData()
// 定时刷新
if (dataConfig.refreshType === 'interval') {
const timer = setInterval(fetchData, dataConfig.refreshInterval || 5000)
timerPool.push(timer)
}
}
// WebSocket数据源
else if (dataConfig.dataSourceType === 'websocket') {
if (!wsInstance) {
wsInstance = new WebSocket('ws://localhost:8080/api/websocket/screen-data')
wsInstance.onmessage = (e) => {
const data = JSON.parse(e.data)
if (component.props.dataConfig.topic === data.type) {
component.realData = data.value
}
}
}
}
})
}
// 生命周期:挂载
onMounted(() => {
init()
// 适配分辨率
canvasRef.value.style.width = window.innerWidth + 'px'
canvasRef.value.style.height = window.innerHeight + 'px'
})
// 生命周期:卸载
onUnmounted(() => {
// 清除定时器
timerPool.forEach(timer => clearInterval(timer))
// 关闭WebSocket
if (wsInstance) wsInstance.close()
})
</script>
<style scoped>
.screen-renderer {
width: 100vw;
height: 100vh;
position: relative;
background-color: #000;
overflow: hidden;
}
</style>
7. 子组件实现
文本组件(TextComponent.vue)
<template>
<div
class="text-component"
:style="{
position: 'absolute',
left: componentConfig.x + 'px',
top: componentConfig.y + 'px',
width: componentConfig.width + 'px',
height: componentConfig.height + 'px',
backgroundColor: componentConfig.props.bgColor,
fontSize: componentConfig.props.fontSize + 'px',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #409eff'
}"
>
{{ componentConfig.name }}:{{ componentConfig.realData || '加载中...' }}
</div>
</template>
<script setup>
const props = defineProps({
componentConfig: {
type: Object,
required: true
},
index: {
type: Number,
required: true
}
})
</script>
<style scoped>
.text-component {
transition: all 0.2s ease;
}
</style>
图表组件(ChartComponent.vue)
<template>
<div
class="chart-component"
:style="{
position: 'absolute',
left: componentConfig.x + 'px',
top: componentConfig.y + 'px',
width: componentConfig.width + 'px',
height: componentConfig.height + 'px',
backgroundColor: componentConfig.props.bgColor
}"
>
<div ref="chartRef" class="echarts-container" style="width: 100%; height: 100%;"></div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
componentConfig: {
type: Object,
required: true
}
})
const chartRef = ref(null)
let chartInstance = null
// 初始化图表
const initChart = () => {
if (!chartRef.value || !props.componentConfig.realData) return
// 创建ECharts实例
chartInstance = echarts.init(chartRef.value)
// 解析字段映射
const { fieldMap } = props.componentConfig.props.dataConfig || {}
const xAxisField = fieldMap?.xAxis || 'date'
const yAxisField = fieldMap?.yAxis || 'amount'
// 构造数据
const xData = props.componentConfig.realData.map(item => item[xAxisField])
const yData = props.componentConfig.realData.map(item => item[yAxisField])
// 图表配置
const option = {
title: {
text: props.componentConfig.name,
textStyle: { color: '#fff' }
},
xAxis: {
type: 'category',
data: xData,
axisLabel: { color: '#fff' }
},
yAxis: {
type: 'value',
axisLabel: { color: '#fff' }
},
series: [{
type: 'line',
data: yData,
itemStyle: { color: '#409eff' }
}],
tooltip: { trigger: 'axis' },
backgroundColor: 'transparent'
}
// 渲染图表
chartInstance.setOption(option)
}
// 监听数据变化
watch(() => props.componentConfig.realData, () => {
if (chartInstance) {
chartInstance.dispose()
}
initChart()
})
// 挂载时初始化
onMounted(() => {
initChart()
// 自适应窗口大小
window.addEventListener('resize', () => {
if (chartInstance) chartInstance.resize()
})
})
</script>
四、部署与运行
1. 后端部署
- 创建 MySQL 数据库screen_db,执行表结构 SQL:
CREATE TABLE `screen_config` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `screen_name` varchar(255) DEFAULT NULL COMMENT '大屏名称', `screen_code` varchar(255) DEFAULT NULL COMMENT '大屏编码', `layout_config` text COMMENT '布局配置JSON', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`screen_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- 启动 SpringBoot 应用,端口 8080。
2. 前端部署
- 编译前端代码:
npm run build
- 将
dist目录部署到 Nginx,配置 Nginx 反向代理:
server {
listen 80;
server_name localhost;
# 前端静态文件
location / {
root /usr/local/nginx/html/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
# 反向代理后端接口
location /api {
proxy_pass http://localhost:8080/api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}- 启动 Nginx,访问
http://localhost/editor进入编辑页面,http://localhost/screen?code=xxx进入预览页面。
五、核心总结
- 配置存储:大屏组件的位置、大小、样式、数据源规则以 JSON 格式存储在后端,实现配置与渲染解耦;
- 通用渲染:前端编译一套通用渲染引擎,通过解析 JSON 配置动态渲染组件布局,无需为每个大屏生成独立代码;
- 实时数据:支持 API 短轮询和 WebSocket 推送两种方式,实现组件数据的实时更新;
- 灵活性:拖拽编辑生成的 JSON 配置可任意修改,前端渲染引擎能实时适配新的布局和数据源规则。
该方案兼顾了灵活性、性能和维护性,是拖拽式可编辑大屏的主流实现方式,可根据实际需求扩展组件类型(如地图、视频)、数据源类型(如 MQTT)和权限控制等功能。
到此这篇关于基于SpringBoot + Vue 实现拖拽式可编辑大屏(实时渲染版)的文章就介绍到这了,更多相关SpringBoot Vue 拖拽式可编辑大屏内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
