资讯动态

校园摆渡车小程序:原生+云开发实时位置上报与预约占座

发布时间:2026/10/26 2:04:23 来源:尧图企业网站定制
简介面向微信小程序初学者与校园交通应用开发者的原创实践项目围绕校园摆渡车实时位置查询、上下车点、运行时间表和座位预订等场景展开可用于理解小程序前端交互与后端接口协作的完整链路。压缩包共79个文件整体约1.01MB主要由Java类、XML配置、JSP页面、JSON/JS/WXSS小程序代码及PNG界面图片等构成后端可见Servlet、DAO、Bean、Control分层与JSON处理依赖前端包含页面、工具与配置模块。已有416人学习。项目突出原创设计与实际需求结合目录按前后端拆分便于按功能定位代码。读者可从中获得可运行的前后端分离样例参考地图组件接入、网络请求封装、数据增删改查与REST风格接口设计并借目录结构梳理从页面到服务端的数据流适合作为课程设计或毕业项目的基础模板也便于按模块替换数据与界面后二次开发。1. 校园摆渡车小程序一辆车、一块地图和每 5 秒一次的位置上报早上七点四十A同学站在校门口摆渡车刚走还是没来全靠猜。这个校园摆渡车小程序解决的就是这件事司机点一下“上线”手机开始按固定间隔上报位置乘客打开小程序地图上就能看到车在哪、还有几站、要不要现在下楼。整套代码是原生小程序加云开发没有自建服务器一个 zip 解压导入开发者工具就能跑。它适合三类人想做个能演示的校园/园区接驳 Demo 的学生手里有真实摆渡车、但养不起后台的运营方以及想找一个“地图 实时位置 预约”完整链路练手的小程序开发者。地图、司机上报、乘客查看、预约占座、后台数据这几块都有代码量不大但该有的边界都在。真正难的不是地图渲染而是位置数据在弱网和手机省电策略下怎么稳定地持续上报这份源码里处理这些细节的地方正是最值得借鉴的部分。2. 选型与骨架原生小程序加云开发三层结构怎么落地2.1 为什么用原生小程序不用 uni-app先说明这个项目我改过一版。原作者的选型很保守微信原生 云开发没上任何跨端框架。刚开始我觉得这选择有点老派拆完代码后理解了摆渡车这种场景的核心诉求是“地图要准、定位要稳、启动要快”跨端框架在这三点上都要打折扣。原生小程序直接调用map组件和wx.onLocationChange没有中间层定位回调的时序是确定的。uni-app 编译到小程序后位置相关的 API 往往要包一层 Promise出问题时排查链路会变长这在你半夜被“车不动了”的电话叫醒时是致命的。方案首屏体积地图能力实时位置运维成本原生 云开发小无框架运行时直接用map组件wx.onLocationChange原生回调几乎为零无需服务器uni-app 自建后端多一层运行时需适配层需自己封装要买服务器、配域名、备案原生 自建 Node 服务小直接用原生回调要维护进程、证书、扩容选云开发的另一个现实原因摆渡车的并发模型是“一写多读”。一辆车 5 秒写一次几百个乘客读峰值 QPS 低得可怜用云函数完全撑得住。这种情况下自己买一台 2 核 4G 的服务器一年下来是纯浪费还要处理备案和证书到期的问题很容易出问题。当然代价也有云开发的冷启动在 1 秒左右第一次打开小程序会感觉卡数据库查询有单次 100 条的上限做后台管理时要分页。这些在后面的章节里都会碰到。2.2 目录结构把司机端和乘客端放进同一个包这是解压后的目录我把注释补全了。你会发现作者没有做分包因为总共就五个页面包体积远没到 2MB 的红线。campus-shuttle/ ├── miniprogram/ # 小程序前端根目录 │ ├── app.js # 云开发初始化、全局登录态 │ ├── app.json # 页面注册、位置权限声明 │ ├── app.wxss │ ├── pages/ │ │ ├── passenger/ # 乘客端地图首页 │ │ │ ├── index.js │ │ │ ├── index.wxml │ │ │ └── index.wxss │ │ ├── driver/ # 司机端上线/下线、位置上报 │ │ ├── order/ # 我的预约 │ │ └── me/ # 个人中心 │ ├── components/ │ │ ├── bus-marker/ # 车辆气泡卡片 │ │ └── station-list/ # 站点列表 │ ├── utils/ │ │ ├── geo.js # 距离计算、坐标精度处理 │ │ └── mockDriver.js # 开发期模拟司机仅开发版 │ └── assets/ # 车辆、站点图标 ├── cloudfunctions/ │ ├── reportLocation/ # 司机位置写入 │ ├── listBuses/ # 乘客端拉取在线车辆 │ ├── reserveSeat/ # 预约占座 │ └── seedDemo/ # 一键造模拟数据 └── project.config.json # 指定 miniprogramRoot / cloudfunctionRootapp.json里有三处容易漏配漏了就是真机上“定位永远失败”{ pages: [ pages/passenger/index, pages/driver/index, pages/order/index, pages/me/index ], permission: { scope.userLocation: { desc: 用于在地图上显示你附近的摆渡车 } }, requiredPrivateInfos: [getLocation, onLocationChange, startLocationUpdate], window: { navigationBarTitleText: 校园摆渡车 } }permission.scope.userLocation.desc是弹窗里给用户看的文案写得含糊会被审核驳回requiredPrivateInfos是小程序 2022 年之后的新要求没声明onLocationChange的话代码能编译、开发者工具也能跑但真机上回调一次都不进。这一点后面避坑章节还会再提。2.3 数据集合与云函数边界云开发的数据库是文档型的作者设计了四个集合职责切得很干净集合关键字段说明bus_routename、stations[]、polyline[]、firstBus线路与站点坐标乘客端渲染用bus_location_id 车牌号、location(GeoPoint)、heading、updatedAt一车一文档司机上报写入bus_status_id 车牌号、capacity、booked、online载客量、在线状态预约时改它order_openid、busId、status、createdAt预约单靠_openid自动隔离关键设计是bus_location用车牌号当_id而不是用自增 ID 再建索引。这样司机上报时是一次doc(busId).set()的幂等写入第一次是插入之后是覆盖云函数里不用写“查一下有没有再决定插还是改”的分支省下一次读的配额也避开了并发下的重复插入。云函数只保留四个入口很窄写位置的只有reportLocation读列表的只有listBuses。这样做的好处是权限收敛客户端不直连数据库所有读写走云函数云函数里再用cloud.getWXContext().OPENID做身份校验。很多教学项目让前端直接db.collection().get()然后集合权限一设成“所有用户可读”别人拿你的 AppID 就能把整张表拉走。// cloudfunctions/listBuses/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const _ db.command exports.main async (event) { const { routeId } event const now Date.now() // 只返回 60 秒内上报过的车避免幽灵车停在地图上不动 const res await db.collection(bus_location) .where({ routeId, online: true, updatedAt: _.gt(now - 60 * 1000) }) .field({ location: true, heading: true, updatedAt: true, plate: true }) .limit(50) .get() return { ok: true, list: res.data.map(item ({ id: item._id, plate: item.plate, lat: item.location.latitude, lng: item.location.longitude, heading: item.heading || 0, // 让前端能显示10 秒前更新比一个静止的图标更有说服力 age: Math.round((now - item.updatedAt) / 1000) })) } }这段代码有三个参数值得说_.gt(now - 60000)是心跳超时阈值摆渡车在校园里穿行会经过楼栋遮挡偶尔丢一两个包是常态阈值设太短车会在地图上反复闪field()只取用得到的字段能把返回体积压缩一半以上limit(50)是兜底一个校区的车不会超过这个数真超了说明数据有问题。3. 核心链路拆解位置从司机手机到乘客地图的 3 秒3.1 司机端上报的节流、去抖与心跳司机端页面只有一个开关和几行状态文字但背后的逻辑不少。位置回调在安卓机上可能一秒来好几次无脑转发会把云函数打到限流所以要做三层过滤时间节流、位移去抖、在线状态判断。// pages/driver/index.js const REPORT_INTERVAL 5000 // 最短上报间隔毫秒 const MIN_MOVE 8 // 位移小于 8 米视为 GPS 飘点丢弃 Page({ data: { online: false, busId: , lastLat: 0, lastLng: 0, lastTs: 0, lastReport: }, onLoad() { // 车牌号在我的页面绑定后存在本地这里直接读 this.setData({ busId: wx.getStorageSync(busId) || }) }, async toggleOnline() { if (this.data.online) return this.stopReport() if (!this.data.busId) { return wx.showToast({ title: 请先绑定车牌号, icon: none }) } // 先确认用户真的授权了定位被拒过要引导去设置页 const setting await wx.getSetting() if (!setting.authSetting[scope.userLocation]) { await wx.authorize({ scope: scope.userLocation }).catch(() { wx.showModal({ title: 需要定位权限, content: 请在设置中开启定位, }) }) } this.startReport() }, startReport() { // 持续定位比 setInterval getLocation 更省电系统会做批量唤醒 wx.startLocationUpdate({ success: () { this._onLoc (loc) this.report(loc) wx.onLocationChange(this._onLoc) this.setData({ online: true }) }, fail: (err) { console.error(startLocationUpdate fail, err) wx.showToast({ title: 定位启动失败, icon: none }) } }) }, report(loc) { const now Date.now() if (now - this.data.lastTs REPORT_INTERVAL) return // 时间节流 const moved distance(loc.latitude, loc.longitude, this.data.lastLat, this.data.lastLng) if (this.data.lastTs moved MIN_MOVE) return // 位移去抖 this.setData({ lastLat: loc.latitude, lastLng: loc.longitude, lastTs: now }) wx.cloud.callFunction({ name: reportLocation, data: { busId: this.data.busId, lat: Number(loc.latitude.toFixed(6)), // 精度截断见避坑章节 lng: Number(loc.longitude.toFixed(6)), heading: Math.round(loc.speed 0.5 ? loc.direction : this.data.lastHeading || 0), speed: loc.speed || 0 } }).then(res { this.setData({ lastReport: new Date().toLocaleTimeString() }) }).catch(err { console.error(上报失败, err) // 失败不弹窗下一轮自动补上 }) }, stopReport() { if (this._onLoc) wx.offLocationChange(this._onLoc) wx.stopLocationUpdate() wx.cloud.callFunction({ name: reportLocation, data: { busId: this.data.busId, offline: true } }) this.setData({ online: false }) }, onUnload() { this.stopReport() } })参数解释一下REPORT_INTERVAL取 5 秒是个平衡点再短乘客端看不出差别只多烧流量MIN_MOVE取 8 米是为了过滤静止时的定位抖动车停在站台等客时如果不去抖地图上的车会像抽搐一样乱跳。stopReport()里那次offline: true的调用很关键否则司机退出小程序后车会以“最后位置”的形态一直挂在线上乘客白等。还有一个隐藏细节toFixed(6)之后再Number()。原始经纬度是一串 15 位的小数直接塞进setData会让视图层传输体积翻好几倍截断到 6 位约 0.1 米精度对摆渡车完全够用。3.2 乘客端地图、站点与车辆图标乘客端的 WXML 很薄因为map组件几乎把活干完了!-- pages/passenger/index.wxml -- view classpage map idbusMap latitude{{latitude}} longitude{{longitude}} scale{{scale}} markers{{markers}} polyline{{polyline}} show-location bindmarkertaponMarkerTap bindcallouttaponCalloutTap / view classbottom-bar text在线车辆 {{onlineCount}} 辆/text text wx:if{{nearestAge}}最近一辆 {{nearestAge}} 秒前更新/text /view /viewmarkers是数组每个元素对应地图上的一个点。这里容易出的问题是每次都重建整个数组导致图标闪烁、callout 弹窗闭合。作者的写法是先在 JS 里做 diff只对变化的车做替换// pages/passenger/index.js const db wx.cloud.database() Page({ data: { latitude: 0, longitude: 0, scale: 17, markers: [], polyline: [], onlineCount: 0, nearestAge: 0 }, onLoad() { this.fetchRoute() this.fetchBuses() this._timer setInterval(() this.fetchBuses(), 5000) // 兜底轮询 }, onUnload() { clearInterval(this._timer) }, async fetchRoute() { const { result } await wx.cloud.callFunction({ name: getRoute, data: { routeId: this.data.routeId } }) this.setData({ polyline: [{ points: result.stations.map(s ({ latitude: s.lat, longitude: s.lng })), color: #3B82F6, width: 6, arrowLine: true }], latitude: result.stations[0].lat, longitude: result.stations[0].lng }) }, async fetchBuses() { const { result } await wx.cloud.callFunction({ name: listBuses, data: { routeId: this.data.routeId } }) if (!result.ok) return const markers result.list.map(bus ({ id: bus.id, latitude: bus.lat, longitude: bus.lng, iconPath: /assets/bus.png, width: 34, height: 34, rotate: bus.heading, // 车头朝向比一个圆点更直观 anchor: { x: 0.5, y: 0.5 }, callout: { content: ${bus.plate} ${bus.age}秒前, color: #111827, fontSize: 12, borderRadius: 6, bgColor: #ffffff, padding: 6, display: BYCLICK } })) this.setData({ markers, onlineCount: markers.length, nearestAge: markers.length ? Math.min(...result.list.map(b b.age)) : 0 }) }, onCalloutTap(e) { const bus this.data.markers.find(m m.id e.detail.markerId) wx.navigateTo({ url: /pages/passenger/detail?id${bus.id} }) } })rotate用司机上报的heading实测发现高德坐标系下这个角度和图标本身有 90 度偏差作者的图标资源是朝上的所以直接赋值就对。你自己换图标时记得把方向掰回来否则车会横着跑看着特别怪。display: BYCLICK是刻意选的。用ALWAYS时车一多屏幕上会堆满气泡把地图压得看不见路改成点击才弹信息密度立刻降下来。3.3 预约与到站提醒把“抢座”当成一次条件更新预约模块最容易写崩的地方是超卖。教学代码常见写法是先get()查当前人数判断没满再update()加一。两个请求撞在一起18 座的车能约进去 22 个人。作者用的是条件更新把“判断”和“写入”压成一次原子操作// cloudfunctions/reserveSeat/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const _ db.command exports.main async (event) { const { OPENID } cloud.getWXContext() const { busId } event const now Date.now() // 幂等同一用户 10 分钟内只允许有一张有效预约 const dup await db.collection(order).where({ _openid: OPENID, busId, status: valid }).count() if (dup.total 0) return { ok: false, code: DUPLICATE } // 核心只有 booked capacity 时才会命中命中数 0 说明满座 const res await db.collection(bus_status) .where({ _id: busId, booked: _.lt(18) }) .update({ data: { booked: _.inc(1) } }) if (res.stats.updated 0) { return { ok: false, code: FULL } } const order await db.collection(order).add({ data: { busId, status: valid, createdAt: now } }) return { ok: true, orderId: order._id } }_.lt(18)里的 18 硬编码了二次开发时应该从bus_status.capacity读出来再拼进条件我在第 6 章给了改法。_.inc(1)是数据库层面的自增不走读改写天然并发安全。res.stats.updated 0这个判断是整个函数的成败关键它同时覆盖了“已满座”和“车牌号不存在”两种情况前端只需要提示“当前车辆不可预约”。取消预约要反向减回来同样用_.inc(-1)并且要先确认订单状态是valid再减否则用户连点两次取消座位数会变成负数。4. 本地跑通从导入工程到地图上出现第一辆车4.1 环境准备与工程导入需要的东西不多微信开发者工具稳定版、一个 AppID测试号也行但云开发必须用真实 AppID以及开通云开发。步骤是解压、导入、改配置。{ miniprogramRoot: miniprogram/, cloudfunctionRoot: cloudfunctions/, appid: 替换成你自己的 AppID, compileType: miniprogram, setting: { urlCheck: false, es6: true, enhance: true, minified: true } }miniprogramRoot和cloudfunctionRoot两个字段必须和实际目录名对上少一个斜杠云开发面板里就看不到云函数列表很多人卡在这一步。urlCheck关掉是因为开发阶段还没配合法域名不关会报“不在以下 request 合法域名列表中”。导入后点开发者工具左上角的“云开发”开通一个环境。环境 ID 形如xxx-1a2b3c把它填进app.js// app.js App({ onLaunch() { if (!wx.cloud) { return console.error(请使用 2.2.3 以上基础库以使用云能力) } wx.cloud.init({ env: your-env-id, // 换成你自己的环境 ID traceUser: true // 在控制台记录用户访问方便排查 }) }, globalData: { routeId: } })traceUser打开后云开发控制台的“用户管理”能看到访问记录调试登录态时非常有用。4.2 初始化集合与数据用 seedDemo 一次造完手工在控制台建四个集合、一条条录数据太累作者写了个seedDemo云函数右键“上传并部署云端安装依赖”然后在控制台点测试就行。// cloudfunctions/seedDemo/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const COLLECTIONS [bus_route, bus_location, bus_status, order] exports.main async () { // 建集合是幂等的重复执行只会抛已存在忽略即可 for (const name of COLLECTIONS) { try { await db.createCollection(name) } catch (e) { /* -501001 */ } } // 一条环线六个站点 const stations [ { seq: 1, name: 南门, lat: 30.0001, lng: 120.0001 }, { seq: 2, name: 图书馆, lat: 30.0012, lng: 120.0008 }, { seq: 3, name: 教学楼, lat: 30.0021, lng: 120.0019 }, { seq: 4, name: 体育馆, lat: 30.0016, lng: 120.0031 }, { seq: 5, name: 宿舍区, lat: 30.0004, lng: 120.0026 }, { seq: 6, name: 食堂, lat: 29.9995, lng: 120.0014 } ] await db.collection(bus_route).add({ data: { name: 1 号线环线, stations, firstBus: 07:00, lastBus: 22:30, enabled: true } }) // 两辆车初始状态离线 for (const plate of [校A-001, 校A-002]) { await db.collection(bus_status).doc(plate).set({ data: { capacity: 18, booked: 0, online: false } }) } return { ok: true, collections: COLLECTIONS.length } }注意doc(plate).set()而不是add()这里必须和reportLocation用同一个_id规则否则司机上线时写的是一份新文档bus_status和bus_location就对不上了。集合建好后要去控制台给bus_location建索引routeId升序online升序updatedAt降序。不建索引几十辆车时查询就是几百毫秒listBuses的冷启动叠加起来首页白屏能到 3 秒。4.3 真机调试与模拟数据注入开发者工具里能跑但定位是假的你拖鼠标它才动。想看到“车在跑”最快的办法是开模拟司机。// utils/mockDriver.js —— 开发期用正式版记得关 const STEP 0.00012 // 每次前进的度数约 13 米 function createMockDriver(start, onTick) { let lat start.lat let lng start.lng return setInterval(() { lat STEP lng STEP / 2 onTick({ latitude: lat, longitude: lng, speed: 3.2, direction: 45 }) }, 3000) } module.exports { createMockDriver }在司机端startReport()里加个开关开发版走模拟、正式版走真实定位if (__wxConfig.envVersion develop) { const { createMockDriver } require(../../utils/mockDriver) this._mock createMockDriver({ lat: 30.0001, lng: 120.0001 }, loc this.report(loc)) } else { wx.onLocationChange(this._onLoc) }__wxConfig.envVersion的值是develop/trial/release用它做开关比自定义常量安全不会因为忘改配置把模拟数据带上线。真机调试时记得在手机设置里给微信开定位权限安卓还要额外开“始终允许”否则熄屏后位置就断了。5. 避坑与排查真机才复现的 5 个典型故障5.1 位置相关飘点、失联与方向错乱现象一车停着不动地图上的点却在半径 30 米内乱跳。原因wx.onLocationChange拿到的是系统融合定位静止时会有几十米误差而代码里没做去抖每次都setData重绘 markers。 解决加MIN_MOVE阈值第 3 章里的 8 米并且在 UI 上做平滑——把新旧坐标按 0.3 / 0.7 加权视觉上就顺了。这一步是纯前端处理不用改后端。现象二开发者工具里一切正常真机上onLocationChange一次回调都没有。原因app.json里漏了requiredPrivateInfos: [onLocationChange, startLocationUpdate]。这个字段是后来加的强制声明缺失时接口静默失败不报错最坑。 解决补齐requiredPrivateInfos重新编译并杀掉小程序重进。热重载不会重新读这个配置很多人改完以为没用其实是没重启。现象三车头图标永远朝着同一个方向。原因部分机型loc.direction返回-1或NaNMath.round之后变成NaNrotate被忽略沿用上一次的值。 解决在report()里加Number.isFinite(loc.direction)判断无效时保留上一帧角度。这个属于平台差异实测不同安卓 ROM 表现不一致只能防御式写。5.2 云开发相关查不到、触发器不跑、并发超卖现象四控制台里明明有数据乘客端列表永远是空的。原因集合权限设成了“仅创建者可读写”而乘客端是直接查库的如果你照着别的教程改过的话。云函数内是管理员权限不受限客户端直查就会被规则拦掉且返回空数组而不是报错。 解决统一走云函数客户端不碰db。如果确实要直查权限改成“所有用户可读”但要在字段层面做脱敏别把司机的手机号之类的东西暴露出去。现象五预约人数超过座位数18 座的车约进去 22 个。原因先get()判断、再update()写入两个请求之间有窗口期并发时全部判到“没满”。 解决换成第 3.3 节的条件更新写法靠_.lt()_.inc()的原子性兜住。上线前我用脚本并发打了 50 个请求改成条件更新后成功数稳定停在 18没再翻过车。现象六定时清理过期预约的云函数怎么配都不触发。原因config.json里的triggers[].name必须和触发器同名且全局唯一作者一开始写成了cleanOrder云函数名是cleanOrders差一个字母触发器上传成功但永远不跑。 解决name和云函数名保持一致改完在控制台“云函数 - 触发器”里确认状态是“启用”并手动点一次“测试”看日志。定时触发器的 cron 是 UTC8 的七段式写0 0 3 * * * *表示每天凌晨 3 点。提示排查云函数问题第一件事是看云开发控制台的“日志”而不是console.log在本地。本地日志和云端日志是两套callFunction失败时前端只拿到一个笼统的错误码。6. 二次开发把单线路摆渡车改成多线路调度系统这份代码的骨架是能撑住的但要真跑起来绕不开三处改造。我按改动量从小到大排改造点涉及文件改动量注意容量改成动态读取reserveSeat、bus_status半小时别再把 18 写死在条件里多线路 线路切换bus_route、listBuses、乘客端首页半天车辆要加routeId字段轮询换实时订阅乘客端onShow1 小时注意监听数上限和断线重连容量那处最该先改。把capacity从bus_status读出来再拼条件云数据库的where支持动态值直接替换数字即可// cloudfunctions/reserveSeat/index.js 片段 const status await db.collection(bus_status).doc(busId).get() const capacity status.data.capacity const res await db.collection(bus_status) .where({ _id: busId, booked: _.lt(capacity) }) // 不再写死 18 .update({ data: { booked: _.inc(1) } })多线路改造的关键是给bus_location和bus_status都补上routeIdlistBuses已经按routeId过滤了前端只要把选中的线路 ID 传进去就行。乘客端首页加一个picker切换线路切换时清掉旧 markers 再拉新数据否则会看到两条线的车叠在一块。轮询换实时订阅体验提升最明显。云开发数据库自带watch位置一写进去就推到客户端延迟从 5 秒降到 1 秒以内// pages/passenger/index.js 用 watch 替换 setInterval onShow() { this._watcher db.collection(bus_location) .where({ routeId: this.data.routeId, online: true }) .watch({ onChange: (snapshot) this.renderMarkers(snapshot.docs), onError: (err) { console.error(watch 断开, err) this._timer setInterval(() this.fetchBuses(), 5000) // 降级回轮询 } }) }, onHide() { this._watcher this._watcher.close() // 不关会持续占用连接 }验证改造是否到位我的做法是造 20 辆车跑一遍写个临时云函数循环调用reportLocation每 3 秒一轮同时开三个客户端开发者工具、iOS、安卓看地图刷新是否一致。这一步能同时暴露索引缺失、setData过大、watch 连接数超限三个问题比单机点来点去靠谱得多。我踩过最深的坑是第一次上线时忘了onHide里关 watch用户切后台再回来监听对象越滚越多跑了半小时内存直接爆炸。从那以后我每次接实时订阅都强制走一遍“切后台—等 30 秒—切回来”的流程看控制台有没有重复的连接建立日志。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑