如何开发微信小程序打车应用
业务流程介绍
项目描述为了解决长途如跨城市的出行撮合需求,满足乘客和司机双方自由定价的意愿,特开发一款非及时的打车应用。基本功能是出行用户登录小程序后,根据自己角色选择发布行程计划。行程计划包括出行时间和起始位置以及期望价格;如果是乘客,发布自己的出行计划之后跳转到乘客发布的出行计划列表页,乘客可以点击期望的行程计划,邀请司机接单;如果是司机,需要验证是否已经认证通过。如果没有认证通过则跳转到认证页面,否则跳转到乘客发布的出行计划列表页,选择期望的出行计划,完成接单。司机和乘客通过聊天页面协调出行计划。暂不支持支付功能,由双方线下完成交易。用流程图描述为:
系统原型
主要提供3个Tab页面:“首页”、“消息”和“我的”。其中“首页”聚焦行程发布、行程查看和聊天会话等核心功能。“消息”聚焦历史会话检索等功能,在首页进行的会话会跳转到该tab页面。“我的”聚焦车主认证、司机或者乘客查看历史行程记录以及客服服务等功能。原型图如下所示:
系统存储设计
根据业务,存储表主要有以下几种:
-
driver:司机认证记录表,包括司机个人信息以及认证状态等
-
driver_route:司机发布的行程记录表
-
passager_route:乘客发布的行程记录表
-
bargin_route:成交的行程记录表
-
chat_partner:聊天的双方参与者
-
message:会话记录
存储表 结构 和各表之间的关系如下所示:
开发准备
a. 帐号申请
开发小程序的第一步需要注册一个小程序帐号,可能这一步是小程序开发最大的障碍,因为不管哪种帐号都需要认证,特别是企业类型帐号认证需要企业工商营业执照和组织机构代码证,如果小程序需要支付功能,还需要提供对公帐号。但是你也可以使用个人帐号类型,但是个人帐号具备的功能很有限,比如不能支持支付功能等。有关小程序的注册类型和认证的疑问可以参考这里:小程序的注册类型和认证。注册小程序帐号之后,就可以得到一个appId和secret key,它们跟小程序应用相绑定的,在后续API调用中是不可缺少的。
b. 开发工具
同开发其他应用程序一样,微信团队同样也有自己的开发集成工具。有关如何注册和下载开发工具,可以参考官方文档:注册和下载开发工具。下面简要介绍开发工具的常用功能:
从上面图中可以看到开发工具由以下几个区域组成:
-
功能预览区:代码编辑保存,开发工具会自动编译并生成预览,在该区域可以及时看到小程序渲染后的效果;
-
文件浏览区:也就是文件浏览器,树状图形式可以展开和收拢;
-
代码编辑区:提供代码阅读、搜索和编辑提示功能;
-
网络调试区:集成的是google开发工具组件,功能相信大家已经很熟悉。
最上面一排的按钮功能区,主要包括编译、代码上传和代码仓库版本管理以及云服务入口功能等。这里唯一需要普及的是小程序的代码构成。
c.小程序代码组成
一个小程序通常由一个描述整体程序的 app 和多个描述各自页面的 page页面模块组成。其中app主体部分由三个文件组成,必须放在项目的根目录,文件为:
-
app.js: 控制小程序的全局业务逻辑;
-
app.json:小程序的全局公共配置信息;
-
app.wxml:小程序的全局公共样式,
每个页面模块由4钟类型的文件组成,放置一个目录里面,四种类型的文件为:
-
.json 后缀的 JSON 配置文件:存放配置信息;
-
.wxml 后缀的 WXML 模板文件:页面内容模板,支持变量的动态渲染;
-
.wxss 后缀的 WXSS 样式文件:页面样式定义;
-
.js 后缀的 JS 脚本逻辑文件:js实现的业务逻辑,是页面模块中最重要的文件。
比如我们项目的代码结构组成如下图所示:
更多信息可以参考文档:小程序目录结构和代码构成。
前端设计
我们先确定小程序的总体展现框架,在app.wxml圈定整体结构:
1{ 2 "cloud": true, 3 "pages": [ 4 "pages/home/home",//home tab页面 5 "pages/position/position",//定位服务页面 6 "pages/drivers/drivers",//司机和乘客发布的行程列表页面 7 "pages/myroutes/myroutes",//我的历史行程 8 "pages/messages/messages",//“message”tab页面 9 "pages/chat/chat",//聊天会话页面 10 "pages/detail/detail",//行程信息详情 11 "pages/certificate/certificate",//企业认证页面 12 "pages/enterprise/enterprise",// 13 "pages/mine/mine" //“我的”tab页面 14 ], 15 "window": { 16 "backgroundTextStyle": "light", 17 "navigationBarBackgroundColor": "#fff", 18 "navigationBarTitleText": "WeChat", 19 "navigationBarTextStyle": "black" 20 }, 21 "tabBar": { 22 "color": "#ccc", 23 "selectedColor": "#35495e", 24 "borderStyle": "white", 25 "backgroundColor": "#f9f9f9", 26 "list": [ 27 { 28 "text": "首页", 29 "pagePath": "pages/home/home", 30 "iconPath": "resources/icon_home.png", 31 "selectedIconPath": "resources/icon_home.png" 32 }, 33 { 34 "text": "消息", 35 "pagePath": "pages/messages/messages", 36 "iconPath": "resources/icon_cate.png", 37 "selectedIconPath": "resources/icon_home.png" 38 }, 39 { 40 "text": "我的", 41 "pagePath": "pages/mine/mine", 42 "iconPath": "resources/icon_member.png", 43 "selectedIconPath": "resources/icon_home.png" 44 } 45 ] 46 }, 47 "sitemapLocation": "sitemap.json" 48}
其中"cloud": true表示我们接下来用到云服务,pages定义我们应用所有定义的页面模块路径,tabBar定义应用的展示框架,它是一个list结构,每个列表项目由tab名称、页面路径和图标路径组成。各个tab接下来详细介绍。
首页Tab
首页主要功能为司机和乘客发布行程计划,一旦行程计划发布就分别跳转到对应的列表页面。具体说就是,如果是乘客,则可以查看司机发布的出行列表信息,并可以邀请司机接单;如果是司机,则可以看到乘客的出行列表信息,并可以选择主动接单。我们将这一部分核心功能放在主页面内完成,因为无论是司机还是乘客都有共同的行为:发布行程信息,且基本项目一样,故可以复用该功能。
a.行程计划
行程计划页面是司机和乘客发布行程的主入口,主要展示行程发布的起始位置和价格等。我们定义一个模板:publishRoute.wxml,有关模板的更多信息可以参考:模板。
1<template name="publishRoute"> 2 <form bindsubmit="publishRoute"> 3 <view style="display: flex;flex-direction: column;"> 4 <input bindtap="inputStartPosition" style='padding: 10rpx;width:300px;margin-top: 10px;' placeholder="当前位置?" value="{{startLocation.title}}"></input> 5 <input name="startLocation" style='display:none;' value="{{startLocation.title}}"></input> 6 <input name="startAddr" style='display:none;' value="{{startLocation.title}}"></input> 7 <input name="startLatitude" style='display:none;' value="{{startLocation.location.lat}}"></input> 8 <input name="startLongitude" style='display:none;' value="{{startLocation.location.lng}}"></input> 9 <input bindtap="inputEndPosition" style='padding: 10rpx;width:300px;'placeholder="想要去哪儿?" value="{{endLocation.title}}"></input> 10 <input name="endLocation" style='display:none;' value="{{endLocation.title}}"></input> 11 <input name="endAddr" style='display:none;' value="{{endLocation.title}}"></input> 12 <input name="endLatitude" style='display:none;' value="{{endLocation.location.lat}}"></input> 13 <input name="endLongitude" style='display:none;' value="{{endLocation.location.lng}}"></input> 14 <input name="price" type="number"style='padding: 10rpx;width:300px;'placeholder="出价(单位:元)"></input> 15 <view class="btn-area"> 16 <button type="primary" formType="submit">发布行程</button> 17 </view> 18 </view> 19 </form> 20</template>
其中style='display:none;'的input组件是隐藏域,在表单提交时用到,它们的值在搜索定位完成后回显。输入起始位置的input组件分别绑定到事件回调函数inputStartPosition和inputEndPosition,当输入焦点落到输入框时候,调用对应函数进入搜索定位页面。
我们将模版导入到首页home.wxml中:
1view class="nav"> 2 <view class='{{isDriver?"default":"red"}}'bindtap="passengerTabed">我是乘客</view> 3 <view class='{{isDriver?"red":"default"}}' bindtap="driverTabed">我是司机</view> 4</view> 5<view class='{{isDriver?"show":"hidden"}}'> 6 <import src="../home/publishRoute.wxml"/> 7 <template is="publishRoute" data="{{isDriver:isDriver,startLocation:startLocation,endLocation:endLocation,dateTimeArray:dateTimeArray,dateTime:dateTime}}"/> 8</view> 9<view class="{{isDriver?'hidden':'show'}}"> 10 <import src="../home/publishRoute.wxml"/> 11 <template is="publishRoute" data="{{isDriver:isDriver,startLocation:startLocation,endLocation:endLocation,dateTimeArray:dateTimeArray,dateTime:dateTime}}"/> 12</view> 13因为publishRoute.wxml作为home.wxml内容的一部分而存在,故我们将回调函数inputStartPosition和inputEndPosition定义在home.js文件中: 14 inputStartPosition: function (e) { 15 wx.navigateTo({ 16 url: '../position/position?isStartPos=true&isDriver=' + this.data.isDriver 17 }) 18 }, 19 inputEndPosition: function (e) { 20 wx.navigateTo({ 21 url: '../position/position?isStartPos=false&isDriver=' + this.data.isDriver 22 }) 23 }
在上面回调函数中导航到位置搜索页面。通过下面我们详细介绍搜索定位的实现。当发布行程的必要信息填写完毕后提交发布,发布事件回调函数绑定在form表单上:
,函数定义接下来我们再做介绍。有关小程序事件的更多信息可以参考文档:小程序事件。
b.位置搜索
搜索页面提供位置模糊搜索功能,示意图如下:
我们创建页面模块position,提供搜索关键词的查询、搜索历史记录查询等高级功能,为此我们把这部分功能封装为一个模块。为了简化开发,这里引入了一个第三方开源组件,可以参考: https://github.com/icindy/wxSearch 。 这里我们不需要这么复杂的功能,只是将我们根据关键词搜索到的候选位置信息展现在下拉列表即可。 wxSearch的展现部分核心代码wxSearch.wxml模板内容为:
1<template name="wxSearch"> 2 <view class="wxSearch" bindtap="wxSearchTap" style="display:{{wxSearchData.view.isShow ? 'block':'none'}};height:{{wxSearchData.view.seachHeight}}px;top:{{wxSearchData.view.barHeight}}px;"> 3 <view class="wxSearchInner"> 4 <view class="wxSearchMindKey"> 5 <view class="wxSearchMindKeyList"> 6 <block wx:for="{{wxSearchData.mindKeys}}"> 7 <view class="wxSearchMindKeyItem" bindtap="wxSearchKeyTap" data-key="{{item}}">{{item}}</view> 8 </block> 9 </view> 10 </view> 11 </view> 12 </view> 13</template>
wxSearchData.mindKeys这里就是将位置列表遍历显示出来。
在position.wxml中引入上述代码:
1<import src="wxSearch/wxSearch.wxml"/> 2<form bindsubmit="confirm"> 3 <view class="wxSearch-section"> 4 <view class="wxSearch-pancel"> 5 <input name="position" bindinput="wxSearchInput" bindfocus="wxSerchFocus" value="{{wxSearchData.value}}" bindblur="wxSearchBlur" class="wxSearch-input" placeholder="搜索"/> 6 <button class="wxSearch-button" size="mini" formType="submit" plain="true">确定</button> 7 </view> 8 </view> 9</form> 10<template is="wxSearch" data="{{wxSearchData}}"/> 11<view class="container"> 12</view>
然后在postion.js中定义函数wxSearchInput:
1 wxSearchInput: function (e) { 2 var that = this 3 this.data.queryLocations=[] 4 console.log("Searching " + e.detail.value) 5 getApp().globalData.qqmapsdk.getSuggestion({ 6 keyword: e.detail.value, 7 region: getApp().globalData.city, 8 success: function (res) { 9 var targets=new Array() 10 for (let i = 0; i < res.data.length; i++) { 11 targets.push(res.data[i].title) 12 that.data.queryLocations[res.data[i].title] = res.data[i] 13 } 14 WxSearch.initMindKeys(targets) 15 } 16 }) 17 WxSearch.wxSearchInput(e, that); 18 }
其中WxSearch.initMindKeys(targets)将搜索到的候选位置名称放入wxSearch组件展示。当提交确认表单,将返回上一页面即home页面,将查询到的位置详细信息回显到上层页面,表单提交处理逻辑为:
1 confirm: function (event) { 2 console.log(event) 3 //WxSearch.wxSearchAddHisKey(this); 4 let pages = getCurrentPages();//当前页面 5 let prevPage = pages[pages.length - 2];//上一页面 6 var data={} 7 if (this.data.isStartPos=='true'){ 8 data = { isStartPos: this.data.isStartPos, startLocation: this.data.selectedLocation} 9 }else{ 10 data = { isStartPos: this.data.isStartPos, endLocation: this.data.selectedLocation} 11 } 12 //直接给上一页面赋值 13 prevPage.setData(data); 14 wx.navigateBack({ 15 delta: 1 16 }) 17 }
有关页面导航接口的详细信息可以参考:页面导航。
qqmapsdk.getSuggestion就是接下来要介绍的定位服务。
c.定位服务
上面调用的api接口:qqmapsdk.getSuggestion用的是腾讯位置服务:提供了地点搜索、关键词提示、(逆)地址解析、路径规划、距离计算、获取城市等功能。接口getSuggestion(options:Object) 中有两个比较重要的参数:关键词:keyword和当前区域:region。其中region参数可选,可以设置城市名,用于限定搜索范围,默认是全国。调用该接口需要申请密钥和下载JavaScriptSDK。有关该接口如何使用的更多信息可以参考官方文档:申请密钥。在本小程序中,我们使用到根据输入关键词获取位置列表接口的详细指导可以参考:获取位置列表接口。这里详细介绍下如何获取当前region,因为当前region使用贯穿于打开小程序的整个请求生命周期,所以把获取的region作为全局变量,在小程序启动时候调用。我们在app.js中加载sdk组件:
1var QQMapWX = require('utils/qqmap-wx-jssdk1.0/qqmap-wx-jssdk.js'); 2App({ 3 onLaunch: function () { 4 var that = this; 5 that.globalData.qqmapsdk = new QQMapWX({ 6 key: conf.getQqMapKey() 7 }); 8} 9})
为了能够获取到当前region,首先需要获取到当前位置的经纬度坐标,然后根据经纬度坐标解析出文字表示的region,具体步骤如下:
1. 获取当前经纬度坐标
这里我们使用微信小程序提供的api接口,接口的详细说明可以参考文档:经纬度坐标
1 wx.getLocation({ 2 type: "gcj02", 3 success: function (res) { 4 console.log(res) 5 var latitude = res.latitude 6 var longitude = res.longitude 7 that.globalData.location = { 8 latitude: latitude, 9 longitude: longitude 10 } 11 } 12 })
2. 逆地址解析
这里我们用到腾信位置服务的另一个接口:reverseGeocoder(options:Object),该接口提供由坐标到坐标所在位置的文字描述的转换,输入坐标返回地理位置信息和附近poi列表。有关该接口的详细信息可以参考这里:逆地址解析
我们在home.js首页加载时候调用获取当前城市位置的文字描述。
1onLoad: function (option) { 2getApp().globalData.qqmapsdk.reverseGeocoder({ 3 location: { 4 latitude: getApp().globalData.location.latitude, 5 longitude: getApp().globalData.location.longitude 6 }, 7 success: function (res) { 8 console.log(res); 9 const { city } = res.result.address_component 10 getApp().globalData.city = city 11 } 12 }) 13}
这里将上面获取的经纬度参数传进去,返回城市city名称。
d.行程发布
回到发布行程的函数定义,因为我们需要持久化用户的行程信息,这里我们使用了腾讯的云开发能力。所谓云开发能力就是微信为开发者提供了全套的云原生支持和微信服务支持,弱化后端开发和运维概念,用户无须搭建自己的服务器即可调用云端API实现自己的业务逻辑,目前微信提供的云开发能力包括云函数、云数据库、存储以及云调用。我们这里使用到云数据库。云数据库是一个 JSON 数据库,数据库中的每条记录都是一个 JSON 格式的对象。一个数据库可以有多个集合(相当于关系型数据中的表),集合可看做一个 JSON 数组,数组中的每个对象就是一条记录,记录的格式是 JSON 对象。使用云数据库需要先初始化,获取数据库实例的引用,我们在app.js中应用启动时候调用:
1App({ 2 onLaunch: function () { 3 wx.cloud.init({ 4 env: conf.getCloudEnv() 5 }) 6 } 7})
其中env参数指定获取的数据库实例所在环境,比如开发环境和生产环境,用于数据库实例的区分。
获取数据库实例之后,我们还需要通过文章开头提到的云开发入口,登录控制台创建集合:
通过控制台我们可以手动创建集合,对集合进行增删改查,数据导入导出等功能。除此之外,集合还提供了丰富的常用api调用接口。有关云数据库的更多信息可以参考这里:云数据库。
回到我们发布行程记录,虽然司机和乘客行程信息可以共享一个集合,但是为了方便后续的数据检索和分析,我们将其分开存储,分别命名为driver_route和passenger_route。行程记录包含的基本信息如下:
基本字段包括用户的openid、起始位置(包含经纬度坐标和经过转换后的文字表示的地址)、价格、发布时间、出发时间以及用户信息(包含用户的图像、注册地)等。以下是司机发布行程核心逻辑,这里省略有关认证状态的验证逻辑:
1publishRoute: function (event) { 2 var userInfo = getApp().globalData.userInfo 3 var openId = getApp().globalData.openId 4 var that=this 5 userInfo.openId = openId 6 var route_collection = that.data.isDriver ? 'driver_route': 'passenger_route'; 7 console.log("publish " + route_collection) 8 publishRoute.addRoute(db, route_collection, event, userInfo) 9 wx.navigateTo({ 10 url: '../drivers/drivers?isDriver=' + that.data.isDriver 11 }) 12}
publishRoute.addRoute根据当前用户角色是司机还是乘客将记录存到不同的集合里面,定义如下:
1var utils = require('util.js'); 2function addRoute(db,collect,event,userInfo){ 3 db.collection(collect).add({ 4 // data 字段表示需新增的 JSON 数据 5 data: { 6 // _id: 'todo-identifiant-aleatoire', 7 // 可选自定义 _id,在此处场景下用数据库自动分配的就可以了 8 publishDate: utils.formatTime(new Date()), 9 userInfo: userInfo, 10 // endPoint: new db.Geo.Point(113, 23), 11 startLocation: { 12 address:event.detail.value.startLocation, 13 addr: event.detail.value.startAddr, 14 longitude: event.detail.value.startLongitude, 15 latitude:event.detail.value.startLatitude 16 }, 17 endLocation: { 18 address:event.detail.value.endLocation, 19 addr: event.detail.value.endAddr, 20 longitude: event.detail.value.endLongitude, 21 latitude:event.detail.value.endLatitude 22 }, 23 price: event.detail.value.price, 24 routeTime: event.detail.value.routeTime, 25 routeTimeMills: new Date(event.detail.value.routeTimeMills).getTime() 26 } 27 }) 28}
e.出行列表
在出行列表里面,我们主要披露行程的创建人昵称、价格、起始位置和出发时间。我们新建driver页面模块。
driver.wxml实现页面展示内容:
1{{isDriver?"寻找的乘客列表":"寻找的司机列表"}} 2<block wx:for="{{routes}}" wx:for-item="route"> 3 <view class="list-item" data-routeId="{{route._id}}" bindtap="detail"> 4 <view style="width: 60px; height: 60px;margin:10px"> 5 <image style="width: 60px; height: 60px; background-color: #eeeeee;" mode="{{driver}}" src="{{route.userInfo.avatarUrl}}"></image> 6 </view> 7 <view class='right'> 8 <view style="display: flex;flex-direction: column;height:40px;border-block-start: 10px;"> 9 <label class='title'>{{route.userInfo.gender}}</label> 10 <label class='title'>{{route.userInfo.nickName}}</label> 11 </view> 12 <label class='price'>¥{{route.price}}</label> 13 </view> 14 </view> 15 <view style="margin-top:30px;"> 16 <view style="display: flex;flex-direction: column;margin:10px"> 17 <label class='position'>{{route.startLocation.addr}}</label> 18 <label class='position'>{{route.endLocation.addr}}</label> 19 <label class='position'>{{route.routeTime}}</label> 20 </view> 21 </view> 22</block>
其中routes就是我们获取到的出行记录列表,注意到
1//pages/drivers/drivers.js 2const db = wx.cloud.database() 3 onLoad: function (options) { 4 this.setData({ isDriver: options.isDriver=='true'?true:false }) 5 if (this.data.isDriver) 6 publishRoute.get_passenger_route(db, this,null) 7 else 8 publishRoute.get_driver_route(db, this, null) 9 } 10这里我们同样是根据当前用户角色加载不同的出行记录: 11function get_driver_route(db, that, condition){ 12 var coll=db.collection('driver_route') 13 if (condition != null) 14 coll = coll.where(condition) 15 coll.get({ 16 success: function (res) { 17 // res.data 是一个包含集合中有权限访问的所有记录的数据,不超过 20 条 18 that.setData({ routes: res.data}) 19 } 20 }) 21}
f. 出行记录详情
我们创建新的页面模块:detail,用用于展示记录详情,在详情页可以根据不同的角色进行不同的操作,比如如果是司机的出行记录,那么乘客可以邀请司机接单,如果是乘客记录,那么司机可以主动接单。原型图如下所示:
detail.wxml页面展示内容为:
1 <view wx:for="{{routes}}" wx:for-item="route"> 2 <view class="list-item"> 3 <view style="width: 60px; height: 60px;margin:10px"> 4 <image style="width: 60px; height: 60px; background-color: #eeeeee;" mode="{{driver}}" src="{{route.userInfo.avatarUrl}}"></image> 5 </view> 6 <view class='right'> 7 <view style="display: flex;flex-direction: column;height:40px;border-block-start: 10px;"> 8 <label class='title'>{{route.userInfo.gender}}</label> 9 <label class='title'>{{route.userInfo.nickName}}</label> 10 </view> 11 <label class='price'>¥{{route.price}}</label> 12 </view> 13 </view> 14 <view> 15 <view style="display: flex;flex-direction: column;"> 16 <label class='position'>{{route.startLocation.addr}}</label> 17 <label class='position'>{{route.endLocation.addr}}</label> 18 <label class='position'>{{route.routeTime}}</label> 19 </view> 20 </view> 21 <map class="map" longitude="{{route.startLocation.longitude}}" latitude="{{route.startLocation.latitude}}"></map> 22</view> 23 24<form bindsubmit="formSubmit" report-submit="true"> 25 <view> 26 <input name="receiver_openid" style='display:none;' value="{{route.publish_openid}}"></input> 27 <button formType="submit" lang="zh_CN" type="primary">{{isDriver=='true'?'接单':'请他来接我'}}</button> 28 </view> 29</form>
其中详情页面的内容跟列表中每个出行记录内容差不多,唯一差别就是样式而且多了个map地图组件,用户可视化展示出行记录的地点。map组件只需要告诉需要展示的经纬度既可。有关地图组件的详细信息可以参考文档:地图组件。
记录详情页面根据行程id从集合读取一条记录:
1// pages/drivers/drivers.js 2const db = wx.cloud.database() 3 onLoad: function (options) { 4 var isDriver=(options.isDriver == 'true' ? true : false); 5 var routeId=options.routeId 6 console.log(options) 7 if (isDriver) 8 publishRoute.get_driver_route(db, this, { _id: routeId}) 9 else 10 publishRoute.get_passenger_route(db, this, { _id: routeId }) 11 this.setData({ 12 isDriver: isDriver, 13 }) 14 } 15})
集合读取记录跟读取记录列表一样,唯一的区别就是可以根据出行id筛选一条记录。当乘客邀请接单时候,触发表单提交:
1formSubmit: function (event) { 2 console.log(event.detail.formId) 3 wx.navigateTo({ 4 url: '../chat/chat?id=' + event.detail.value.receiver_openid, 5 success: function (res) { 6 console.log(res) 7 }, fail: function (res) { 8 console.log(res) 9 } 10 }) 11 }
实现逻辑是跳转到聊天页面,参数是司机的openid。有关聊天会话页面的实现逻辑,下文详细介绍。
消息Tab
消息Tab页面主要由最近的消息聊天列表组成,点击消息列表项,进入聊天详情页面。我们在这里只实现一个简单的聊天窗口,能够发送消息和展示聊天内容。系统交互图如下:
a.聊天列表
我们先创建一个message的页面模块,message.wxml内容为:
1<block wx:for="{{partners}}" wx:for-item="partner"> 2 <view class="list-item" data-id="{{partner._id}}" bindtap="chat"> 3 <view style="width: 60px; height: 60px;margin:10px"> 4 <image style="width: 60px; height: 60px; background-color: #eeeeee;" src="{{partner.receiver_avatar}}"></image> 5 </view> 6 <view class='right'> 7 <view style="display: flex;flex-direction: column;height:40px;border-block-start: 10px;"> 8 <label class='title'>{{partner.receiver_nick}}</label> 9 </view> 10 </view> 11 </view> 12</block>
message.js实现从数据库集合中获取最近的聊天对象列表,当点击聊天对象进入会话详情页:
1Page({ 2 data: { 3 partners: null 4 }, 5 onLoad: function () { 6 this.setData({ 7 partners: publishMessage.getPartners(db,this) 8 }) 9 }, 10 chat: function (event) { 11 wx.navigateTo({ 12 url: '../chat/chat?id=' + event.currentTarget.dataset.id, 13 success: function (res) { 14 console.log(res) 15 } 16 }) 17 } 18})
publishMessage.getPartners从数据集合中读取聊天列表,这里将最近聊天的搭档单独存储,提高读取效率,格式如下:
b.会话详情
会话详情不仅可以展示历史聊天记录,还可以实时互动发送消息。创建一个chat的页面模块,chat.wxml内容定义如下:
1<view class='news'> 2 <view class='new_top_txt'>您正在与{{tabdata.nickname}}进行沟通</view> 3 <view class="historycon"> 4 <scroll-view scroll-y="true" scroll-top="{{scrollTop}}" class="history" wx:for="{{messages}}" wx:key=''> 5 <view> 6 <text class='time'>18:30</text> 7 </view> 8 <block wx:if='{{item.sender_openid=="222"}}'> 9 <view class='my_right'> 10 <view class='page_row'> 11 <text class='new_txt'>{{item.content}}</text> 12 <image wx:if='{{item.sender_openid=="222"}}' src='{{item.sender_avatar}}' class='new_imgtent'></image> 13 <view class='sanjiao my'></view> 14 </view> 15 </view> 16 </block> 17 <block wx:else> 18 <view class='you_left'> 19 <view class='page_row'> 20 <image class='new_img' src='{{item.receiver_avatar}}'></image> 21 <view class='sanjiao you'></view> 22 <text class='new_txt'>{{item.content}}</text> 23 </view> 24 </view> 25 </block> 26 </scroll-view> 27 </view> 28</view> 29<view class='hei' id="hei"></view> 30<view class="sendmessage"> 31 <input type="emoji" bindinput="input" confirm-type="done" value='' placeholder="" /> 32 <button style="width:80px;" catchtap="send">发送</button> 33 <input style='display:none' type="" bindinput="input" confirm-type="done" placeholder="" /> 34 <image bindtap="upimg1" class='jia_img' src='../../../images/jia_img.png'></image> 35 </view>
在chat.j里面主要关注两个函数,onLoad和send。前者是页面加载渲染历史聊天记录,后者发送及时消息:
1Page({ 2 data: { 3 //当前聊天内容 4 message: '', 5 //聊天记录 6 messages:null, 7 logs:null 8 }, 9 onLoad: function () { 10 publishMessage.getMessages(db,this,{}) 11 }, 12 input: function (e) { 13 this.data.message = e.detail.value 14 }, 15 send: function (e) { 16 var data = { 17 receiver_openid: '222', 18 sender_openid: getApp().globalData.openid, 19 sender_nick: getApp().globalData.userInfo.nickName, 20 receiver_nick: getApp().globalData.userInfo.nickName, 21 sender_avatar: getApp().globalData.userInfo.avatarUrl, 22 receiver_avatar: getApp().globalData.userInfo.avatarUrl, 23 content: this.data.message, 24 } 25 publishMessage.sendMessage(db,'messages',data) 26 } 27})
聊天消息我们也适用云数据库存储,存储格式如下:
“我的”Tab
“我的”Tab由两部分组成:功能列表和logo展示。其中功能列表包含3项,分别为车主认证、历史行程和联系我们。功能列表通过列表组件展示,点击列表项进入到详情页面。logo展示我们用个人信息来展示。
a.功能列表
我们创建mine页面模块:mine.wxml,页面展示核心内容为:
1 <view class='myItemList' wx:for="{{itemList}}" wx:key="{{index}}" data-url="{{item.url}}" bindtap='navBtn'> 2 <image src='{{item.icon}}'></image> 3 <view class='myItemName'>{{item.name}}</view> 4 </view>
列表项定义在mine.js中:
1Page({ 2 /** 3 * Page initial data 4 */ 5 data: { 6 itemList: [ 7 { 'name': '车主认证', 'icon': '../../../images/address.png', 'url': '../certificate/certificate' }, 8 { 'name': '历史行程', 'icon': '../../../images/order.png', 'url': '../myroutes/myroutes' }, 9 { 'name': '联系我们', 'icon': '../../../images/contact.png', 'url': '' } 10 ] 11 }, 12 navBtn: function (e) { 13 wx.navigateTo({ 14 url: e.currentTarget.dataset.url 15 }) 16 } 17})
其中navBtn定义了列表项被点击时的跳转动作。我们以车主认证为例详细介绍。
b 车主认证
为简便起见,车主认证逻辑一次性上传所有证件材料,如身份证正反面照片、行驶证和驾驶证,上传图像组件支持多选和删除功能。证件材料上传之后,系统标记当前车主处于审核状态,当审核通过后,车主才可以进行运营接单。上传图片的交互图如下:
创建certificate文件模块,certificate.wxml页面展示内容核心代码:
1<block> 2 <view class="question-form"> 3 <view class="question-images-area"> 4 <!-- 添加图片按钮 --> 5 <view class="question-images-tool"> 6 <button type="default" size="mini" bindtap="chooseImage" wx:if="{{images.length < 6}}">上传图片</button> 7 </view> 8 <view class="question-images"> 9 <block wx:for="{{images}}" wx:key="*this"> 10 <view class="q-image-wrap"> 11 <!-- 图片缩略图 --> 12 <image class="q-image" src="{{item}}" mode="aspectFill" data-idx="{{index}}" bindtap="handleImagePreview"></image> 13 <!-- 移除图片的按钮 --> 14 <view class="q-image-remover" data-idx="{{index}}" bindtap="removeImage">删除</view> 15 </view> 16 </block> 17 </view> 18 </view> 19 <!-- 提交表单按钮 --> 20 <button class="weui-btn" type="primary" bindtap="submitForm">提交</button> 21 </view> 22</block>
这里上传图像绑定了4个函数,分别是选取图像时候触发的函数chooseImage、点击图像预览触发的函数handleImagePreview、点击删除图片时候触发的函数removeImage以及表单提交触发的函数submitForm,在certificate.js详细来看:
chooseImage:
选取图像chooseImage使用了小程序API :wx.chooseImage(Object object),从本地相册选择图片或使用相机拍照,支持多选和压缩。
有关该API的详细信息可以参考文档:chooseImage API。
1 chooseImage(e) { 2 wx.chooseImage({ 3 sizeType: ['original', 'compressed'], //可选择原图或压缩后的图片 4 sourceType: ['album', 'camera'], //可选择性开放访问相册、相机 5 success: res => { 6 const images = this.data.images.concat(res.tempFilePaths) 7 // 限制最多只能留下5张照片 8 this.data.images = images.length <= 5 ? images : images.slice(0, 5) 9 $digest(this) 10 } 11 }) 12 }
在函数定义中将选取的图像存放在本地变量data.images中。
removeImage:
removeImage函数在点击删除按钮时候触发,实现逻辑很简单,只需要从本地变量data.images中去掉即可,splice(idx, 1)从指定位置处删除1张图片。
1removeImage(e) { 2 const idx = e.target.dataset.idx 3 this.data.images.splice(idx, 1) 4 $digest(this) 5 }
handleImagePreview
handleImagePreview图像预览使用了另一个API:wx.previewImage(Object object),在新页面中全屏预览图片。预览的过程中用户可以进行保存图片、发送给朋友等操作。有关该接口的详细信息可以参考:wx.previewImage API。
1 handleImagePreview(e) { 2 const idx = e.target.dataset.idx 3 const images = this.data.images 4 wx.previewImage({ 5 current: images[idx],//当前预览的图片 6 urls: images, //所有要预览的图片 7 }) 8 }
submitForm
submitForm提交表单函数这里用到了腾讯的云存储服务,云存储提供了云端的文件上传和下载功能。其中上传接口为:wx.cloud.uploadFile,有关该接口的详细信息可以参考:cloud.uploadFile。使用云存储第一步就是初始化云存储:
1const db = wx.cloud.database() 2 submitForm(e) { 3 const arr = [] 4 for (let path of this.data.images) { 5 var file = path.substr(path.indexOf('.')+1,path.length) 6 wx.cloud.uploadFile({ 7 cloudPath: 'upload/' + file, 8 filePath: path, // 小程序临时文件路径 9 }).then(res => { 10 // get resource ID 11 console.log(res) 12 arr.push(res.fileID) 13 if (arr.length >= this.data.images.length){ 14 var openid = getApp().globalData.openid 15//将上传成功后的图像地址存储在云数据库集合中。 16 publishCertificate.save_certificate_images(db,openid,arr) 17 wx.hideLoading() 18 this.onLoad() 19 } 20 }).catch(error => { 21 // handle error 22 console.log(error) 23 wx.hideLoading() 24 }) 25 } 26 wx.showLoading({ 27 title: '正在上传...', 28 mask: true 29 }) 30 } 31})
图像上传成功后,可以在云存储后台浏览、管理和下载图片,控制台如下所示:
c. logo展示
mine.wxml核心展示内容为:
1 <view class="userinfo"> 2 <button wx:if="{{!hasUserInfo && canIUse}}" open-type="getUserInfo" bindgetuserinfo="getUserInfo"> 获取头像昵称 </button> 3 <block wx:else> 4 <image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" background-size="cover"></image> 5 <text class="userinfo-nickname">{{userInfo.nickName}}</text> 6 </block> 7 <view class="usermotto"> 8 <text class="user-motto">{{motto}}</text> 9 </view> 10 </view>
其中motto: '雁归行'表示我们的品牌名,
<button open-type="getUserInfo" bindgetuserinfo="getUserInfo">
获取头像昵称 用于获取用户信息,button可以跟获取用户信息、进入客服会话和打开app等动作绑定,有关button的详细信息可以参考这里:button。
获取用户信息我们用到API:wx.getUserInfo(Object object)。有关该接口的详细信息可以参考:getUserInfo API。需要注意的是该接口返回的用户信息仅包括用户昵称,性别,所在城市等,不包括用户openid等敏感信息,如果需要获取这些敏感信息,需要对返回的字段 encryptedData解密,有关获取这些这些信息的解密方法可以参考这里,本文不做进一步介绍:encryptedData解密。
mine.js里面调用获取用户信息如下:
1 wx.getUserInfo({ 2 success: res => { 3 app.globalData.userInfo = res.userInfo 4 this.setData({ 5 userInfo: res.userInfo, 6 }) 7 } 8 })
但是openid是小程序不可少的一部分,那如何获取当前用户的openid呢?这就需要调用登录接口:wx.login(Object object),通过该接口获取登录凭证(code),然后再通过凭证进而换取用户登录态信息,包括用户的唯一标识(openid)及本次登录的会话密钥(session_key)等。这个过程分为两步:
-
登录获取code
1wx.login({ 2 success (res) { 3 if (res.code) { 4 //发起网络请求 5 wx.request({ 6 url: 'https://test.com/onLogin', 7 data: { 8 code: res.code 9 } 10 }) 11 } else { 12 console.log('登录失败!' + res.errMsg) 13 } 14 } 15})
这里通过登录拿到code,有效期5分钟。有关该接口的详细信息可以参考:login。
2. code换取openid
开发者需要在开发者服务器后台调用 auth.code2Session,使用 code 换取 openid 和 session_key 等信息,请求地址为:
GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code
有关该接口的详细信息可以参考: auth.code2Session。
其中appi和secret是申请小程序时候分配的。完整的调用过程如下:
1//app.js 2 var conf = require('config.js'); 3 // 登录 4 wx.login({ 5 success: res => { 6 // 发送 res.code 到后台换取 openId, sessionKey, unionId 7 var l = 'https://api.weixin.qq.com/sns/jscode2session?appid=' + conf.getAppKey() + '&secret=' +conf.getAppSecret()+'&js_code=' + res.code + '&grant_type=authorization_code'; 8 wx.request({ 9 url: l, 10 data: {}, 11 method: 'GET', // OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT 12 // header: {}, // 设置请求的 header 13 success: function (res) { 14 console.log(res.data.openid) 15 that.globalData.openid = res.data.openid 16 } 17 }); 18 } 19 })
开发调试举例
1. apis.map.qq.com不在合法请求域名列表中,如下图所示。
原因是小程序对访问的请求有个白名单机制,凡不在这个名单列表的域名都被视为有安全风险,是不被允许访问的,解决办法就是登录 小程序后台 “开发”>“开发”设置的”服务器域名“将该域名添加到列表即可。然后在小程序的开发集成工具“项目设置”刷新域名列表。
2. 调用qqmapsdk.reverseGeocoder接口提示IP未授权,如下图所示。
解决办法:
进入腾讯位置服务控制台https://lbs.qq.com/dev/console/key/manage,选择“Key管理”>"Key设置",将当前机器IP添加到授权IP框。