资讯动态

Qt5 UDP局域网通信:QUdpSocket收发、广播与避坑拆解

发布时间:2026/10/26 4:29:28 来源:尧图企业网站定制
简介这是一份面向Qt5网络编程初学者与进阶学习者的UDP局域网通信示例源码聚焦在Qt环境下对UDP协议进行轻量封装通过在同一IP地址下绑定不同端口号实现客户端之间的消息收发与连接演示适合用于课程实验、自学练手或网络模块原型验证。压缩包共8个文件以cpp与h源码为主体配有2个ui界面文件和1个pro工程文件整体仅7KB结构紧凑可直接用Qt打开编译运行。目前已有2282人学习下载说明其在入门示例中具有一定参考热度。读者可从中了解UDP套接字创建、端口绑定、数据报发送接收、事件响应及界面与业务逻辑配合的基本写法并借助服务端与客户端双界面观察通信流程为后续扩展广播、多客户端并发或可靠性处理打下基础。1. 两台机器互发一句话Qt5 这套 UDP 方案值得拆开看去年帮一个做设备调试的朋友改工具现场有两台工控机连在同一个交换机上他想让 A 机给 B 机发一句“参数改完了重启一下”中间不架服务器、不写后端、不配数据库。我给的方案是 Qt5 里一个QUdpSocket连界面加收发不到两百行就跑通了。这套基于 Qt5 的 UDP 局域网通信软件简单就是同一个思路的完整工程能编译、能跑、两台机器能对着发消息。它不适合拿去做生产级 IM但对三类人有用想搞明白 socket 编程到底在干什么的 A 同学、需要给内网工具加一条“喊话”通道的开发者、手头有个 Qt 项目想抠一个最小网络模块出来改的人。代码量小这件事本身就是价值小到你能一行一行看完。2. 工程骨架从 LanChat.pro 到 main.cpp 的启动链路这套工程没有第三方依赖装了 Qt5 加一个能用的编译器就能开。我拆包第一件事是看目录目录能反映作者的模块划分意图比读 README 快。2.1 目录结构与 .pro 里不能少的那几行一份典型的简单版工程长这样.ui文件用 Designer 拖出来的界面udpworker是后来抽出来的通信类LanChat/ ├── LanChat.pro # qmake 工程描述 ├── main.cpp # 入口只做编码设置和窗口拉起 ├── mainwindow.h/.cpp # 主窗口管 UI 和交互 ├── mainwindow.ui # Designer 布局文件 ├── udpworker.h/.cpp # 通信封装只管收发 └── resources/ └── icons/ # 发送按钮、在线状态小图标.pro文件是整套工程能不能编过的第一道坎我见过不止一个人卡在这里QT core gui QT network # 少了这行QUdpSocket 直接找不到头文件 greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET LanChat TEMPLATE app CONFIG c11 SOURCES main.cpp \ mainwindow.cpp \ udpworker.cpp HEADERS mainwindow.h \ udpworker.h FORMS mainwindow.uiQT network是这套工程里唯一一个“不加就编译不过”的开关QUdpSocket、QHostAddress、QNetworkInterface全在这个模块里。c11是为了能用nullptr和 lambdaQt5.7 之后的 qmake 一般默认开着写上算保险。FORMS那行把.ui交给uic处理编译时会自动生成ui_mainwindow.h这个文件不要手动改改了下次构建就被覆盖。提示Qt Creator 里打开.pro之后先执行一次 qmake构建菜单里的“执行 qmake”否则新增的源文件不参与编译报错会莫名其妙。2.2 main.cpp 与主窗口的启动顺序入口文件短但两个细节值钱编码和窗口生命周期。#include mainwindow.h #include QApplication #include QTextCodec int main(int argc, char *argv[]) { QApplication a(argc, argv); // 显式指定本地编码为 UTF-8。 // 影响的是 QString::toLocal8Bit / fromLocal8Bit 这类接口 // 在中文 Windows 上默认是 GBK跨机器通信时很容易一头 UTF-8 一头 GBK。 QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8)); MainWindow w; w.show(); // 先 show 再 exec让窗口在事件循环启动前完成布局 return a.exec(); // 阻塞在这里直到最后一个窗口关闭 }QApplication必须在任何窗口对象之前构造因为它负责初始化 QPA 平台插件、字体和样式。w是栈对象exec()返回后按逆序析构所以不需要手动delete。QTextCodec在 Qt6 里被移出了 QtCore如果哪天想升 Qt6这行得删掉并改成QStringConverter那一套这是后话。真正容易翻车的点在后面MainWindow的构造函数里就要把 socket 建好、把端口绑上、把信号槽连完不能等到show()之后。有些写法把初始化塞进showEvent窗口一最小化再恢复就会重复绑定端口直接报占用。2.3 模块边界把 socket 关进单独的 UdpWorker 类简单版最容易写成“所有代码堆在 MainWindow 里”两百行的时候还能忍加到文件传输就成一锅粥了。这份工程把网络部分抽成了独立的类头文件大概是这样// udpworker.h #ifndef UDPWORKER_H #define UDPWORKER_H #include QObject #include QUdpSocket #include QHostAddress class UdpWorker : public QObject { Q_OBJECT public: explicit UdpWorker(QObject *parent nullptr); ~UdpWorker(); bool start(quint16 localPort); // 绑定本地端口 void sendText(const QString nick, const QString text, const QHostAddress to, quint16 port); // 单播 void sendBroadcast(const QString nick, const QString text, quint16 port); // 广播 quint16 localPort() const { return m_localPort; } signals: void messageReceived(const QString fromIp, quint16 fromPort, const QString nick, const QString text); void socketError(const QString message); private slots: void onReadyRead(); // 处理可读事件 private: QUdpSocket *m_socket nullptr; quint16 m_localPort 0; }; #endif // UDPWORKER_H这个类只干三件事绑定端口、往外发、收到的东西通过信号抛出去。它不知道界面上有几个控件也不知道消息要显示成什么颜色。MainWindow拿着它的信号去更新 UI双向依赖被切断了。这样分带来的直接好处是测试成本。想验证收发逻辑写个main只创建UdpWorker、连上messageReceived打个qDebug不用起整个界面。血泪经验网络 bug 里有一半是 UI 线程被阻塞导致的问题把网络层单独拎出来跑一遍能立刻分清是网络问题还是界面问题。3. 收发核心QUdpSocket 的绑定、广播与报文格式网络层这几十行是整个工程的心脏也是最容易写成“能跑但不知道为什么能跑”的部分。下面按绑定、收发、组包三段拆。3.1 bind 的三个参数以及 ShareAddress 什么时候必须加bool UdpWorker::start(quint16 localPort) { if (!m_socket) m_socket new QUdpSocket(this); // 参数1 AnyIPv4只监听 IPv4避免双栈环境下收到 ::1 的干扰包 // 参数2 localPort0 表示由系统分配聊天场景一般要固定端口 // 参数3 绑定模式ShareAddress 允许多个 socket 绑同一端口 // ReuseAddressHint 让系统在端口处于半关闭状态时也允许复用 bool ok m_socket-bind(QHostAddress::AnyIPv4, localPort, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); if (!ok) { emit socketError(m_socket-errorString()); return false; } connect(m_socket, QUdpSocket::readyRead, this, UdpWorker::onReadyRead); m_localPort localPort; return true; }AnyIPv4和Any的区别在双栈机器上很明显。用Any会同时绑 IPv4 和 IPv6某些系统上会收到一堆意外的 IPv6 数据报readDatagram拿到的地址打印出来是::ffff:192.168.x.x这种形式界面上显示很难看。ShareAddress这个标志是调试救星。同一台机器上开两个实例做自测时不加它第二个实例bind直接返回 false加上了两个实例都能收到发到该端口的数据报联调时非常方便。代价是同一端口上所有绑定者都会收到包生产环境里如果只想让一个进程处理就得反过来去掉这个标志。还有个坑跟版本有关。Qt5.15 之前QUdpSocket的错误信号是error(QUdpSocket::SocketError)5.15 之后改成errorOccurred老代码直接搬过去会编译失败。判断版本用QT_VERSION_CHECK(5, 15, 0)宏包一下就行。3.2 广播发现与单播回复局域网里怎么找到对方局域网通信绕不开一个问题我不知道对方 IP。简单版的处理方式是先广播收到的人回一个单播。void UdpWorker::sendBroadcast(const QString nick, const QString text, quint16 port) { QByteArray payload buildPacket(nick, text); // 自定义组包见 3.3 // 方案 A受限广播 255.255.255.255 // 很多路由器/系统会直接丢弃跨网段也过不去只适合同交换机场景 // m_socket-writeDatagram(payload, QHostAddress::Broadcast, port); // 方案 B按每块网卡算定向广播地址例如 192.168.1.255成功率更高 const auto ifaces QNetworkInterface::allInterfaces(); for (const QNetworkInterface iface : ifaces) { // 跳过未启用、回环、非运行状态的网卡 if (!(iface.flags() QNetworkInterface::IsUp) || (iface.flags() QNetworkInterface::IsLoopBack)) continue; for (const QNetworkAddressEntry entry : iface.addressEntries()) { if (entry.ip().protocol() ! QAbstractSocket::IPv4Protocol) continue; QHostAddress bcast entry.broadcast(); // 网卡自己算好的广播地址 if (bcast.isNull()) continue; m_socket-writeDatagram(payload, bcast, port); } } }QHostAddress::Broadcast是 255.255.255.255理论上最省事实际经常发得出去收不到——交换机有时候不转Windows 防火墙的默认规则也拦。遍历网卡拿entry.broadcast()得到的是定向广播地址命中率高得多。代价是机器上有几块启用网卡就要发几份接收端会收到重复包得靠消息 ID 去重。单播回复就简单了收到广播包时readDatagram已经把发送方地址和端口填好了直接拿来回// onReadyRead 里解析出是发现包之后 if (type PacketType::Discover) { QByteArray reply buildPacket(m_nick, QStringLiteral(ONLINE)); m_socket-writeDatagram(reply, senderAddr, senderPort); // 原路返回 }3.3 readDatagram 的循环读取与自定义报文格式readyRead信号只告诉你“有数据了”不告诉你“有几个包”。一次信号可能对应一到多个数据报所以必须while循环读到干净。void UdpWorker::onReadyRead() { // hasPendingDatagrams 为真表示队列里还有未读的数据报 while (m_socket-hasPendingDatagrams()) { QByteArray buf; buf.resize(int(m_socket-pendingDatagramSize())); QHostAddress senderAddr; quint16 senderPort 0; m_socket-readDatagram(buf.data(), buf.size(), senderAddr, senderPort); // 解析自定义包头 if (buf.size() 6) continue; // 长度不够直接丢弃 QDataStream ds(buf); ds.setByteOrder(QDataStream::BigEndian); // 收发两端必须一致 quint8 type 0; quint8 nickLen 0; ds type nickLen; if (buf.size() 2 nickLen) continue; // 昵称长度异常丢弃 QByteArray nickRaw buf.mid(2, nickLen); QByteArray body buf.mid(2 nickLen); QString nick QString::fromUtf8(nickRaw); QString text QString::fromUtf8(body); emit messageReceived(senderAddr.toString(), senderPort, nick, text); } }报文格式我用的是定长头加变长体而不是昵称|内容这种分隔符拼接偏移长度字段说明01 字节type0x01 文本 / 0x02 发现 / 0x03 心跳 / 0x04 文件块11 字节nickLen昵称字节数0322nickLennickUTF-8 编码的昵称2nickLen剩余bodyUTF-8 编码的正文分隔符方案在昵称里带|的时候会直接解析错位这不是理论风险用户真会输入竖线。定长头 长度字段是最省心的做法多写十行换来不用做转义。QDataStream的字节序一定要显式设。默认是 BigEndian但要是哪天有人改成LittleEndian忘了同步另一端收到的长度字段会是一个天文数字表现为“收到数据但解析出来是空的”。3.4 发送侧的三个分支单播、广播、组播发送只有writeDatagram一个接口区别全在目标地址上void UdpWorker::sendText(const QString nick, const QString text, const QHostAddress to, quint16 port) { if (!m_socket) return; QByteArray packet buildPacket(nick, text); // 统一走 UTF-8 组包 // writeDatagram 返回值是实际写入的字节数-1 表示失败 qint64 written m_socket-writeDatagram(packet, to, port); if (written -1) { emit socketError(m_socket-errorString()); return; } if (written ! packet.size()) { // UDP 一般要么全发要么全失败出现部分写入通常是内核缓冲满了 emit socketError(QStringLiteral(部分写入可能出现丢包)); } }单播是最稳的只要 IP 和端口对同网段基本一次到位。广播按 3.2 的做法算定向地址。组播是第三种选择地址段 224.0.0.0239.255.255.255适合“一发给多”的场景比如一个主机同时通知十台设备。组播要先joinMulticastGroup还要注意 TTL默认 1 表示不出本网段需要跨路由得手动setSocketOption调大。这三种方式在简单版里通常只用前两种。组播的配置成本高对一个两个人聊天的场景来说是过度设计。4. 界面与业务解耦信号槽、消息列表与配置持久化界面这一层看着没什么技术含量实际踩坑密度不比网络层低。控件用法不对轻则消息显示乱码重则主线程被卡住。4.1 信号槽连线网络事件怎么进到 UIMainWindow构造函数里的连线是整套工程的中枢写法上没什么花活MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) , m_worker(new UdpWorker(this)) // 挂在窗口对象树上随窗口析构 { ui-setupUi(this); // 收到消息 → 追加到聊天区 connect(m_worker, UdpWorker::messageReceived, this, MainWindow::onMessageReceived); // socket 出错 → 状态栏提示 connect(m_worker, UdpWorker::socketError, this, MainWindow::onSocketError); // 点发送按钮 → 走发送流程 connect(ui-sendBtn, QPushButton::clicked, this, MainWindow::onSendClicked); loadSettings(); // 读上次用的端口和昵称 if (!m_worker-start(quint16(ui-portSpin-value()))) ui-statusBar-showMessage(QStringLiteral(端口绑定失败)); }把m_worker的 parent 设成this是为了让它跟着窗口一起析构省掉手动delete。信号槽用的是函数指针语法Qt5 的写法编译期就能检查签名对不对比老式SIGNAL()/SLOT()宏安全得多。一个我见过的错误写法把UdpWorker用moveToThread挪到子线程但QUdpSocket是在主线程 new 的。Qt 的 socket 对象有线程亲和性跨线程使用会出现“在 A 线程创建、在 B 线程读写”的警告严重时直接崩。简单版本身数据量小不值得上多线程非要上就得保证 socket 的创建和使用在同一个线程里。4.2 消息列表的增量追加与富文本陷阱显示区一般用QTextEdit或者QTextBrowser。追加消息看着就一行append()里面有个浅坑void MainWindow::onMessageReceived(const QString fromIp, quint16 fromPort, const QString nick, const QString text) { // 关掉富文本自动识别否则用户发一段 bhello/b 会被当成标签渲染 ui-chatView-setAcceptRichText(false); // 昵称和正文都可能带尖括号先转义再拼防止被当成 HTML QString safeNick nick.toHtmlEscaped(); QString safeText text.toHtmlEscaped(); safeText.replace(\n, br/); // 保留用户输入的换行 QString line QStringLiteral([%1:%2] %3 %4) .arg(fromIp, QString::number(fromPort), safeNick, safeText); ui-chatView-append(line); // 滚动条自动跟到底部 QScrollBar *bar ui-chatView-verticalScrollBar(); bar-setValue(bar-maximum()); }关掉setAcceptRichText之后append()里的 HTML 就完全不生效了br/也不会变成换行。这两个设置是互斥的得选一个要么开富文本自己把用户内容全转义要么关富文本用insertPlainText插入并用\n换行。混着用出来的效果是“消息里的换行丢了”或者“尖括号里的内容消失了”排查起来很费劲。消息量大之后QTextEdit会变卡几千条之后滚动明显掉帧。给个上限就行超过 500 条把头部的QTextCursor整段removeSelectedText用户感知不到内存也不会一直涨。4.3 配置持久化QSettings 存端口和昵称每次启动都要重填端口和昵称很烦QSettings两行就能解决void MainWindow::loadSettings() { // 参数是组织名和应用名决定存储位置 // Windows 落在注册表 HKCU\Software\LanChat\LanChat // Linux 落在 ~/.config/LanChat/LanChat.conf QSettings s(QStringLiteral(LanChat), QStringLiteral(LanChat)); ui-portSpin-setValue(s.value(QStringLiteral(net/port), 45454).toInt()); ui-nickEdit-setText(s.value(QStringLiteral(net/nick), QStringLiteral(anonymous)).toString()); } void MainWindow::saveSettings() { QSettings s(QStringLiteral(LanChat), QStringLiteral(LanChat)); s.setValue(QStringLiteral(net/port), ui-portSpin-value()); s.setValue(QStringLiteral(net/nick), ui-nickEdit-text()); // 析构时自动 sync也可以显式调 s.sync() }value()的第二个参数是默认值键不存在时返回它所以第一次启动不会拿到空端口。端口那一项存成int读的时候用.toInt()如果存的是QString类型再toInt也能work但类型混着写会有精度丢失的隐患建议写什么类型读什么类型。端口默认值我一般挑 45454 这种高位端口1024 以下要 root 权限5000 附近经常被系统服务占着踩过一次记得挺清楚。4.4 发送前的输入校验与状态提示发送按钮的回调里要做三件事空消息拦截、超长消息截断、发送结果反馈。void MainWindow::onSendClicked() { QString text ui-inputEdit-toPlainText().trimmed(); if (text.isEmpty()) { ui-statusBar-showMessage(QStringLiteral(内容为空), 2000); return; // 空消息不发省得对方收到一堆空行 } // UDP 单包超过 MTU以太网 1500会触发 IP 分片 // 丢任意一片整个包就废了所以业务层自己先卡住长度 const int kMaxBytes 1200; QByteArray body text.toUtf8(); if (body.size() kMaxBytes) { ui-statusBar-showMessage( QStringLiteral(单条超过 %1 字节已截断).arg(kMaxBytes), 3000); text QString::fromUtf8(body.left(kMaxBytes)); } QString targetIp ui-targetEdit-text().trimmed(); QHostAddress addr; if (!addr.setAddress(targetIp)) { // 顺便校验 IP 格式 ui-statusBar-showMessage(QStringLiteral(目标地址不合法), 3000); return; } m_worker-sendText(ui-nickEdit-text(), text, addr, quint16(ui-targetPortSpin-value())); ui-inputEdit-clear(); // 发送后清空输入框 }trimmed()不只是为了好看全空格的输入如果不拦对方界面上会多出一行看着像 bug 的空白。截断那段的kMaxBytes取 1200 是老经验以太网 MTU 1500 减去 IP 头 20 字节和 UDP 头 8 字节再留点余量给自定义包头1200 是个安全值。超过这个数的消息要么分片要么干脆拒绝不要指望 IP 层帮你分——分片丢了任何一片接收端整包都收不到还查不出原因。5. 避坑与排查绑定失败、收不到包、中文乱码这几件事这套工程跑起来之后的故障八成集中在这五条上。每条我都按“看到什么、为什么、怎么办”记。5.1 网络层三个高频故障故障一程序启动就提示端口绑定失败现象是bind返回 falseerrorString打印Address already in use。多半是上一次的进程没退干净或者同一台机器上开了两个实例。先在命令行查一下谁占着端口Windows 用netstat -ano | findstr 45454拿到 PID 再去任务管理器结束Linux 用ss -ulnp | grep 45454。如果确实需要多实例共存把 3.1 里的ShareAddress加上如果只想单实例就在启动时提示用户换端口别让程序带着错误状态继续跑。故障二广播发出去了对面没反应现象是同网段两台机器A 发 B 收不到B 发 A 也收不到但各自用 127.0.0.1 自测正常。原因通常是两块一是用了QHostAddress::Broadcast255.255.255.255部分交换机和系统防火墙默认不转发二是 Windows 防火墙把入站 UDP 拦了。解决办法是把发送目标换成 3.2 里算出来的定向广播地址测试阶段先把防火墙临时关掉验证确认是防火墙问题再给程序加一条入站规则而不是让用户一直关着防火墙用。故障三小消息通稍长一点就丢现象是发一句话没问题发一段两百字的内容对方偶尔收不全。这不是 UDP 的“不可靠”玄学是 IP 分片在捣鬼。包体超过 MTU 会被拆成多片接收端只要少收一片就丢整个数据报而丢失概率随片数上升。办法就一个业务层把单包卡在 1200 字节以内超出部分自己在应用层切片编号接收端重装。简单版不做重装也可以把输入框限制住长度比收一半内容强。5.2 编码与部署层面的两个坑故障四中文变成问号或者一堆\xxx现象是英文正常中文在对方那里显示成乱码。根源是两端编码不一致一端用toLocal8Bit()转本地编码中文 Windows 是 GBK另一端用fromUtf8()解。统一成 UTF-8 就没事——发送端text.toUtf8()接收端QString::fromUtf8()中间不经过本地编码。顺带检查一下源文件本身的编码.cpp存成 GBK 而编译器按 UTF-8 读字符串字面量在编译期就已经是乱的了这种问题用调试器看内存里的字节最快。故障五自己电脑能跑拷给别人就缺 DLL现象是双击 exe 弹窗报“缺少 Qt5Core.dll”或者“无法定位程序输入点”。Qt 的动态库不在系统 PATH 里必须跟 exe 放一起。Windows 下用 Qt 自带的部署工具先建一个空目录把 exe 拷进去再执行# 在 Qt 命令行环境里执行把依赖的 DLL 和插件拷到 exe 同目录 windeployqt --release --no-translations LanChat.exe--no-translations去掉一堆用不上的语言包体积能小一大截。很多人只拷了 Qt5Core、Qt5Gui、Qt5Widgets忘了Qt5Network.dll跑到 bind 那一行才崩报错还很隐晦。还有个更容易漏的platforms/qwindows.dll这个插件目录缺了整个程序起不来。6. 二次开发从纯文本到文件传输以及我怎么验证改动这套工程最合理的第一个扩展是文件传输因为它把 UDP 的两大短板一次性暴露出来包大小限制和丢包。我的做法是加一个类型 0x04 的报文用QDataStream组包每片不超过 1200 字节// 文件分片发送fileId 区分并发传输序号用于接收端排序和查缺 void sendFileChunk(const QByteArray data, quint32 fileId, const QHostAddress to, quint16 port) { const int kChunk 1200; const quint32 total quint32((data.size() kChunk - 1) / kChunk); for (quint32 i 0; i total; i) { QByteArray packet; QDataStream ds(packet, QIODevice::WriteOnly); ds.setByteOrder(QDataStream::BigEndian); ds quint8(0x04) // 类型文件块 fileId // 文件编号避免多个文件串包 i // 当前片序号从 0 开始 total // 总片数接收端据此判断是否收齐 data.mid(int(i) * kChunk, kChunk); m_socket-writeDatagram(packet, to, port); } }发送端要限速一口气往内核缓冲里灌几千个包后面的会被悄悄丢掉表现为“传到 60% 就停住”。中间加个QThread::msleep(1)或者用定时器每 10ms 发一批实测能稳定下来。接收端用一个QHashquint32, QMapquint32, QByteArray存片收齐total片再拼回去缺片就发一个类型 0x05 的 NAK 让对方重传指定的几片。第二个扩展是在线列表。简单版没有“谁在线”的概念加一个类型 0x03 的心跳包每 5 秒广播一次接收端维护一张IP → 最后心跳时间的表超过 15 秒没心跳就从列表里摘掉。这个机制简单到不需要协议文档但能解决“不知道对方在不在发出去石沉大海”的体验问题。验证改动的方式我有固定的一套很少跳步。第一步永远是在本机开两个实例用ShareAddress让它们绑同一个端口互相发几条带中文、带换行、带尖括号的消息看显示和解析对不对。第二步换到两台真机这一步能暴露广播地址和防火墙问题本机测永远测不出来。第三步开抓包工具过滤udp.port 45454逐个数据报看字节组包解析错了在抓包结果里一眼就能看出来比在代码里加十个qDebug快。有一次我图省事跳过了第一步直接两台机器测结果死活收不到包查了两个小时防火墙、路由器、网线最后发现是接收端拼包时把buf.mid(2, nickLen)的偏移写成了buf.mid(1, nickLen)本机双实例跑一遍三分钟就能看出来。从那以后我每次动到报文格式都强制先在单机起两个实例对发一遍再谈联调。希望这套拆法能帮到你代码不长值得自己跑一遍再改。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑