资讯详情

资讯详情

QCAD二次开发实战:ECMAScript与C++插件从零到部署

QCAD 二次开发这件事我是怎么一步步把自定义插件跑起来的C 和 ECMAScript 两条路都走了一遍今天直接把完整过程拆开来讲。如果你是刚接触 QCAD 二次开发或者已经在官方文档里翻过一圈但始终没找到“从零搭一个插件”的连贯参考那这篇文章应该能帮你省掉不少折腾时间。我自己的场景是给一个内部 CAD 工具链做一个批量图元统计的小插件框选一批图形对象自动按类型统计数量并生成一份清单写到当前图纸里。功能看起来不大但涉及插件注册、命令行交互、图形文档遍历、结果写回几乎覆盖了 QCAD 二次开发中最常用到的一整套 API 调用路径。更关键的是我分别用 ECMAScript 脚本和 C 原生插件两种方式各实现了一遍正好可以对比两种方案的开发成本和运行表现。这篇文章不只给代码更会把我在实际整合过程中踩过的坑、排查思路和关键结论写清楚。1. 整体设计拆解先搞清楚 QCAD 的插件机制再动手1.1 QCAD 二次开发的核心思路QCAD 本质上是一个基于 Qt 框架构建的桌面 CAD 应用它的图形内核RDocument、REntity 等核心类和用户交互层命令行、工具栏、视图都被封装成了一整套可供外部调用的对象模型。与其把 QCAD 想象成一个“封闭的绘图软件”不如把它理解为“一个带 CAD 内核的 Qt 应用宿主”。这意味着我们可以用两种语言去对接这套宿主第一种是 ECMAScript也就是我们常说的 JavaScript 语法体系QCAD 内置了脚本引擎脚本可以直接调用 QCAD 暴露出来的对象和 API不需要编译改完就能热加载运行。第二种是 C通过 Qt 的插件机制编写原生动态库在 QCAD 启动时由宿主程序加载注册性能和底层控制力更强。理解这个机制后二次开发的设计思路就清晰了做一个自定义插件本质上是“向 QCAD 注册一组新的命令和界面行为”而不是去修改 QCAD 本身的源码。插件的核心职责是三个接收用户输入鼠标操作、命令行参数调用图形文档接口完成增删改查把结果反馈给用户命令行输出、图形标注或对话框1.2 ECMAScript 与 C 插件如何选择很多人在选型上纠结我直接给你我的判断依据。ECMAScript 插件适合绝大多数业务场景尤其是那些强依赖“CAD 操作流程”的功能——框选、拾取、遍历实体、读写属性、动态绘制辅助图形。QCAD 官方提供的 API 封装层对脚本非常友好命名风格统一而且脚本可以直接使用命令行对象、输入对象这些交互组件做原型验证特别快。对于非计算机专业的工程师来说脚本方案学习曲线也更平缓改了就能立即看到效果不需要编译环境的折腾。C 插件适合三类情况一是性能敏感型操作比如海量实体遍历和几何计算脚本引擎的解释执行开销在这种场景下会比较明显二是需要深度集成原生第三方库比如对接自定义几何算法库、读写私有二进制格式三是需要与 QCAD 内部框架深度定制比如替换或扩展核心渲染逻辑、注册全新的视图组件。我的建议是能用 ECMAScript 搞定的功能别急着上 C。很多功能脚本方案的性能已经够用真正耗时的部分是几何计算这部分可以通过把算法下沉到 C 插件再通过命令接口组合调用的方式来做。典型的混合架构就是脚本负责交互流程和界面粘合C 负责重计算和底层封装。1.3 确定功能边界和插件形态在动手写代码之前先把“插件要做什么”拆成可执行的接口清单。我拿我的图元统计插件举例显示对话框或命令行交互接受用户选择 遍历当前图纸的所有实体或者用户框选区域的实体 按实体类型分类统计线、圆、弧、文字、标注、图块引用等 清点结果把统计结果写入图纸指定位置以文本实体的方式呈现这个功能边界听起来简单但它的技术覆盖非常全有交互选实体、有遍历文档对象、有分类类型判断、有输出写入新实体。任何一个新插件都可以从这套骨架开始扩展。把功能边界想清楚后再去写代码你会发现每一步的目的都特别明确不会出现“这个 API 为什么会在这里出现”的困惑。2. 开发环境准备与基础工程结构2.1 ECMAScript 插件开发需要准备什么ECMAScript 插件的准备门槛很低你只需要一个有 QCAD 可执行文件的环境再加一个文本编辑器就够了。开发时可以分成两种情况一种是直接把脚本放到 QCAD 的用户脚本目录中通常是 QCAD 安装目录下的 scripts 文件夹或者用户配置目录下的 scripts 目录。QCAD 启动时会自动扫描这些目录并在脚本管理器里注册脚本提供的命令。另一种是在 QCAD 的脚本控制台Script Console里直接编写或者调试代码。控制台适合验证 API 调用片段正式功能还是得放到脚本文件里做成可复用的插件。如果你装了 QCAD 源码包会发现官方大量功能本身都是 ECMAScript 脚本实现的直接翻源码是最有效的 API 学习资料。我的建议是开发时手边放一份 QCAD 帮助文档的脚本接口清单再放一份官方脚本源码目录查接口和找用法都比靠记忆靠谱。2.2 C 插件开发环境:版本匹配是最关键的一步C 插件开发和 ECMAScript 完全不是一个量级的准备工作。因为 QCAD 是基于 Qt 的应用C 插件必须以动态库形式与 QCAD 宿主进程共享 Qt 运行环境。这里有一个最容易踩坑的点编译插件用的 Qt 版本必须和 QCAD 编译时用的 Qt 版本一致包括编译器版本和构建配置32 位、64 位、动态链接还是静态链接版本不一致轻则插件加载失败重则崩溃。按我实际整合的步骤C 插件开发环境的搭建分这么几步获取 QCAD 源码包确认源码里标记的 Qt 版本号。安装对应版本的 Qt 开发环境我这里是 Qt 5.x 系列一个较新的版本配套的编译器选择了 MSVCWindows 平台和 GCCLinux 平台分别验证。配置构建工具Windows 上可以用 Qt 自带的套件生成 Makefile 或者通过 IDE 打开工程。构建 QCAD 的依赖库环境这一步需要根据你选的平台和源码说明操作确保基础库能正常产出。这里特别提醒不要用系统里随便装的一个 Qt 去编译 QCAD 插件。我一开始图省事用了一个较新版本的 Qt 去编译插件结果运行加载时直接报版本不匹配排查了很长时间才发现问题根源。严格匹配宿主应用编译环境是 C 插件开发最重要的前提。2.3 插件工程的基本布局无论哪种语言插件在文件系统上的组织方式都应该是工程化的而不是在根目录丢一个孤零零的脚本。我的建议目录结构是这样的my_custom_plugin/ |-- ecma_scripts/ | |-- MyPlugin.js | |-- MyPluginDialog.js | -- info.json |-- cpp_native/ | |-- nativeplugin.pro或 CMakeLists.txt | |-- src/ | | |-- NativePlugin.cpp | | |-- NativePlugin.h | -- include/ | -- plugin_interface.h |-- docs/ | -- DEVELOPMENT_NOTES.mdECMAScript 脚本目录里的 info.json 用来描述插件元信息名称、版本、作者等也会被 QCAD 的插件管理机制读取。C 部分通过工程文件构建出独立动态库安装阶段复制到 QCAD 的插件目录。把两种实现放在同一个项目目录下管理是为了在同一个功能迭代里保持逻辑一致脚本版本负责快速迭代和数据验证C 版本负责最终的生产环境性能和深度能力。3. ECMAScript 插件从命令注册到功能实现的完整路径3.1 创建脚本插件的基本骨架ECMAScript 插件的入口就是一个脚本文件。QCAD 的脚本引擎会加载这个文件并执行顶层代码所以我们要在顶层完成“注册”动作。一个最基础的插件骨架长这样include(scripts/EAction.js); function MyPlugin(guiAction) { EAction.call(this, guiAction); } MyPlugin.prototype new EAction(); MyPlugin.prototype.beginEvent function() { EAction.prototype.beginEvent.call(this); // 在这里执行插件的主逻辑 }; MyPlugin.init function(basePath) { var action new RGuiAction(qsTranslate(MyPlugin, 图元统计), { scriptFile: MyPlugin.js, type: RGuiAction.ActionCommand }); action.setRequiresDocument(true); action.setDefaultShortcut(new QKeySequence(mystat)); action.setDefaultCommand(mystat); action.setGroupName(MyTools); EAction.registerAction(action, basePath); };这段骨架里有几个关键点直接决定插件是否可用的核心include(scripts/EAction.js)把 QCAD 提供给我们的动作基类引进来后续的自定义行为都基于 EAction 派生。RGuiAction是插件与 QCAD 命令系统之间的桥梁。通过setDefaultCommand(mystat)我们可以让用户直接在命令行输入 mystat 来唤起功能。setRequiresDocument(true)表示该命令必须在打开的图纸文档环境下运行避免在无文档状态下访问图形接口时报错。init是脚本引擎在扫描插件时自动调用的静态方法basePath参数是脚本所在路径。写到这里时你可能会纠结为什么用 RGuiAction 而不是直接注册一个快捷键事件实际上 QCAD 的命令行、菜单、工具栏和快捷键都是通过 RGuiAction 统一抽象的插件只要创建并注册一个 RGuiAction就同时获得了命令行、菜单栏和工具栏三种触发入口。这比逐个去绑定 UI 控件要简化得多。3.2 命令入口与交互流程怎么组织有了命令注册接下来需要处理的是“用户选实体”这个交互环节。EAction 的机制是当命令被触发后插件进入 beginEvent我们可以在其中协调 QCAD 的交互状态。我的选择是用对话框让用户勾选统计范围和统计类型然后回到绘图界面让用户框选区域最后生成统计结果。这个流程里脚本的异步交互模型很关键——QCAD 的交互不是简单的顺序执行而是事件驱动的状态流转。MyPlugin.prototype.beginEvent function() { EAction.prototype.beginEvent.call(this); var doc this.getDocument(); if (isNull(doc)) { EAction.prototype.finishEvent.call(this); return; } // 获取对话框输入参数 var di this.dialog; if (isNull(di)) { this.dialog new MyPluginDialog(this); this.dialog.show(); return; } if (this.dialog.exec() ! QDialog.Accepted) { EAction.prototype.finishEvent.call(this); return; } // 进入框选状态 this.setState(MyPlugin.SelectArea); this.setCrosshair(); };这里有个我实际用下来觉得挺重要的经验不要在 beginEvent 里直接把所有逻辑写死而是把流程拆成状态机。QCAD 的交互机制非常适合状态机表达每个状态对应鼠标或键盘的一个处理阶段状态之间通过 setState 切换。我从官方源码里学到的写法是预先定义几个状态常量在每个状态对应的鼠标事件里做判断完成后再进入下一个状态。比如框选状态结束收集到选择集后再进入“生成结果”状态这个状态里画出统计清单。3.3 读取图形文档里的实体数据无论交互怎么复杂图元统计功能的数据来源始终是当前文档对象。QCAD 脚本接口里文档对象提供了queryAllEntities()以及queryContainedEntities()等方法。前者返回文档中全部实体后者接受一个区域参数返回该区域范围内的实体列表。实际操作时我优先采用了区域查询的方式框选后构造 RBox 对象传给查询接口这样可以直接排除图面以外的干扰实体效率更高。再辅助判断实体类型核心代码如下var rect new RBox(p1, p2); var ids doc.queryContainedEntities(rect); for (var i 0; i ids.length; i) { var entity doc.queryEntity(ids[i]); if (isNull(entity)) continue; var type entity.getType(); stats[type] (stats[type] || 0) 1; }这里有个细节值得注意queryContainedEntities返回的是实体 ID 数组通过queryEntity才能拿到实体对象。QCAD 的实体对象继承自 REntity对getType()返回的类型枚举官方文档里有完整列表。在实际项目中你可能还想区分“计量单位”“文字内容”“图层归属”等属性统一用entity.getProperty或者针对子类调用特定接口即可。如果你想统计的是全图实体而不是框选区域直接用queryAllEntities()即可。但这个接口的开销和图纸实体数量线性相关上万图元时在脚本环境里会有可感知的等待这也是我后来在 C 版本里重点做性能对比的测试点之一。3.4 统计结果写回图纸统计不是终点——把统计结果变成可读、可保存的图纸内容才算完。QCAD 脚本里创建新实体并写入文档是一个非常典型的操作链路我把它拆成三步第一步创建文本实体对象RTextEntity设置内容、位置、高度、角度和对齐方式。第二步把实体附加到文档中通过doc.addEntity(entity)完成。第三步调用doc.getStorage().getTransactionGroup()配合doc.endTransaction()来保证实体操作归入事务系统这样用户按 CtrlZ 可以撤销这次操作。var textEntity new RTextEntity( doc, new RTextData( RTextData.create( new RVector(x, y), new RVector(0, 0), 2.5, 1.0, RTextData.Left, style, statText ) ) ); doc.addEntity(textEntity); doc.endTransaction();我在第一次实现时漏掉了事务的开始和结束导致批量写入的实体在操作历史里是割裂的撤销的时候出现了一些奇怪的现象。后来去看官方插件源码注意到一个规律凡是修改文档数据的操作几乎都包在事务里面。这就是为什么我说“看官方脚本源码是最高效的学习方式”再详尽的文档也不如真实项目里的事务用法直观。3.5 部署到 QCAD 并验证效果脚本插件的部署几乎不需要安装步骤把脚本文件复制到 QCAD 的脚本目录重启 QCAD 后命令行输入 mystat 就能看到效果。这里有一个我不太推荐的做法直接改 QCAD 安装目录下的 scripts 文件夹。因为有些系统对安装目录有权限保护而且容易在你升级 QCAD 版本时被覆盖掉。更稳妥的方案是放到用户配置目录下的 scripts 文件夹QCAD 同样会自动扫描而且升级软件不会动用户目录。我自己的验证流程是先在脚本控制台依次执行函数片段确认各环节 API 行为符合预期再重启 QCAD 做整链路验证。如果发现命令没有出现优先检查脚本文件有没有语法错误、文件名和 RGuiAction 的 scriptFile 是否一致、命令是否被占用。命令行输入 mystat 后 QCAD 如果没有反应多半是注册失败或脚本抛异常这时打开脚本控制台看看有没有红色的错误输出就行。4. C 原生插件深入 QCAD 内核的实现方案4.1 C 插件工程如何构建C 插件走的是 Qt 插件机制。QCAD 源码里提供了一组插件接口头文件我们只需要在自己的动态库工程里包含这些头文件实现对应的接口类然后通过 Qt 的 Q_PLUGIN_METADATA 机制导出插件即可。工程创建的核心点是 .pro 文件的配置。我用的项目文件大概是这样的QT core gui CONFIG plugin TARGET mystatnative TEMPLATE lib INCLUDEPATH \ $$PWD/../qcad_src/src/core \ $$PWD/../qcad_src/src/ecma \ $$PWD/../qcad_src/src/scripting \ $$PWD/../qcad_src/src/dxflib SOURCES \ src/NativePlugin.cpp配置里最关键的三件事CONFIG plugin会告诉 Qt 构建系统生成插件式动态库TEMPLATE lib会生成共享库INCLUDEPATH必须准确指向 QCAD 源码的关键头文件目录否则编译时一堆找不到头文件的错误会让人崩溃。编译完成后会生成一个 dll 或 so 文件。这个文件需要放到 QCAD 的插件目录一般在安装目录的 plugins 子目录下。QCAD 启动时会扫描插件目录动态加载所有符合接口约定的库文件。4.2 实现原生插件接口类原生插件的核心接口是 QCAD 定义的一个抽象类我们需要继承它并实现几个关键方法。这个类的设计思想很清晰QCAD 在运行时向每个插件传递一个上下文环境插件接口、脚本引擎、资源路径等插件通过这个上下文可以在 C 层注册命令、操作文档、调用脚本。我实现的接口类大致长这样class MyStatNativePlugin : public QObject, public RPluginInterface { Q_OBJECT Q_PLUGIN_METADATA(IID QCAD_PLUGIN_IID) Q_INTERFACES(RPluginInterface) public: bool initPlugin( RPluginInterface* pluginInterface ) override; bool uninitPlugin( RPluginInterface* pluginInterface ) override; bool initScriptExtensions( QScriptEngine engine ) const override; };三个方法的职责分别是initPlugin:插件加载后的初始化入口在这里注册命令和资源。可以把一个 RGuiAction 实例注册到 QCAD 的命令系统中方式与 ECMAScript 里的 RGuiAction 类似。uninitPlugin:插件卸载时的清理入口释放资源。initScriptExtensions:让 C 插件暴露自定义类和方法给脚本环境调用。这是混合架构里的关键接口通过qScriptRegisterMetaType或者直接在引擎上设置全局对象可以让 ECMAScript 脚本直接调用 C 层的函数。实际开发中我更多把 C 插件当作“计算引擎”把交互流程仍然放在脚本层。脚本层通过调用 C 暴露出来的接口传入实体数据和参数C 层做完统计或几何计算后再把结构化的结果返回脚本。这套模式既兼顾了脚本的开发效率又拿到了 C 的计算性能。4.3 用 C 遍历实体与性能观察C 层遍历实体和脚本层在 API 上非常相似因为底层本就共用同一套 RDocument 接口。区别主要体现在两点一是对象访问不再需要经过脚本引擎的包装层二是数据密集循环里不需要处理脚本对象的垃圾回收执行速度自然更可控。我的图元统计功能在 C 层的核心循环如下QSetREntity::IdType ids; if (useSelection) { ids document-querySelectedEntities(); } else if (useBox) { ids document-queryContainedEntities(box); } else { ids document-queryAllEntities(); } QHashint, uint64_t typeCount; for (auto id : ids) { QSharedPointerREntity entity document-queryEntity(id); if (entity.isNull()) { continue; } typeCount[ entity-getType() ] 1; }我把同一个统计功能分别用脚本和 C 实现了之后做了一个粗略性能对比在一张包含 5 万条图元的测试图纸里全图遍历ECMAScript 脚本版本大约耗时 3 秒多C 版本大约耗时 0.3 秒左右相差接近 10 倍。这个差距在大多数交互场景下可能感知不强但如果你要做的是批量处理几十张图纸的自动化任务这个累积差异就非常可观了。当然这个对比不是绝对的脚本引擎在不同版本上的性能也会有波动。但它至少说明了一个趋势如果你已经知道你的功能会在海量数据下反复执行C 层下沉计算是值得投入的方向。4.4 C 插件的加载与调试技巧C 插件开发最痛苦的部分是加载失败时几乎没有有用的日志。我总结了一套排查顺序按这个顺序来能很快定位大部分问题第一步确认动态库的依赖环境。用依赖检查工具看库文件是否能解析到 Qt 的核心库。因为 QCAD 内部打包的 Qt 库路径往往不是系统标准的插件安装目录周边缺少库文件时会加载失败。第二步确认编译位数和构建模式是否与 QCAD 匹配。64 位的 QCAD 不能加载 32 位插件Release 和 Debug 混用也容易出问题如果宿主是 Release 版插件尽量也用 Release 编译否则在内存管理上很容易踩雷。第三步确认插件接口头文件版本与 QCAD 版本一致。不同版本的 QCAD 可能调整过插件接口用旧头文件去加载新版宿主的插件轻则接口调用错乱重则直接崩溃。加载成功后调试手段主要靠日志输出和断点调试。我在插件里统一封装了一个全局日志输出函数所有关键入口都留了输出点。你可以把日志写到一个独立文件也可以直接输出到 QCAD 的命令行窗口。个人经验是给插件加一个“调试模式”开关正式发布时关掉开发时打开排查问题会轻松很多。5. 常见问题与排查技巧实录5.1 ECMAScript 插件不生效怎么办最常遇到的就是命令注册了但输入没反应。我整理了一份速查表现象可能原因排查思路命令行输入命令后提示未知命令RGuiAction 的 defaultCommand 设置错误或未注册成功在脚本控制台手动执行 init 函数看有没有异常命令存在但执行后界面无任何反馈脚本抛出未捕获异常打开脚本控制台查看红色异常堆栈命令点击后提示需要在文档中运行未设置 setRequiresDocument打开图纸后再执行命令或调整注册参数脚本被修改后依旧执行旧逻辑脚本缓存未刷新完全退出 QCAD 再重启不要只用关闭图纸的方式对话框闪现后立即消失对话框对象生命周期管理不当确认对话框对象作为插件成员持有不能是栈上局部变量对话框闪退这个问题我要专门说一下。ECMAScript 脚本的对话框如果作为局部变量创建函数结束后对象可能被垃圾回收界面就会异常关闭。正确做法是把对话框实例保存在插件的属性里例如赋给 this.dialog生命周期就跟随插件实例了。5.2 C 插件加载失败的通用解法C 插件加载不起来是另一套排查思路。我专门整理过症状归类QCAD 能正常启动但插件功能列表里找不到你的命令这通常是插件库本身没有被 QCAD 识别为有效插件优先查Q_PLUGIN_METADATA里的 IID 是否匹配当前 QCAD 版本声明。启动时出现弹窗错误或者崩溃常见原因是依赖库缺失。用依赖分析工具把动态库所依赖的模块打开看一遍特别是 Qt 的 platform 插件路径是否配置正确。你可以把插件目录下的发布说明文件翻出来对照一下看看官方对运行时依赖的约束。运行时操作特定功能时才崩溃那多半是和宿主之间的接口版本有差异。比如 QCAD 在后续版本里改了某个虚函数签名你用旧头文件编译的插件自然就会踩内存错位。这种问题唯一的解法是把 QCAD 源码升级到与宿主一致的版本重新编译插件。5.3 混合架构集成时需要注意什么当你的插件既要跑 ECMAScript 脚本又要调用 C 计算库时最容易出的问题是脚本引擎里找不到 C 导出的对象。原因通常是initScriptExtensions里没有正确注册或者注册逻辑没有在脚本调用前执行。我的建议是在脚本主入口的init函数里显式检查 C 接口对象是否存在不存在时给出明确的错误提示。这样可以让使用者在第一时间知道是插件部署不完整而不是等到功能深度执行时才报一个莫名其妙的错误。举个例子MyPlugin.init function(basePath) { if (typeof NativeStatsProcessor ! undefined) { // 注册完整版功能 } else { // 注册纯脚本降级版功能或提示缺失原生插件 } };这个降级逻辑在项目集成时非常实用。我在内部发布时经常遇到同事只拷贝了脚本目录、漏掉了 C 动态库的情况有了这个检查逻辑脚本会主动提示缺了原生组件省去了排查时间。如果你想让维护更简单还可以让脚本在检测到缺失时直接把功能降级为纯脚本实现只是性能差一点但至少流程能跑完。5.4 跨平台部署的差异点QCAD 是一个跨平台的软件插件在不同平台上的部署路径和编译方式有差异。Windows 上插件一般复制到安装目录的 plugins 文件夹Linux 上很多发行版打包的 QCAD 会从/usr/lib/qcad/plugins这类路径加载macOS 上则是应用包内的 Contents/PlugIns 目录。我的建议是先运行一次 QCAD 自带的插件扫描日志输出看看程序实际扫描了哪些目录再把插件丢到扫描范围内。另外记住一点ECMAScript 脚本是纯文本跨平台没有问题但 C 动态库必须分别在目标平台上各自编译一份。不要试图在 Windows 上交叉编译 Linux 的插件再拿过去用老老实实在每个平台搭环境编译虽然折腾但最稳妥。6. 实操总结基于真实调试经验说的几句我在整个自定义插件整合过程中最有感触的一点是QCAD 的二次开发并不玄学它和你熟悉的任何基于框架做扩展的开发模式一样核心是搞清楚宿主的扩展契约。ECMAScript 插件的契约就是 RGuiAction EAction 的状态机C 插件的契约就是 RPluginInterface 和 Qt 插件机制。把这两个契约理解透了后面无论功能怎么变都是在两张蓝图里填充业务而已。另外我个人建议无论你的最终目标是 C 版本还是脚本版本第一步都应该先用 ECMAScript 把完整功能跑通。脚本版本的开发调试速度快得多可以让你快速验证业务逻辑是否合理、UI 交互是否顺手、计算流程有没有遗漏。确认流程没问题后再针对性能瓶颈或系统集成需求去写 C 版本这样能把 C 的开发成本花在真正需要的地方而不是浪费在反复调整业务逻辑上。最后再分享一个小技巧QCAD 里现成的官方脚本插件非常多而且全部带源码。遇到不确定的 API 时我通常第一步不是去看接口文档而是直接搜索官方插件目录里有没有类似场景的代码。比如我想做“批量写入文本实体”就直接搜官方脚本里所有 RTextEntity 出现的文件看它们在事务处理、坐标系转换、样式设置上是如何处理的然后照着里面的写法适配自己的场景。这个方法帮我解决了很多文档里没讲清楚的实际问题。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →