尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
uniapp实战:运动轨迹记录与手环蓝牙数据接入全攻略
我最初接触这个项目是帮一个朋友做运动类App的初版。需求听起来不复杂打开App记录跑步/骑行的轨迹同步智能手环的心率、步数结束后展示一份运动报告。真正动手才发现从“能定位”到“轨迹不漂移”从“连上手环”到“心率数据稳定刷新”每一环都有不少细节。折腾了一阵子之后我最终把整套方案落在uniapp上——一套代码同时覆盖Android、iOS和微信小程序踩过的坑和沉淀下来的处理思路这篇文章一次性讲清楚。如果你正打算用uniapp做运动轨迹记录或者在做手环类穿戴设备App这篇内容会帮你省掉大量调研时间。我会从最基础的定位选型讲起一直讲到蓝牙通信、业务计算和打包上线的坑每一段都有可以直接抄走的代码和参数配置。1. 运动轨迹这块硬骨头到底难在哪做运动轨迹App和做普通地图展示完全是两码事。普通地图App是“我在哪”运动轨迹是“我从哪来、怎么过来的”——前者只需要一次定位后者需要连续、稳定、足够密集的定位点然后把这一串点还原成一条合理的轨迹线。1.1 定位不只是一个getLocationuniapp提供了三种定位相关的API很多新手容易搞混uni.getLocation一次性定位拿当前坐标。uni.startLocation开启持续定位配合uni.onLocationChange监听位置更新。uni.stopLocation关闭持续定位。运动轨迹需要的显然是第二种。但这里有个关键认知uni.startLocation在App端到底准不准、稳不稳取决于你在manifest里配了什么定位模块。很多人的轨迹断断续续、点位乱跳不是代码写得不对而是模块配置这一步就错了。在uniapp的manifest.json里App模块配置中找到Geolocation必须勾选你实际要用的定位SDK。我建议国内项目直接用高德定位原因有两个一是高德在国内的定位精度和偏移处理比原生GPS靠谱二是uniapp对高德的封装最完整坐标系也直接统一成gcj02。1.2 为什么选uniapp而不是原生开发一开始团队里也有人提议Android用Kotlin、iOS用Swift各写一套体验最“原生”。但现实是这个项目的预算和排期只够养一个前端团队而且产品还要尽快覆盖微信小程序。uniapp的R甚至让我出乎意料——地图组件原生支持polyline画轨迹线蓝牙API也封装到位连手环通信都能在JS层搞定。对于中小团队做运动类App这是性价比最高的路径。当然它也有短板后台持续定位在iOS上非常受限必须配合原生插件或者原生工程修改才能稳定驻留后台。这个问题我会在第5章详细说但结论先行——不是不能做而是需要提前规划方案不能指望纯前端硬扛。2. 轨迹采集与绘制从卫星信号到屏幕上的线这一章是整个App的核心。我按“配置参数 → 采集坐标 → 处理数据 → 绘制轨迹”的顺序来讲每一步都会给出实际可用的代码。2.1 定位参数的行业标准配置定位不是越快越好也不是越频繁越好。频率太高一秒钟十几个点电量哗哗掉轨迹线还因为定位抖动变成锯齿频率太低转弯处轨迹会被拉直。我实测下来跑步场景用1000ms间隔比较稳妥骑行可以放宽到2000ms。代码这样写uni.startLocation({ type: gcj02, // 定位坐标系国内地图必须用gcj02 accuracy: high, // 高精度模式GPS基站WiFi综合定位 interval: 1000, // 回调频率单位ms isHighAccuracy: true, // 强制高精度工作在特定场景下必开 highAccuracyExpireTime: 3000, success: () { uni.onLocationChange(res { // res.latitude, res.longitude, res.speed, res.accuracy collectPoint(res); }); }, fail: (err) { console.error(定位启动失败, err); } });关于isHighAccuracy这个参数有两点要注意。第一它底层依赖高德SDK的startUpdatingLocationWithLocationReformer高频回调开了之后部分Android机型会明显发热第二如果活动场景是室内跑步机这个参数会加速耗电建议做一个设置项让用户自己选“户外模式”和“室内模式”而不是写死。GPS信号的强弱对Accuracy字段影响很大。我的判断标准很简单只有当res.accuracy小于50米时才记入轨迹点精度太差的点丢弃。这样轨迹线不会出现“突然飘到马路对面”的毛刺。2.2 轨迹点采集坐标系与抽稀算法采集到坐标后第一个坑就是坐标系。手机GPS芯片拿到的是WGS84坐标而高德/腾讯地图用GCJ02火星坐标系如果直接拿WGS84坐标画到高德地图上轨迹会整体偏移几十米。uniapp的type: gcj02其实已经在SDK层帮你做了转换但如果你混用了uni.getLocation默认参数可能是wgs84和startLocation就会出现轨迹前半段和后半段不接缝的问题。我建议统一规范所有定位调用都显式传type: gcj02。如果后端或者手环App端返回的是WGS84坐标前端需要做一次转换。网上流传的坐标转换代码很多我这个版本实测精度足够function wgs84ToGcj02(latitude, longitude) { const a 6378245.0; const ee 0.00669342162296594323; let dLat transformLat(longitude - 105.0, latitude - 35.0); let dLon transformLon(longitude - 105.0, latitude - 35.0); const radLat latitude / 180.0 * Math.PI; let magic Math.sin(radLat); magic 1 - ee * magic * magic; const sqrtMagic Math.sqrt(magic); dLat (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI); dLon (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI); return { latitude: latitude dLat, longitude: longitude dLon }; }坐标拿到了接下来是抽稀。轨迹点积多了地图组件渲染会卡传到后端也浪费流量。我用的是最简单的距离阈值法记录当前点与上一个已记录点的直线距离超过5米才存。let lastPoint null; function collectPoint(point) { if (!lastPoint) { savePoint(point); lastPoint point; return; } const distance calcDistance(lastPoint, point); if (distance 5) { savePoint(point); lastPoint point; } }阈值5米对跑步和骑行都适用。跑直道时一分钟也就记录几十个点转弯、绕圈时点位自然变密轨迹还原度很高。如果你做的是越野、登山这类轨迹复杂的场景可以考虑道格拉斯-普克算法做后处理抽稀效果更好但计算量大一些适合运动结束后的离线处理。2.3 用map组件把轨迹画出来轨迹线绘制直接用map组件的polyline属性配置一个包含所有坐标点的数组即可。map idtrackMap :latitudecenterLat :longitudecenterLng :polylinepolyline :scale16 stylewidth: 100%; height: 400px; /mapexport default { data() { return { polyline: [{ points: [], color: #00AA00, width: 5, dottedLine: false, arrowLine: true }] }; }, methods: { updatePolyline(point) { this.polyline[0].points.push(point); // 实时把最后记录的点设为中心点让视野跟着跑者走 this.centerLat point.latitude; this.centerLng point.longitude; } } }有一个小细节arrowLine: true可以在轨迹线上显示方向箭头对导航回放类功能体验提升很明显。但箭头数量太多时部分Android机型会有渲染卡顿我一般只在地图尺度小于15时开启箭头。地图自适应也是容易被忽略的点。运动结束后如果轨迹范围很大比如骑行20公里用户需要重新定位到整条轨迹。可以用uni.createMapContext(trackMap, this).includePoints()让地图自动调整视野const ctx uni.createMapContext(trackMap, this); ctx.includePoints({ points: this.polyline[0].points, padding: [60, 60, 60, 60] });这样用户就能一屏看完整段轨迹不需要手动缩放。3. 手环数据接入BLE蓝牙通信的完整链路手环接入是运动类App的另一大块。市面上绝大多数手环都支持BLE蓝牙低功耗协议上遵循标准服务但厂商又在标准之上叠了私有的东西。uniapp的蓝牙API虽然好用但不理解BLE协议连起来会非常迷茫。3.1 蓝牙连接前的设备发现流程BLE通信的完整流程是初始化蓝牙适配器 → 开始扫描 → 发现设备 → 连接设备 → 获取服务列表 → 获取特征值列表。每一步都有对应的uniapp API。我写一个流程骨架// 1. 初始化 uni.openBluetoothAdapter({ success: () { // 扫描设备 uni.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, success: () { // 通过监听发现设备 uni.onBluetoothDeviceFound(res { res.devices.forEach(item { // 根据设备名称或广播数据筛选手环 if (item.name item.name.includes(Band)) { deviceList.push(item); } }); }); } }); }, fail: (err) { // 蓝牙未打开或设备不支持给出引导提示 } });发现设备后使用uni.createBLEConnection({ deviceId })发起连接。成功后一定要调用uni.getBLEDeviceServices和uni.getBLEDeviceCharacteristics因为BLE设备的服务和特征值是分层级的不主动获取后面想监听数据根本无从下手。3.2 心率特征值的监听与解析心率是运动手环最核心的数据。BLE标准协议规定心率服务Heart Rate的Service UUID是0x180D下面有心率测量特征值0x2A37。拿到特征值后用uni.notifyBLECharacteristicValueChange开启通知然后监听uni.onBLECharacteristicValueChange。uni.getBLEDeviceCharacteristics({ deviceId, serviceId: 0000180D-0000-1000-8000-00805F9B34FB, success: (res) { res.characteristics.forEach(char { const uuid char.uuid.toLowerCase(); if (uuid.includes(2a37)) { uni.notifyBLECharacteristicValueChange({ deviceId, serviceId: 0000180D-0000-1000-8000-00805F9B34FB, characteristicId: char.uuid, state: true, success: () { // 开始监听心率数据自动推送 uni.onBLECharacteristicValueChange(handleHeartRateData); } }); } }); } });handleHeartRateData里拿到的res.value是ArrayBuffer需要手动解析。心率数据帧的格式有规定第一个字节的bit0表示心率格式0表示UINT8格式心率值占1个字节1表示UINT16格式心率值占2个字节。function handleHeartRateData(res) { const data new Uint8Array(res.value); const flags data[0]; const heartRateFormat flags 0x01; let heartRate 0; if (heartRateFormat 0) { heartRate data[1]; } else { heartRate data[1] | (data[2] 8); } // heartRate 就是当前心率 }很多手环在运动模式下还会通过私有特征值上报步频、配速、消耗这些不遵循标准协议需要厂商提供协议文档。如果没有协议文档只能靠抓包逆向工程量大且不推荐。3.3 兼容多品牌手环的通用策略市面上的手环品牌五花八门完全按照每一家的私有协议去适配工作量是无限的。我的做法是分层处理层级数据来源兼容策略心率、步数标准BLE服务0x180D、0x180A等直接读取运动模式控制厂商私有Service常见0xFF00-0xFFF0区间动态扫描匹配已知UUID字典设备电量、固件版本标准Device Information服务0x180A直接读取非标数据如血氧、HRV厂商私有特征值按机型维护映射表优先适配主流机型兼容的核心思想是“尽量往标准协议上靠私有协议做成配置表”。我维护了一个deviceProfile对象不同品牌手环对应不同的服务和特征值UUID。当用户连接上一个新设备时先尝试标准协议如果拿不到数据再遍历配置表中的UUID探测。这样新增一款手环通常只需要往配置表里加几行而不是重写一版通信逻辑。4. 业务层计算里程、配速与卡路里是怎么算出来的轨迹点和心率数据都有了接下来是从这些原始数据得出用户真正关心的指标跑了多远、配速多少、消耗了多少卡路里。4.1 距离计算的数学方案地球上两点间的距离不能用平面几何的欧几里得距离算——地球是球体纬度1度的经线长度在赤道和极地不一样。我用的是Haversine公式它能很好地在球面上计算大圆距离function calcDistance(p1, p2) { const R 6371000; const radLat1 p1.latitude * Math.PI / 180; const radLat2 p2.latitude * Math.PI / 180; const deltaLat (p2.latitude - p1.latitude) * Math.PI / 180; const deltaLon (p2.longitude - p1.longitude) * Math.PI / 180; const a Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) Math.cos(radLat1) * Math.cos(radLat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); const c 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; }计算总里程时把相邻轨迹点的距离累加即可。但这里有个细节如果抽稀阈值设置过大比如超过10米实际跑的是弧线而相邻两点连线是直线距离会被低估。所以轨迹点之间的距离阈值和最终里程计算是强相关的。我用5米阈值实测下来误差在2%左右可接受。4.2 配速、步频与心率区间的实时计算配速的行业标准是“每公里用时多少分钟”计算方式很简单累计运动时间除以累计里程单位换算成公里。function calcPace(timeSeconds, distanceMeters) { if (distanceMeters 10) return --; const paceSeconds timeSeconds / (distanceMeters / 1000); const minutes Math.floor(paceSeconds / 60); const seconds Math.floor(paceSeconds % 60); return ${minutes}${seconds.toString().padStart(2, 0)}; }展示层面有个细节配速一般显示“0530这种格式比直接显示十进制数值更符合跑者习惯。如果做的是骑行App展示的是“平均速度”计算方式是总里程除以总时间单位是km/h。心率区间这块医学上常用的是最大心率百分比法。最大心率估算公式是220 - 年龄然后划分5个区间区间名称最大心率百分比Z1热身区间50%-60%Z2燃脂区间60%-70%Z3有氧区间70%-80%Z4乳酸阈值区间80%-90%Z5无氧极限区间90%-100%用户应该长期停留在哪个区间取决于训练目标。App端只需要在运动报告中展示每个区间的累计时长让用户直观看到自己的运动强度分布。这个功能对跑者非常有用也属于穿戴设备App的核心卖点。卡路里计算相对粗略用MET代谢当量值推算跑步MET值约9.8骑行约7.5。公式是卡路里(千卡) MET * 体重(kg) * 时间(小时)。比如70kg的人跑1小时约消耗686千卡。注意这个算法没考虑坡度、风速、个体差异只能作为参考值展示不要标榜“精准”。4.3 运动记录的后台保持与本地存储运动途中用户经常会切出App看消息、锁屏放口袋里所以后台保持是运动App的生死线。在uni-app App端建议做法是启动一个原生前台服务ForegroundService。纯JS层做不到这个需要借助原生插件或自定义基座。实现思路是在App启动时把“运动记录”设为前台服务通知栏常驻显示当前运动时长和里程。用户点通知可回到App。Android上这样做还能有效防止系统在内存不足时杀掉定位进程。iOS上后台连续定位需要开启Capability里的Location updates后台模式同时必须在info.plist里声明NSLocationAlwaysAndWhenInUseUsageDescription。即使配置齐全iOS的定位回调也会出现“冻结”情况App长时间后台可能收不到onLocationChange。数据存储上我强烈建议“边采边存、增量落盘”。不要等用户点结束时才把所有点保存万一App崩溃用户的运动数据全丢了。每入队10个轨迹点就同步一次本地缓存可以是plus.storage自带的本地存储或SQLite。运动结束后再批量上报服务端上报成功后清除本地缓存。这样即使用户中途接了个电话回来App被系统杀掉重开App也能自动恢复未上报的轨迹。5. 避坑清单权限、打包和H5场景的实战问题项目走到上线阶段会遇到一堆和业务无关但卡着脖子的问题。我把自己真实踩过的坑列出来。5.1 后台定位权限的Android/iOS差异处理Android 10及以上定位权限分“仅使用期间允许”和“始终允许”。运动App必须在用户第一次进入时就用清晰的文案请求“始终允许”否则后台轨迹记录直接失效。在uniapp的manifest.json中要声明完整权限列表permissions: { scope.userLocation: { desc: 用于记录运动轨迹 }, scope.userLocationBackground: { desc: 用于应用后台时持续记录运动轨迹 } }iOS这边从iOS 13开始系统默认收集Always权限很严格。你必须先请求WhenInUse有了一定使用时间之后系统才可能弹窗询问是否升级为Always。这里有个体验技巧首次进入运动页时先请求WhenInUse当用户点“开始运动”时再引导开启后台权限并把权限用途说清楚否则系统拒了之后用户完全不知道去哪改。5.2 打包上架的常见报错与配置细节用uniapp打Android包最容易出问题是证书和Gradle版本。如果遇到Could not find method compile()这类报错通常是因为工程里的Gradle版本过新和部分插件不兼容。解决思路是锁死Gradle版本不追求最新。上架Android应用市场特别是华为、小米那种要求严格的需要准备隐私政策、APP权限使用说明、软件著作权缺一不可。权限声明里定位权限的用途描述必须和实际功能严格对应我遇到过审核因为权限描述模糊被驳回的。iOS打包要单独准备证书和描述文件开发证书和发布证书分开。App Store审核时对于定位权限有明确要求必须在权限弹窗和隐私政策里解释为什么需要“始终允许定位”。如果审核员发现App在后台也会使用定位并且文案没写清楚大概率被拒。还有一个经常被忽略的点manifest.json里配置的startLocation模块在iOS上需要勾选NSLocationAlwaysUsageDescription和NSLocationWhenInUseUsageDescription两项少一个就打不进去。5.3 H5嵌入微信公众号的定位与分享问题很多产品除了App端还要把运动记录页面嵌入微信公众号H5。这里有一个很坑的现实uniapp的H5版定位完全依赖浏览器的Geolocation API。在PC浏览器上倒是能用但到了微信内置浏览器定位权限经常因为用户没授权或者微信JS-SDK没注册而失效。微信内置浏览器的H5定位正确做法是通过微信JS-SDK的wx.getLocation接口这需要在后端配合做签名。uniapp H5中通过uni.web-view或者直接调起JSSDK// 引入微信JS-SDK后 wx.config({ // 后端签名接口返回的数据 debug: false, appId: 你的AppID, timestamp: 签名接口返回, nonceStr: 签名接口返回, signature: 签名接口返回, jsApiList: [getLocation] }); wx.ready(() { wx.getLocation({ type: gcj02, success: (res) { // res.latitude, res.longitude } }); });H5端的另一个坑是轨迹实时更新太频繁浏览器性能扛不住。在H5版本里我把定位回调间隔调大到3000ms抽稀阈值也提高到10米能明显降低页面卡顿感。H5分享这块微信里分享H5页面用uni.share可以触发微信内置分享面板但链接参数必须带防爬标识。App端自定义分享好友则用uni.shareWithSystem或者各平台的原生分享SDK注意小程序的onShareAppMessage和普通分享API是两套别混用。5.4 地图重置与切换地图组件还有一个高频需求运动结束时用户点击“重新查看起点”地图要能从终点跳回起点。另外从运动报告页回到地图页时上一段运动的轨迹线要能清除并重置。这个场景直接用polyline赋空数组就行但如果地图上有多个图层比如轨迹线、起点图标、终点图标建议用一个reloadMap方法统一复位function resetMap() { this.polyline [{ points: [] }]; this.markers []; this.centerLat this.startPoint.latitude; this.centerLng this.startPoint.longitude; const ctx uni.createMapContext(trackMap, this); ctx.moveToLocation({ latitude: this.startPoint.latitude, longitude: this.startPoint.longitude }); }6. 个人踩坑后的一些坚持最后聊点未必写在技术文档里的东西。第一运动类App的定位功能一定要在真机上反复测试模拟器上完全看不出真实效果。同一个代码我在Android 11的小米手机上定位正常换到Android 8的老机型上就出现回调频率骤减最后还是通过降低interval值加手动启动一次getLocation兜底解决的。第二不要过度依赖手势交互。用户在运动中大概率是单手操作甚至运动到一半屏幕是锁着的。所有关键操作开始、暂停、结束最好在运动中保持可点击状态并且按钮区域设计得足够大。我一开始把“结束运动”按钮放在页面右上角小图标实际真机测试自己跑步都很难精准点中后来改成底部居中大按钮体验立刻不同。第三把“数据可信”当作产品底线。宁可显示“当前信号不稳定”也不要给用户一条飘到小区外面的轨迹。我们后来给运动记录增加了“GPS信号质量”标识信号差时明确提示用户数据显示也标注为“仅供参考”。用户对虚假数据的反感远大于对误差的容忍这一点在多轮用户反馈里得到了验证。这套方案从立项到上线大概用了两个多月中间踩的坑写出来也就这些但每一个都是真金白银换来的经验。如果你正卡在轨迹漂移、蓝牙断连或者打包上架的某个环节希望这篇文章能帮你多走一段直路。下一件事我打算把手环端的固件升级流程和App端的OTA联动写一写那个坑也很有意思。
RELATED

相关推荐

用 Xdebug + VS Code 断点调试 PHP,告别 var_dump:把 launch.json 改到 TaoToken 的实操记录

用 Xdebug + VS Code 断点调试 PHP,告别 var_dump:把 launch.json 改到 TaoToken 的实操记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/4 13:18:06
ESP32-S3驱动7.3英寸ACeP彩色电子纸:Arduino实战与避坑指南

ESP32-S3驱动7.3英寸ACeP彩色电子纸:Arduino实战与避坑指南

去年年底我拿到这块 Seeed 出品的 reTerminal E1002 时,第一反应是:7.3 英寸彩色电子纸,还是 ESP32-S3 做主控,这组合简直是为折腾而生的。当时网上的资料非常少,官方 wiki 也偏简略,大部分用 Arduino 驱动…

📅 2026/10/4 13:18:06
agency-agents-zh 会议纪要专家智能体实战指南:从 transcript 到四段式结构化纪要的完整工作流

agency-agents-zh 会议纪要专家智能体实战指南:从 transcript 到四段式结构化纪要的完整工作流

人工智能AI 技能提示工程 【免费下载链接】agency-agents-zh 🎭 277 个即插即用的 AI 专家角色 — 支持 Claude Code/Cursor/Copilot 等 20 种工具,覆盖工程/设计/营销/金融等 20 个部门。含 64 个中国市场原创智能体(小红书/抖音/微信/飞书/…

📅 2026/10/4 13:18:06
MORE NEWS

更多资讯

📰

【八个月网安课程】第八周·周六:靶场命令/代码执行漏洞综合通关——从单点利用到攻击链

以下是第八周周六的详细学习内容。今天将不再有新的漏洞类型,而是将你过去一周所学的目录遍历、文件包含、命令注入(有回显与无回显)全部串联起来,在多个靶场场景中完成综合实战。你将脱离“单漏洞利用”的思维,开始构…

📰

从IDE到ADE:智能体开发环境的核心技术与落地实践

1. 从IDE到ADE:开发环境正在经历一次范式迁移如果你最近在开发者社区里频繁看到"ADE"这个词,不是拼写错误,也不是某个新品牌的缩写,而是智能体开发环境(Agentic Development Environment)。这个词…

📰

Cursor插件开发核心机制与避坑指南

1. “plugins”不是功能菜单,而是Cursor生态的神经中枢很多人第一次在Cursor里点开Settings → Extensions,看到“Plugins”标签页时,下意识以为这只是个“插件市场入口”——就像VS Code里点Extensions Marketplace那样,搜一搜、…

📰

90DaysOfDevOps 第 30 天:Microsoft Azure 安全模型——身份、目录、RBAC 与 Azure Policy 实战

文档/教程 【免费下载链接】90DaysOfDevOps This repository started out as a learning in public project for myself and has now become a structured learning map for many in the community. We have 3 years under our belt covering all things DevOps, including Pri…

📰

DeepSeek Harness桌面端安装部署与插件Skill机制全解析

1. 从命令行到桌面窗口:DeepSeek Harness 桌面端到底解决了谁的痛点第一次听说 DeepSeek Harness 出了桌面端,我的反应是"终于有人干了这件事"。如果你之前用过命令行版本的 Harness,应该能理解那种感受——功能确实强,…

📰

用Qt实现鼠标自动点击:从GetCursorPos到mouse_event的完整配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬