☰
送药机器人源码设计:基于Python的A*路径规划与PID控制解析
2026/10/31 6:26:04 网站建设 项目流程

简介:这是一份第二十五届中国机器人及人工智能大赛智慧药房组参赛作品DrugDeliverer的完整设计源码,面向机器人竞赛选手、智慧医疗开发者和自动化物流研究者,用于解决药房药品分发与递送环节依赖人工、效率低的问题。压缩包约55.36MB,共163个文件,核心为23个Python脚本与ROS通信体系(11个launch、10个srv),配合39个YAML配置、21张JPG图片及Shell、C++等辅助文件,覆盖业务逻辑、参数配置、部署维护和结构示意。已有804人学习下载。读者可获得完整的赛题方案:从yolox视觉识别、环境依赖打包(imutils、playsound)到send_goals_node等运动控制节点,并附有PDF/MD文档、3D模型等支撑材料,便于复现调试与二次开发。整体目录结构清晰,既可用于竞赛复盘,也可作为智慧药房自动化配送系统的设计参考。

1. 智慧药房组的DrugDeliverer:一台送药机器人背后的源码设计线

第二十五届中国机器人及人工智能大赛的智慧药房组,核心任务听起来不复杂:让一台机器人识别处方、从药柜取药、把药送到指定病房并返回。但真到比赛现场,你会发现“送药”这两个字压着三条硬约束——场地地图是赛前公布的,但药盒位置、病床号牌和临时障碍会在现场变动;机器人必须在规定时间内完成多次配送,超时直接扣分;底盘、机械臂和视觉模块各自来自不同厂商,软件层稍没衔接好就整场翻车。DrugDeliverer这类基于Python的设计源码,就是把这台机器人从“硬件拼装”变成“能按策略跑任务”的决策与控制层。它的价值不在于某个算法多深,而在于把感知、规划、底盘控制串成一个闭环。适合正在备赛的学生团队、做课程设计的本科高年级学生,以及想从仿真快速迁移到实物的机器人入门者——这篇文章会照着这套源码最常见的组织方式,把架构、代码模块、参数和踩坑一处一处讲透。

2. DrugDeliverer的架构设计:四层职责与Python选型理由

2.1 从传感器到电机:源码里的四层划分

我经手过的送药机器人设计方案,无论代码写得乱不乱,逻辑上都能拆成四层。最底层是硬件抽象层,处理激光雷达、摄像头、底盘电机和机械臂的驱动——这一层在Python里通常表现为串口或者TCP指令封装。第二层是感知层,做药盒识别、Apriltag/Aruco码定位、障碍物检测。第三层是决策规划层,负责读地图、执行A*寻路、维护一个有限状态机(FSM)来切换“取药—送药—返回”的状态。最上层是任务调度层,解析处方文件,决定先送哪个房间、路径冲突时谁优先。

这四层分开写有它的道理:比赛前场地会改,改动最多的是感知参数和地图文件,而底盘控制代码几乎不用动。如果全揉在一个脚本里,换一张地图就要重调整台车。下表是我推荐的组织方式,目录结构基本能对应到DrugDeliverer源码的常见模块命名。

层级典型目录/模块主要职责常用到的Python库
硬件抽象drivers/、serial_comm.py底盘速度指令、编码器反馈、机械臂角度pyserial、threading
感知perception/、vision.py药盒识别、标记码定位、障碍物聚类OpenCV、numpy
决策规划planner/、astar.py、fsm.py栅格地图维护、路径搜索、任务状态切换heapq、numpy
任务调度main.py、task_manager.py处方解析、配送顺序、超时与重试json、collections

这条职责链其实就回答了“这是什么”的问题:Python源码不是让机器人动起来的最低层驱动,而是让机器人“知道该往哪动、为什么动”的中间大脑。比赛中很大一部分队伍挂掉,不是因为电机不转、相机没画面,而是因为规划层跑出来了路径,底盘控制层看不懂路径坐标系,或者感知层检测结果直接塞给了决策层没有带时间戳。

2.2 为什么用Python做调度和决策:开发效率优先于运行效率

智慧药房组的比赛场景是结构化场地,不是高速动态环境,机械臂运动速度被限制在安全范围内,底盘速度通常不超过0.5 m/s。这种场景对决策层代码的实时性要求并不苛刻,控制周期在100 ms级别完全够用。Python在这个尺度下的优势很明显:OpenCV和NumPy的生态让视觉方案的验证成本极低,A*路径规划的核心代码几十行写完,跑一帧地图搜索时间在毫秒级,串口通信用pyserial封装后发一条速度指令只花几微秒。

作为对比,用C++写同一套逻辑,性能和部署占优,但比赛前夜调参时每改一个视觉阈值就要重新编译一次,这是致命的。Python允许你开着调试器改参数热重载,这对赛前时间极度碎片化的团队来说是最大红利。我见过不少工科生纠结“用Python会不会被评委觉得技术含量低”,恰好相反——第二十五届这类赛项看重的是“系统完成度”和“现场运行稳定性”,一套清晰可读的Python源码在答辩环节更容易讲清楚,也方便评委直接看代码提问。

另一个选型理由是硬件厂商的SDK支持。很多比赛底盘的协议文档随手给的就是Python示例,机械臂也提供pybind11封装好的接口。真正需要底层硬实时的地方,比如步进电机的脉冲控制,直接交给单片机处理就好;上位机Python只发目标速度,不参与插补。这个分工让Python处在“下达意图”的位置,避开了它的软肋。

提示:如果你的团队里有同学只熟悉C++,不要让他单独写中间层,否则最后拼起来时类型对齐和数据流交接会浪费大量时间。定好每个模块对外的输入输出接口,用Python统一胶水层。

3. 按源码模块逐块落地:感知、规划与控制层的代码怎么组织

3.1 药盒识别与定位:用Aruco码还是训练YOLO模型

比赛场地里的药品通常有两种识别思路。一种是给每个药盒贴上不同ID的Aruco(或Apriltag)码,通过单目相机识别码的位置和ID,再借助已知的标签边长换算距离。另一种是直接用深度学习模型端到端识别药盒类别。我的建议是:新手优先做Aruco码方案,原因是可复现性高、训练时间成本为零、对光照稍微敏感但在室内灯光下表现稳定;深度学习的检测率更高但需要采集现场样本并训练,备赛时间往往不够。第二十五届不少队伍栽在“模型训练过拟合,换一个光照就漏检”上面。

Aruco定位的核心代码并不长,下面这段写进了我常用的vision.py里。它做的事情是:从相机帧里找到所有Aruco码,输出每个码的ID和四个角点的像素坐标,再用cv2.solvePnP配合标定好的内参算出码中心相对于相机坐标系的xyz坐标。关键是把识别和定位分开,后续若换相机,只替换内参矩阵,不必重写识别逻辑:

import cv2 import numpy as np ARUCO_DICT = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_250) PARAMS = cv2.aruco.DetectorParameters() # 提前用棋盘格标定得到的相机内参,不要用随便填的值 CAM_MATRIX = np.array([[640.0, 0, 320.0], [0, 640.0, 240.0], [0, 0, 1.0]]) DIST_COEFFS = np.zeros((5, 1)) def detect_aruco_pose(frame, marker_size_cm=5.0): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) corners, ids, _ = cv2.aruco.detectMarkers(gray, ARUCO_DICT, parameters=PARAMS) poses = {} if ids is None: return poses # 每个码单独做姿态估计,避免多个码互相干扰 for i, marker_id in enumerate(ids.flatten()): c = corners[i].reshape(4, 2) # 此处定义的是码平面上四个角点在世界坐标系下的坐标,单位与marker_size一致 obj_points = np.array([[-1, -1, 0], [1, -1, 0], [1, 1, 0], [-1, 1, 0]], dtype=np.float32) * (marker_size_cm / 2.0) success, rvec, tvec = cv2.solvePnP(obj_points, c, CAM_MATRIX, DIST_COEFFS) if success: poses[int(marker_id)] = (tvec[0][0], tvec[1][0], tvec[2][0]) return poses

这段代码里最需要调整的是三个参数。DICT_6X6_250决定了码的样式,比赛现场建议统一打印同一字典下的码,不要混用,否则检测率下降。marker_size_cm必须和实际打印尺寸一致,这直接关系到z轴距离估计的精度,差1厘米在1米距离外就会产生约20厘米的误差。CAM_MATRIX是用棋盘格标定出来的,不能在网上下一个通用值就上真机,否则solvePnP解出来的坐标会整体偏移。还有一个容易被忽略的改进点:在cv2.aruco.detectMarkers之前做一次直方图均衡化,能明显缓解逆光时漏检的问题。

3.2 栅格地图与A*路径规划:写一个能直接抄进源码的版本

路径规划这部分,比赛官方通常会给一张场地俯视图,但不会直接给你可以寻路的代价地图。常见做法是把场地图按分辨率栅格化,障碍物所在格子标记为1,可行区域标记为0。栅格分辨率视场地面积定,一般2厘米一格就够了——这个尺寸兼顾了底盘宽度(通常30~40 cm)和路径平滑度。下面是我常用的A*实现,支持开启对角移动,适合差速底盘在药房这种窄直角走廊里调用:

import heapq import numpy as np def astar(grid_map, start, goal, allow_diagonal=True): """ grid_map: 2D numpy数组,0可通行,1障碍 start/goal: (row, col) 元组 返回: 路径点列表,从start到goal(含两端);无解返回None """ h, w = grid_map.shape open_list = [] heapq.heappush(open_list, (0.0, start)) came_from = {start: None} cost_so_far = {start: 0.0} if allow_diagonal: neighbors = [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)] else: neighbors = [(-1, 0), (1, 0), (0, -1), (0, 1)] while open_list: _, current = heapq.heappop(open_list) if current == goal: break for dr, dc in neighbors: nr, nc = current[0] + dr, current[1] + dc if not (0 <= nr < h and 0 <= nc < w): continue if grid_map[nr, nc] == 1: continue # 对角移动时检查相邻两个正交位置是否都是障碍,防止穿墙 if dr != 0 and dc != 0: if grid_map[current[0] + dr, current[1]] == 1 or grid_map[current[0], current[1] + dc] == 1: continue step_cost = 1.414 if dr != 0 and dc != 0 else 1.0 new_cost = cost_so_far[current] + step_cost if (nr, nc) not in cost_so_far or new_cost < cost_so_far[(nr, nc)]: cost_so_far[(nr, nc)] = new_cost priority = new_cost + abs(nr - goal[0]) + abs(nc - goal[1]) heapq.heappush(open_list, (priority, (nr, nc))) came_from[(nr, nc)] = current if goal not in cost_so_far: return None path = [] cur = goal while cur is not None: path.append(cur) cur = came_from[cur] path.reverse() return path

启发函数我直接用曼哈顿距离,没有乘以大于1的膨胀系数。原因很简单:比赛地图的尺寸通常在10米乘10米以内,A*的搜索空间很小,启发函数精确一点反而能保证路径最优。allow_diagonal这个开关值得单独说——如果底盘的转向半径过大或走廊太窄,开对角会让路径出现斜穿墙角的情况,这时候宁可不允许对角移动,让路径走直角折线,跟着走反而更稳。

路径出来后还有一个关键细节:把它从离散栅格点转成平滑轨迹时,不要直接让底盘逐格点追点。中间有个非堵塞规划器,转成直线插值点,再用一个前瞻距离强制机器人跟踪路径而不是盯单个点。我一般把两颗栅格点之间等距插值成5厘米间隔的sub-goal,控制层逐点下发。

3.3 底盘控制与差速解算:PID参数和限幅才是重点

DrugDeliverer方案的底盘大多是两轮差速,偶尔有麦克纳姆轮版本。两轮差速的控制逻辑很简单:根据当前机器人的位置和目标点的偏角计算角速度,根据距离计算线速度,再用底盘的轮距把线速度和角速度拆成左右轮转速。比较常被忽略的是PID参数里I项的限幅。机器人满载药盒后惯性变大,如果I项无限累积,过弯时会明显超调,表现为蛇形走位。下面是我惯用的限幅写法:

class PID: def __init__(self, kp, ki, kd, max_i=0.0): self.kp = kp self.ki = ki self.kd = kd self.max_i = max_i self._integral = 0.0 self._last_error = 0.0 def update(self, error, dt): self._integral += error * dt # 积分限幅:防止大误差持续累加导致超调 if self.max_i > 0: self._integral = max(-self.max_i, min(self.max_i, self._integral)) derivative = (error - self._last_error) / dt if dt > 0 else 0.0 self._last_error = error return self.kp * error + self.ki * self._integral + self.kd * derivative

调PID时的套路是固定的:先只留P项,从小到大试到车头出现小幅振荡,记录此时的Kp,然后回退60%作为最终Kp;再给一个很小的Ki用来消除稳态误差,重点是不让它破坏掉P项调出来的稳定性;最后加一点点Kd来抑制过弯时的抖动,数值通常在Kp的十分之一以下。底盘直行可以用高Kp,但送药转弯时不允许那么激进,我一般会在状态机切到转弯状态下切换一组更保守的PID参数。

速度指令下发时要留意底盘协议的单位换算。名牌上写的是“1.0表示1 m/s”,实际很多底盘协议里1.0表示“单位:0.01 m/s”,直接送1.0会让车原地不动或极慢爬行,这一项是现场出BUG概率最高的地方。编码器反馈回来的单位也要换算清楚,否则闭环控制把单位搞错,PID会往错误方向纠偏。

4. 用PyBullet在本地跑通最小闭环:仿真联调的具体方法

4.1 搭一个能跑完整送药流程的仿真场地

真机调试时间很有限,赛前把逻辑跑熟一定要靠仿真。我推荐PyBullet而不是Gazebo,理由是安装简单、单文件就能构建场景、离开ROS也能跑,对纯Python方案尤其友好。下面这段脚本创建一个简化药房:地面、四面墙、两个药柜模型和机器人小车本体,机器人用简单的Box拼出来,上面装一个虚拟摄像头的位置标记:

import pybullet as p import pybullet_data import time p.connect(p.GUI) p.setAdditionalSearchPath(pybullet_data.getDataPath()) p.setGravity(0, 0, -9.8) p.loadURDF("plane.urdf") # 建一面墙,用固定底座的长方体表示 def add_box(pos, half_extents, color=(0.6, 0.6, 0.6, 1.0)): visual = p.createVisualShape(p.GEOM_BOX, halfExtents=half_extents, rgbaColor=color) collision = p.createCollisionShape(p.GEOM_BOX, halfExtents=half_extents) return p.createMultiBody(0, collision, visual, basePosition=pos) # 场地尺寸:8米 x 6米,墙高1米,墙厚0.1米 walls = [ ([4.0, 0.0, 0.5], [0.1, 3.0, 0.5]), ([-4.0, 0.0, 0.5], [0.1, 3.0, 0.5]), ([0.0, 3.0, 0.5], [4.0, 0.1, 0.5]), ([0.0, -3.0, 0.5], [4.0, 0.1, 0.5]), ] for pos, half in walls: add_box(pos, half) # 药柜A和药柜B,放置在两侧 add_box([2.0, 1.5, 0.4], [0.5, 0.2, 0.4], color=(0.3, 0.6, 0.9, 1.0)) add_box([-2.0, -1.5, 0.4], [0.5, 0.2, 0.4], color=(0.9, 0.6, 0.3, 1.0)) while True: p.stepSimulation() time.sleep(1.0 / 240.0)

这段代码的职责是快速验证“障碍物位置是否有冲突、地图尺寸是否符合预期”。在仿真中需要调的参数是墙的位置和半尺寸,要和比赛规则给的场地图纸严格一致,否则后续A路径规划跑通了,搬到现场又会因为实际地图有偏差而失效。pybullet里用的单位是米,而A栅格地图如果用厘米为单位,中间要统一换算,这是仿真转实物最容易出坐标数量级错误的地方。

4.2 把感知、规划、控制串成主循环的最小结构

仿真环境建好之后,接下来的工作是把之前三个模块用主循环串起来。比赛用源码通常写成一个状态机,主循环按固定帧率运行。我习惯的主循环大概是这样的骨架,实际项目里会把每个handle_函数再拆文件:

import time from planner.astar import astar from control.pid import PID # 假设已经有一张从仿真地图生成的grid_map grid_map = build_grid_from_map() start_cell = (80, 30) # 根据毫米坐标换算成栅格坐标 goal_cell = (20, 70) pid_linear = PID(kp=0.6, ki=0.02, kd=0.05, max_i=1.0) pid_angular = PID(kp=1.2, ki=0.0, kd=0.2, max_i=0.5) def compute_velocity(robot_pose, target_pose, dt): dx = target_pose[0] - robot_pose[0] dy = target_pose[1] - robot_pose[1] angle_to_target = math.atan2(dy, dx) angle_diff = normalize_angle(angle_to_target - robot_pose[2]) # 距离小于0.05米时不再前进,避免在目标点来回振荡 dist = math.hypot(dx, dy) if dist < 0.05: return 0.0, 0.0 linear = pid_linear.update(dist, dt) angular = pid_angular.update(angle_diff, dt) # 转弯优先:角速度误差大时压低线速度,防止原地画圈 if abs(angle_diff) > 0.3: linear *= 0.3 return linear, angular path = astar(grid_map, start_cell, goal_cell) while not arrive: # 从仿真或真机读取当前位姿(里程计或AMCL定位结果) robot_pose = get_current_pose() # 找路径上离当前位置最近的前瞻点,距离约0.3米 lookahead = pick_lookahead(path, robot_pose, 0.3) v, w = compute_velocity(robot_pose, lookahead, dt) send_velocity(v, w) time.sleep(dt)

这个循环里值得说的参数是lookahead的距离。它取太大会切弯,机器人会从走廊内侧冲过去,太小则会在每个路径点附近画圈。我对差速底盘的经验值是车体宽度的1.2倍左右,普遍在0.3~0.5米之间。转弯时的线速度削弱比例0.3也要根据底盘的加减速能力调,麦克纳姆轮可以给高一点,两轮差速要保守。

4.3 仿真到真机迁移时会变的参数清单

下面这张表是从仿真切到真机时一定要重新确认的参数,我每次迁移都会照着过一遍,基本能避免到现场才发现的低级问题:

参数仿真里的值真机常见的坑建议的确认方法
轮距(base_width)0.3 m写错单位,或左右轮标反原地旋转90度,量实际角度
编码器线数无(直接读仿真速度)每转脉冲数配错,速度反馈偏大或偏小推车走2米,对比里程计读数
相机内参理想针孔模型真机镜头畸变导致Aruco坐标偏移用标定板重新标定
最高线速度0.5 m/s电机扭矩不足或重心偏高翘头满载实测逐步增加
PID参数空载调好药盒满载后惯性变化需要重新调满载状态下跑直角弯验证

迁移过程中还会发现一个仿真和真机本质不同:仿真的里程计是理想数据,真机的轮子会有打滑,尤其在药房地面光滑瓷砖上表现明显。所以实车跑时定位不能只靠里程计,多数队伍会在药柜旁贴Aruco码做视觉矫正,每到一个标记点就把里程计位置重置一次,这是防止长跑后误差累积最简单的办法,代码里加一个if marker_visible: reset_pose(marker_pose)分支就行。

5. 避坑指南:DrugDeliverer从仿真到比赛现场的5个典型问题

5.1 地图栅格单位看错,路径规划“穿过”药柜

现象:仿真里A*规划的路径完全正常,但真机第一次跑就直接朝墙开,把底盘保护撞停了。

原因:栅格地图的坐标原点和单位不统一。比赛图纸给的是毫米,代码里栅格分辨率写成2厘米一格,某处换算时少除了一个100,导致起点或目标点被映射到障碍格附近,A*找不到路径或走出直接穿越障碍的“假路径”。

解决:在栅格地图构建函数入口处加一个断言,检查地图尺寸和图纸标注尺寸是否一致。比如图纸是8米×6米,地图shape应为(400, 300)(2厘米一格),不是(4000, 3000)也不是(80, 60)。每次加载新地图先打点验证:把起点和终点坐标打印出来,在图上可视化,确认没有落在障碍物格内。

5.2 PID积分饱和导致满载后转弯画龙

现象:空车调好的PID参数,装上药盒之后过弯轨迹呈S形,甚至到最后转不过来。

原因:满载后整车重心上移、惯量变大,同样的误差在积分项里累积得更快,积分项输出过大,等到误差反向时积分还没退下来,车就会冲过目标再往回纠。

解决:给积分项加限幅,也就是前面代码里的max_i。空车和满载分别标定一组参数,状态机里检测到机械臂取药动作完成后,主动切换PID参数。不要试图用一个万能参数覆盖两种载荷,比赛现场换药盒重量是经常的事,把这个切换做成自动的。

5.3 Aruco码识别闪烁:同一个码这一帧识别下一帧丢失

现象:视觉定位时药盒坐标忽远忽近,机器人接近目标点时不断前后调整,无法稳定抓取。

原因:光照变化导致Aruco码的二值化阈值抖动,或者打印的码表面反光,部分帧的角点检测失败。另一个常见原因是相机白平衡在自动模式,移动中画面色温变化。

解决:固定相机曝光和白平衡,不要在程序里用cv2.COLOR_BGR2GRAY之前做动态自适应阈值处理。加一个多帧滤波,取最近5帧中3个连续成功的检测结果做均值,能显著平滑坐标。真机上还有一个土办法:在Aruco码表面贴一层哑光膜,反光问题立刻缓解。

5.4 串口指令丢失,表现为机器人响应一下停一下

现象:底盘有时能走有时不动,上位机日志显示指令发出去了,但电机没动作。重插USB线又好了,过一会儿又复发。

原因:USB转串口芯片在Windows下供电不足,或USB休眠机制把端口挂起了。另一个隐蔽原因是底盘协议的应答帧和心跳要求,如果上位机没有按协议定期回心跳,底盘安全机制自动停机。

解决:在代码的send_velocity函数里加帧校验和和重传机制,超过50毫秒没有收到底盘应答就重发。同时把USB连接器的电源管理里的“允许计算机关闭此设备以节约电源”选项关掉。真机比赛期间建议给USB-Hub外接供电,不要直接用主板USB口带底盘。

5.5 电池电量下降后速度指令不变但车明显变慢

现象:比赛后半程,同一段路用时比上午长了近一倍,PID闭环逻辑没变,但实际速度就是提不上去。

原因:电池电压下降后,电机在同样PWM占空比下输出的实际转速下降。如果控制代码是开环输出占空比,速度自然变慢;即使闭环,编码器反馈正常,但电机扭矩不足也可能导致响应跟不上。

解决:不要用占空比作为速度指令单位,直接用目标转速(rpm或m/s)发给底盘控制器,让底盘的底层驱动去补偿电压变化。每次充满电后用同一段路径实测标定一次最大速度,在比赛策略里预留15%的时间余量。我习惯在代码里根据电池电压自动限制最大线速度,电压低于阈值时把PID目标速度调低,至少保证任务能稳定完成而不是突然停摆。

6. 赛前最后一晚:一个验证整机闭环的固定流程

临近比赛时不要做任何大改动,只做验证和微调。我每次赛前都会跑一遍“三点往返测试”:在场地中任选三个点(药柜、目标病房、出发点),让机器人按“取药→送药→返回”完整跑五遍,统计成功率、平均用时和最大偏差。这个测试能在20分钟内暴露大部分模块对接问题。

具体做法是写一个独立的smoke_test.py,不进主比赛代码,直接调用各模块接口。它做的事情很简单:依次调用感知模块检测药柜标志,把结果喂给规划模块生成路径,再下发速度指令给底盘,并用独立秒表记录每段耗时。如果五遍里有两遍失败,宁可加班查问题也不要带着侥幸心理上赛场。这个脚本的作用是让整个团队的验证步骤固定下来,避免比赛前夜三个人各测各的、测的东西不一样。

我自己的习惯是,最后一次修改代码后永远不直接上真机,而是在仿真里重跑一遍上一章的完整主循环。仿真通过之后再上真机做三点往返。这套流程帮我避免了两次“改了一行坐标变换就毁掉整个调试成果”的事故。另一个人人适用的做法是每次跑完数据都写回一个run_log.csv,记录电压、平均速度、PID参数版本和成功率,数据多了之后你会发现,很多“玄学”故障其实是某个参数在特定电压区间触发的,翻看日志比现场猜靠谱得多。

送药机器人的技术点并不比一个复杂的自动驾驶项目少,但它的上手门槛恰好合适——地图小、速度低、任务明确。希望你拿着这套源码组织方式和排错思路,把第二十五届智慧药房组的任务跑通,也把调试机器人的手感练出来,这个能力在后续任何机器人项目里都用得上。希望帮到你。

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

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

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

立即咨询