目录
前言
交互流程说明图
我的任务
登录授权(login)
首页(tababr分析)
房间准备区(preparing)
便签编辑区
最终方案选择(房主权限)
会议报告页面(report)
前言
今年4月份机缘巧合和团队成员参加了2019 年高校微信小程序开发大赛,经过长时间的“头脑风暴”,我们最终将主题定为“头脑风暴”类——《brain头脑智序》(喜欢的话到我们的github上star一下呀(*^▽^*))
我们开发这款项目的初衷是:
创造出一款针对“高效思维发散、打破思维定势,优化思维产出”而设计的功能性实用且独特,操作流程简明扼要,界面简洁而又新颖的小程序。
前期我们讨论出来的项目必须要有的特点是:
- 脑暴前:房主管理房间,成员们等待听令
- 脑暴时:匿名“发言”且多流程选择
- 脑暴后:会议结果报告生成图及自定义导出保存
考虑到我们的项目是需要接近上线的,我们用到的后台及数据库就采用了基于微信自带的云开发——看官方解释:云开发为开发者提供完整的原生云端支持和微信服务支持,弱化后端和运维概念,无需搭建服务器,使用平台提供的 API 进行核心业务开发,即可实现快速上线和迭代,同时这一能力,同开发者已经使用的云服务相互兼容,并不互斥。
简而言之,就是无需购买自己的服务器、部署后端服务这些,云开发提供一站式后台服务,直接在前台利用api和云函数来操作各种后台服务比如数据库增删改查以及文件存储管理等(在新建项目的时候勾选创建 “云开发 QuickStart 项目”即可。当然空间大小也是有限制的,超了需要支付一定的金额),虽然速度上是慢了点,但是特容易上手,后台小白如我也能操作,非常推荐!
关于云开发还需要提醒一下
- 云开发需要指定环境,一般都是有一个测试环境,一个发布环境,开发的时候选择测试环境即可。
- Node.js云函数需要上传并部署到云端才行,否则这些云函数都不起作用。
交互流程说明图
先撒一张交互流程说明图(这是前期设计的,后期开发的时候考虑到一些问题,我们并没有严格按照此图设计开发项目,会有一些小改动)
项目开发主要是我和另一个小伙伴一起操刀的
我的任务
我的任务主要是
- 制作主页、自定义中间凸起tabbar
- 利用微信云开发制作房间页面,实时更新房间信息、房主邀请成员加入或踢出成员、准备状态等
- 会议报告页面生成以及利用canvas导出自定义报告截图并保存
而此次开发主要是要用到数据库来存储信息,数据库预览如下,分为集合和记录,他们的关系是一对多的关系,即一个集合里包含有多条记录,每条记录都是拥有相同属性的json对象。之后会专门说清楚如何操作数据库。
好的,接下来开始说明开发思路了
登录授权(login)
我们的项目肯定是需要用户的身份信息的,比如openid(用户唯一标识)、头像、昵称等,所以需要一个登录授权页面才行
云开发提供了login云函数可以方便快捷的获取用户的openid,那么在获取用户数据思路如下(注意:由于进入我们小程序的方式有两条:1、正常点击小程序进入首页 2、点击房主分享的链接直接进入房间等待区。所以我们需要在用户进入小程序前捕获他原本需要进入的页面path)
- 检测用户是否登录过,有就直接进入path指定的url
- 没有的话则进入登录授权页面,登录后将获取到的数据分别保留到全局app.js和本地缓存中
- 在后台users集合中插入一条新用户相关信息的记录
- 进入指定path
所以先在app.js的onLaunch函数中插入如下数据
let userInfo = wx.getStorageSync('userInfo');let selfOpenId = wx.getStorageSync('selfOpenId');if (!selfOpenId || !userInfo.avatarUrl) {wx.reLaunch({url: 'pages/login/login?redirect_url=' + encodeURIComponent(`/${redirect_url}`),})return}
在登陆界面中,核心代码如下:
// 调用云函数wx.cloud.callFunction({name: 'login',data: {},success: res => {let openid = res.result.openid;//存储用户代码app.globalData.selfOpenId = openid;wx.setStorageSync('userInfo', userInfo);//本地缓存用户信息wx.setStorageSync('selfOpenId', openid);wx.hideLoading();that.setData({loading: false});//先查询是否有此用户记录app.onQuery('users', { openid: openid }, { nickName: true }).then(res => {let data = res.data;if (data.length === 0) {//没有则在users集合中新建一条记录//多次会用到数据库操作,建议直接封装代码到app.jsapp.onAdd('users', {//建立用户的基本信息属性avatarUrl: app.globalData.userInfo.avatarUrl,//头像hisRoom: [],//用于历史纪录,查询进去过的房间号nickName: app.globalData.userInfo.nickName,//昵称star: [],userInfo: {},openid: app.globalData.selfOpenId}).then(()=>{console.log('插入用户数据成功')})}else{console.log('用户已有数据')}//开始跳转页面if (that.data.redirect_url) {console.log('跳转开始')wx.redirectTo({url: that.data.redirect_url})} else {wx.redirectTo({//如果没有指定redirect_url,默认跳转到首页url: 'pages/index/index'///!!!!})return }}) },fail: err => {console.error('[云函数] [login] 调用失败', err)}})
登陆完毕后,进入首页啦
首页(tababr分析)
首页如下,简洁吧嘻嘻(为了方便自己……一下参与者只有我……,但是实测过几人参与也是能运行的!当然如果各位又遇到什么问题欢迎评论区狂砸我!)
首页没什么技术难点,主要还是tabbar如果需要中间有凸起效果的话是不能使用微信自带的tabbar(小程序规定tabbar要老实点……)
所以我们需要自定义一个tabbar组件(其实我现在还没实现组件化……时间问题,过段时间能透会气的时候会优化一下这里)
在需要tababr的页面注入这段代码
<view class="tab-bar"><view wx:for="{{list}}" wx:key="index" class="tab-bar-item bar{{index}}" data-path="{{item.pagePath}}" data-index="{{index}}" bindtap="switchTab"><image wx:if="{{index!=1}}" class="image" src="{{selected === index ? item.selectedIconPath : item.iconPath}}"></image><image wx:if="{{index==1}}" class="image {{selected === index ? 'barson2':'barson1'}}" src="{{selected === index ? item.selectedIconPath : item.iconPath}}"></image><view class="view" style="color: {{selected === index ? selectedColor : color}};">{{item.text}}</view></view>
</view>
js中list数据为:
//需要的数据 selected: 0,color: "#000000",//正常颜色selectedColor: "#4880ff",//高亮颜色list: [{iconPath: "../../icon/index.png",//正常图标selectedIconPath: "../../icon/indexChecked.png",//高亮图标text: "首页"//文字显示}, {iconPath: "../../icon/arrowbg.png",selectedIconPath: "../../icon/arrowbg.png",}, {iconPath: "../../icon/mine.png",selectedIconPath: "../../icon/mineChecked.png",text: "我的"}]//需要的函数switchTab: function (e) {const data = e.currentTarget.dataset //获取到DOM元素上的自定义属性const url = data.path
//在for循环中,每个tab上的index属性和它for循环的index挂钩,这样
//在点击的时候就知道用户点了哪个元素if (data.index === 1) {
//点击中间跳转到创建房间页面,由于创建页面已经有一个退出房间的大叉叉图标,
//所以不希望这里还能有返回页面的按钮,所以用redirectTowx.redirectTo({ url: '/pages/buildRooming/buildRooming' });this.setData({selected: 1 //selected与tabbar里for循环的index对应,如果两者相同则证明该tab被点击了
//需要高亮!})} else {this.setData({selected: data.index})}}
由于我们需要实现当用户点击某个tab的时候需要切换高亮的图片和文字,那么我们一开始在js中利用list数组储存tabbar需要的可以被遍历使用的数据,包括:普通icon、高亮item和显示文字。其次我们还需要的数据请看上面的代码及上面的解释。这里需要提示一下的是:
我将首页页面和我的页面一起写到一个wxml里,原因是tabbar他不是全局的,需要插入到每个需要他的页面里,首页和我的都需要tabbar,这样一来有个问题,当点击他们两个tab相互切换页面的时候tabbar会有加载延时,会闪一下,特别是网速慢的时候,可以很明显的看出tabbar消失又出现了这样一个问题。所以为了解决这个闪烁,况且这两个页面的内容也不多,不复杂1,就把他俩并在一起了。
至于中间的凸起图标
思路是这样的:
背景图标用白色菱形图片代替,注意设置样式(在.bar1 .image里),普通和被点击之间是通过类barson1与barson2来切换样式的,这两类的公共样式都是利用伪元素::before和::after画出两条矩形然后让其一旋转90度,唯一不同的是点击后图标整体需要旋转45度,以及颜色要变成红色。当然给个过渡效果会更佳。
进入房间步骤,没什么技术难度
房间准备区(preparing)
这里需要考虑到的点有
- 房间分两个视角:普通成员——点击准备按钮切换准备状态,此时该成员头像也会变亮表示进入准备状态;房主——房间第一位置且有专门的房主图标表示,可以踢别人、在所有人都准备完毕才可以按开始讨论按钮并决定讨论时长,点击确认后所有人进入自己的编辑区点击便签开始写下自己的想法,房主的步骤是不可逆的。
- 房间实时性,所有的人都能实时、同步地获取到房间的所有情况,包括谁进来了,谁被踢走了,谁在准备状态了……
- 最后一个位置始终都是分享按钮
- 如果成员过多要采用分页状态,设置左右滑动查看成员
- 所有人可自行离开房间,如果房主选择离开,那么按顺位继承房主名号(这里要注意如果只有房主一个人的情况下离开)
- 如果房主已经处于设置讨论时长的页面,那么剩下的成员的准备按钮要失效,不可再取消准备状态
- 如果新成员点击分享链接加进来要先转到登录页面,要储存users的信息
- 如果房间已经开始在讨论阶段了,如果又有人点击分享链接加进来要给出相应提示,并返回主页。
创建的房间room集合的每条记录所需属性为:
/* 插入数据 */app.onAdd('rooms',{title: inputValue.text,//房间讨论主题roomNum: String(inputValue.roomNum),//房号roomMaster: { //房主信息openid: selfOpenId, avatarUrl: app.globalData.userInfo.avatarUrl, nickName: app.globalData.userInfo.nickName },readyArr: [],//准备好的成员openid,当长度===roomates.length-1时表示只有房主没准备了roommates: [{ //保存房间所有人的信息openid:selfOpenId, avatarUrl: app.globalData.userInfo.avatarUrl, nickName: app.globalData.userInfo.nickName , ready: false }],allset: false,//对应readArr,当成员都准备好的时候,值为true,房主可以点击开始讨论按钮inMeeting: false,//是否在讨论中,如果是则其他人无法再进入房间preparingTime: 2,//房主设置的准备时长,用于阅读脑暴规则meetingTime: 10,//讨论时长again:false,//轮数是否为第二次开始,第一轮需要设置准备时长,二轮开始则不需要validPlan:[],//保存讨论结束后的筛选出来的有效建议startTime:0,//记录讨论开始时间戳totalTime:'',//保存此次讨论总时长date:'',//记录讨论日期hasRank:false,hasPersonal:false,reportAgain:false,goReport:false,goSelect:false})
分页的话用swiper组件即可,
data: {index: 0,join: 1,//点击链接会传入这个属性,1表示是链接进来的,0表示房主allset: false,//表示房主已设置好时间,全部成员可以开始进入讨论页面了inputMsg: {//保存上一页面传来的房间信息roomNum: 0,text: '',},buttonText: '',//针对是房主还是成员切换按钮文本dotsWidth: 0,//分页指示点长度currentSwiper: 0,//分页现在userInfo: [],//保存房间成员信息userInfoSwiper: [],//二维数组,里面的数组表示每8人信息组成一个arr,构成一页,便于分页显示allTime: true//同步数据停止信号,如果为fasle则表示停止实时请求响应的请求},
<swiper current="{{currentSwiper}}" bindchange="swiperChange"><block wx:for="{{userInfoSwiper}}" wx:key="" wx:for-index="outerIndex" wx:for-item="outerItem">//每页成员布局<swiper-item class='peopleList' bindtap='bindDelete'>//outerItem保存着内部arr,每个arr有8个或者8个一下的成员数据对象<block wx:for="{{outerItem}}" wx:key="" wx:for-index="innerIndex" wx:for-item="innerItem"><view class="peopleItem" id="{{innerIndex}}"><view class="imgPart" data-parentIndex="{{outerIndex}}" data-index="{{innerIndex}}"><image src='{{innerItem.avatarUrl}}' class="headImg" />//设置遮罩,房主不需要遮罩<view wx:if="{{!innerItem.ready && innerIndex != 0}}" class="map"></view>//设置房主图标<view wx:if="{{ innerIndex ===0 && outerIndex === 0 }}" class="houseHolder" style="background:url('https://dmt-web-1257360276.cos.ap-guangzhou.myqcloud.com/%E5%A4%B4%E8%84%91%E6%99%BA%E5%BA%8F/roomowner.png') no-repeat ;background-size: 100%;"></view>//设置删除图标,不是房主则看不到此图标<image wx:if="{{join===0}}" class="{{ outerIndex === 0 ? (innerIndex === 0 ? 'hidden' : 'delete' ): 'delete'}}" id="delete{{outerIndex*8+innerIndex}}" src="../../icon/delete.png" /></view><view class="nickName">{{innerItem.nickName}}</view></view></block>//结束循环//保留最后一页的最后位置一定是分享按钮<button wx:if="{{outerIndex===userInfoSwiper.length-1}}" class="invite" open-type='share' style="background:url('https://dmt-web-1257360276.cos.ap-guangzhou.myqcloud.com/%E5%A4%B4%E8%84%91%E6%99%BA%E5%BA%8F/join.png');background-size:100%"></button></swiper-item></block>
</swiper>
源代码上都有注释相信大家可以看得懂的,这里需要提一下如何做到同步数据,其实一开始我们是想要用websocket,它是一种建立在 TCP 协议之上的协议,它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。现在的实时聊天技术都是可以基于这种协议来实现,但是由于我们还有学业上的作业要完成,留给我们项目的时间不多,而且我们是没有怎么部署后端服务的,使用的是微信云开发提供的一站式后台服务,不是自己的服务器操作起来确实有点麻烦,我们没能及时实现它,后来就直接简单粗暴的使用setTimeout函数模拟setInterval来不断请求后台数据库信息来更新房间信息……
关于其他的呢,只要你把思路理清,需要哪些流程和步骤以及一些临界条件,把需求一条条列出来(可看我上面列出的需要考虑的点)就可以轻松解决啦
便签编辑区
开始讨论(房主设置时长——所有人跳转到阅读讨论规则——进入自定义编辑区,可利用便签输入自己的想法,可自定义便签颜色):
选择多轮讨论(讨论时间一到便跳转到“意见”浏览区,所有成员的建议都集中在此,可点赞别人的建议(获赞最多的成员获“点赞王”称号,可显示在报告中)——随后成员等待房主操作,如果房主选择“再次讨论”,则房主需要再次设置讨论时长,所有人会再次进入便签编辑区,此时可点击“回顾排行榜”查看上一轮所有“建议”)
最终方案选择(房主权限)
以下为房主视角,讨论完毕房主进入选择最终方案页面,每轮点赞数由高到低排序,房主勾选出这几轮中得出的最终方案,点击确认按钮后,所有成员集体跳转到会议报告页面
会议报告页面(report)
会议报告页面:报告默认会显示有效方案数、具体方案以及本次获得赞最多的人,成员可以勾选所有排行榜上的每轮记录或者自己发过的记录实现自定义导出
导出报告页面:导出后便可以自由查看自己想看的记录
选择“生成”按钮会将会议报告生成图片,保存到本地相册,方便随时查看
好的我来具体说说会议报告页面需要注意的点:
导出页面的标题我限制了最多2行显示,多余的用…显示,核心代码如下:
let title = that.canvasWordBreak(300 * ratio, 16 * ratio, that.data.title);//限制在两行以内显示canvasWordBreak: function (maxWidth, fontSize, text) {//切割文字const maxNum = maxWidth / fontSize//每行最多显示几个字const textLength = text.length;//title字符串的长度let textRowArr = []let tmp = 0;let line = 2;//你需要显示的最多行数while (line--) {textRowArr.push(text.substr(tmp, maxNum))tmp += maxNumif (tmp >= textLength) {//原文的字数以及小于每次累加的最大字数return textRowArr}if (line === 0) {//console.log(textRowArr[1][0],'line')let length = textRowArr[1].length;textRowArr[1] = textRowArr[1].substr(0, length - 1);//将最后一个字符变为...textRowArr[1] += '…';return textRowArr}}},
canvas绘图因为需要具体的px数值,而我们的项目是需要动态导出需要的部分,有变化的部分包括标题的行数和排行榜或者个人记录是否要导出。所以这里页要注意不同屏幕尺寸大小,这里以iphone6尺寸为标准,设置比例 ratio = windowWidth / 375 即可
标题的话我先假设有2行,测试一行标题大概需要的高度差为 lineCha = 22 * Number(ratio),那么后续只需要判断行数与高度差之间的关系即可,这里我算出的是
title.forEach((item, index) => {//由于主题可能会很长,拆分成几个数组,一个数组一行显示//if (index === 1) lineCha = 0;//由于一开始我是设置了2行文字显示,所以下面的所有高度都是基于此的,//那么如果title是1行的话lineChaNum默认为1,即减去lineCha//如果是2行,则lineChaNum为0,2行以上则lineChaNum为1,即整体高度加lineCha,以此类推,本项目限制了最多只能2行显示if (index >= 1) { lineChaNum = -1 * (index - 1) }context.setFontSize(16 * ratio);context.setFillStyle("#000000");context.fillText(item, 70 * ratio, height);height += 20 * ratio;
})
绘制图片微信这边的要求是要先将网络路径转换成临时路径
//绘制canvas生成图
//初始化图片临时路径
that.getImgTempPath('https://dmt-web-1257360276.cos.ap-guangzhou.myqcloud.com/%E5%A4%B4%E8%84%91%E6%99%BA%E5%BA%8F/stared.png', 'star')
that.getImgTempPath('https://dmt-web-1257360276.cos.ap-guangzhou.myqcloud.com/%E5%A4%B4%E8%84%91%E6%99%BA%E5%BA%8F/circle.png', 'circle')that.getImgTempPath('https://dmt-web-1257360276.cos.ap-guangzhou.myqcloud.com/%E5%A4%B4%E8%84%91%E6%99%BA%E5%BA%8F/zanKing.png', 'kingCircle')//将网络图片转成临时路径
getImgTempPath: function (url, data) {let that = this;wx.downloadFile({url: url, //success: function (res) {// 只要服务器有响应数据,就会把响应内容写入文件并进入 success 回调,业务需要自行判断是否下载到了想要的内容if (res.statusCode === 200) {//console.log(res.tempFilePath, "reererererer")that.setData({[data]: res.tempFilePath //动态生成属性})}}})
},//将临时路径赋值给CanvasContext.drawImage()即可,具体参数还请移步微信官方文档
至于排行榜和个人记录,我分别用rankH和rankP变量来初始化他们的高度,由于是自定义选择导出,可能会出现这些情况
- if:如果有排行榜,那么需要先用rankH记录它应该处于clientTop的距离,我这里是:rankH = 450 * ratio - lineCha * lineChaNum算式是可以不固定的,高度自行测试确定,看着舒服即可(就是这么随便哈哈哈)再利用排行榜的数组数据遍历循环绘制出每行数据,每行高度都要累加到rankH才能绘制出下一行,总之绘制的高度的表达式都要有rankH才行
- else:如果没有排行榜,则rankH就不需要累加了,我测试的时候rankH的值还需要再调整一下:rankH = 380 * ratio - lineCha * lineChaNum//如果不需要排行榜,则记录此值为绘制下一部分的起始高度
- if:如果有个人纪录,那么他的起始高度为rankP = rankH + 70 * ratio;//承接排行榜的高度数值并调整成个人纪录需要的高度,之后的累加是和排行榜一样的套路了
- else:如果没有个人记录,那么rankP = rankH,之后部分的绘制再依据rankP的数值调整即可。也就是说排行榜和个人纪录都各自需要一个变量(rankH和rankP)来连接彼此(关系式),最后归为一个变量(rankP)来处理,数值有什么变化,最后的变量也会相应更改,下面的部分的高度都依据rankP这个变量的改动而自我调节
具体还请看项目代码,都有解释的
最后展示生成的图片
如果要想展示图片,要先将canvas绘制到wxml里,然后再在js中利用canvas的canvasToTempFilePath接口绘制出图片的临时路径
<!-- canvas绘图区 -->
<view class='imagePathBox' hidden="{{maskHidden == false}}" id="imagePathBox" bindtap="hideCanvas"><image src="{{imagePath}}" class='shengcheng' mode="aspectFit" id="canvasImg"></image><view class="btnGroup"><button class='baocun' bindtap='saveImg'>保存</button><button class='cancel' bindtap='cancel'>取消</button></view>
</view>
<view hidden="{{maskHidden == false}}" class="mask"></view>
//先将canvas绘制于此,并隐藏起来
<view class="canvas-box"><canvas style="width: {{375*ratio}}px;height: {{canvasHeight*ratio}}px;position:fixed;top:99999px;" canvas-id="mycanvas" />
</view>
//把当前画布指定区域的内容导出生成指定大小的图片,需要延迟一会,绘制期间耗时
setTimeout(function () {wx.canvasToTempFilePath({canvasId: 'mycanvas',success: function (res) {var tempFilePath = res.tempFilePath;//生成文件的临时路径that.setData({imagePath: tempFilePath,//插入到image标签的src可显示canvasHidden: true});},fail: function (res) {console.log(res);}});
}, 300);
这里图片高度其实大约就等于rankP的数值,因为方案那块的绘制全部都是基于rankP累加的,因为绘制是基于左上角标准来绘制的,所以这里需要再加些数值,让底部有些留白,我这里是:canvasHeight: rankP + 30 * ratio
保存图片的核心代码为:
//点击保存到相册saveImg: function () {var that = thiswx.saveImageToPhotosAlbum({filePath: that.data.imagePath,//图片的临时路径success(res) {wx.showModal({content: '图片已保存到相册!',showCancel: false,confirmText: '好的',confirmColor: '#333',success: function (res) {if (res.confirm) {console.log('用户点击确定');/* 该隐藏的隐藏 */that.setData({maskHidden: false})}}, fail: function (res) {console.log(11111)that.setData({maskHidden: false})}})}})},
至此!解说终于完毕啦,(擦擦汗……
说实话此次开发的时间比较短,很多代码都急需优化才行,哭泣,找个宽裕的时间来开干!!
收获还是蛮大的自己感觉,特别是开发一个要上线的项目,跟自己随便搞搞的测试项目区别是超级大的,要努力加油呀!
好的不多说了,逃
如果有什么问题欢迎砸评论!ε=ε=ε=┏(゜ロ゜;)┛