基于QT框架的工业级CAN总线上位机开发实战指南
2026/9/4 19:40:39 网站建设 项目流程

简介:这是一套面向嵌入式开发与汽车电子方向学习者的高完成度QT上位机实战项目,聚焦CAN总线通信的可视化监控与交互控制需求,适用于课程设计、毕业设计及工业现场调试场景。资源包含98个文件,涵盖13个核心cpp源码、7个ui界面定义、5个pro工程配置及29个运行依赖dll,完整呈现从CAN帧收发、报文解析、实时曲线绘制到多通道数据存储的全流程实现;压缩包大小为20.37MB,结构清晰,便于二次开发与模块化学习。已有854人下载学习,配套可直接运行的exe程序及CHAI硬件适配支持,提供完整的跨平台编译环境适配说明(含libwinpthread、libstdc++等关键运行库),并内置多语言qm翻译文件与详细txt文档,显著降低初学者在QT信号槽机制、QCustomPlot绘图及CAN驱动集成中的实践门槛。

1. 项目概述:一个工业级QT CAN总线上位机的诞生

最近在整理过往的项目资料,翻出了一个几年前做的CAN总线上位机项目。这个项目在当时是为了配合一个电机控制器测试台架而开发的,需求很明确:需要一个稳定、直观、能长时间运行且方便二次开发的软件,来实时监控和配置控制器通过CAN总线发送的各种参数。市面上虽然有一些通用的CAN分析仪软件,但要么功能太杂不够聚焦,要么定制化程度低,无法满足我们特定的数据解析和自动化测试需求。于是,基于QT框架从头开发一个专用的上位机,就成了最直接有效的选择。

这个项目最终完成度很高,不仅完美支撑了当时的测试任务,其架构和代码也成为了后续几个类似项目的模板。今天,我就把这个项目的核心设计思路、关键技术实现细节以及踩过的那些“坑”系统地梳理出来。无论你是刚接触QT和CAN总线的新手,想了解一个完整的上位机是如何搭建的;还是有一定经验的开发者,正在寻找更优雅的解决方案,这篇文章都能给你提供一份可直接参考、甚至复现的“作业”。项目完全使用C++/QT开发,包含了从界面设计、通信层、数据处理到日志记录的全套源代码,我会重点讲解那些在官方文档里不会细说,但在实际项目中至关重要的实战经验。

2. 项目整体设计与核心思路拆解

2.1 为什么选择QT作为开发框架?

在项目启动之初,框架选型是第一个要决策的问题。当时主要对比了C# WinForms/WPF、Python PyQt/PySide以及C++ QT。最终选择C++ QT,是基于以下几个核心考量:

首先,跨平台性是决定性因素。我们的测试环境虽然以Windows为主,但部分实验室和未来可能的产线环境涉及Linux。QT“一次编写,到处编译”的特性,能极大降低后期维护和移植的成本。你不需要为两个平台维护两套界面逻辑,这是C#(尽管有.NET Core但GUI生态仍不成熟)难以比拟的优势。

其次,对硬件和底层通信的良好支持。QT不仅提供了QSerialPort用于串口,其底层对Socket、线程、定时器的封装非常成熟稳定。更重要的是,对于CAN通信,虽然QT官方没有直接提供CAN模块,但其强大的I/O和事件循环机制,使得集成第三方CAN库(如基于SocketCAN的Linux驱动或Windows下的PCAN Basic API)变得非常顺畅。C++的背景也让我们在调用这些C接口的库时毫无障碍。

再者,性能与资源控制。上位机需要长时间稳定运行,实时绘制数据曲线,并可能同时处理多条CAN总线的高频数据。C++ QT在性能上具有天然优势,内存和CPU占用可控,避免了像某些托管语言运行时可能出现的不可预测的垃圾回收暂停,这对于工业软件的稳定性至关重要。

最后,信号与槽(Signals & Slots)机制。这简直是GUI开发与异步逻辑处理的“神器”。CAN数据接收是硬件中断驱动的,完全异步于UI线程。使用信号槽,我们可以轻松地将数据接收线程的事件安全地传递到UI线程进行更新,避免了繁琐且易出错的线程同步操作。整个架构会因此变得非常清晰。

注意:很多新手会纠结于用PyQt快速原型开发。对于一次性小工具这没问题,但对于需要长期维护、性能要求高或需要分发的工业软件,C++ QT是更专业的选择。编译后的可执行文件是独立的,无需担心用户环境缺少Python解释器或特定库。

2.2 上位机核心功能模块规划

一个完整的CAN总线上位机,远不止是“收到数据并显示”那么简单。我们需要像搭积木一样,规划好各个功能模块,并明确它们之间的交互关系。本项目的核心模块划分如下:

  1. 通信管理层:这是软件的“神经中枢”。负责与物理CAN适配器(如PCAN-USB, ZLG USBCAN等)的驱动进行交互,实现CAN通道的打开/关闭、波特率设置、帧(数据帧、远程帧)的发送与接收。这一层需要封装不同厂家CAN卡API的差异,向上提供统一的接口。
  2. 协议解析层:这是业务的“大脑”。原始CAN帧只是一串ID和数据字节。这一层负责根据预先定义好的通信协议(如CANopen, J1939,或自定义协议),将原始的字节流解析成有实际工程意义的物理量,如转速、电流、温度等。同时,也负责将用户的设置参数打包成符合协议的CAN帧。
  3. 数据模型层:这是数据的“仓库”。管理所有解析后的实时数据、发送报文配置、历史数据缓存等。它采用观察者模式,当数据更新时,通知所有相关的视图(如仪表、表格、曲线)。
  4. 人机交互层:这是软件的“脸面”。基于QT Widgets或QML构建的用户界面,包括:
    • 仪表盘:显示关键参数(速度、电压)。
    • 数据表格:以列表形式实时显示所有收发报文,包含时间戳、ID、数据、解析值等。
    • 曲线绘制区:使用QCustomPlotQChart库,实现多通道数据的实时趋势绘制。
    • 报文发送面板:允许用户手动或周期性地发送自定义CAN帧。
    • 参数配置页:以表单形式配置设备参数,背后触发协议打包和发送。
  5. 辅助功能层:包括数据记录(保存为CSV或数据库)、报警管理、用户操作日志、软件设置持久化等。这些是保证软件可用性和可维护性的关键。

2.3 架构设计:高内聚、低耦合的实现

为了让软件易于维护和扩展,我们采用了典型的模型-视图-控制器(MVC)变体,在QT的语境下,更准确地说是模型-视图模式,控制器逻辑分散在界面和特定的管理类中。

  • 模型(Model):对应上述的数据模型层。我们创建了CanMessageModel(继承自QAbstractTableModel)来管理报文列表,这样可以直接绑定到QTableView,实现高效更新。同时,有独立的DeviceParameterModel来管理所有设备参数的状态。
  • 视图(View):即人机交互层的各个QT Widgets组件。
  • “控制器”与数据流
    • CanDriverThread:一个独立的QThread子类,封装CAN卡API。它循环读取CAN帧,每收到一帧,就通过信号frameReceived(const CanFrame&)发出。
    • ProtocolParser:单例或静态工具类。它连接到CanDriverThreadframeReceived信号。收到原始帧后,根据ID查找对应的解析规则,计算物理值,然后更新DeviceParameterModel中对应的数据项。
    • DeviceParameterModel在数据更新后,发出自定义信号parameterUpdated(int paramId, double value)
    • 各个视图组件(如一个显示转速的QLabel或一个QCustomPlot曲线)会连接到它们关心的参数更新信号,并刷新自己的显示。

这种基于信号槽的松耦合设计,使得增加一个新的显示部件或新的协议解析器变得非常容易,只需要连接相应的信号即可,无需修改核心通信和解析逻辑。

3. 核心细节解析与实操要点

3.1 CAN驱动封装:统一接口应对不同硬件

市面上CAN卡品牌众多,每家都有自己的API(PCAN, ZLG, Kvaser, IXXAT等)。我们的目标是写一套代码,通过编译开关或运行时配置就能切换适配器。这里的关键是抽象出一个统一的驱动接口

// can_driver.h - 抽象接口类 class CanDriver : public QObject { Q_OBJECT public: enum CanBaudrate { Baud1M, Baud500K, Baud250K, Baud125K, Baud100K, Baud50K }; virtual bool open(int channelIndex, CanBaudrate baudrate) = 0; virtual void close() = 0; virtual bool writeFrame(const CanFrame &frame) = 0; virtual bool isOpen() const = 0; signals: void frameReceived(const CanFrame &frame); void errorOccurred(const QString &errorString); }; // can_frame.h - 统一的数据结构 struct CanFrame { quint32 id; // CAN标识符 (11位或29位) bool isExtended; // 是否是扩展帧 bool isRemote; // 是否是远程帧 QByteArray data; // 数据域,最多8字节 qint64 timestamp; // 时间戳(微秒或纳秒精度,取决于驱动) };

然后,为每种支持的CAN卡实现一个具体的驱动类,如PcanBasicDriverZlgUsbCanDriver。在工厂类CanDriverFactory中根据用户选择实例化对应的驱动对象。这样,上层通信管理模块只与CanDriver接口交互,完全不用关心底层硬件细节。

实操心得:不同厂家的API对时间戳的定义可能不同(有的从驱动打开开始,有的是系统时间)。为了统一分析,我们通常在收到帧后,立即用QDateTime::currentMSecsSinceEpoch()生成一个毫秒级的应用层时间戳,并和驱动时间戳一起保存。这样既保证了软件内时间统一,又保留了可能用于深度分析的硬件时间戳。

3.2 协议解析:从字节到工程值的魔法

这是上位机的业务核心。假设我们有一个最简单的协议:ID为0x101的报文,数据域8个字节,前两个字节(小端序)表示电机转速,单位是0.1 RPM。

// protocol_parser.h class ProtocolParser : public QObject { Q_OBJECT public: explicit ProtocolParser(DeviceParameterModel *model, QObject *parent = nullptr); public slots: void onRawFrameReceived(const CanFrame &frame); private: DeviceParameterModel *m_paramModel; // 可能还需要一个协议规则映射表 std::map<quint32, ParseRule> }; // protocol_parser.cpp void ProtocolParser::onRawFrameReceived(const CanFrame &frame) { switch(frame.id) { case 0x101: { if (frame.data.size() >= 2) { // 小端序解析两个字节 quint16 raw = static_cast<quint8>(frame.data[0]) | (static_cast<quint8>(frame.data[1]) << 8); double rpm = static_cast<double>(raw) * 0.1; // 转换为实际转速 m_paramModel->updateParameter(PARAM_MOTOR_RPM, rpm); } break; } // ... 处理其他ID default: break; } }

对于复杂的协议,如CANopen的SDO/PDO,或者J1939的多包传输,需要设计更复杂的解析状态机。一个实用的技巧是使用XML或JSON文件来定义协议数据库(DBC文件的概念)。这样,当协议变更时,无需修改代码,只需更新配置文件即可。我们可以在程序启动时加载这个“数据库”,构建一个从CAN ID到解析函数的动态映射。

3.3 线程安全与数据吞吐:避免界面卡死的艺术

CAN总线数据速率可能很高(1Mbps),如果直接在UI线程中处理接收和解析,界面肯定会卡住。因此,必须将耗时的I/O和计算操作放到工作线程

  1. 接收线程:如前所述,CanDriver及其具体实现最好运行在一个独立的QThread中。它的frameReceived信号会跨线程传递CanFrame对象。由于CanFrame只包含POD类型和隐式共享的QByteArray,QT的信号槽跨线程拷贝是安全的。
  2. 解析线程:协议解析也可能比较耗时,特别是涉及复杂计算或查表时。如果解析逻辑很重,可以考虑再单独开辟一个解析线程池(QThreadPool配合QtConcurrent)。但在大多数中低速场景下,将解析放在主线程(由接收信号触发)也是可以接受的,因为QT的事件循环效率很高。关键在于快速处理,尽快返回。如果解析太慢,可以使用一个**无锁环形缓冲区(Ring Buffer)**作为接收线程和解析逻辑之间的缓存。
  3. UI更新:所有对界面控件的操作(如setText,repaint)必须在主线程。通过信号槽,解析层将更新后的数据以信号形式发出,连接到UI控件的槽函数。这些槽函数在主线程执行,是线程安全的。
// 在主窗口构造函数中连接信号槽 connect(m_canDriver, &CanDriver::frameReceived, m_protocolParser, &ProtocolParser::onRawFrameReceived); connect(m_paramModel, &DeviceParameterModel::parameterUpdated, this, &MainWindow::onParameterUpdated); // MainWindow的槽函数在主线程 void MainWindow::onParameterUpdated(int paramId, double value) { // 此函数在主线程执行,可以安全更新UI switch(paramId) { case PARAM_MOTOR_RPM: ui->labelRpm->setText(QString::number(value, 'f', 1)); m_plot->addData(value); // 向曲线添加数据点 break; } }

避坑指南:切忌在onParameterUpdated这样的槽函数中进行复杂的计算或阻塞操作。它的任务应该仅限于“轻量级的数据展示”。如果需要基于新数据触发复杂逻辑,应该将其放入另一个工作线程,或者使用QTimer进行延迟分批处理。

4. 实操过程与核心环节实现

4.1 开发环境搭建与项目配置

我们使用的是QT 5.15.2 LTS版本和MSVC 2019编译器,在Windows平台上开发。选择LTS版本是因为其长期支持,稳定性有保障。

  1. 安装QT:从QT官网或镜像下载在线安装器,勾选MSVC 2019 64-bit组件以及Sources(便于调试QT源码)。同时,建议安装Qt ChartsQt Data Visualization模块,它们对于绘制曲线和3D数据很有用,虽然我们最终可能选择更强大的第三方库如QCustomPlot
  2. 创建项目:使用QT Creator新建一个Qt Widgets Application项目。在.pro项目文件中,需要添加必要的模块。例如:
    QT += core gui serialport charts # 串口和图表模块 greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++17 # 如果你使用第三方库,如QCustomPlot INCLUDEPATH += $$PWD/thirdparty/qcustomplot DEPENDPATH += $$PWD/thirdparty/qcustomplot HEADERS += $$PWD/thirdparty/qcustomplot/qcustomplot.h SOURCES += $$PWD/thirdparty/qcustomplot/qcustomplot.cpp
  3. 集成CAN库:以PCAN Basic为例。将PCAN Basic API的头文件(PCANBasic.h)和库文件(PCANBasic.lib/.dll)放入项目目录。在.pro文件中添加库引用,并在代码中包含头文件。注意区分32位和64位库。
    win32 { # 假设库文件在 thirdparty/pcan 目录下 LIBS += -L$$PWD/thirdparty/pcan -lPCANBasic INCLUDEPATH += $$PWD/thirdparty/pcan }

4.2 用户界面设计与布局管理

界面采用经典的停靠窗口布局,使用QDockWidget来组织各个功能面板,这样用户可以根据需要拖动和排列。

  • 主视图区:中心区域是一个QTabWidget,包含“实时曲线”、“数据表格”和“报文发送”等主要页面。
  • 侧边停靠栏:左侧放置“设备参数树”(QTreeWidget),以树形结构展示所有可配置参数,双击可修改。右侧放置“报警列表”(QListWidget)和“快速控制面板”(几个重要的按钮和状态指示灯)。
  • 状态栏:底部状态栏(QStatusBar)用于显示CAN连接状态、接收/发送帧统计、当前时间等信息。

关键技巧:所有界面布局都在QT Designer中完成,生成.ui文件。但复杂的自定义控件(如带特殊渲染的仪表)需要手写代码继承自QWidget。使用样式表(QSS)来统一美化界面,使其更符合工业软件的风格,例如深色主题、高对比度颜色,方便在光线复杂的车间里查看。

4.3 数据持久化与日志记录

工业软件必须记录数据以备后续分析。我们设计了两种记录方式:

  1. CSV文件记录:这是最通用、最易分享的方式。我们创建一个CsvLogger类,它运行在单独的线程中。当需要记录时,主线程将数据包通过信号槽放入日志线程的缓冲区。日志线程定时或定量地将缓冲区中的数据写入CSV文件。这样做避免了文件I/O阻塞主线程。
    class CsvLogger : public QThread { Q_OBJECT public slots: void enqueueLogEntry(const LogEntry &entry); protected: void run() override; private: QQueue<LogEntry> m_queue; QMutex m_mutex; QWaitCondition m_condition; // ... 文件操作 };
  2. SQLite数据库记录:对于需要复杂查询或长期海量存储的场景,我们集成SQLite。使用QT自带的QSqlDatabaseQSqlQuery模块即可。可以设计两张表:一张记录原始CAN帧(时间戳、ID、数据),另一张记录解析后的工程参数(时间戳、参数名、值)。数据库操作同样建议放在专用线程中。

操作日志:除了数据,用户的所有关键操作(如连接CAN、发送指令、修改参数)也需要记录到文本日志文件中,便于问题追溯。可以使用QFileQTextStream简单实现,或者用log4cplus等专业日志库。

4.4 曲线绘制模块的深度优化

实时曲线绘制是上位机的性能瓶颈之一。我们选择了QCustomPlot库,因为它比QtCharts更轻量、功能更强大、性能更好,特别是在处理大量数据点时。

优化策略

  • 数据缓冲与降采样:不要每收到一个数据点就立即重绘整个曲线。而是将数据点添加到一个缓冲数组,然后使用一个QTimer,以固定的频率(如50ms)触发重绘。在重绘前,如果数据点过多,可以进行降采样(例如,只取这段时间内的最大、最小、首、尾点),在保持趋势的同时大幅减少渲染负担。
  • 双缓冲与OpenGL加速QCustomPlot支持OpenGL加速(setOpenGl)。如果显卡支持,开启后FPS会有巨大提升。对于动态曲线,启用setNotAntialiasedElements(QCP::aePlottables)可以关闭抗锯齿,进一步提高绘制速度。
  • 智能范围调整:对于自动滚动的实时曲线,不要频繁调用rescaleAxes(),这很耗资源。可以手动计算数据在x轴方向上的范围,并只更新x轴的范围,y轴可以设置为固定范围或根据一个滑动窗口内的数据动态调整。
// 在定时器的槽函数中更新曲线 void RealTimePlot::onRefreshTimer() { double currentTime = QDateTime::currentMSecsSinceEpoch() / 1000.0; // 秒为单位 static double lastPlotTime = 0; if (currentTime - lastPlotTime < 0.05) return; // 控制刷新率不超过20Hz lastPlotTime = currentTime; // 从线程安全的缓冲区获取新数据 QVector<QCPGraphData> newData = m_dataBuffer->fetchAll(); if (!newData.isEmpty()) { m_graph->addData(newData); // 移除旧数据,只保留最近30秒的数据 double removeBefore = currentTime - 30.0; m_graph->data()->removeBefore(removeBefore); // 只更新x轴范围,实现滚动效果 ui->customPlot->xAxis->setRange(currentTime - 30, currentTime); ui->customPlot->replot(QCustomPlot::rpQueuedReplot); // 使用排队重绘,更平滑 } }

5. 常见问题与排查技巧实录

在开发和调试CAN总线上位机的过程中,会遇到各种各样的问题。下面是我总结的一些典型问题及其解决方法。

5.1 通信连接与硬件问题

问题现象可能原因排查步骤与解决方案
打开CAN通道失败1. CAN适配器未连接或驱动未安装。
2. 通道号错误。
3. 波特率设置与总线其他节点不匹配。
4. 通道已被其他软件占用。
1. 检查设备管理器,确认CAN卡识别正常,安装官方最新驱动。
2. 确认使用的通道索引(通常0代表Channel 1)。
3. 使用CAN卡自带的配置工具先测试连通性,确保波特率一致(125K, 250K, 500K, 1M)。
4. 关闭其他可能占用该通道的软件(如CANalyzer)。
能打开通道但收不到任何数据1. 终端电阻未接或损坏。
2. 硬件连接错误(CAN_H, CAN_L接反)。
3. 过滤器设置过于严格,屏蔽了所有报文。
4. 总线无活动。
1.这是最常见的原因!确认CAN总线两端(或一端,如果只有两个节点)已正确连接120Ω终端电阻。用万用表测量CAN_H和CAN_L之间的电阻,应在60Ω左右。
2. 检查接线,CAN_H接CAN_H(通常黄色/橙色),CAN_L接CAN_L(通常绿色/白色)。
3. 检查代码中的过滤器设置,初始调试时可设置为接收所有帧(ID掩码为0)。
4. 使用一个已知好的节点(如另一个CAN工具)发送测试帧。
收到大量错误帧1. 波特率不匹配。
2. 总线物理层问题(短路、开路、干扰)。
3. 节点同步问题。
1. 再次核对所有节点的波特率。
2. 检查总线是否有对地或对电源短路,线缆是否过长(超过40米需考虑中继),是否靠近强干扰源。
3. 检查是否有节点持续发送错误帧导致总线关闭。

实操心得:准备一个USB-CAN分析仪作为独立的“监听者”非常有用。当你的上位机软件出现通信问题时,可以同时用这个分析仪监听总线,对比两者收到的数据,能快速定位问题是出在你的软件、你的CAN卡驱动,还是总线本身。

5.2 软件功能与性能问题

问题现象可能原因排查步骤与解决方案
界面卡顿,特别是曲线绘制时1. UI线程被阻塞(如直接在槽函数中处理大量数据)。
2. 曲线数据点过多,重绘开销大。
3. 频繁的内存分配/释放。
1. 使用QThreadQtConcurrent将耗时操作移出UI线程。使用性能分析工具(如QT Creator自带的性能分析器)找到热点函数。
2. 实施数据缓冲和降采样策略,如4.4节所述。限制曲线显示的点数(例如最多10000个点)。
3. 对于高频数据,在通信线程和数据处理线程间使用预分配内存的环形缓冲区,避免new/delete
数据解析错误(值不对)1. 字节序(Endianness)弄错。
2. 数据格式(如IEEE754浮点数、有符号整数)解析错误。
3. 协议理解有误(如多包传输处理不全)。
1. 与硬件工程师确认协议文档的字节序(大端还是小端)。使用qFromBigEndian/qFromLittleEndian函数进行转换。
2. 对于浮点数,确认是IEEE754标准,并使用memcpyunion安全地进行转换,避免类型双关(type-punning)的未定义行为。
3. 使用CAN分析仪捕获完整的数据流,对比你的解析逻辑,逐字节分析。
内存使用量随时间增长(内存泄漏)1. 未正确删除动态分配的对象(尤其是QObject派生类)。
2. 容器(如QList,QVector)中的数据只增不减。
3. 信号槽连接未断开,导致对象无法释放。
1. 遵循QT对象树管理原则,将子对象的父对象设置为合适的父级,通常会自动释放。对于手动new的对象,确保在适当的时候delete,或使用智能指针(QScopedPointer,std::unique_ptr)。
2. 对于实时数据队列,设置一个上限,当数据超过一定数量时,丢弃旧数据。
3. 在对象的析构函数中,检查并断开所有信号槽连接。使用QObject::disconnect
软件崩溃,特别是在频繁操作后1. 多线程访问共享数据未加锁。
2. 野指针或悬空指针。
3. 信号槽跨线程传递了非线程安全的数据类型。
1. 使用QMutexQReadWriteLock保护所有被多个线程访问的共享数据结构。注意:对QListQVector等容器的迭代器操作尤其需要保护。
2. 使用QPointer来持有QObject指针,它可以自动在对象被删除后变为nullptr。
3. 确保跨线程传递的自定义数据类型是可拷贝的,并且其拷贝构造函数是线程安全的(对于QT的隐式共享类,如QString,QByteArray,它们是线程安全的)。

5.3 打包与部署问题

开发完成后,如何将软件交付给测试或生产人员?

  1. 动态链接库依赖:使用windeployqt工具(QT安装目录下)可以自动将程序依赖的QT DLL复制到可执行文件目录。命令如:windeployqt --release --no-compiler-runtime your_app.exe。但注意,它不会复制第三方库(如PCAN Basic的DLL)和VC++运行时库。
  2. 第三方库与运行时:手动将CAN卡厂商的DLL(如PCANBasic.dll)复制到exe同级目录。对于VC++运行时,可以在安装包中引导用户安装,或者使用静态链接(但需注意许可证)。
  3. 创建安装包:使用Inno SetupNSIS等工具制作专业的安装程序。安装程序可以自动添加环境变量、注册COM组件(如果需要)、创建桌面快捷方式和开始菜单项。
  4. 静态编译:为了避免依赖问题,可以考虑静态编译QT和你的程序。但这会使最终的可执行文件变得很大(几十MB),并且需要遵守QT的静态链接许可证(开源项目需遵循LGPL,商业项目需购买商业许可)。

避坑指南:在交付前,务必在一台干净的、没有开发环境的虚拟机上进行测试。这是发现缺失DLL或路径问题的最有效方法。同时,记录下完整的部署清单,包括所有需要分发的文件及其路径。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询