Skip to content

底稿(不维护):本文是早期知识底稿,主线已改为 工业数字化实验室(Mini MES · 判断 · 实验)。请勿当作当前方案。

03-实时系统设计 ​

前置知识: 01-设备接入与数据采集 | 02-数据平台设计

🎯 学习目标 ​

掌握工业实时系统的核心技术:

  • ✅ WebSocket 双向通信实现
  • ✅ Netty 高性能网关设计
  • ✅ 实时告警引擎(阈值判断、防抖、分级)
  • ✅ 前端实时数据刷新策略
  • ✅ 消息推送可靠性保障

📡 实时通信方案对比 ​

方案延迟双向通信浏览器支持适用场景
WebSocket⭐⭐⭐⭐⭐✅✅ 现代浏览器实时监控、SCADA
SSE⭐⭐⭐⭐❌ 单向✅ 现代浏览器服务端推送通知
Long Polling⭐⭐✅✅ 所有浏览器兼容旧浏览器
MQTT over WS⭐⭐⭐⭐⭐✅✅ 需库支持IoT 设备管理

推荐: WebSocket(低延迟 + 双向通信 + 广泛支持)


🔌 WebSocket 实现 ​

1. Spring Boot 集成 WebSocket ​

xml
<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2. WebSocket 配置类 ​

java
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    
    @Autowired
    private DeviceDataWebSocketHandler webSocketHandler;
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler, "/ws/device")
                .setAllowedOrigins("*");  // 生产环境应限制域名
        
        // 支持 SockJS 降级(兼容旧浏览器)
        registry.addHandler(webSocketHandler, "/ws/device-sockjs")
                .withSockJS();
    }
}

3. WebSocket 处理器 ​

java
@Component
@Slf4j
@ChannelHandler.Sharable
public class DeviceDataWebSocketHandler extends TextWebSocketHandler {
    
    /** 存储会话:deviceId -> Session */
    private final Map<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();
    
    /** 订阅关系:deviceId -> Set<Session> */
    private final Map<String, Set<WebSocketSession>> subscriptions = new ConcurrentHashMap<>();
    
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("WebSocket 连接建立: {}", session.getId());
        
        // 从 URL 参数中获取设备 ID
        String deviceId = getDeviceIdFromSession(session);
        
        if (deviceId != null) {
            // 注册会话
            sessionMap.put(session.getId(), session);
            
            // 添加订阅
            subscriptions.computeIfAbsent(deviceId, k -> ConcurrentHashMap.newKeySet())
                        .add(session);
            
            log.info("设备 {} 订阅成功,当前订阅数: {}", 
                deviceId, subscriptions.get(deviceId).size());
            
            // 发送欢迎消息
            sendTextMessage(session, JSON.toJSONString(Map.of(
                "type", "welcome",
                "message", "连接成功",
                "deviceId", deviceId
            )));
        } else {
            session.close(CloseStatus.BAD_DATA);
        }
    }
    
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        try {
            // 解析客户端消息
            Map<String, Object> msg = JSON.parseObject(message.getPayload(), Map.class);
            String action = (String) msg.get("action");
            
            switch (action) {
                case "subscribe":
                    // 订阅设备
                    String deviceId = (String) msg.get("deviceId");
                    subscribeDevice(session, deviceId);
                    break;
                    
                case "unsubscribe":
                    // 取消订阅
                    deviceId = (String) msg.get("deviceId");
                    unsubscribeDevice(session, deviceId);
                    break;
                    
                default:
                    log.warn("未知操作: {}", action);
            }
            
        } catch (Exception e) {
            log.error("处理 WebSocket 消息失败", e);
        }
    }
    
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        log.info("WebSocket 连接关闭: {}, 状态: {}", session.getId(), status);
        
        // 清理会话
        sessionMap.remove(session.getId());
        
        // 清理订阅关系
        for (Set<WebSocketSession> sessions : subscriptions.values()) {
            sessions.remove(session);
        }
    }
    
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) {
        log.error("WebSocket 传输错误: {}", session.getId(), exception);
    }
    
    /**
     * 向指定设备的所有订阅者推送数据
     */
    public void pushToDevice(String deviceId, DeviceData data) {
        Set<WebSocketSession> sessions = subscriptions.get(deviceId);
        
        if (sessions == null || sessions.isEmpty()) {
            return;
        }
        
        String jsonData = JSON.toJSONString(Map.of(
            "type", "data",
            "deviceId", deviceId,
            "timestamp", System.currentTimeMillis(),
            "data", data
        ));
        
        // 并行推送
        sessions.parallelStream().forEach(session -> {
            try {
                if (session.isOpen()) {
                    session.sendMessage(new TextMessage(jsonData));
                }
            } catch (IOException e) {
                log.error("推送数据失败", e);
                // 移除失效会话
                sessions.remove(session);
            }
        });
    }
    
    /**
     * 广播告警消息
     */
    public void broadcastAlarm(AlarmMessage alarm) {
        String jsonData = JSON.toJSONString(Map.of(
            "type", "alarm",
            "timestamp", System.currentTimeMillis(),
            "alarm", alarm
        ));
        
        sessionMap.values().parallelStream().forEach(session -> {
            try {
                if (session.isOpen()) {
                    session.sendMessage(new TextMessage(jsonData));
                }
            } catch (IOException e) {
                log.error("广播告警失败", e);
            }
        });
    }
    
    private void subscribeDevice(WebSocketSession session, String deviceId) {
        subscriptions.computeIfAbsent(deviceId, k -> ConcurrentHashMap.newKeySet())
                    .add(session);
        
        log.info("会话 {} 订阅设备 {}", session.getId(), deviceId);
    }
    
    private void unsubscribeDevice(WebSocketSession session, String deviceId) {
        Set<WebSocketSession> sessions = subscriptions.get(deviceId);
        if (sessions != null) {
            sessions.remove(session);
        }
    }
    
    private String getDeviceIdFromSession(WebSocketSession session) {
        // 从 URI 查询参数中获取
        String query = session.getUri().getQuery();
        if (query != null) {
            String[] params = query.split("&");
            for (String param : params) {
                if (param.startsWith("deviceId=")) {
                    return param.substring("deviceId=".length());
                }
            }
        }
        return null;
    }
    
    private void sendTextMessage(WebSocketSession session, String text) {
        try {
            session.sendMessage(new TextMessage(text));
        } catch (IOException e) {
            log.error("发送消息失败", e);
        }
    }
}

4. 前端 WebSocket 客户端 ​

javascript
// useDeviceWebSocket.js - Vue Composition API
import { ref, onMounted, onUnmounted } from 'vue';

export function useDeviceWebSocket(deviceId) {
  const ws = ref(null);
  const deviceData = ref(null);
  const alarms = ref([]);
  const isConnected = ref(false);
  
  const connect = () => {
    const wsUrl = `ws://localhost:8080/ws/device?deviceId=${deviceId}`;
    ws.value = new WebSocket(wsUrl);
    
    ws.value.onopen = () => {
      console.log('WebSocket 连接成功');
      isConnected.value = true;
      
      // 订阅设备数据
      ws.value.send(JSON.stringify({
        action: 'subscribe',
        deviceId: deviceId
      }));
    };
    
    ws.value.onmessage = (event) => {
      const message = JSON.parse(event.data);
      
      switch (message.type) {
        case 'welcome':
          console.log('欢迎消息:', message.message);
          break;
          
        case 'data':
          // 更新设备数据
          deviceData.value = message.data;
          break;
          
        case 'alarm':
          // 添加告警
          alarms.value.unshift(message.alarm);
          // 限制告警数量
          if (alarms.value.length > 50) {
            alarms.value.pop();
          }
          break;
      }
    };
    
    ws.value.onerror = (error) => {
      console.error('WebSocket 错误:', error);
      isConnected.value = false;
    };
    
    ws.value.onclose = () => {
      console.log('WebSocket 连接关闭');
      isConnected.value = false;
      
      // 自动重连(指数退避)
      setTimeout(() => {
        console.log('尝试重新连接...');
        connect();
      }, 3000);
    };
  };
  
  const disconnect = () => {
    if (ws.value) {
      ws.value.close();
    }
  };
  
  onMounted(() => {
    connect();
  });
  
  onUnmounted(() => {
    disconnect();
  });
  
  return {
    deviceData,
    alarms,
    isConnected
  };
}
vue
<!-- DeviceMonitor.vue -->
<template>
  <div>
    <el-tag :type="isConnected ? 'success' : 'danger'">
      {{ isConnected ? '已连接' : '未连接' }}
    </el-tag>
    
    <!-- 实时数据展示 -->
    <el-card v-if="deviceData">
      <el-descriptions title="设备实时数据" :column="2" border>
        <el-descriptions-item label="温度">
          {{ deviceData.temperature }}°C
        </el-descriptions-item>
        <el-descriptions-item label="压力">
          {{ deviceData.pressure }} MPa
        </el-descriptions-item>
        <el-descriptions-item label="电流">
          {{ deviceData.current }} A
        </el-descriptions-item>
        <el-descriptions-item label="电压">
          {{ deviceData.voltage }} V
        </el-descriptions-item>
      </el-descriptions>
    </el-card>
    
    <!-- 告警列表 -->
    <el-card class="mt-4">
      <template #header>
        <span>实时告警</span>
      </template>
      <el-timeline>
        <el-timeline-item
          v-for="alarm in alarms"
          :key="alarm.id"
          :type="getAlarmType(alarm.level)"
          :timestamp="formatTime(alarm.timestamp)"
        >
          {{ alarm.message }}
        </el-timeline-item>
      </el-timeline>
    </el-card>
  </div>
</template>

<script setup>
import { useDeviceWebSocket } from '@/composables/useDeviceWebSocket';

const props = defineProps({
  deviceId: {
    type: String,
    required: true
  }
});

const { deviceData, alarms, isConnected } = useDeviceWebSocket(props.deviceId);

const getAlarmType = (level) => {
  const types = {
    'INFO': 'info',
    'WARNING': 'warning',
    'ERROR': 'danger',
    'CRITICAL': 'danger'
  };
  return types[level] || 'info';
};

const formatTime = (timestamp) => {
  return new Date(timestamp).toLocaleString('zh-CN');
};
</script>

⚡ Netty 高性能网关 ​

为什么需要 Netty? ​

对于高并发场景(数千设备同时连接),Spring WebSocket 可能成为瓶颈。Netty 提供:

  • ✅ 更高性能: NIO 模型,单线程可处理数万连接
  • ✅ 更低延迟: 零拷贝、内存池优化
  • ✅ 更灵活: 自定义协议、编解码器

Netty WebSocket 网关实现 ​

java
@Component
@Slf4j
public class NettyWebSocketGateway {
    
    private EventLoopGroup bossGroup;
    private EventLoopGroup workerGroup;
    private Channel serverChannel;
    
    @Value("${netty.websocket.port:8081}")
    private int port;
    
    @PostConstruct
    public void start() throws InterruptedException {
        bossGroup = new NioEventLoopGroup(1);
        workerGroup = new NioEventLoopGroup();
        
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 1024)
                .childOption(ChannelOption.SO_KEEPALIVE, true)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ChannelPipeline pipeline = ch.pipeline();
                        
                        // HTTP 编解码器
                        pipeline.addLast(new HttpServerCodec());
                        pipeline.addLast(new HttpObjectAggregator(65536));
                        
                        // WebSocket 协议处理器
                        pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
                        
                        // 自定义业务处理器
                        pipeline.addLast(new NettyWebSocketHandler());
                    }
                });
        
        serverChannel = bootstrap.bind(port).sync().channel();
        log.info("Netty WebSocket 网关启动成功,端口: {}", port);
    }
    
    @PreDestroy
    public void stop() {
        if (serverChannel != null) {
            serverChannel.close();
        }
        if (workerGroup != null) {
            workerGroup.shutdownGracefully();
        }
        if (bossGroup != null) {
            bossGroup.shutdownGracefully();
        }
        log.info("Netty WebSocket 网关已停止");
    }
}
java
@ChannelHandler.Sharable
@Slf4j
public class NettyWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
    
    private static final Map<String, Channel> channelMap = new ConcurrentHashMap<>();
    private static final Map<String, Set<Channel>> subscriptions = new ConcurrentHashMap<>();
    
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        log.info("客户端连接: {}", ctx.channel().remoteAddress());
        channelMap.put(ctx.channel().id().toString(), ctx.channel());
    }
    
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
        try {
            String text = frame.text();
            Map<String, Object> msg = JSON.parseObject(text, Map.class);
            String action = (String) msg.get("action");
            
            switch (action) {
                case "subscribe":
                    String deviceId = (String) msg.get("deviceId");
                    subscribe(ctx.channel(), deviceId);
                    break;
                    
                case "unsubscribe":
                    deviceId = (String) msg.get("deviceId");
                    unsubscribe(ctx.channel(), deviceId);
                    break;
            }
            
        } catch (Exception e) {
            log.error("处理消息失败", e);
        }
    }
    
    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        log.info("客户端断开: {}", ctx.channel().remoteAddress());
        channelMap.remove(ctx.channel().id().toString());
        
        // 清理订阅关系
        for (Set<Channel> channels : subscriptions.values()) {
            channels.remove(ctx.channel());
        }
    }
    
    /**
     * 向设备推送数据
     */
    public static void pushToDevice(String deviceId, DeviceData data) {
        Set<Channel> channels = subscriptions.get(deviceId);
        if (channels == null || channels.isEmpty()) {
            return;
        }
        
        String jsonData = JSON.toJSONString(Map.of(
            "type", "data",
            "deviceId", deviceId,
            "data", data
        ));
        
        TextWebSocketFrame frame = new TextWebSocketFrame(jsonData);
        
        channels.forEach(channel -> {
            if (channel.isActive()) {
                channel.writeAndFlush(frame.retain());
            }
        });
    }
    
    private void subscribe(Channel channel, String deviceId) {
        subscriptions.computeIfAbsent(deviceId, k -> ConcurrentHashMap.newKeySet())
                    .add(channel);
        log.info("通道 {} 订阅设备 {}", channel.id(), deviceId);
    }
    
    private void unsubscribe(Channel channel, String deviceId) {
        Set<Channel> channels = subscriptions.get(deviceId);
        if (channels != null) {
            channels.remove(channel);
        }
    }
}

🚨 实时告警引擎 ​

告警规则配置 ​

java
@Data
public class AlarmRule {
    
    /** 规则 ID */
    private String ruleId;
    
    /** 设备 ID */
    private String deviceId;
    
    /** 测点名称 */
    private String pointName;
    
    /** 条件类型 */
    private ConditionType conditionType;
    
    /** 阈值 */
    private Double threshold;
    
    /** 告警级别 */
    private AlarmLevel level;
    
    /** 告警消息模板 */
    private String messageTemplate;
    
    /** 是否启用 */
    private boolean enabled;
    
    /** 防抖时间(毫秒) */
    private long debounceTime;
    
    public enum ConditionType {
        GREATER_THAN,    // 大于
        LESS_THAN,       // 小于
        EQUALS,          // 等于
        NOT_EQUALS,      // 不等于
        RANGE            // 范围
    }
    
    public enum AlarmLevel {
        INFO, WARNING, ERROR, CRITICAL
    }
}

告警引擎实现 ​

java
@Component
@Slf4j
public class AlarmEngine {
    
    @Autowired
    private WebSocketService webSocketService;
    
    @Autowired
    private AlarmRecordService alarmRecordService;
    
    /** 告警规则缓存 */
    private final Map<String, List<AlarmRule>> ruleCache = new ConcurrentHashMap<>();
    
    /** 告警防抖:记录最后触发时间 */
    private final Map<String, Long> lastAlarmTime = new ConcurrentHashMap<>();
    
    /**
     * 检查数据是否触发告警
     */
    public void checkAlarms(String deviceId, DeviceData data) {
        List<AlarmRule> rules = ruleCache.get(deviceId);
        if (rules == null || rules.isEmpty()) {
            return;
        }
        
        for (AlarmRule rule : rules) {
            if (!rule.isEnabled()) {
                continue;
            }
            
            // 获取测点值
            Object value = getPointValue(data, rule.getPointName());
            if (value == null) {
                continue;
            }
            
            // 判断是否满足条件
            if (evaluateCondition(value, rule)) {
                // 防抖检查
                if (shouldTriggerAlarm(rule)) {
                    triggerAlarm(rule, value);
                }
            }
        }
    }
    
    /**
     * 评估条件
     */
    private boolean evaluateCondition(Object value, AlarmRule rule) {
        double numValue = convertToDouble(value);
        
        switch (rule.getConditionType()) {
            case GREATER_THAN:
                return numValue > rule.getThreshold();
                
            case LESS_THAN:
                return numValue < rule.getThreshold();
                
            case EQUALS:
                return numValue == rule.getThreshold();
                
            case RANGE:
                // 假设 threshold 存储为 "min,max"
                String[] range = rule.getThreshold().toString().split(",");
                double min = Double.parseDouble(range[0]);
                double max = Double.parseDouble(range[1]);
                return numValue >= min && numValue <= max;
                
            default:
                return false;
        }
    }
    
    /**
     * 触发告警
     */
    private void triggerAlarm(AlarmRule rule, Object value) {
        String key = rule.getRuleId();
        lastAlarmTime.put(key, System.currentTimeMillis());
        
        // 构建告警消息
        AlarmMessage alarm = AlarmMessage.builder()
            .id(UUID.randomUUID().toString())
            .deviceId(rule.getDeviceId())
            .ruleId(rule.getRuleId())
            .level(rule.getLevel())
            .pointName(rule.getPointName())
            .currentValue(value)
            .threshold(rule.getThreshold())
            .message(formatMessage(rule, value))
            .timestamp(LocalDateTime.now())
            .build();
        
        // 保存告警记录
        alarmRecordService.save(alarm);
        
        // WebSocket 推送
        webSocketService.broadcastAlarm(alarm);
        
        // 发送通知(短信/邮件/钉钉)
        notificationService.send(alarm);
        
        log.warn("触发告警: {}", alarm.getMessage());
    }
    
    /**
     * 防抖检查
     */
    private boolean shouldTriggerAlarm(AlarmRule rule) {
        String key = rule.getRuleId();
        Long lastTime = lastAlarmTime.get(key);
        
        if (lastTime == null) {
            return true;
        }
        
        long now = System.currentTimeMillis();
        return (now - lastTime) >= rule.getDebounceTime();
    }
    
    private String formatMessage(AlarmRule rule, Object value) {
        return rule.getMessageTemplate()
            .replace("{device}", rule.getDeviceId())
            .replace("{point}", rule.getPointName())
            .replace("{value}", value.toString())
            .replace("{threshold}", rule.getThreshold().toString());
    }
    
    private Object getPointValue(DeviceData data, String pointName) {
        // 根据测点名称获取对应的值
        return data.getDataPoints().stream()
            .filter(p -> p.getPointName().equals(pointName))
            .map(DeviceData.DataPoint::getValue)
            .findFirst()
            .orElse(null);
    }
    
    private double convertToDouble(Object value) {
        if (value instanceof Number) {
            return ((Number) value).doubleValue();
        }
        return Double.parseDouble(value.toString());
    }
}

📊 前端实时刷新策略 ​

ECharts 实时更新 ​

javascript
// useRealtimeChart.js
import { ref, onMounted, onUnmounted } from 'vue';
import * as echarts from 'echarts';

export function useRealtimeChart(deviceId, pointName, maxDataPoints = 100) {
  const chartRef = ref(null);
  let chartInstance = null;
  const dataHistory = ref([]);
  
  const initChart = () => {
    chartInstance = echarts.init(chartRef.value);
    
    const option = {
      title: {
        text: `${pointName} 实时曲线`
      },
      tooltip: {
        trigger: 'axis'
      },
      xAxis: {
        type: 'time',
        boundaryGap: false
      },
      yAxis: {
        type: 'value'
      },
      series: [{
        name: pointName,
        type: 'line',
        smooth: true,
        showSymbol: false,
        data: dataHistory.value,
        areaStyle: {
          opacity: 0.3
        }
      }]
    };
    
    chartInstance.setOption(option);
  };
  
  const updateData = (timestamp, value) => {
    dataHistory.value.push([timestamp, value]);
    
    // 限制数据点数量
    if (dataHistory.value.length > maxDataPoints) {
      dataHistory.value.shift();
    }
    
    // 更新图表
    chartInstance.setOption({
      series: [{
        data: dataHistory.value
      }]
    });
  };
  
  onMounted(() => {
    initChart();
  });
  
  onUnmounted(() => {
    if (chartInstance) {
      chartInstance.dispose();
    }
  });
  
  return {
    chartRef,
    updateData
  };
}
vue
<template>
  <div ref="chartRef" style="width: 100%; height: 400px;"></div>
</template>

<script setup>
import { useDeviceWebSocket } from '@/composables/useDeviceWebSocket';
import { useRealtimeChart } from '@/composables/useRealtimeChart';

const props = defineProps({
  deviceId: String,
  pointName: {
    type: String,
    default: 'temperature'
  }
});

const { deviceData } = useDeviceWebSocket(props.deviceId);
const { chartRef, updateData } = useRealtimeChart(props.deviceId, props.pointName);

// 监听数据变化,更新图表
watch(deviceData, (newData) => {
  if (newData) {
    const timestamp = Date.now();
    const value = newData[props.pointName];
    updateData(timestamp, value);
  }
});
</script>

💡 最佳实践 ​

1. 心跳检测与断线重连 ​

javascript
// 客户端心跳
let heartbeatTimer = null;

const startHeartbeat = () => {
  heartbeatTimer = setInterval(() => {
    if (ws.value && ws.value.readyState === WebSocket.OPEN) {
      ws.value.send(JSON.stringify({
        action: 'ping',
        timestamp: Date.now()
      }));
    }
  }, 30000); // 每 30 秒发送一次心跳
};

const stopHeartbeat = () => {
  if (heartbeatTimer) {
    clearInterval(heartbeatTimer);
  }
};
java
// 服务端心跳处理
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
    Map<String, Object> msg = JSON.parseObject(message.getPayload(), Map.class);
    
    if ("ping".equals(msg.get("action"))) {
        // 回复 pong
        sendTextMessage(session, JSON.toJSONString(Map.of(
            "type", "pong",
            "timestamp", System.currentTimeMillis()
        )));
        
        // 更新最后活跃时间
        updateLastActiveTime(session.getId());
    }
}

2. 消息确认机制 ​

java
@Data
public class ReliableMessage {
    private String messageId;
    private String type;
    private Object payload;
    private long timestamp;
    private int retryCount;
}

// 发送消息并等待确认
public void sendWithAck(String deviceId, Object data) {
    String messageId = UUID.randomUUID().toString();
    
    ReliableMessage message = new ReliableMessage();
    message.setMessageId(messageId);
    message.setType("data");
    message.setPayload(data);
    message.setTimestamp(System.currentTimeMillis());
    
    // 存储待确认消息
    pendingMessages.put(messageId, message);
    
    // 发送消息
    pushToDevice(deviceId, message);
    
    // 超时重试
    scheduledExecutor.schedule(() -> {
        if (pendingMessages.containsKey(messageId)) {
            log.warn("消息 {} 未收到确认,重试", messageId);
            retryMessage(messageId);
        }
    }, 5, TimeUnit.SECONDS);
}

// 客户端发送确认
public void handleAck(String messageId) {
    pendingMessages.remove(messageId);
}

🔗 相关资源 ​


下一步: 学习 04-工业可视化(SCADA),实现组态画面与实时监控大屏。