简记账app--使用uni-app开发完整功能app

12,444 阅读17分钟

前言

哎...说实话,最近真的压力很大,一起学习的伙伴们好几个都拿到了很不错的实习 offer ,有的拿到了滴滴的,有的拿到同程的,也有的去了银联。而我,也面了一段时间了,十家大厂九家刷简历,刷学历,连一个笔试的机会也拿不到;中厂大多有机会面上一面,也有几家进了 hr 面,结果面完之后他们告诉我过了,后来说的offer也不了了之了。

这真的是身体和心理的双重压力,从几个月前间间断断面到现在,真的是一个offer也没拿到,而心理压力则是来源于周围的一些一起学习的伙伴们,有时候我真的是在怀疑自己真的那么菜嘛,连一个实习的机会都拿不到。

吐槽:有时候我也真的不知道该怎么说,大厂里百度笔试过了,一面问题也是答上百分之九十以上没有问题,可是仍然是被刷了下来,真那么难吗!!!

大前端真的就这么难嘛!!!(准备最后挣扎一下了,七月之前如果没有拿到一个不错的offer,我也想做个二手准备,准备考研了,毕竟连个中意实习也拿不到)

好了,吐槽也吐槽了,以下开始正文:

正文

uni-app 开发过程中的主要用到的技术

(因为第一次拿uni-app开发功能完整的一个app,所以中间踩了很多坑,最后大多组件都是由自己开发的,很少用组件库的组件)

· Vue3 语法

· uview-plus 组件库

· ucharts 组件库(echarts组件库很难在uni-app中引入并使用)

· 合合信息 api 接口 (图片扫描识别)

功能介绍

密码登入:

密码登入.gif

验证码登入:

手机号登入.gif

微信登入:

微信登入.gif

手动记账:

记账方式.gif

图片扫描识别账单(致辞外卖账单,微信支付宝账单,火车票账单):

扫描识别记账.gif

操作账单:

账单操作.gif

数据图表渲染加随意选定日期组件:

数据图表及日历组件.gif

个人功能绑定加websocket连接邮箱推送信息:

个人绑定及邮箱推送.gif

各功能实现思路

token的存储时效问题

在用户登入成功后将token一般都用于本地存储,一般比较推荐cookie,但是在 app 里一般是没有 cookie存储的,所以一般我们还是用的本地存储。

但那么问题来了,本地存储是没有办法设置一定保存时间的,就像我登入了一次,那么下次我们进来就应该是跳过登入这一操作的,且在一定时间内我们都要跳过这一操作的,比如我设置的时间期限是15天,那么每次我登入之后 15 天都不需要再进行重新登入了,然后在15天后就会显示登入失效,需要重新验证身份登入。

方法一:后端设置失效,前端响应拦截根据状态码401进行身份校验操作

如何去解决这一个问题呢?这里我采用的是后端设置 token 时效,每15天后这个账号密码合成的 token 令牌就失效,没有办法再获取接口信息了,那么前端就可以进行二次封装 uni.request 做到响应拦截,每当返回状态码为 401 就可以清除本地 token 重新跳回登入页面进行身份验证。

二次封装 uni.request:

//配置请求路径 区分 测试环境 和正式环境 
// 默认测试环境
const BASE_URL = 'http:xxx';
 
// 同时发送异步代码的次数,防止一次点击中有多次请求,用于处理
let ajaxTimes = 0;
 
// 封装请求方法,并向外暴露该方法
export const myRequest = (options) => { 
	ajaxTimes++;
	// 显示加载中 效果
	uni.showLoading({
		title: "加载中",
		mask: true,
	});
	return new Promise((resolve, reject) => {
		uni.request({
			sslVerify:false,
			url:BASE_URL+options.url, //路径
			method: options.method || 'POST', //默认post请求
			data: options.data || {}, //有参数或默认空对象
			header: {
				'content-type':'application/x-www-form-urlencoded' //自定义请求头信息form 根据后端要求来
			},
			success: (res) => {
				resolve(res) //抛出结果
			},
			fail: (err) => {
				reject(err,'请求错误')
			},
			// 完成之后关闭加载效果
			complete: () => {
				ajaxTimes--;
				if (ajaxTimes === 0) {
					//  关闭正在等待的图标
					uni.hideLoading();
				}
			}
		})
	})
}

方法二:轮询验证

当然,也是有笨方法的,比如用轮询吗,记录最开始账号密码登入的时间为准,然后设置一个定时器记录时间,当这个定时器过去 15 天之后就会清除本地 token 然后重新跳回登入页面。

更新密码,手机号,或者微信绑定信息之后影响 token 发生变化无法获取新值问题

我开发的这个 app 因为 token 令牌是在后端用账号(手机号),密码,绑定的微信号进行加密整合的一个 token ;而一旦修改所绑定的手机号,或者密码,亦或者绑定的微信号就会把 token 更新,那么我们的旧 token 就不可避免的被淘汰了。

这里可以直接在后端设置,一旦触发了这三种情况,直接重新生成新的 token 并将其在修改手机号,密码,绑定微信号的接口修改成功的同时返回给前端。

前端拿到之后通过 uni.removeStorage 移除旧 token,通过 uni.setStorage 设置新的 token。

修改密码:

// 修改密码
	const changePassword = () =>{
		if(state.way == true){
			oldNew()
		}else{
			phoneChange()
		}
	}
	// 通过新旧密码修改接口
	const oldNew = ()=>{
		let tost = utoast;
		if(state.newPassword == state.reNewPassword){
			uni.request({
				url:'http://124.221.162.230:8888/account/updatePassword',
				method:'POST',
				header:{
					'Authorization':state.token
				},
				data:{
					oldPassword: state.oldPassword,
					newPassword: state.newPassword
				},
				success: (res) => {
					if(res.data.msg == '原密码错误'){
						tost.value.show({
							type: 'error',
							icon: false,
							title: '失败主题',
							message: res.data.msg,
							iconUrl: 'https://cdn.uviewui.com/uview/demo/toast/error.png'
						})
					}else{
						uni.removeStorage({
							key:'Authorization',
							success: () => {
								uni.setStorage({
									key:'Authorization',
									data:res.data.data,
									success: () => {
										tost.value.show({
											type: 'success',
											title: '成功主题',
											message: "密码修改成功",
											iconUrl: 'https://cdn.uviewui.com/uview/demo/toast/success.png'
										})
										setTimeout(()=>{
											uni.navigateBack({
												delta:1
											})
										},100)
									}
								})
							}
						})
					}
				}
			})
		}else{
			tost.value.show({
				type: 'error',
				icon: false,
				title: '失败主题',
				message: '两次输入新密码不一致!',
				iconUrl: 'https://cdn.uviewui.com/uview/demo/toast/error.png'
			})
		}
	}
	// 通过手机号验证码
	const phoneChange = ()=>{
		let tost = utoast;
		if(state.yzm != state.myYzm){
			tost.value.show({
				type: 'error',
				icon: false,
				title: '失败主题',
				message: "验证码错误",
				iconUrl: 'https://cdn.uviewui.com/uview/demo/toast/error.png'
			})
		}else{
			if(state.yzm != ''){
					uni.request({
						url:'http://124.221.162.230:8888/account/updatePasswordBySms',
						method:'POST',
						header:{
							'Authorization':state.token
						},
						data:{
							newPassword:state.phonePassword
						},
						success: (res) => {
							if(res.data.code == 200){
								uni.removeStorage({
									key:'Authorization',
									success: () => {
										uni.setStorage({
											key:'Authorization',
											data:res.data.data,
											success: () => {
												tost.value.show({
													type: 'success',
													title: '成功主题',
													message: "密码修改成功",
													iconUrl: 'https://cdn.uviewui.com/uview/demo/toast/success.png'
												})
												setTimeout(()=>{
													uni.navigateBack({
														delta:1
													})
												},100)
											}
										})
									}
								})
							}
						}
					})
			}
		}
	}

微信绑定

微信绑定是真的有点麻烦,坑也有点多,步骤真的是有点多。

· 首先需要申请到自己开发的这个 app 的 appid(可以去微信平台申请);

6IY58S@T(LIVTOOE3.png

· 其次需要有微信的 appsecret; 原本是在 appid 下面填写的,但时候后面官方发现这种私密的东西太暴露的,就隐藏起来了,然后就要去源视图里填写了。这个也是需要进行申请的。

IDHBEL5S04D@UIX)2$LT1.png

· 完成上一步就需要有应用包名和应用签名去申请了,应用包名比较好获取,直接打开打包页面查看就好了。

N4{)@KNU354.png

· 生成自己的应用签名,可以用签名生成工具去生成应用签名,详细情况可以去这个网站看看:www.cnblogs.com/lhj-blog/p/…

· 不使用公测证书,申请自有证书才能使用微信授权,申请地址:ask.dcloud.net.cn/article/357…

JIT$HZWL7T)3CO_77GHAKRA.png

· 申请完了这些东西之后就可以开始使用了,自有证书和证书相关东西都要在打包的时候填写进去。

接下来就可以在开发里使用了,微信绑定

// 解除微信绑定
	const removeWechat = () =>{
		let tost = utoast;
		uni.request({
			url:'http://124.221.162.230:8888/account/deleteWx',
			method:'POST',
			header:{
				Authorization:state.token
			},
			success: (res) => {
				if(res.data.code == 200){
					state.showModal = false
					tost.value.show({
						type: 'success',
						title: '成功主题',
						message: "微信解绑成功",
						iconUrl: 'https://cdn.uviewui.com/uview/demo/toast/success.png',
					})
					upUserInfo()
				}else{
					
				}
			},
			fail: (err) => {
				console.log(err)
			}
		})
	}
	// 微信绑定
	const wechatBind = ()=>{
		uni.login({
			provider: 'weixin',
			// onlyAuthorize: true,
			success: function(loginRes) {
				let onj = loginRes.authResult;
				state.aa = JSON.stringify(loginRes)
				uni.getUserInfo({
					provider: 'weixin',
					success: function(info) {
						sendOpenid(info.userInfo.openId)
					}
				})
			}
		})
	}
	// 将拿到的微信openid给后端
	const sendOpenid = (id) =>{
		let tost = utoast;
		uni.request({
			url:'http://124.221.162.230:8888/account/saveWxByOpenId',
			method:'POST',
			header:{
				Authorization:state.token
			},
			data:{
				openId:id
			},
			success: (res) => {
				if(res.data.code == 200){
					tost.value.show({
						type: 'success',
						title: '成功主题',
						message: "微信授权成功",
						iconUrl: 'https://cdn.uviewui.com/uview/demo/toast/success.png'
					})
					upUserInfo()
				}
			},
			fail: (err) => {
				console.log(err)
			}
		})
	}

接下来打包的文件里微信授权,登入等功能就可以正常使用了(不包括支付)。

微信绑定.gif

一定时间内只能发送一条短信

设置120s内只可发送一条手机号验证短信思路:

通过设置关键词进行状态的更换,每当点击发送验证码后就会设置一个 setInterval 定时器,当这个定时器的值小于 0 就进行状态切换,变为可发送验证码的状态。(说白了就很像是防抖节流,一定时间内只能发送一个请求)

代码实现如:

// 发送验证码
	const sendCheck = () => {
		let re = /^1[3,4,5,6,7,8,9][0-9]{9}$/;
		let result = re.test(state.phone);
		if(!result){
			// 轻提示手机号不正确
		}else{
			if (state.checkTime == false) {
				state.checkTime = true;   //切换数据源标记状态
				let count = 120;    //设置倒数时间
				let time = setInterval(() => {   //通过定时器变更状态
					state.check = count;
					count = count - 1;
					if (count === -1) {
						clearInterval(time)
						state.check = '发送验证码'
						state.checkTime = false
					}
				}, 1000)
				uni.request({
					url:'http://124.221.162.230:8888/account/sendCode',
					method:"POST",
					data:{
						tel:state.phone
					},
					success: (res) => {
						state.sendCode = res.data.data
					}
				})
			}
		}
	}
手机号登入.gif

日历从屏幕下方弹出以及移除

思路:

使用vue的语法,点击变更状态,通过不同状态加类名,从而控制 CSS 变更日历框的位置,同时加上一个遮罩层在中间,重要的是中间作为“润滑剂”的 css 属性 transition 了。

更改标识符:

const changeDate = () => {
		state.hidenshow = true
		state.dateChange = true
}

通过标识符控制给日历子组件外层容器加类:

<view class="changeTime" :class="state.dateChange==true?'transTime':''">
	<dateSelect @getDate="getDate"></dateSelect>
</view>

css 动画属性控制日历的移入屏幕和移除屏幕:

           .changeTime {
			transition: all 0.5s;
			width: 100vw;
			height: 860rpx;
			background-color: #ffffff;
			position: fixed;
			bottom: -1400rpx;
			left: 0;
			z-index: 5;
			border-top-left-radius: 30rpx;
			border-top-right-radius: 30rpx;
			border-top: 2rpx solid #000;
		}

		.transTime {
			transition: all 0.5s;
			transform: translateY(-1400rpx);
		}

实现动态切换bar

思路:参考美团订酒店日期的日历组件功能,实现可以选定单日,选定多日,一个月内的某段任意日期。每年每个月有多少天通过

功能一:选择方式切换动态效果

日历选定方式动态切换.gif

首先,这是这三种方式的选择栏html布局:

                <!-- 查看方式 -->
		<view class="selectWay">
			<!-- 自定义日期查询,一次得在一个月内 -->
			<view class="selectWayItem" @click="changeWay(1)">
				选定查看
				<view class="selectWayItemBar1" :class="`selectWayItemBar${state.selectWayId}`">
					<view class="bar"></view>
				</view>
			</view>
			<!-- 按月查询 -->
			<view class="selectWayItem" @click="changeWay(2)">
				按月查看
			</view>
			<!-- 按年查询 -->
			<view class="selectWayItem" @click="changeWay(3)">
				按年查看
			</view>
		</view>
                          

CSS(这里用的是less) 样式:

.selectWay {
			width: 100%;
			height: 100rpx;
			display: grid;
			grid-template-columns: 1fr 1fr 1fr;

			.selectWayItem {
				height: 100rpx;
				display: flex;
				align-items: center;
				justify-content: center;
				color: #999;
				position: relative;

				.selectWayItemBar1 {
					transition: all 0.3s;
					width: 100%;
					height: 100rpx;
					position: absolute;
					left: 0;
					bottom: 0;
					display: flex;
					align-items: flex-end;
					justify-content: center;

					.bar {
						width: 60%;
						height: 4rpx;
						background-color: aqua;
						border-radius: 2rpx;
					}
				}

				.selectWayItemBar2 {
					transition: all 0.3s;
					transform: translateX(100%);
				}

				.selectWayItemBar3 {
					transition: all 0.3s;
					transform: translateX(200%);
				}
			}
}

简单讲解一下这里,通过网格布局,将三个选择方式控制在一行内,且平均分为三等分,然后在第一个“选定查看”view的标签下做底部移动的蓝色横杠定位,这里是相对于第一个标签进行相对定位!因为这里被网格布局进行了平均三等分,相对于本身定位平移比相对于外层容器更加的方便,每次只需要移动父容器的100%。

那么,如何控制点击三种方式里任意一种方式实现动态平移效果呢?很简单,也是利用 transition 这个 CSS 属性来控制的。

首先在 js 部分的响应式数据源里设置一个标识符 selectWayId 默认为1,即选定第一个 item,然后再通过设置在 view 标签上的点击事件传参 id 来改变标识符的值,从而响应式的改变底部移动栏的css类名,类名则是通过 js 控制模板拼接来更改位置的。

<script setup>
     import {reactive} from "vue";
	const emits = defineEmits(['getDate'])
	const state = reactive({
		selectWayId: 1
	})
        // 改变查询方式
	const changeWay = (id) => {
		state.selectWayId = id
	}
</script>

这样,在这里就简单的实现了动态移动栏。

自由选定日期

效果图:

日历自由选定日期.gif

先上代码:

html部分:

<view v-if="state.selectWayId == 1" class="cardone">
			<!-- 年份月份选定 -->
			<view class="timeSelect">
				<view class="icon" @click="nextMonth(-1)">
					<image src="../../static/rili/zuojiantou.svg"></image>
				</view>
				<view class="mid">{{state.date[0]}}年{{state.date[1]}}月</view>
				<view class="icon" @click="nextMonth(1)">
					<image src="../../static/rili/youjiantou.svg"></image>
				</view>
			</view>
			<!-- 日期获取 -->
			<view class="dateGrid">
				<view class="dateGrid-item" :class="index > 4 ?'xiucolor':''" v-for="(item,index) in state.week" :key="item">
					{{item}}
				</view>
				<view class="dateGrid-item" v-for="(item,index) in state.blockNumber" :key="item">
					{{item}}
				</view>
				<view class="dateGrid-item" v-for="(item,index) in state.monthNumber" @click="selectBg(item)" :class="addbgg(item)" :key="item">
					<view>
						{{item}}
					</view>
					<view v-if="state.startTime === item" style="font-size: 20rpx;">
						开始
					</view>
					<view v-if="state.endTime === item" style="font-size: 20rpx;">
						结束
					</view>
				</view>
			</view>
		</view>

css(less) 部分

.cardone {
			margin-top: 16rpx;
			// height: 450rpx;
			width: 100%;
			padding: 0rpx 12rpx 0rpx 12rpx;
			box-sizing: border-box;

			.timeSelect {
				display: flex;
				align-items: center;
				justify-content: space-between;

				.icon {
					image {
						width: 46rpx;
						height: 46rpx;
					}
				}

				.mid {}
			}
			.dateGrid{
				margin-top: 14rpx;
				width: 100%;
				display: grid;
				grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
				.dateGrid-item{
					height: 70rpx;
					display: flex;
					flex-direction: column;
					align-items: center;
					margin-bottom: 10rpx;
				}
				.xiucolor{
					color: orangered;
				}
				.bg{
					background-color: palevioletred;
					border-radius: 4rpx;
				}
			}
			.monthGrid{
				margin-top: 14rpx;
				width: 100%;
				display: grid;
				grid-template-columns: 1fr 1fr 1fr;
				.monthGrid-item{
					height: 90rpx;
					margin-top: 20rpx;
					margin-bottom: 10rpx;
					display: flex;
					align-items: center;
					justify-content: center;
				}
				.monthBg{
					background-color: palevioletred;
					border-radius: 6rpx;
				}
			}
		}

js 部分:

import {reactive} from "vue";
const state = reactive({
		startTime:0,
		endTime:0
})
// 自由选定时间选中时间加背景
	const selectBg = (item)=>{
		if(state.startTime === 0 && state.endTime === 0){
			state.startTime = item;
		}else if(state.startTime !== 0 && state.endTime === 0){
			if(item<=state.startTime){
				state.startTime = item
			}else{
				state.endTime = item
			}
		}else if(state.startTime !== 0 &&state.endTime !== 0){
			state.endTime = 0;
			state.startTime = item;
		}
	}
	// 增加颜色
	const addbgg = (item)=>{
		if(state.startTime ===item || state.endTime === item){
			return 'bg'
		}else if(state.startTime !== 0 && state.endTime !== 0 && item<=state.endTime && item>=state.startTime){
			return 'bg'
		}
	}

思路大概就是设置两个响应式数据,分别为 startTime 和 endTime,这两个分别控制渲染的区间,每次点击这些日期则都会触发 selectBg 这个函数,从而根据算法来判断确定 startTime 和 endTime两个值,而每个日期下都有两个隐藏的定位组件,但平时不开启,只有当它们的 item 满足条件的情况下才会开启,即展示这个背景。

这样操作下来,自由选定日期就算是完成了。

日历子组件将选定好的值传给父组件,发起请求,并将获取的数据整合渲染

日历子组件:

// 提交表单
	const submit = () =>{
        //根据周,月,年的时间选定返回不同数据格式
		if(state.selectWayId === 1){
			let arr = [state.date[0],state.date[1],state.startTime,state.endTime]
			emits('getDate',arr)
		}else if(state.selectWayId === 2){
			let arr = [state.date[0],parseInt(state.monthBgId[0]),1,getDays(state.date[0],parseInt(state.monthBgId[0])-1)]
			emits('getDate',arr)
		}else if(state.selectWayId === 3){
			let arr = [state.YearBgId]
			emits('getDate',arr)
		}
	}

父组件接收并根据不同选定方式向后端发起请求获取一定时间内的账单:

<dateSelect @getDate="getDate"></dateSelect>
// 接收子组件传值
	const getDate = (e) => {
		state.hidenshow = false
		state.dateChange = false
		let startTime = '';
		let endTime = ''
		if (e.length == 4) {
			startTime = e[0] + '-' + e[1] + '-' + e[2];
			endTime = e[0] + '-' + e[1] + '-' + e[3];
		} else if (e.length == 1) {
			startTime = e[0] + '-01-01';
			endTime = e[0] + '-12-31';
		}
		if(parseInt(endTime.split('-')[1])-parseInt(startTime.split('-')[1])>10){
			state.date = startTime.slice(0,4) + '年';
		}else{
			if(parseInt(endTime.split('-')[2])-parseInt(startTime.split('-')[2])>27){
				state.date = startTime.split('-').slice(0,2).join('.');
			}else{
				state.date = startTime.split('-').join('.') + '~' + endTime.split('-').join('.');
			}
		}
		getSomeAccount(startTime, endTime)
		getMonthData(startTime,endTime);
	}
        // 本月结余组件数据获取
	const getMonthData = (sTime,eTime) => {
		if(!sTime){
			state.jieyuTitle = '本月结余'
		}else{
			if(parseInt(eTime.split('-')[1])-parseInt(sTime.split('-')[1])>10){
				state.jieyuTitle = sTime.slice(0,4) + '年结余';
			}else{
				if(parseInt(eTime.split('-')[2])-parseInt(sTime.split('-')[2])>27){
					state.jieyuTitle = sTime.split('-').slice(0,2).join('.') + '结余';
				}else{
					state.jieyuTitle = sTime.split('-').join('.') + '~' + eTime.split('-').join('.') + '结余';
				}
			}
		}
		let date = new Date();
		let monthDay = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
		let year = date.getFullYear().toString();
		let month = (parseInt(date.getMonth()) + 1).toString();
		let start = year + '-' + month + '-' + '01';
		let end = year + '-' + month + '-' + monthDay[date.getMonth()].toString();
		uni.request({
			url: 'http://124.221.162.230:8888/bill/sumByApp',
			method: 'POST',
			header: {
				Authorization: state.token
			},
			data: {
				id: state.userInfo.id,
				startTime:sTime || start,
				endTime:eTime || end
			},
			success: (res) => {
				// console.log(res)
				let arr = [{text: '收入',id: 1,number: 0},{text: '支出',id: 2,number: 0}];
				arr[0].number = res.data.data[0]
				arr[1].number = res.data.data[1]
				state.expensePay = arr;
				state.jieyu = ((state.expensePay[0].number*10000-state.expensePay[1].number*10000)/10000).toString();
				// console.log(arr,state.jieyu)
			}
		})
	}
        // 获取指定时间账单
	const getSomeAccount = (startDay, endDay) => {
		// console.log(state.userInfo.id)
		uni.request({
			url: 'http://124.221.162.230:8888/bill/findBillByAll',
			method: "POST",
			header: {
				Authorization: state.token
			},
			data: {
				id: state.userInfo.id,
				startTime: startDay,
				endTime: endDay,
				page: state.countId,
				pageSize: "1000"
			},
			success: (res) => {
				// console.log(res)
				state.allData = res.data.data.data;
				sortTime(state.allData)
			}
		})
	}

扫描识别账单(uni-app里图片格式转换)

运用技术:合合信息api接口(图片格式转换)

合合信息开源文档地址:TextIn - API中心

首先采用图片切边增强识别,实现可识别度更高,然后进行通用账单接口扫描,如果扫描成功则直接拿到获取的值进行操作,否则进行外卖账单扫描,如果外卖类账单扫描依旧不成功则进行最后的火车票扫描,如果扫描识别成功,则获得返回值,否则抛出错误,提示图片不规范或者种类不属于可扫描类。

其中较为麻烦的是 uni-app 里的图片类型转换,base64 -> blob, img->blob 等等;

这里也是踩了很多坑,首先采用的原生 JS 下的 window 里的转换方式,发现在电脑上测试的时候可以使用,一旦真机调试就出问题,后面也是查了很多文档,因为 uni-app 的生态圈没有想象中的那么繁荣,很多问题都很难直接搜到,最后找出问题是由于 uni-app 在 app 环境下是不可调用 window 下的方法,它不存在 window 这个环境。

而 uni-app 自己提供了图片转换的方式,不过需要引入罢了。 如:uni.base64ToArrayBuffer(),功能是将 Base64 字符串转成 ArrayBuffer 对象。而 uni.arrayBufferToBase64(arrayBuffer),将 ArrayBuffer 对象转成 Base64 字符串。

下拉刷新和上拉刷新

在 uni-app 里有提供页面下拉刷新和上拉翻页的 api 方法。

下拉刷新

下拉刷新:uni.startPullDownRefresh(OBJECT)。

不过这个方法适用于最外层容器高度没有定死,一旦定死了最外层容器的高度,那就没有办法触发下拉刷新啦,当触发下拉刷新后设置多久之后为刷新结束。

让人肝疼的是这个下拉刷新的引入位置是没有提示的,大家可以记住一下从哪里import引入。

     import {onPullDownRefresh} from '@dcloudio/uni-app';
// 下拉刷新
	onPullDownRefresh(()=>{
		getServeData()  //调用更新数据接口函数
		// 获取用户信息
		getUserInfo()
		setTimeout(()=>{
			uni.stopPullDownRefresh()
		},700)
	})
下拉刷新.gif

上拉刷新

上拉翻页:onReachBottom

上拉翻页也是跟下拉刷新差不多的方式,也是从同一个地方引入,并且高度也不能定死,

import {onReachBottom} from '@dcloudio/uni-app';
// 上拉刷新
	onReachBottom(() => {
		if (state.date === '时间选择') {
			uni.showLoading({
				title: '获取更多账单中...'
			})
			state.page = state.page + 1;   //数据源中记录页数的 page + 1,获取下一页账单
			getAccount(state.page);    //获取下一页账单
			setTimeout(() => {
				uni.hideLoading()
			}, 200)
		} else {

		}
	})
        
        // 获取账单接口
	const getAccount = (pages) => {
		uni.getStorage({
			key: 'Authorization',
			success: (res) => {
				state.token = res.data
				uni.request({
					url: 'http://124.221.162.230:8888/bill/findBillByPageId',
					method: "POST",
					header: {
						Authorization: state.token
					},
					data: {
						page: pages,
						pageSize: 10
					},
					success: (res) => {
						// console.log(res.data.data.data)
						let arr = res.data.data.data
						state.allData = state.allData.concat(arr)
						sortTime(state.allData)  // 获取的账单排序函数
						getUserInfo()
					}
				})
			}
		})
	}
上拉翻页.gif

ucharts 使用

ucharts 官网地址: ucharts

使用的话推荐去 HbuildX 里引入依赖,地址为:ext.dcloud.net.cn/plugin?id=2…

因为 echarts 很难在 uni-app 引入使用,所以这里推荐 ucharts ,虽然可选择的图表没那么多,但是效果还是不错的,而且各种参数也是和 echarts 差不多,有过 echarts 使用经验的上手起来都不会太难。

uCharts是一款基于canvas API开发的适用于所有前端应用的图表库,开发者编写一套代码,可运行到 Web、iOS、Android(基于 uni-app / taro )、以及各种小程序(微信/支付宝/百度/头条/飞书/QQ/快手/钉钉/淘宝/京东/360)、快应用等更多支持 canvas API 的平台。

正常使用:

<qiun-data-charts type="column" :opts="state.opts" :chartData="state.chartData" :canvas2d="true" />
const state = reactive({
		chartData: {},
		opts: {
			// color: ["#FAC858", "#EE6666", "#FAC858", "#EE6666", "#73C0DE", "#3CA272", "#FC8452", "#9A60B4",
			// 	"#ea7ccc"
			// ],
			color: [
				"#31D0CA",
				"#FFC35E",
				"#73C0DE"
			],
			padding: [15, 15, 0, 5],
			enableScroll: false,
			legend: {
				itemGap: 20
			},
			xAxis: {
				disableGrid: true,
				axisLine: false
			},
			yAxis: {
				data: [{
					min: 0,
					axisLine: false
				}],
				splitNumber: 5,
				gridType: 'dash'
			},
			extra: {
				column: {
					type: "group",
					width: 14,
					activeBgColor: "#000000",
					activeBgOpacity: 0.08,
					linearType: "custom",
					seriesGap: 2,
					linearOpacity: 0.5,
					barBorderCircle: true,
					customColor: [
						"#31D0CA",
						"#FFC35E",
						"#73C0DE"
					]
				}
			},
			dataLabel: false
		}
                
})

// 折线图
	const getServerDataArea = () => {
        //声明接下来用于渲染在 ucharts 折线图上值的数据
		let ydate = [];
		let expense = [];
		let pay = [];
		let expy = [];
		if(state.dataList[1][0].length == 12){
			for (let i = 0; i < state.dataList[1][0].length; i++) {
				ydate.push(state.dataList[1][0][i].duration.split('.')[0].toString()+'月');
				expense.push(state.dataList[1][0][i].sum);
				pay.push(state.dataList[1][1][i].sum);
				expy.push(state.dataList[1][2][i].sum);
			}
		}else{
			for (let i = 0; i < state.dataList[1][0].length; i++) {
				ydate.push(state.dataList[1][0][i].duration.split('-')[2]);
				expense.push(state.dataList[1][0][i].sum);
				pay.push(state.dataList[1][1][i].sum);
				expy.push(state.dataList[1][2][i].sum);
			}
		}
		setTimeout(() => {
			//模拟服务器返回数据,如果数据格式和标准格式不同,需自行按下面的格式拼接
			let res = {
				categories: ydate,
				series: [{
						name: "收入",
						data: expense
					},
					{
						name: "支出",
						data: pay
					},
					{
						name: "结余",
						data: expy
					}
				]
			};
			state.chartData = JSON.parse(JSON.stringify(res));
		}, 500);
	}

切记,跟 echarts一样的是,如果图表数据是实时更新的,就需要把上一次数据渲染的图表删除,然后重新渲染,否则出来的图表就刷新不出来。

根据满足不同条件自由使用柱状图和折线图渲染

图标切换.gif

因为这里统计页面有日期选择查看,可以查看多种情况的数据图表展示。

如果使用柱状图,虽然可以让人一眼看出来数据的变化,但是在 app 端却很难展示多个数据,时间选定超过一周的数据,那么柱状图就会密密麻麻的让人眼花,所以一旦满足选定时间超过一周,便自动切换到折线图去展示,就不会显得很难看。

这里是在时间选择之后使用标识符去区别使用不同 ucharts 图表去展示。当日历组件选定日期传值回父组件扣便控制响应式数据 state.sweekShow 去展示不同图表,当为 false 便展示折线图,为 true 则展示柱状图。

           <view class="uchartType">
			<!-- 柱状图 -->
			<view class="ucharts-line" v-if="state.sweekShow == true">
				<qiun-data-charts type="column" :opts="state.opts" :chartData="state.chartData" :canvas2d="true"
					canvasId="pdwSBmykusPTdKaXcFgGIgxiQYszuJyn" />
			</view>
			<!-- 折线图 -->
			<view class="ucharts-line" v-if="state.sweekShow == false">
				<qiun-data-charts type="area" :opts="state.optss" :chartData="state.chartData" />
			</view>
		</view>
    // 日历组件传值
    	const getDate = (e) => {
    		let obj = {}
    		obj.id = state.userInfo.id
    		if (e.length == 1) {
    			obj.type = 3;
    			obj.startTime = e[0].toString() + '-01-01';
    			obj.endTime = e[0].toString() + '-12-31';
    			state.sweekShow = false
    			state.dateTitle = '一年内'
    		} else {
    			if (e[3] - e[2] > 7) {
    				obj.type = 2;
    				obj.startTime = e[0].toString() + '-' + e[1].toString() + '-' + e[2].toString();
    				obj.endTime = e[0].toString() + '-' + e[1].toString() + '-' + e[3].toString();
    				state.sweekShow = false
    				state.dateTitle = '一月内'
    			} else {
    				obj.type = 1;
    				obj.startTime = e[0].toString() + '-' + e[1].toString() + '-' + e[2].toString();
    				obj.endTime = e[0].toString() + '-' + e[1].toString() + '-' + e[3].toString();
    				state.sweekShow = true
    				state.dateTitle = '一周内'
    			}
    		}
    		state.dateHiden = false
    		getServerDataList(obj)
    	}

建立websocket消息通知,滑动删除信息,未读信息标红

信息通知.gif

建立websocket实时监听消息

// 生命周期
	onLoad((e) => {
		let userInfo = JSON.parse(e.obj);
		// console.log(userInfo)
		state.userInfo = userInfo;
		// 获取token
		getToken()
	})
	onUnload(()=>{
		uni.closeSocket({
			success: () => {
			}
		})
	})
	// 获取token
	const getToken = () => {
		uni.getStorage({
			key: 'Authorization',
			success: (res) => {
				state.token = res.data
				// 建立websocket链接
				buildWebsocket()
			}
		})
	}
	// 建立websocket
	const buildWebsocket = () => {
		uni.connectSocket({
			url: `http://124.221.162.230:8888/websocket/${state.userInfo.id}`,
			method: 'POST',
			header: {
				Authorization: state.token
			},
			success: (res) => {
				listenWebsocket()
			},
			fail: (err) => {
				console.log(err)
			}
		})
	}
	// 监听websocket打开事件
	const listenWebsocket = () => {
		uni.onSocketOpen((res) => {
			listenWebsocketData()
		})
		uni.onSocketClose((err) => {
			console.log(err)
		})
	}
	// 监听websocket返回数据事件
	const listenWebsocketData = () => {
		// console.log(258)
		uni.onSocketMessage((res) => {
			// console.log(JSON.parse(res.data))
			let data = state.dataList;
			data.push(JSON.parse(res.data));
			state.dataList = data;
			// 分类两种类型的消息通知
			sortNoticType(data);
		})
	}
	// 分类消息
	const sortNoticType = (arr) => {
		let arr1 = [];
		let arr2 = [];
		let numArr = [0, 0, 0]
		for (let i = 0; i < arr.length; i++) {
			if (arr[i].type == 0) {
				arr1.push(arr[i]);
				if (arr[i].readState == 0) {
					numArr[0] = numArr[0] + 1;
					numArr[1] = numArr[1] + 1;
				}
			} else {
				arr2.push(arr[i]);
				if (arr[i].readState == 0) {
					numArr[0] = numArr[0] + 1;
					numArr[2] = numArr[2] + 1;
				}
			}
		}
		state.userList = arr1;
		state.systemList = arr2;
		state.readStateList = numArr;
	}

标红未读消息

先通过 websocket 获取到的信息值数计算一次,计算出所有未读的消息并进行分类,然后分开储存数组state.readStateList 。然后通过三元运算符来返回值并加上 css 定位定位标红红点样式。

<template>
	<view class="outbox">
		<!-- 标题栏 -->
		<view class="title">
			<view @click="back" class="icon"
				style="display: flex;align-items: center;justify-content: center;color: #fff;">
				<image src="../../static/user/back.svg"></image>
				<text>返回</text>
			</view>
			<view style="color: #fff;display: flex;align-items: center;justify-content: center;">
				消息通知
			</view>
		</view>
		<!-- 消息类型 -->
		<view class="noticeType">
			<view class="noticeItem" @click="changeNoticeType(0)" :class="state.noticeType==0?'textColor':''">
				全部通知
				<view class="readNum" v-if="state.readStateList[0]>0">
					{{state.readStateList[0]<100?state.readStateList[0]:'99+'}}
				</view>
			</view>
			<view class="noticeItem" @click="changeNoticeType(1)" :class="state.noticeType==1?'textColor':''">
				用户消息
				<view class="readNum" v-if="state.readStateList[1]>0">
					{{state.readStateList[1]<100?state.readStateList[1]:'99+'}}
				</view>
			</view>
			<view class="noticeItem" @click="changeNoticeType(2)" :class="state.noticeType==2?'textColor':''">
				系统通知
				<view class="readNum" v-if="state.readStateList[2]>0">
					{{state.readStateList[2]<100?state.readStateList[2]:'99+'}}
				</view>
			</view>
			<view class="itembar0" :class="`itembar${state.noticeType}`"></view>
		</view>
		<!-- 消息内容 -->
		<view class="content">
			<view v-if="state.noticeType==0" class="contentItem" @touchmove="getItemIndex(index)"
				:class="state.moveSIndex==index?'moveleft':''" @touchstart="itemMoveStart" @touchend="itemMoveEnd"
				@click="goDetail(item)" v-for="(item,index) in state.dataList" :key="item">
				<view class="itemTitle">
					{{item.type==0?'用户绑定申请':'系统通知'}}
				</view>
				<view class="text">
					{{item.msg}}
				</view>
				<view class="readState" v-if="item.readState==0"></view>
				<view class="deleteBtn" @click.stop="deleteItem(item)">
					删除
				</view>
			</view>
			<view v-if="state.noticeType==1" class="contentItem" @click="goDetail(item)" v-for="item in state.userList"
				:key="item">
				<view class="itemTitle">
					用户绑定申请
				</view>
				<view class="text">
					{{item.msg}}
				</view>
				<view class="readState" v-if="item.readState==0"></view>
			</view>
			<view v-if="state.noticeType==2" class="contentItem" @click="goDetail(item)"
				v-for="item in state.systemList" :key="item">
				<view class="itemTitle">
					系统通知
				</view>
				<view class="text">
					{{item.msg}}
				</view>
				<view class="readState" v-if="item.readState==0"></view>
			</view>
		</view>
	</view>
</template>
.noticeType {
			width: 100%;
			height: 120rpx;
			background-color: #fff;
			color: rgb(153, 153, 153);
			display: grid;
			grid-template-columns: 1fr 1fr 1fr;
			position: relative;

			.noticeItem {
				display: flex;
				align-items: center;
				justify-content: center;
				height: 100%;
				position: relative;

				.readNum {
					position: absolute;
					top: 30rpx;
					right: 16rpx;
					width: 40rpx;
					height: 26rpx;
					border-radius: 13rpx;
					background-color: #EE3F35;
					color: #fff;
					display: flex;
					align-items: center;
					justify-content: center;
					font-size: 20rpx
				}
			}

			.textColor {
				color: #2A65A7;
			}

			.itembar0 {
				transition: all 0.4s;
				width: 33.3%;
				height: 4rpx;
				position: absolute;
				bottom: 0;
				left: 0;
				background-color: #2A65A7;
				border-radius: 2rpx;
			}

			.itembar1 {
				transition: all 0.4s;
				transform: translate(100%);
			}

			.itembar2 {
				transition: all 0.4s;
				transform: translate(200%);
			}
		}

		.content {
			padding-top: 40rpx;
			box-sizing: border-box;
			width: 100%;
			height: calc(100% - 280rpx);
			overflow: hidden;
			overflow-y: scroll;

			.contentItem {
				transition: all 0.5s;
				margin-bottom: 16rpx;
				background-color: #fff;
				width: 100%;
				height: 200rpx;
				padding-top: 20rpx;
				padding-left: 50rpx;
				padding-right: 50rpx;
				box-sizing: border-box;
				position: relative;

				.itemTitle {
					font-size: 34rpx;
				}

				.text {
					color: #A0A0A0;
					max-height: 80rpx;
					white-space: normal;
					overflow: hidden;
					text-overflow: ellipsis;
				}

				.readState {
					position: absolute;
					right: 20rpx;
					top: 20rpx;
					width: 12rpx;
					height: 12rpx;
					border-radius: 50%;
					background-color: #EE3F35;
				}

				.deleteBtn {
					width: 190rpx;
					height: 100%;
					color: #fff;
					background-color: #EE3F35;
					position: absolute;
					right: -200rpx;
					top: 0;
					display: flex;
					align-items: center;
					justify-content: center;
				}
			}

			.moveleft {
				transition: all 0.5s;
				transform: translateX(-200rpx);
			}
		}

手滑删除功能

通过监听收支触摸开始和结束的函数,然后监听收支滑动的位置是否展示右侧定位的删除;

再通过 transition 来动态的划出来删除按钮,实现删除效果。

// 手指滑动结束
	const itemMoveEnd = (e) => {
		if (state.moveStartX - e.changedTouches[0].pageX > 50) {
			state.moveSIndex = state.moveIndex;
		} else if (e.changedTouches[0].pageX - state.moveStartX > 30) {
			state.moveSIndex = -1;
		}
	}
	// 手指滑动获取下标
	const getItemIndex = (index) => {
		state.moveIndex = index
	}

结尾

大家有兴趣也可以玩玩 uni-app ,除了坑有点多,还是挺有意思的。