从0到1打造一款WebStyle串口调试工具【上篇】

发布时间:2023年12月19日

Tip:No Ego

Some programmers have a huge problem: their own ego. But there is no time for developing an ego. There is no time for being a rockstar.

Who is it who decides about your quality as programmer? You? No. The others? Probably. But can you really compare an Apple with a Banana? No. You are an individual. You cannot compare your whole self with another human being. You can only compare a few facettes.

A facet is nothing what you can be proud of. You are good at Java? Cool. The other guy is not as good as you, but better with bowling. Is Java more important than bowling? It depends on the situation. Probably you earn more money with Java, but the other guy might have more fun in life because of his bowling friends.

Can you really be proud because you are a geek??Programmers with ego don’t learn.?Learn from everybody, from the experienced and from the noobs at the same time.

Kodo Sawaki once said: you are not important.

Think about it.

——The 10 rules of a Zen programmer

零、背景:为什么要造这个轮子

传统的桌面应用大多数是低代码例如 WinForm、WPF、QT 等基于现有的组件进行拖拽式开发,如果没有特别去优化改善界面,用户体验感是很差的,因此衍生出一种嵌入式浏览器方案 CEF,尝试使用现有的前端技术去解决桌面 UI 问题。

基于这个背景下,本文从学习研究的角度实现一个示例以探索 CEF 解决方案在工业领域的应用,现模拟一个工业调试设备的场景,例如从称重机中获取重量、发送亮灯信号、控制电路开关等。串口调试工具用于检验硬件设备是否能够正常运作,如下图所示:

  • Step1、界面上选择设备的串口参数
  • Step2、根据串口参数连接到设备
  • Step3、读取并解析设备返回的数据
  • Step4、将数据回显到界面上
  • Step5、根据界面的数据判断设备运行情况

一、技术栈

Vite + Vue3 + TS + WebSocket+ ElementUI(plus) + .NET Framework 4.7.2 + WPF + SQLITE3,开发环境为 Win10,VS2019,VS Code。?

二、后端设计与实现

开发环境(补充)

1、WS服务器类WebSocketServer

安装 fleck 库,这里使用的版本是 1.2.0,

using Fleck;
using System.Diagnostics;

namespace SerialDevTool.WS
{
    class MyWebSocketServer
    {
        /// <summary>
        /// 运行 WS 服务器
        /// </summary>
        public static void Run()
        {
            FleckLog.Level = LogLevel.Debug;
            var server = new WebSocketServer("ws://127.0.0.1:3000");
            server.Start(socket =>
            {
                // 建立连接
                socket.OnOpen = () =>
                {
                    Debug.WriteLine("客户端连接成功");
                };
                // 关闭连接
                socket.OnClose = () =>
                {
                    Debug.WriteLine("客户端已经关闭");
                };
                // 收到消息
                socket.OnMessage = message =>
                {
                    Debug.WriteLine(string.Format("收到客户端信息:{0}",message));
                    socket.Send(message);
                };
                // 发生错误
                socket.OnError = exception => {
                    Debug.WriteLine(string.Format("发生错误:{0}",exception.Message));
                };
            });
            Debug.WriteLine("WS服务器已启动");
        }
    }
}

这里我们创建了一个 WS 服务器,地址为?ws://127.0.0.1:3000 ,并且实现了?OnOpen、OnClose 、OnMessage、OnError 对应的方法,启动方式如下,

Task.Run(() =>
{
    MyWebSocketServer.Run();
});

使用 Postman 测试 WS,点击左上角?File–> New,选择 WebSocket,

可以看到,Postman 向服务器发送 hello world,服务器也向 Postman 返回 hello world,

2、串口通讯工具类SerialPortlUtil

using System;
using System.Diagnostics;
using System.IO.Ports;

namespace SerialDevTool.Utils
{
    /// <summary>
    /// 串口工具类
    /// </summary>
    public class SerialPortlUtil
    {
        /// <summary>
        /// 默认偏移
        /// </summary>
        private static readonly int OFFSET = 0;
        /// <summary>
        /// 默认数据位
        /// </summary>
        private static readonly int COUNT = 8;
        /// <summary>
        /// 默认超时时间,单位 ms
        /// </summary>
        private static readonly int DEFAULT_TIMEOUT = 500;
        /// <summary>
        /// 默认COM口
        /// </summary>
        private static readonly string DEFAULT_COM = "COM1";
        /// <summary>
        /// 默认波特率
        /// </summary>
        private static readonly int DEFAULT_BAUDRATE = 9600;
        /// <summary>
        /// 默认校验位
        /// </summary>        
        private static readonly Parity DEFAULT_PARITY = Parity.None;
        /// <summary>
        /// 默认数据位
        /// </summary>
        private static readonly int DEFAULT_DATABITS = 8;
        /// <summary>
        /// 默认停止位
        /// </summary>
        private static readonly StopBits DEFAULT_STOPBITS = StopBits.One;

        /// <summary>
        /// 获取默认串口实例
        /// </summary>
        public static SerialPort GetDefaultSerialPortInstance()
        {
            return GetSerialPortInstance(DEFAULT_COM);
        }
        /// <summary>
        /// 获取串口实例
        /// </summary>
        /// <param name="com"></param>
        /// <returns></returns>
        public static SerialPort GetSerialPortInstance(string com)
        {
            // COM1,9600,0,8,1
            if (com.Contains(","))
            {
                string[] comParams = com.Split(new string[] { "," }, StringSplitOptions.None);

                return new SerialPort(comParams[0], int.Parse(comParams[1]), GetParity(comParams[2]), int.Parse(comParams[3]), GetStopBits(comParams[4]))
                {
                    ReadTimeout = DEFAULT_TIMEOUT,
                    WriteTimeout = DEFAULT_TIMEOUT
                };
            }

            // COM1
            return new SerialPort(com, DEFAULT_BAUDRATE, DEFAULT_PARITY, DEFAULT_DATABITS, DEFAULT_STOPBITS)
            {
                ReadTimeout = DEFAULT_TIMEOUT,
                WriteTimeout = DEFAULT_TIMEOUT
            };
        }

        /// <summary>
        /// 解析停止位
        /// </summary>
        /// <param name="stopBits"></param>
        /// <returns></returns>
        public static StopBits GetStopBits(string stopBits)
        {
            switch (stopBits)
            {
                case "0":
                    {
                        return StopBits.None;
                    }
                case "1":
                    {
                        return StopBits.One;
                    }
                case "2":
                    {
                        return StopBits.Two;
                    }
                case "3":
                    {
                        return StopBits.OnePointFive;
                    }
                default:
                    return StopBits.One;
            }
        }

        /// <summary>
        /// 解析校验位
        /// </summary>
        /// <param name="parity"></param>
        /// <returns></returns>
        public static Parity GetParity(string parity)
        {
            switch (parity)
            {
                case "0":
                    {
                        return Parity.None;
                    }
                case "1":
                    {
                        return Parity.Odd;
                    }
                case "2":
                    {
                        return Parity.Even;
                    }
                case "3":
                    {
                        return Parity.Mark;
                    }
                case "4":
                    {
                        return Parity.Space;
                    }
                default:
                    return Parity.None;
            }
        }

        /// <summary>
        /// 写入 8 位字节数据
        /// </summary>
        /// <param name="serialPort"></param>
        /// <param name="buffer"></param>
        public static void Write(SerialPort serialPort, byte[] buffer)
        {
            try
            {
                if (!serialPort.IsOpen)
                {
                    serialPort.Open();
                }
                serialPort.Write(buffer, OFFSET, COUNT);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("Write Exception: {0}", ex.Message));
            }
        }
        /// <summary>
        /// 将指定的字符串和 System.IO.Ports.SerialPort.NewLine 值写入输出缓冲区。
        /// </summary>
        /// <param name="serialPort"></param>
        /// <param name="text"></param>
        public static void WriteLine(SerialPort serialPort, string text)
        {
            try
            {
                if (!serialPort.IsOpen)
                {
                    serialPort.Open();
                }
                serialPort.WriteLine(text);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("WriteLine Exception: {0}", ex.Message));
            }
        }

        /// <summary>
        /// 读 8 位字节数据
        /// </summary>
        /// <param name="serialPort"></param>
        /// <param name="buffer"></param>
        public static int Read(SerialPort serialPort, byte[] buffer)
        {
            try
            {
                if (!serialPort.IsOpen)
                {
                    serialPort.Open();
                }
                return serialPort.Read(buffer, OFFSET, COUNT);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("Read Exception: {0}", ex.Message));
            }
            return 0;
        }

        /// <summary>
        ///  一直读取到输入缓冲区中的 System.IO.Ports.SerialPort.NewLine 值。
        /// </summary>
        /// <param name="serialPort"></param>
        /// <returns></returns>
        public static string ReadLine(SerialPort serialPort)
        {
            string line = "";
            try
            {
                if (!serialPort.IsOpen)
                {
                    serialPort.Open();
                }
                line = serialPort.ReadLine();
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("ReadLine Exception: {0}", ex.Message));
            }
            return line;
        }
    }
}

使用虚拟串口测试,

        /// <summary>
        /// 测试串口通讯
        /// </summary>
        private void TestSerialPort()
        {
            Task.Run(() =>
            {
                SerialPort readCom = SerialPortlUtil.GetSerialPortInstance("COM6");
                int length = 0;
                while (true)
                {
                    byte[] buffer = new byte[8];
                    length = SerialPortlUtil.Read(readCom, buffer);
                    if (length > 0)
                    {
                        Debug.Write("receive: ");
                        for (int i = 0; i < length; i++)
                        {
                            Debug.Write(string.Format("{0} ", buffer[i]));
                        }
                        Debug.Write("\n");
                        Thread.Sleep(1000);
                    }
                }
            });
            Task.Run(() =>
            {
                SerialPort writeCom = SerialPortlUtil.GetSerialPortInstance("COM5");
                while (true)
                {
                    SerialPortlUtil.Write(writeCom, new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 });
                    Thread.Sleep(500);
                }
            });
        }

这里的虚拟串口?COM5 每 500ms 向缓存区写入数据,COM6 每 1000ms 从缓存区中读取数据,SerialPort 读写数据类型均支持 Byte、Char、String,

3、将串口通讯绑定到WS方法

using Fleck;
using SerialDevTool.Utils;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

namespace SerialDevTool.WS
{
    class MyWebSocketServer
    {
        /// <summary>
        /// 写标志
        /// </summary>
        private const string WRITE_FLAG = "##WRITE##";
        private readonly string[] WRITE_FLAG_SEPARATOR = new string[] { WRITE_FLAG };

        /// <summary>
        /// 打开串口标志
        /// </summary>
        private const string OPEN_FLAG = "##OPEN##";
        private readonly string[] OPEN_FLAG_SEPARATOR = new string[] { OPEN_FLAG };
        /// <summary>
        /// 关闭串口标志
        /// </summary>
        private const string CLOSE_FLAG = "##CLOSE##";
        private readonly string[] CLOSE_FLAG_SEPARATOR = new string[] { CLOSE_FLAG };

        /// <summary>
        /// 当前连接的 socket
        /// </summary>
        private Dictionary<string,IWebSocketConnection> _webSocketDic;

        /// <summary>
        /// 当前连接的串口
        /// </summary>
        private Dictionary<string, SerialPort> _serialPortDic;

        public MyWebSocketServer(){
            this._webSocketDic = new Dictionary<string, IWebSocketConnection>();
            this._serialPortDic = new Dictionary<string, SerialPort>();
        }

        /// <summary>
        /// 运行 WS 服务器
        /// </summary>
        public void Run()
        {
            FleckLog.Level = LogLevel.Debug;
            var server = new WebSocketServer("ws://127.0.0.1:3000");
            server.Start(socket =>
            {
                // 建立连接
                socket.OnOpen = () =>
                {
                    Debug.WriteLine("客户端连接成功");
                    // 获取请求路径参数
                    string pathParams = GetPathParams(socket.ConnectionInfo.Path);
                    Debug.WriteLine($"WebSocket opened with path params: {pathParams}");
                };
                // 关闭连接
                socket.OnClose = () =>
                {
                    Debug.WriteLine("客户端已经关闭");
                    // 移除缓存 socket
                    this._webSocketDic.Remove(GetClientKey(socket.ConnectionInfo.ClientIpAddress, socket.ConnectionInfo.ClientPort));
                };
                // 收到消息
                socket.OnMessage = message =>
                {
                    Debug.WriteLine(string.Format("收到客户端信息: {0}", message));

                    // 鉴权
                    if (message.StartsWith("Authorization: Bearer"))
                    {
                        // 进行身份验证逻辑,检查 token 令牌的有效性
                        string token = message.Split(' ')[2];
                        if (ValidateToken(token))
                        {
                            // 身份验证通过,处理业务逻辑
                            // 新增缓存 socket
                            this._webSocketDic.Add(GetClientKey(socket.ConnectionInfo.ClientIpAddress, socket.ConnectionInfo.ClientPort), socket);
                            // 返回信息
                            socket.Send("Authorization: PASS");
                        }
                        else
                        {
                            // 身份验证失败,关闭连接
                            socket.Close();
                        }
                        return;
                    }


                    // 处理信息
                    HandleMessage(GetClientKey(socket.ConnectionInfo.ClientIpAddress, socket.ConnectionInfo.ClientPort), message);


                };
                // 发生错误
                socket.OnError = exception =>
                {
                    Debug.WriteLine(string.Format("发生错误: {0}", exception.Message));
                };
            });

            Debug.WriteLine("WS服务器已启动");
        }
        /// <summary>
        /// 获取客户端 ip + port
        /// </summary>
        /// <param name="socket"></param>
        /// <returns></returns>
        private string GetClientKey(string ip,int port )
        {
            return string.Format("{0}:{1}", ip, port);
        }
        /// <summary>
        /// 获取客户端串口Key
        /// </summary>
        /// <param name="clientKey"></param>
        /// <param name="com"></param>
        /// <returns></returns>
        private string GetClientSerialKey(string clientKey, string com)
        {
            return string.Format("{0}:{1}", clientKey, com);
        }
        /// <summary>
        /// 检验 token 是否有效
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        private bool ValidateToken(string token)
        {
            return true;
        }
        /// <summary>
        /// 解析参数
        /// </summary>
        /// <param name="path"></param>
        /// <returns></returns>
        private string GetPathParams(string path)
        {
            // 解析路径参数
            int startIndex = path.LastIndexOf('/') + 1;
            return path.Substring(startIndex);
        }

        /// <summary>
        /// 发送信息到客户端
        /// </summary>
        /// <param name="clientKey"></param>
        /// <param name="message"></param>
        public void SendMessage(string clientKey,string message)
        {
            if (this._webSocketDic != null && this._webSocketDic.ContainsKey(clientKey))
            {
                Debug.WriteLine(string.Format("发送给客户端{0}: {1}", clientKey, message));
                this._webSocketDic[clientKey].Send(message);
            }
        }
        /// <summary>
        /// 发送信息到客户端
        /// </summary>
        /// <param name="clientKey"></param>
        /// <param name="message"></param>
        public void SendMessage(string clientKey,byte[] message)
        {
            if (this._webSocketDic != null && this._webSocketDic.ContainsKey(clientKey))
            {
                this._webSocketDic[clientKey].Send(message);
            }
        }

        /// <summary>
        /// 处理信息
        /// </summary>
        /// <param name="message"></param>
        private void HandleMessage(string clientKey,string message)
        {
            if (string.IsNullOrEmpty(message))
            {
                return;
            }

            // 串口写入数据
            if (message.Contains(WRITE_FLAG))
            {
                // 将数据报文切割为 COM + DATA
                var data = message.Split(WRITE_FLAG_SEPARATOR, StringSplitOptions.None);
                // 如果串口或者数据为空则不处理
                string com = data[0];
                string text = data[1];
                if (string.IsNullOrEmpty(com) || string.IsNullOrEmpty(text))
                {
                    return;
                }
                // 获取串口实例
                SerialPort writeCom = this._serialPortDic[GetClientSerialKey(clientKey, com)];
                // 写入数据
                SerialPortlUtil.WriteLine(writeCom, text);
                return;
            }

            // 打开串口
            if (message.Contains(OPEN_FLAG))
            {
                // 获取串口
                var data = message.Split(OPEN_FLAG_SEPARATOR, StringSplitOptions.RemoveEmptyEntries);
                // 如果串口为空则不处理
                string com = data[0];
                if (string.IsNullOrEmpty(com))
                {
                    return;
                }
                // 实例化串口
                SerialPort writeCom = SerialPortlUtil.GetSerialPortInstance(com);
                // 打开串口
                writeCom.Open();
                // 缓存串口
                this._serialPortDic.Add(GetClientSerialKey(clientKey, writeCom.PortName), writeCom);
                // 返回信息
                SendMessage(clientKey, string.Format("打开串口成功: {0}", com));
                // 轮询数据
                Task.Run(() => {
                    while (writeCom.IsOpen)
                    {
                        SendMessage(clientKey, writeCom.ReadExisting());
                        Thread.Sleep(500);
                    }
                });
                return;
            }

            // 关闭串口
            if (message.Contains(CLOSE_FLAG))
            {
                // 获取串口
                var data = message.Split(CLOSE_FLAG_SEPARATOR, StringSplitOptions.RemoveEmptyEntries);
                // 如果串口为空则不处理
                string com = data[0];
                if (string.IsNullOrEmpty(com))
                {
                    return;
                }
                // 缓存键名
                string key = GetClientSerialKey(clientKey, com);
                // 获取串口实例
                SerialPort closeCom = this._serialPortDic[key];
                // 关闭串口
                closeCom.Close();
                // 释放资源
                closeCom.Dispose();
                // 移除缓存
                this._serialPortDic.Remove(key);
                // 返回信息
                SendMessage(clientKey, string.Format("关闭串口成功: {0}", com));
                return;
            }
        }

    }
}

三、前端设计与实现

1、界面设计

  1. 顶部为串口的参数选择,提供 OPEN、CLOSE 按钮用于打开、关闭串口;
  2. 中间两个文本框:数据接收区用来输出日志、数据发送区用来输入自定义数据;
  3. 底部提供 SEND、STOP 按钮用于开始发送数据、停止发送数据,勾选自动发送时,会周期发送数据;

2、代码实现

引入 Vue + Vite + ElementUI,新增页面?MainPage.vue ,

<!-- src\components\MainPage.vue -->
<template>
  <el-row :gutter="10">
    <el-col :span="24">
      <el-row justify="space-between" :gutter="10">
        <el-col :span="4">
          <el-select v-model="comValue" clearable placeholder="串口">
            <el-option v-for="item in comOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>
        <el-col :span="4">
          <el-select v-model="baudRateValue" clearable placeholder="波特率">
            <el-option v-for="item in baudRateOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>

        <el-col :span="4">
          <el-select v-model="parityValue" clearable placeholder="校验位">
            <el-option v-for="item in parityOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>
        <el-col :span="4">
          <el-select v-model="dataBitsValue" clearable placeholder="数据位">
            <el-option v-for="item in dataBitsOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>
        <el-col :span="4">
          <el-select v-model="stopBitsValue" clearable placeholder="停止位">
            <el-option v-for="item in stopBitsOptions" :key="item.value" :label="item.label" :value="item.value" />
          </el-select>
        </el-col>
        <el-col :span="4">
          <div style="text-align: right;">
            <el-button @click="OpenCom" type="primary">OPEN</el-button>
            <el-button @click="CloseCom" type="danger">CLOSE</el-button>
          </div>
        </el-col>
      </el-row>
    </el-col>
  </el-row>

  <el-row :gutter="10">
    <el-col :span="24">
      <h3>数据接收区</h3>
      <el-input ref="logTextarea" v-model="receiveTextarea" :rows="8" type="textarea" placeholder="Log" />
    </el-col>
  </el-row>

  <el-row :gutter="10">
    <el-col :span="24">
      <h3>数据发送区</h3>
      <el-input v-model="sendTextarea" :rows="8" type="textarea" placeholder="Please input" />
    </el-col>
  </el-row>

  <el-row justify="space-between" :gutter="10">
    <el-col :span="2">
      <div class="mt-4">
        <el-checkbox v-model="autoSendChecked" label="自动发送" border />
      </div>
    </el-col>
    <el-col :span="6">
      <el-input v-model="interval" placeholder="1000" type="number">
        <template #prepend>
          周期
        </template>
        <template #append>ms</template>
      </el-input>
    </el-col>
    <el-col :span="12">
    </el-col>
    <el-col :span="4">
      <div style="text-align: right;">
        <el-button @click="sendMessage" type="primary">SEND</el-button>
        <el-button @click="stopTimer" type="danger">STOP</el-button>
      </div>
    </el-col>
  </el-row>
</template>

<script lang="ts" setup>
import { ref, onMounted } from 'vue'
// 串口
const comValue = ref('')
// 波特率
const baudRateValue = ref('')
// 校验位
const parityValue = ref('')
// 数据为
const dataBitsValue = ref('')
// 停止位
const stopBitsValue = ref('')
// 发送文本
const sendTextarea = ref('hello world')
// 接收文本
const receiveTextarea = ref('')
// 自动发送的 timer
let timerId: number
// 自动发送时间间隔
const interval = ref(1000)
// 是否自动发送
const autoSendChecked = ref(false)
// 接收数据的文本框
const logTextarea = ref()
// ws 实例
const socket = ref()

// 串口列表
const comOptions = [
  {
    value: 'COM1',
    label: 'COM1',
  },
  {
    value: 'COM2',
    label: 'COM2',
  },
  {
    value: 'COM3',
    label: 'COM3',
  },
  {
    value: 'COM4',
    label: 'COM4',
  },
  {
    value: 'COM5',
    label: 'COM5',
  },
  {
    value: 'COM6',
    label: 'COM6',
  },
  {
    value: 'COM7',
    label: 'COM7',
  },
  {
    value: 'COM8',
    label: 'COM8',
  },
  {
    value: 'COM9',
    label: 'COM9',
  },
  {
    value: 'COM10',
    label: 'COM10',
  },
]
// 波特率列表
const baudRateOptions = [
  {
    value: '300',
    label: '300',
  },
  {
    value: '600',
    label: '600',
  },
  {
    value: '1200',
    label: '1200',
  },
  {
    value: '2400',
    label: '2400',
  },
  {
    value: '4800',
    label: '4800',
  },
  {
    value: '9600',
    label: '9600',
  },
  {
    value: '19200',
    label: '19200',
  },
  {
    value: '38400',
    label: '38400',
  },
  {
    value: '43000',
    label: '43000',
  },
  {
    value: '56000',
    label: '56000',
  },
  {
    value: '57600',
    label: '57600',
  },
  {
    value: '115200',
    label: '115200',
  },
]
// 校验位列表
const parityOptions = [
  {
    value: '0',
    label: 'None',
  },
  {
    value: '1',
    label: 'Odd',
  },
  {
    value: '2',
    label: 'Even',
  },
  {
    value: '3',
    label: 'Mark',
  },
  {
    value: '4',
    label: 'Space',
  }
]
// 数据位列表
const dataBitsOptions = [
  {
    value: '6',
    label: '6',
  },
  {
    value: '7',
    label: '7',
  },
  {
    value: '8',
    label: '8',
  }
]
// 停止位列表
const stopBitsOptions = [
  {
    value: '0',
    label: '0',
  },
  {
    value: '1',
    label: '1',
  },
  {
    value: '2',
    label: '2',
  },
  {
    value: '3',
    label: '3',
  }
]
// 停止自动发送
const stopTimer = () => {
  if (timerId) {
    // 取消之前设置的定时操作
    console.log(timerId)
    clearTimeout(timerId)
  }
}
// 关闭串口
const CloseCom = () => {
  if (socket.value && socket.value.readyState === WebSocket.OPEN && comValue.value.length > 0) {
    socket.value.send(`##CLOSE##${comValue.value}`);
    stopTimer();
  }
}
// 打开串口
const OpenCom = () => {
  if (comValue.value.length == 0 || baudRateValue.value.length == 0 || parityValue.value.length == 0 || dataBitsValue.value.length == 0 || stopBitsValue.value.length == 0) {
    console.log('OpenCom', '未选择串口参数');
    return;
  }
  if (socket.value && socket.value.readyState === WebSocket.OPEN) {
    socket.value.send(`##OPEN##${comValue.value},${baudRateValue.value},${parityValue.value},${dataBitsValue.value},${stopBitsValue.value}`);
  }
}

const connectToServer = () => {
  socket.value = new WebSocket('ws://127.0.0.1:3000/api');

  socket.value.onopen = () => {
    console.log('WebSocket连接已打开')
    receiveTextarea.value = `${getDateTimeString()} => WebSocket连接已打开\n`;
    socket.value.send(`Authorization: Bearer Gx9epQIqlKTHaV7a57eUkGQ02egvT1FhvD0vblqau1ncmB8ZgyNTu29gM6N+UdgoNkQZyPYx490tekmttk6B6q307rY2P+7ADtJ0L4ZUflCTCrihYdFROtMI0ZdHd/zCOw47FE7n9IsChjpHdIvngJ7cvVCtzejC5E0w1lpH/5/Nb0JT3cEqdi6sI7ybePyq+jg5FQwmOloxKHJ8X1GxqxqVX7LgKBvpZsMrTnyZ9gJeWSbRhZXDe5de0TvOabdMvEPHxFaq3nqOM+seFSk1TLG/LRvAwJizetVV/RWCfz9hAFMZ+f2ThCS547zghuXGRqCNsARa/YumRexehpkNZQ==`);
  };

  socket.value.onmessage = (event: any) => {
    // 滚屏到底部
    logTextarea.value.textarea.scrollTop = logTextarea.value.textarea.scrollHeight;

    if (event.data == '') {
      return;
    }
    console.log('收到消息:', event.data);
    receiveTextarea.value += `${getDateTimeString()} => ${event.data}\n`;
  };

  socket.value.onclose = () => {
    console.log('WebSocket连接已关闭');
    stopTimer();
    receiveTextarea.value += `${getDateTimeString()} => WebSocket连接已关闭\n`;
  };
};

const sendMessage = () => {
  
  console.log(autoSendChecked.value);

  if (socket.value && socket.value.readyState === WebSocket.OPEN) {
    // 自动发送
    if (autoSendChecked.value) {
      timerId = setInterval(() => {
        socket.value.send(`${comValue.value}##WRITE##${sendTextarea.value}`);
      }, interval.value);
    } else {
      // 手动发送
      socket.value.send(`${comValue.value}##WRITE##${sendTextarea.value}`);
    }

  }
};


const getDateTimeString = () => {
  // 创建一个新的Date对象
  const date = new Date();

  // 获取年份、月份和日期
  const year = date.getFullYear();
  const month = ("0" + (date.getMonth() + 1)).slice(-2);
  const day = ("0" + date.getDate()).slice(-2);

  // 获取小时、分钟和秒钟
  const hours = ("0" + date.getHours()).slice(-2);
  const minutes = ("0" + date.getMinutes()).slice(-2);
  const seconds = ("0" + date.getSeconds()).slice(-2);

  // 格式化时间字符串
  const formattedTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;

  return formattedTime
}
onMounted(() => {
  connectToServer();
})

</script>

<style>
.el-row {
  margin-bottom: 5px;
}

.el-row:last-child {
  margin-bottom: 0;
}

.el-col {
  border-radius: 4px;
}
</style>

四、运行效果

1、启动 WS 与串口连接

2、串口读写数据

3、停止发送数据

4、关闭串口

五、整合前后端为一个桌面应用

至此,我们可以将后端作为一个服务 SerialPortService 发布到每台电脑,前端界面用 Nginx 发布在一个远程服务器,用户通过浏览器访问到 Web 操作界面,Web 界面通过 WS 访问到本地服务?SerialPortService 。

然而,我们的目标是将前后端整合为一个 EXE 使用。WPF 有自带的浏览器控件,直接使用 WPF 访问 Web 就能实现我们的目标,但这里我们换成 CefSharp 来实现更灵活高效。

1、引入 CefSharp

这里使用的版本是 119.1.20 ,

2、MainWindow.xaml 添加控件挂载点

<Window x:Class="SerialPortDevToolWpfApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:SerialPortDevToolWpfApp"
        mc:Ignorable="d"
        Title="MainWindow" 
        WindowState="Normal"
        WindowStartupLocation="CenterScreen">
    <Grid>
        <Grid x:Name="ChromiumWebBrowserGrid"></Grid>
    </Grid>
</Window>

我们添加了一个名字为 ChromiumWebBrowserGrid 的控件在主窗口界面,用于挂载 Browser?控件,

3、MainWindow.xaml.cs 添加 Browser 控件

using CefSharp.Wpf;
using SerialPortDevToolWpfApp.WS;
using System.Threading.Tasks;
using System.Windows;

namespace SerialPortDevToolWpfApp
{
    /// <summary>
    /// MainWindow.xaml 的交互逻辑
    /// </summary>
    public partial class MainWindow : Window
    {

        /// <summary>
        /// ChromiumWebBrowser
        /// </summary>
        private static ChromiumWebBrowser browser;

        public MainWindow()
        {
            InitializeComponent();
            RunWebSocketServer();
            AddChromiumWebBrowser();
        }

        /// <summary>
        /// 运行 WS 服务
        /// </summary>
        private void RunWebSocketServer()
        {
            Task.Run(() =>
            {
                new MyWebSocketServer().Run();
            });
        }

        /// <summary>
        /// Create a new instance in code or add via the designer
        /// </summary>
        private void AddChromiumWebBrowser()
        {

            // 远程 URL
            browser = new ChromiumWebBrowser("http://localhost:5173");
            this.ChromiumWebBrowserGrid.Children.Add(browser);
        }

    }
}

在构造方法中,我们除了运行 WS 服务(RunWebSocketServer),还将 Browser 控件添加到 ChromiumWebBrowserGrid 控件中(AddChromiumWebBrowser),Browser? 访问的是远程 URL,

4、运行效果(访问远程网页)

5、访问本地 URL

我们也可以将前端打包为静态资源,然后在后端引入静态资源,直接访问本地 URL,

# 打包前端
npm run build

将 dist 文件夹下所有文件复制到 Frontend 文件夹,并注册代理域,

using CefSharp;
using CefSharp.SchemeHandler;
using CefSharp.Wpf;
using System;
using System.IO;
using System.Windows;

namespace SerialPortDevToolWpfApp
{
    /// <summary>
    /// App.xaml 的交互逻辑
    /// </summary>
    public partial class App : Application
    {

        public App()
        {
            InitCefSettings();
        }

        /// <summary>
        /// 初始化 CEF 配置
        /// </summary>
        private static void InitCefSettings()
        {

#if ANYCPU
            CefRuntime.SubscribeAnyCpuAssemblyResolver();
#endif

            // Pseudo code; you probably need more in your CefSettings also.
            var settings = new CefSettings()
            {
                //By default CefSharp will use an in-memory cache, you need to specify a Cache Folder to persist data
                CachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CefSharp\\Cache")
            };

            //Example of setting a command line argument
            //Enables WebRTC
            // - CEF Doesn't currently support permissions on a per browser basis see https://bitbucket.org/chromiumembedded/cef/issues/2582/allow-run-time-handling-of-media-access
            // - CEF Doesn't currently support displaying a UI for media access permissions
            //
            //NOTE: WebRTC Device Id's aren't persisted as they are in Chrome see https://bitbucket.org/chromiumembedded/cef/issues/2064/persist-webrtc-deviceids-across-restart
            settings.CefCommandLineArgs.Add("enable-media-stream");
            //https://peter.sh/experiments/chromium-command-line-switches/#use-fake-ui-for-media-stream
            settings.CefCommandLineArgs.Add("use-fake-ui-for-media-stream");
            //For screen sharing add (see https://bitbucket.org/chromiumembedded/cef/issues/2582/allow-run-time-handling-of-media-access#comment-58677180)
            settings.CefCommandLineArgs.Add("enable-usermedia-screen-capturing");

            // 本地代理域
            settings.RegisterScheme(new CefCustomScheme
            {
                SchemeName = "http",
                DomainName = "serialdevtool.test",
                SchemeHandlerFactory = new FolderSchemeHandlerFactory(rootFolder: @"..\..\..\SerialPortDevToolWpfApp\Frontend",
                            hostName: "serialdevtool.test", //Optional param no hostname/domain checking if null
                            defaultPage: "index.html") //Optional param will default to index.html
            });

            //Perform dependency check to make sure all relevant resources are in our output directory.
            Cef.Initialize(settings, performDependencyCheck: true, browserProcessHandler: null);

        }

    }
}

此时,访问 http://serialdevtool.test 就是访问 Frontend 文件夹下的 index.html 网页,接着配置 Browser 的 URL,

        /// <summary>
        /// Create a new instance in code or add via the designer
        /// </summary>
        private void AddChromiumWebBrowser()
        {
            // 本地代理域
            browser = new ChromiumWebBrowser("http://serialdevtool.test");
            // 远程 URL
            // browser = new ChromiumWebBrowser("http://localhost:5173");
            this.ChromiumWebBrowserGrid.Children.Add(browser);
        }

运行效果如下,

6、运行效果(访问本地资源)

六、应用多开(补充)

1、目前的不足

仅支持单例应用运行,多应用运行会存在 WS 连接问题,试想这样一个场景:

  • Step 1、应用 A 访问串口 1,应用 A 启动 WS 服务器;
  • Step 2、应用 B 访问串口 2,应用 B 不启动 WS 服务器,访问应用 A 的 WS 服务器通讯;
  • Step 3、应用 A 关闭,应用 B 运行,WS 通讯失败;

2、解决对策

  1. 单例应用支持多标签页实现多个窗口;
  2. 将端口 3000 ~ 3099 作为应用端口,每次应用启动时锁住一个端口号,关闭时解锁;

这样无论在单个应用多开窗口还是启动多个应用都不会存在 WS 连接问题。

3、引入 Sqlite 作为嵌入式缓存

安装?System.Data.Sqlite 包,这里使用的版本是 1.0.118,

4、缓存逻辑封装

主要封装五个功能,

  1. 若无缓存表,则创建表
  2. 若无数据,则新增记录
  3. 获取一个可用端口
  4. 锁定端口
  5. 解锁端口
using SerialPortDevToolWpfApp.Constant;
using System;
using System.Data.SQLite;
using System.Text;

namespace SerialPortDevToolWpfApp.Utils
{
    public class MySqliteUtil
    {
        /// <summary>
        /// 生成连接字符串
        /// </summary>
        /// <returns></returns>
        private static string CreateConnectionString()
        {
            SQLiteConnectionStringBuilder connectionString = new SQLiteConnectionStringBuilder();
            connectionString.DataSource = AppDomain.CurrentDomain.SetupInformation.ApplicationBase + SystemConstant.DATABASE_NAME;
            return connectionString.ToString();
        }

        /// <summary>
        /// 创建端口缓存表
        /// Id 为主键,AppWsPort 为 WS 端口,IsUsing 是否在使用
        /// </summary>
        public static void CreatePortCacheTable()
        {
            string connectionString = CreateConnectionString();
            using (SQLiteConnection connection = new SQLiteConnection(connectionString))
            {
                // 打开连接
                connection.Open();

                // 创建表
                using (SQLiteCommand command = new SQLiteCommand(SystemConstant.CREATE_PORT_CACHE_TABLE_SQL, connection))
                {
                    command.ExecuteNonQuery();
                }

                // 新增数据
                StringBuilder cmd = new StringBuilder();
                for (int i = 0; i < SystemConstant.PORT_RANGE; i++)
                {
                    cmd = cmd.AppendFormat(SystemConstant.INSERT_PORT_CACHE_TEMPLATE_SQL, SystemConstant.START_PORT + i, 0, SystemConstant.START_PORT + i);
                }
                using (SQLiteCommand command = new SQLiteCommand(cmd.ToString(), connection))
                {
                    command.ExecuteNonQuery();
                }

                // 关闭连接
                connection.Close();
            }
        }

        /// <summary>
        /// 获取一个可用的端口
        /// </summary>
        /// <returns></returns>
        public static int GetAvailablePort()
        {
            int port = 0;
            string connectionString = CreateConnectionString();
            using (SQLiteConnection connection = new SQLiteConnection(connectionString))
            {
                connection.Open();
                // 查询数据
                using (SQLiteCommand command = new SQLiteCommand(SystemConstant.GET_AVAILABLE_PORT_SQL, connection))
                {
                    using (SQLiteDataReader reader = command.ExecuteReader())
                    {
                        if (reader.Read())
                        {
                            port = Convert.ToInt32(reader[SystemConstant.ROW_APP_WS_PORT]);
                        }
                    }
                    command.Dispose();
                }
                connection.Close();
            }
            return port;
        }

        /// <summary>
        /// 解锁或者锁定一个端口
        /// </summary>
        /// <param name="toLock"> true 锁定,false 解锁</param>
        /// <param name="port"></param>
        public static void LockOrUnLockPort(bool toLock,int port)
        {
            string connectionString = CreateConnectionString();
            using (SQLiteConnection connection = new SQLiteConnection(connectionString))
            {
                connection.Open();

                string cmd = toLock ? SystemConstant.LOCK_PORT_SQL : SystemConstant.UNLOCK_PORT_SQL;

                // 更新数据
                using (SQLiteCommand command = new SQLiteCommand(cmd, connection))
                {
                    command.Parameters.AddWithValue(SystemConstant.SQL_PARAM_APP_WS_PORT, port);
                    command.ExecuteNonQuery();
                }

                connection.Close();
            }
        }
    }
}

常量类、全局参数类封装,


namespace SerialPortDevToolWpfApp.Constant
{
    public class SystemConstant
    {
        /// <summary>
        /// 新增模板 SQL
        /// </summary>
        public const string INSERT_PORT_CACHE_TEMPLATE_SQL = "INSERT INTO PortCache (AppWsPort, IsUsing) SELECT {0},{1} WHERE NOT EXISTS (SELECT 1 FROM PortCache WHERE AppWsPort={2});";
        /// <summary>
        /// 创建表 SQL
        /// </summary>
        public const string CREATE_PORT_CACHE_TABLE_SQL = "CREATE TABLE IF NOT EXISTS PortCache (Id INTEGER PRIMARY KEY, AppWsPort INTEGER, IsUsing INTEGER);";
        /// <summary>
        /// 获取一个可用的端口 SQL
        /// </summary>
        public const string GET_AVAILABLE_PORT_SQL = "SELECT AppWsPort FROM PortCache WHERE IsUsing = 0 LIMIT 1;";
        /// <summary>
        /// 锁定端口 SQL
        /// </summary>
        public const string LOCK_PORT_SQL = "UPDATE PortCache SET IsUsing = 1 WHERE AppWsPort=@AppWsPort;";
        /// <summary>
        /// 解锁端口 SQL
        /// </summary>
        public const string UNLOCK_PORT_SQL = "UPDATE PortCache SET IsUsing = 0 WHERE AppWsPort=@AppWsPort;";

        /// <summary>
        /// 数据库名
        /// </summary>
        public const string DATABASE_NAME = "IkunDB.db";

        /// <summary>
        /// 端口范围
        /// </summary>
        public const int PORT_RANGE = 100;

        /// <summary>
        /// 开始端口
        /// </summary>
        public const int START_PORT = 3000;

        /// <summary>
        /// 数据表字段
        /// </summary>
        public const string ROW_APP_WS_PORT = "AppWsPort";

        /// <summary>
        /// SQL 参数
        /// </summary>
        public const string SQL_PARAM_APP_WS_PORT = "@AppWsPort";
    }
}

namespace SerialPortDevToolWpfApp.Parameters
{
    /// <summary>
    /// app 全局参数
    /// </summary>
    public class GlobalParameters
    {
        /// <summary>
        /// WS 端口
        /// </summary>
        public static int WsPort { get; set; }
    }
}

5、修改 WS 启动逻辑

        /// <summary>
        /// WS URL
        /// </summary>
        /// <returns></returns>
        private string GetWsLocationString()
        {
            int port = MySqliteUtil.GetAvailablePort();

            if (port == 0)
            {
                return "";
            }

            // 锁定当前端口
            MySqliteUtil.LockOrUnLockPort(true, port);
            // 全局端口参数
            GlobalParameters.WsPort = port;

            return string.Format(@"ws://127.0.0.1:{0}", port);
        }

        /// <summary>
        /// 运行 WS 服务器
        /// </summary>
        public void Run()
        {
            FleckLog.Level = LogLevel.Debug;

            string location = GetWsLocationString();

            if (string.IsNullOrEmpty(location))
            {
                Debug.WriteLine("端口 3000 - 3099 内暂无可用端口,WS 启动失败!");
                return;
            }

            var server = new WebSocketServer(location);
            Debug.WriteLine(string.Format("WebSocketServer Port: {0}", server.Port));
        
            // 略
            ......
        }

6、导出 WS 端口

新增 GlobalParametersUtil 类,导出 GetGlobalWsPort 方法,

using SerialPortDevToolWpfApp.Parameters;

namespace SerialPortDevToolWpfApp.Utils
{
    public class GlobalParametersUtil
    {
        /// <summary>
        /// 返回全局 WS 端口
        /// </summary>
        /// <returns></returns>
        public int GetGlobalWsPort()
        {
            return GlobalParameters.WsPort;
        }
    }
}

修改 MainWindow.xaml.cs,新增 ExposeDotnetClass 导出 GlobalParametersUtil 实例,并重写?OnClosing 方法,

using CefSharp;
using CefSharp.JavascriptBinding;
using CefSharp.Wpf;
using SerialPortDevToolWpfApp.Parameters;
using SerialPortDevToolWpfApp.Utils;
using SerialPortDevToolWpfApp.WS;
using System.ComponentModel;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Windows;

namespace SerialPortDevToolWpfApp
{
    /// <summary>
    /// MainWindow.xaml 的交互逻辑
    /// </summary>
    public partial class MainWindow : Window
    {

        private static bool IsSetNameConverter = false;

        /// <summary>
        /// ChromiumWebBrowser
        /// </summary>
        private static ChromiumWebBrowser browser;

        public MainWindow()
        {
            InitializeComponent();
            RunWebSocketServer();
            // 最大化窗口
            this.WindowState = WindowState.Maximized;
            // 添加浏览器控件
            AddChromiumWebBrowser();
        }

        /// <summary>
        /// 运行 WS 服务
        /// </summary>
        private void RunWebSocketServer()
        {
            Task.Run(() =>
            {
                new MyWebSocketServer().Run();
            });
        }

        /// <summary>
        /// Create a new instance in code or add via the designer
        /// </summary>
        private void AddChromiumWebBrowser()
        {
            // 本地代理域
            browser = new ChromiumWebBrowser("http://serialdevtool.test");
            // 远程 URL
            // browser = new ChromiumWebBrowser("http://localhost:5173");
            // 导出 .NET 方法
            ExposeDotnetClass();
            // 将 browser 挂载到页面
            this.ChromiumWebBrowserGrid.Children.Add(browser);
        }


        /// <summary>
        /// 导出类方法
        /// </summary>
        public static void ExposeDotnetClass()
        {
            browser.JavascriptObjectRepository.ResolveObject += (sender, e) =>
            {
                // 注册 GlobalParametersUtil 实例
                DoRegisterDotNetFunc(e.ObjectRepository, e.ObjectName, "globalParametersUtil", new GlobalParametersUtil());

                // 注册其他实例 ...
            };

            browser.JavascriptObjectRepository.ObjectBoundInJavascript += (sender, e) =>
            {
                var name = e.ObjectName;
                Debug.WriteLine($"Object {e.ObjectName} was bound successfully.");
            };

        }
        /// <summary>
        /// 注册 DoNet 实例
        /// </summary>
        /// <param name = "repo" > IJavascriptObjectRepository </ param >
        /// < param name="eventObjectName">事件对象名</param>
        /// <param name = "funcName" > 方法名 </ param >
        /// < param name="objectToBind">需要绑定的DotNet对象</param>
        private static void DoRegisterDotNetFunc(IJavascriptObjectRepository repo, string eventObjectName, string funcName, object objectToBind)
        {

            if (eventObjectName.Equals(funcName))
            {
                if (!IsSetNameConverter)
                {
                    repo.NameConverter = new CamelCaseJavascriptNameConverter();
                    IsSetNameConverter = true;
                }

                BindingOptions bindingOptions = null;
                bindingOptions = BindingOptions.DefaultBinder;

                repo.Register(funcName, objectToBind, isAsync: true, options: bindingOptions);
            }
        }

        /// <summary>
        /// 重写关闭方法
        /// </summary>
        /// <param name="e"></param>
        protected override void OnClosing(CancelEventArgs e)
        {
            // 解锁 WS 端口
            MySqliteUtil.LockOrUnLockPort(false, GlobalParameters.WsPort);
            // 释放资源
            browser.Dispose();
            Cef.Shutdown();
        }

    }
}

7、app 启动调整

除了初始化 CEF 配置,还要初始化数据库缓存,

        public App()
        {
            InitCefSettings();
            MySqliteUtil.CreatePortCacheTable();
        }

8、前端 api 封装

// src\api\GlobalParametersUtil.ts
export const getGlobalWsPort = async (): Promise<any> => {
    await CefSharp.BindObjectAsync("globalParametersUtil")
    return globalParametersUtil.getGlobalWsPort()
}

9、调整组件 WS 连接

// src\components\MainPage.vue

import { getGlobalWsPort } from '../api/GlobalParametersUtil.ts'

onMounted(() => {

  getGlobalWsPort().then((port: any) => {
    connectToServer(`ws://127.0.0.1:${port}/api`);
  })

})

10、新增 MyTabs 组件

通过点击界面上的按钮 “+” 来新增一个相同的 Tab 页面,

// src\components\MyTabs.vue
<template>
    <el-tabs v-model="editableTabsValue" type="card" editable class="demo-tabs" @edit="handleTabsEdit">
        <el-tab-pane v-for="item in editableTabs" :key="item.name" :label="item.title" :name="item.name">
            <MainPage />
        </el-tab-pane>
    </el-tabs>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import type { TabPaneName } from 'element-plus'
import MainPage from './MainPage.vue'

let tabIndex = 1
const editableTabsValue = ref('1')
const editableTabs = ref([
    {
        title: 'Tab 1',
        name: '1',
        content: 'Tab 1 content',
    },
])

const handleTabsEdit = (
    targetName: TabPaneName | undefined,
    action: 'remove' | 'add'
) => {
    if (action === 'add') {
        const newTabName = `${++tabIndex}`
        editableTabs.value.push({
            title: `T${newTabName}`,
            name: newTabName,
            content: 'New Tab content',
        })
        editableTabsValue.value = newTabName
    } else if (action === 'remove') {
        const tabs = editableTabs.value
        let activeName = editableTabsValue.value
        if (activeName === targetName) {
            tabs.forEach((tab, index) => {
                if (tab.name === targetName) {
                    const nextTab = tabs[index + 1] || tabs[index - 1]
                    if (nextTab) {
                        activeName = nextTab.name
                    }
                }
            })
        }

        editableTabsValue.value = activeName
        editableTabs.value = tabs.filter((tab) => tab.name !== targetName)
    }
}
</script>
<style>
.demo-tabs>.el-tabs__content {
    color: #6b778c;
}
</style>
  

11、运行效果

七、不足与改进

1、应用多开

一个 EXE 上开多个标签页实现应用多开时,组件只做了挂载资源操作,没有释放资源操作,因此需要补充组件销毁时释放资源:停止自动发送、关闭串口;

EXE 多开没有问题,启动和关闭都由 WPF 的生命周期进行资源的申请和释放;

2、数据轮询

串口读取数据是通过 Task 任务在 While 循环里轮询来实现,这种方式如同操作系统中的 Selector ,那不妨借鉴 Netty 的思想,可以通过异步事件驱动实现高效读取数据;

3、响应式布局

界面只是使用前端组件进行简单的拼凑,还没完全实现响应式布局来适配不同分辨率的屏幕;

4、数据绑定

思考一种数据绑定方式:能否不通过 WS 就可以将串口数据绑定到对应的前端组件上,如同 WPF 调用原生组件一样方便?

参考

使用 ElementUI 组件构建 Window 桌面应用探索与实践(WPF)-CSDN博客文章浏览阅读785次。基于 CEF 实现 Vue + Vite + ElementUI 组件构建 Window 桌面应用(WPF)https://blog.csdn.net/weixin_47560078/article/details/134189591

文章来源:https://blog.csdn.net/weixin_47560078/article/details/134871007
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。