在线体验地址:http://amuniapp.top/
项目地址:https://gitee.com/xiao-ming-1999/uniapp-online-education.git
1、首页配置项pages.json
项目pages.json配置代码
{
// 主包
"pages": [{
"path": "pages/tabbar/index/index",
"style": {
"app-plus": {
// 隐藏导航栏
"titleNView": false
},
// 下拉刷新
"enablePullDownRefresh": true
}
}, {
"path": "pages/tabbar/learn/learn"
}, {
"path": "pages/tabbar/home/home",
"style": {
"enablePullDownRefresh": false, // 刷新
"navigationBarBackgroundColor": "#5ccc84", //导航栏背景色
"navigationBarTextStyle": "white", // 文字颜色
"app-plus": {
"titleNView": { // 自定义导航栏
"titleAlign": "left",
"titleText": "我的",
"buttons": [{ // 自定义按钮
"type": "menu"
}]
}
}
}
},
{
"path": "pages/login/login",
"style": {
"app-plus": {
"titleNView": false
}
}
}, {
"path": "pages/userNeedKnow/userNeedKnow",
"style": {
"app-plus": {
"titleNView": false
},
"enablePullDownRefresh": false
}
}, {
"path": "pages/search/search",
"style": {
"enablePullDownRefresh": false,
"app-plus": {
"titleNView": {
"searchInput": {
"placeholder": "请输入关键词搜索",
"autoFocus": true,
"align": "left",
"backgroundColor": "#f8f8f8",
"borderRadius": "50px"
},
"buttons": [{
"text": "搜索",
"fontSize": "15px"
}]
}
},
// 小程序不兼容配置搜索框
"mp-weixin": {
"navigationStyle": "custom"
}
}
}, {
"path": "pages/search-result/search-result",
"style": {
"enablePullDownRefresh": false,
"app-plus": {
"titleNView": {
"searchInput": {
"placeholder": "请输入关键词搜索",
"disabled": true,
"align": "left",
"backgroundColor": "#f8f8f8",
"borderRadius": "50px"
}
}
}
}
}, {
"path": "pages/list/list",
"style": {
"navigationBarTitleText": "列表页",
"enablePullDownRefresh": true
}
},
{
"path": "pages/update-password/update-password",
"style": {
"navigationBarTitleText": "修改密码",
"enablePullDownRefresh": false
}
},
{
"path": "pages/webview/webview",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false
}
}
],
// 分包
"subPackages": [{
"root": "pages-book",
"pages": [{
"path": "my-book/my-book",
"style": {
"navigationBarTitleText": "我的电子书",
"enablePullDownRefresh": true
}
}]
},
{
"root": "pages-media",
"pages": [{
"path": "live/live",
"style": {
"navigationBarTitleText": "直播详情",
"enablePullDownRefresh": false
}
}, {
"path": "course/course",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false
}
}, {
"path": "column/column",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false
}
}]
},
{
"root": "pages-order",
"pages": [{
"path": "creat-order/creat-order",
"style": {
"navigationBarTitleText": "创建订单",
"enablePullDownRefresh": false
}
}, {
"path": "h5pay/h5pay",
"style": {
"navigationBarTitleText": "微信h5支付",
"enablePullDownRefresh": false
}
},
{
"path": "order-list/order-list",
"style": {
"navigationBarTitleText": "我的订单",
"enablePullDownRefresh": true,
"onReachBottomDistance": 100
}
}
]
},
{
"root": "pages-test",
"pages": [{
"path": "test-list/test-list",
"style": {
"navigationBarTitleText": "考试列表",
"enablePullDownRefresh": true
}
}, {
"path": "test-detail/test-detail",
"style": {
"navigationBarTitleText": "开始考试",
"enablePullDownRefresh": false
}
}, {
"path": "my-test/my-test",
"style": {
"navigationBarTitleText": "我的考试",
"enablePullDownRefresh": true
}
}]
},
{
"root": "pages-user",
"pages": [{
"path": "setting/setting",
"style": {
"navigationBarTitleText": "我的设置",
"backgroundColor": "#fff",
"enablePullDownRefresh": false
}
}, {
"path": "my-coupon/my-coupon",
"style": {
"navigationBarTitleText": "我的优惠券",
"enablePullDownRefresh": true
}
}, {
"path": "user-info/user-info",
"style": {
"navigationBarTitleText": "编辑资料",
"enablePullDownRefresh": false
}
},
{
"path": "bind-phone/bind-phone",
"style": {
"app-plus": {
"titleNView": false
},
"enablePullDownRefresh": false
}
}, {
"path": "forget-password/forget-password",
"style": {
"app-plus": {
"titleNView": false
},
"enablePullDownRefresh": false
}
}
]
}
],
// 分包预载配置
"preloadRule": {
"pages-user/my-coupon/my-coupon": {
"network": "all",
"packages": ["__APP__"]
}
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uniApp在线教育",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#ffffff",
"app-plus": {
"background": "#efeff4"
}
},
"tabBar": {
"color": "#BBBAC7",
"selectedColor": "#2c2c2c",
"borderStyle": "black",
"list": [{
"pagePath": "pages/tabbar/index/index",
"iconPath": "/static/tabbar/index1.png",
"selectedIconPath": "/static/tabbar/index1_selected.png",
"text": "首页"
},
{
"pagePath": "pages/tabbar/learn/learn",
"iconPath": "/static/tabbar/learn.png",
"selectedIconPath": "/static/tabbar/learn_selected.png",
"text": "学习"
},
{
"pagePath": "pages/tabbar/home/home",
"iconPath": "/static/tabbar/home.png",
"selectedIconPath": "/static/tabbar/home_selected.png",
"text": "我的"
}
]
},
"condition": { //模式配置,仅开发期间生效
"current": 0, //当前激活的模式(list 的索引项)
"list": [{
"name": "", //模式名称
"path": "", //启动页面,必选
"query": "" //启动参数,在页面的onLoad函数里面得到
}]
}
}
2、uni拦截器/接口 封装
1、拦截器封装(get/post/upload)
请求拦截器理解:就是将调用拦截器的方法传进的option参数,统一做一些参数上的添加处理
思路:
uni.request()返回一个promise
一、请求拦截器:
1.给请求添加公共请求头以及baseurl
2.返回一个promise
二、响应拦截器:
1.将错误状态码进行判断,返回一个reject
2.将数据进行剥离,请求数据返回给调用者
三、get/post/upload请求封装:
get接收参数:url、params(拼接在url后面),options
注意点:params应该为对象形式({a:1,b:2}),将对象形式转为a=1&b=2
解决:Object.keys(params).map(key => key + '=' + params[key]).join('&')
upload:接收文件参数(包含文件名,文件路径),调用uni.uploadFile将相应请求头,参数传
递,对相应结果进行判断,并返回给调用接口,并对上传进度进行监听
封装后的代码
import store from "@/store/index.js"
export default {
// 请求拦截器 原理:利用微任务.then 让所有使用请求拦截器的函数 在拦截器函数之后执行
config: {
// 请求拦截器(给请求统一添加 公共请求头、baserUrl)
beforeRequest(options = {}) {
return new Promise((resolve, reject) => {
const baseUrl = 'http://demonuxtapi.dishait.cn'
const appid = 'bd9d01ecc75dbbaaefce'
const token = store.state.token
// 添加公共请求参数
options.url = baseUrl + options.url
options.header = {
appid,
token
}
options.method = options.method || 'GET'
resolve(options)
})
},
// 响应拦截器 (接收参数请求后得到的数据,处理非成功数据reject,并将数据剥离返回resolve)
responseRequest(data) {
return new Promise((resolve, reject) => {
const [error, res] = data
if (res.data.msg !== 'ok') {
const msg = res.data.data || '请求失败'
uni.showToast({
title: msg,
icon: 'none'
})
if(msg === 'Token 令牌不合法,请重新登录' || res.data.data === '您没有权限访问该接口!') {
store.dispatch('loginOut')
uni.navigateTo({
url:'/pages/login/login'
})
}
return reject(msg)
}
return resolve(res.data.data)
})
}
},
request(options) {
// console.log(options, 'options');
// 使用的beforeRequest方法也是promise应将beforeRequest方法直接返回 (不然读不到return uni.request(opt))
return this.config.beforeRequest(options).then(opt => {
return uni.request(opt)
}).then(this.config.responseRequest)
},
get(url, params = null, options = {}) {
options.url = url
options.url += params ? ('?' + Object.keys(params).map(key => key + '=' + params[key]).join('&')) : ''
options.method = 'GET'
return this.request(options)
},
post(url, data = null, options = {}) {
options.url = url
options.data = data
options.method = 'POST'
return this.request(options)
},
// 文件上传
upload(url, data = null, options = {}) {
const toast = function(title, icon) {
uni.showToast({
title,
icon
})
}
options.url = url
options.method = 'POST'
return this.config.beforeRequest(options).then(opt => {
return new Promise((resolve, reject) => {
const uploadTask = uni.uploadFile({
url: options.url,
filePath: data.file,
name: 'file',
header: options.header,
success: (res) => {
if (res.statusCode !== 200) {
toast('上传失败', 'fail')
return reject('上传失败' + errMsg)
}
toast('上传成功', 'success')
return resolve(JSON.parse(res.data))
},
fail: (res) => {
toast('上传失败', 'fail')
return reject('上传失败' + res.errMsg)
}
});
// 上传进度
if (options.onProgress && typeof options.onProgress === 'function') {
uploadTask.onProgressUpdate((res) => {
options.onProgress(res.progress)
});
}
})
})
}
}
2、接口统一调用
// api.js
import api from "./request"
export default {
// 获取首页数据
getIndexData() {
return api.get('mobile/index')
}
}
// main.js
// 将api挂载在全局
import api from "@/api/api.js"
Vue.prototype.$api =api
其余代码省略、、、
2.接口使用
// index.vue
<template>
<view>
<block v-for="(item,index) in templates" :key="index">
<!-- 搜索模块 -->
<f-search-bar v-if="item.type == 'search'" :placeholder="item.placeholder"></f-search-bar>
<!-- 轮播图模块 -->
<template v-else-if="item.type == 'swiper'">
<swiper :indicator-dots="true" :autoplay="true" :interval="3000" :duration="1000"
class="flex justify-center mt-2">
<swiper-item class="flex justify-center shadow" v-for="(item,index) in item.data" :key="index">
<image :src="item.src" mode="aspectFill" style="width: 720rpx;height: 300rpx;" class="rounded">
</image>
</swiper-item>
</swiper>
</template>
<!-- icon图标模块 -->
<icon-nav v-else-if="item.type === 'icons'" :list='item.data'></icon-nav>
<!-- 优惠券模块 -->
<coupon-list v-else-if="item.type === 'coupon'"></coupon-list>
<!-- 拼团模块 -->
<template v-else-if="item.type === 'promotion'">
<view class="blank-line" />
<view class="p-2">
<text class="font-md font-weight-bold">
{{item.listType === 'group' ?'拼团' :'秒杀'}}
</text>
</view>
<scroll-view scroll-x="true" class="scroll-row mt-1">
<course-list v-for="(item,index) in groupList" :key="index" :item="item"></course-list>
</scroll-view>
</template>
<!-- 最新列表模块 -->
<template v-else-if="item.type === 'list'">
<view class="blank-line" />
<view class="p-2 flex justify-between">
<text class="font-md font-weight-bold">最新列表</text>
<text class="font-sm text-secondary">查看全部</text>
</view>
<view>
<course-list v-for="(item,index) in item.data" :key="index" :item="item" :type="item.listType">
</course-list>
</view>
</template>
<!-- 底部模块 -->
<template v-else-if="item.type === 'imageAd'">
<view class="blank-line" />
<image :src="item.data" mode="aspectFill" style="height: 375rpx;width: 100%;"></image>
</template>
</block>
</view>
</template>
<script>
export default {
data() {
return {
groupList: [{
"group_id": 19,
"id": 12,
"title": "unicloud商城全栈开发",
"cover": "http://demo-mp3.oss-cn-shenzhen.aliyuncs.com/egg-edu-demo/79023e0596c23aff09e6.png",
"price": "4.00",
"t_price": "10.00",
"type": "media",
"start_time": "2021-04-15T16:00:00.000Z",
"end_time": "2022-05-16T16:00:00.000Z"
}],
// 模板数据
templates: []
}
},
created() {
this.getData()
},
// 监听下拉刷新
onPullDownRefresh() {
this.getData()
},
methods: {
getData() {
this.$api.getIndexData()
.then(data => {
this.templates = data
// console.log(this.templates, 'this.templates');
}).finally(res => {
// .finally不管成功失败都会调用
// 停止刷新loading
uni.stopPullDownRefresh();
})
}
}
}
</script>
3、登录注册、绑定手机页逻辑
1、登录注册
1.登录token和用户信息存入vuex中
2.将store挂载vue原型上
3.调用uni的setStorage方法进行持久化操作
4.登录后在vuex中的login方法中调用uni.setStorageSync,在vuex中写数据初始化方法init,页面刷新在App钩子中调用init方法,该方法将本地储存中的userInfo数据重新赋值给state.userInfo
5、登录后未绑定手机则跳转至绑定手机页
// login.vue 登录注册页
<template>
<view>
<!-- #ifndef MP -->
<view class="login-back" @click="back">
<uni-icons type="arrowleft" size="20" color="#FFFFFF"></uni-icons>
</view>
<!-- #endif -->
<view class="login-bg"></view>
<view class="login">
<view class="flex">
<text class="title">{{ type == 'login' ? '登 录' : '注 册' }}</text>
</view>
<view class="login-form">
<uni-icons type="person"></uni-icons>
<input type="text" placeholder="请输入用户名" class="rounded font-md" v-model="form.username" />
</view>
<view class="login-form">
<uni-icons type="locked"></uni-icons>
<input type="text" placeholder="请输入密码" class="rounded font-md" v-model="form.password" />
</view>
<view class="login-form" v-if="type == 'reg'">
<uni-icons type="locked"></uni-icons>
<input type="text" placeholder="请输入确认密码" class="rounded font-md" v-model="form.repassword" />
</view>
<view class="bg-main btn" hover-class="bg-main-hover" @click="submit">{{ type == 'login' ? '登 录' : '注 册' }}
</view>
<view class="flex align-center justify-between my-3 font">
<text class="py-3 text-main" @click="changeType">{{ type == 'login' ? '注册账号' : '去登录' }}</text>
<text class="py-3 text-light-muted" @click="openForget">忘记密码?</text>
</view>
<view class="flex align-center justify-center wechatlogin">
<!-- #ifndef MP -->
<uni-icons type="weixin" size="25" color="#5ccc84" @click="wxLogin"></uni-icons>
<!-- #endif -->
<!-- #ifdef MP -->
<button type="default" open-type="getUserInfo" @getuserinfo="mpWxLogin">
<uni-icons type="weixin" size="25" color="#5ccc84"></uni-icons>
</button>
<!-- #endif -->
</view>
<checkbox-group v-if="type == 'login'" class="flex align-center justify-center mt-4"
@change="handleCheckboxChange">
<label class="text-light-muted">
<checkbox value="1" color="#7fd49e" style="transform: scale(0.7);" :checked="confirm" /><text class="font"
@click.stop="userNeed">已阅读并同意用户协议&隐私声明</text>
</label>
</checkbox-group>
</view>
</view>
</template>
<script>
import tool from '@/common/tool.js';
export default {
data() {
return {
confirm: false,
type: "login",
form: {
username: "",
password: "",
repassword: ""
}
}
},
onLoad(e) {
if(e.confirm) {
this.confirm = !!e.confirm
console.log(!!e.confirm);
}
// #ifdef H5
this.handleH5WxLogin()
// #endif
},
methods: {
mpWxLogin(e) {
if (!this.beforeLogin()) {
return
}
let rawData = e.detail.rawData
uni.login({
provider: "weixin",
success: (res) => {
let code = res.code
uni.showLoading({
title: '登录中...',
mask: false
});
this.$api.wxLogin({
type: "mp",
rawData,
code
}).then(user => {
this.handleLoginSuccess(user)
}).finally(() => {
uni.hideLoading()
})
}
})
},
handleH5WxLogin() {
let code = tool.getUrlCode("code")
if (!code) {
return
}
uni.showLoading({
title: '登录中...',
mask: false
});
this.$api.wxLogin({
type: "h5",
code
}).then(user => {
this.handleLoginSuccess(user)
}).finally(() => {
uni.hideLoading()
})
},
wxLogin() {
if (!this.beforeLogin()) {
return
}
// #ifdef H5
tool.getH5Code()
// #endif
// #ifdef APP-PLUS
this.appWxLogin()
// #endif
},
appWxLogin() {
uni.login({
provider: "weixin",
success: (res) => {
let {
access_token,
openid
} = res.authResult
uni.showLoading({
title: '登录中...',
mask: false
});
this.$api.wxLogin({
type: "app",
access_token,
openid
}).then(user => {
this.handleLoginSuccess(user)
}).finally(() => {
uni.hideLoading()
})
}
})
},
openForget() {
uni.navigateTo({
url: '/pages-user/forget-password/forget-password',
});
},
handleCheckboxChange(e) {
this.confirm = !!e.detail.value.length
},
back() {
uni.navigateBack({
delta: 1
});
},
changeType() {
this.type = this.type == 'login' ? 'reg' : 'login'
},
resetForm() {
this.form = {
username: "",
password: "",
repassword: ""
}
},
beforeLogin() {
if (!this.confirm && this.type == 'login') {
this.$toast('请先阅读并同意用户协议&隐私声明')
return false
}
return true
},
handleLoginSuccess(user) {
this.$toast('登录成功')
this.$store.dispatch('login', user)
if (!user.phone) {
uni.redirectTo({
url: "/pages/bind-phone/bind-phone"
})
return
}
setTimeout(() => {
// #ifdef H5
uni.switchTab({
url: "../tabbar/home/home"
})
// #endif
// #ifndef H5
this.back()
// #endif
}, 350)
},
submit() {
if (!this.beforeLogin()) {
return
}
uni.showLoading({
title: '提交中...',
mask: false
});
let data = Object.assign(this.form, {})
this.$api[this.type](data).then(user => {
if (this.type == 'reg') {
this.$toast('注册成功')
this.resetForm()
this.changeType()
} else {
this.handleLoginSuccess(user)
}
}).finally(() => {
uni.hideLoading()
})
},
userNeed() {
uni.navigateTo({
url: '/pages/userNeedKnow/userNeedKnow'
})
}
}
}
</script>
2、绑定手机号页面
2.1发送验证码封装成组件(倒计时功能)
2.2手机号绑定成功后在vuex中写一个更新userInfo的方法,在绑定成功后调用此方法
// code-btn.vue
<template>
<view class="code-btn bg-main" hover-class="bg-main-hover" @click="sendCode">
{{ time > 0 ? (time + 's') : '发送' }}
</view>
</template>
<script>
let timer = null
export default {
name:"code-btn",
props: {
phone: {
type: [Number,String],
default: ''
},
},
data() {
return {
time:0
};
},
methods: {
sendCode() {
if(this.time > 0){
return
}
this.$api.getCaptchat({
phone:this.phone
}).then(res=>{
console.log(res);
if(typeof res == 'number'){
this.$toast('验证码:'+res)
} else {
this.$toast('发送成功')
}
this.time = 60
timer = setInterval(()=>{
this.time--
if(this.time <= 0){
clearInterval(timer)
}
},5000)
})
}
},
}
</script>
<style>
.code-btn{
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 200rpx;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #FFFFFF;
border-top-right-radius: 8rpx;
border-bottom-right-radius: 8rpx;
z-index: 999;
}
</style>
// bind-phone
<template>
<view>
<!-- 顶部返回&背景色 -->
<view class="login-bg" />
<!-- #ifndef MP -->
<view class="py-3 px-4 back-btn" @click="goBack">
<uni-icons color="#fff" type="back" size="20"></uni-icons>
</view>
<!-- #endif -->
<!-- 登录注册 模块 -->
<view class="login">
<view class="flex">
<text class="title">绑定手机号</text>
</view>
<view class="login-form">
<uni-icons type="person"></uni-icons>
<input type="text" placeholder="请输入手机号" class="rounded font-md" v-model="form.phone" />
</view>
<view class="login-form">
<uni-icons type="locked"></uni-icons>
<input type="text" placeholder="验证码" class="rounded font-md" v-model="form.code" />
<code-btn :phone="form.phone"></code-btn>
</view>
<view class="bg-main btn" hover-class="bg-main-hover" @click="submit">绑 定</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
form: {}
}
},
methods: {
submit() {
console.log(this.form,'form');
const data = Object.assign(this.form, {})
this.$api.getBindPhone(data).then(res => {
if (res === 'ok') {
this.$toast('绑定成功')
this.$store.dispatch('updateUserInfo', {
phone: data.phone
})
}
uni.navigateBack()
this.form = {}
}).catch(err => {
console.log(err, 'err');
})
},
goBack() {
uni.navigateBack()
}
}
}
</script>
4、个人中心模块开发思路
4.1、此模块功能点分为(权限验证、修改密码、缓存计算(tool.js)、清除缓存、编辑资料页面,我的订单列表)
1、权限验证:vue原型上挂载方法authJump验证是否登录和是否绑定手机号
2、修改密码模块:修改密码后点保存调接口后提示,延迟几秒后路由退回,清空本地缓存数据
3、缓存计算、清除缓存:
缓存计算:uni.getStoregeInfo获取缓存信息( tool.js内方法转换kb单位)
清除缓存:循环缓存信息内的keys,将userInfo剔除出来,点击清除循环keys,调用removeStoregeSync遍历删除key
4、编辑资料页面:难点为封装upload接口(uni.chooseImage选择本地图片,uni.showActionSheet底部弹框)
5、订单列表页:
下拉刷新 :pages.json内配置开启刷新,下拉刷新钩子onPullDownRefresh监听,监听后让page=1,并重新获取数据
上拉加载:结合uni-load-more组件使用,status属性的三种状态(见下图),获取数据时对状态进行判断,数据长度小于请求长度status为noMore,等于请求长度则为more,上拉触底onReachBottom钩子触发上拉事件,上拉事件内判断status不为more则reture,否则让page+1,刷新数据
<template>
<view>
<view v-for="(item,index) in list" :key="index">
<uni-card isFull note="true">
<view>
<view class="flex font-sm text-muted py-2 justify-between">
<text>订单时间:{{ item.created_time }}</text>
<text>订单号:{{ item.no }}</text>
</view>
<view class="flex font-md">{{ item.goods }}</view>
<view class="flex font-md justify-end text-danger font-weight-bold">¥{{ item.price }}</view>
</view>
<view slot="actions" class="flex align-center py-2"
:class="item.status == 'success' ? 'text-success' : ''">
<view>
{{ item.status == 'success' ? '交易成功' : '等待支付' }}
</view>
<view class="ml-auto">
<main-button bClass="px-2 font" bStyle="height: 70rpx;" v-if="item.status == 'pendding'"
@click="pay(item.no)">立即支付</main-button>
</view>
</view>
</uni-card>
<view class="divider"></view>
</view>
<uni-load-more :status="loadStatus"></uni-load-more>
</view>
</template>
<script>
import $tool from '@/common/tool.js';
export default {
data() {
return {
loadStatus: "loading",
page: 1,
limit: 5,
list: []
}
},
created() {
this.getData()
},
onPullDownRefresh() {
this.page = 1
this.getData().finally(() => {
uni.stopPullDownRefresh()
})
},
onReachBottom() {
this.handleLoadMore()
},
methods: {
pay(no) {
// H5支付
// #ifdef H5
uni.navigateTo({
url: '../h5pay/h5pay?no=' + no,
});
// #endif
// app端/小程序端支付
// #ifdef APP-PLUS || MP
$tool.wxpay(no, () => {
this.page = 1
this.getData()
})
// #endif
},
handleLoadMore() {
if (this.loadStatus != 'more') {
return
}
this.page = this.page + 1
this.getData()
},
getData() {
let page = this.page
return this.$api.getOrderList({
page: this.page,
limit: this.limit
}).then(res => {
this.list = page == 1 ? res.rows : [...this.list, ...res.rows],
console.log(this.list, 'list');
if (res.rows.length < this.limit) {
this.loadStatus = 'noMore'
} else if (res.rows.length === this.limit) {
this.loadStatus = 'more'
}
}).catch(err => {
this.loadStatus = 'more'
if (page > 1) {
this.page = this.page - 1
}
})
}
}
}
</script>
5、首页开发
1、优惠券模块
1.1:用户领取优惠券,判断是否登录,未登录则跳转至登录页
1.2:退出登录刷新优惠券领取状态:在vuex中登录和退出 分别使用uni.$emit('事件名')进行事件触发
首页发布跨组件事件:created钩子内uni.$on('事件名',函数)事件发布,**页面销毁时使用uni.$off('事件名')注销掉监听的两个事件,**只要退出或登录就重新获取优惠券数据刷新状态,
// store.js
import Vue from "vue"
import Vuex from "vuex"
Vue.use(Vuex)
export default new Vuex.Store({
state: {
userInfo: null,
token: null
},
actions: {
// 持久化数据
init({
state
}, data) {
const userInfo = uni.getStorageSync('userInfo')
if (userInfo) {
state.userInfo = JSON.parse(userInfo)
state.token = JSON.parse(userInfo).token
}
},
login({
state
}, userInfo) {
state.userInfo = userInfo
state.token = userInfo.token
uni.setStorageSync('userInfo', JSON.stringify(userInfo))
uni.$emit('userLogin', userInfo)
},
loginOut({
state
}, data) {
state.userInfo = null
state.token = null
uni.removeStorageSync('userInfo')
uni.$emit('userLoginOut', data)
},
updateUserInfo({
state
}, values) {
Object.keys(values).forEach(k => state.userInfo[k] = values[k])
uni.setStorageSync('userInfo', JSON.stringify(state.userInfo))
}
}
})
2、搜索模块
1、pages.json内配置 搜索框及搜索按钮
配置后的搜索输入框
2、页面钩子事件
onsearchinput快捷指令 页面钩子监听配置input内的值,并赋值给当前组件变量this.searchValue
onbutton快捷指令 监听配置按钮事件 触发搜索事件
onNavigationBarSearchInputConfirmed监听表单内回车事件 触发搜索事件
3、添加历史记录规则,每触发一次搜索事件,就更新本地储存的历史记录(如果已经有该历史记录, 则将历史记录置顶,如果该值已经是第一个,则不管)
4、onload钩子内获取历史记录数据
5、空值判断、清除记录提示,
6、触发搜索事件后,进入搜索结果页
<template>
<view>
<!-- #ifdef MP -->
<search-bar v-model="searchValue" @confirm="handleSearchEvent()"></search-bar>
<!-- #endif -->
<view class="p-2 flex justify-between align-center" v-if="list.length">
<text class="font-md font-weight-bold">历史记录</text>
<text class="font-sm text-secondary" @click="clearHistory">清除全部</text>
</view>
<view class="flex flex-wrap p-2">
<view v-for="(item,index) in list" :key="index" class="border font-sm mr-2 mb-2 p-2"
style="border-radius: 4rpx;background-color: #f8f8f8;" @click="goResult(index)">{{item}}</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
list: [],
searchValue: ''
}
},
onNavigationBarSearchInputChanged(e) {
this.searchValue = e.text
},
onNavigationBarButtonTap() {
this.handleSearchEvent(this.searchValue)
},
onNavigationBarSearchInputConfirmed() {
this.handleSearchEvent(this.searchValue)
},
onLoad() {
const list = uni.getStorageSync('searchHistory')
if (list) {
this.list = JSON.parse(list)
}
console.log(this.list,'list');
},
methods: {
goResult(index) {
this.$navigateTo(`/pages/search-result/search-result?value=${this.list[index]}`)
},
handleSearchEvent(v) {
if (!v) return this.$toast('请输入关键词')
const findItem = this.list.findIndex(item => item === v)
if (findItem !== -1 && findItem !== 0) {
this.list.splice(findItem, 1)
this.list.unshift(v)
} else {
this.list.unshift(v)
}
uni.setStorageSync('searchHistory', JSON.stringify(this.list))
this.$navigateTo(`/pages/search-result/search-result?value=${v}`)
},
clearHistory() {
uni.showModal({
content: '是否确认清除历史记录',
success: (res)=> {
if (res.confirm) {
uni.removeStorageSync('searchHistory')
this.list =[]
} else if (res.cancel) {
}
}
});
}
}
}
</script>
<style>
</style>
<template>
<view class=" flex flex-column" style="height: 100%;">
<tabs :current="current" :tabs="tabs" @change="changeTab"></tabs>
<!-- 搜索内容 -->
<!-- swiper组件需要设置固定高度,否则内容无法显示 -->
<view class="flex-1" style="height: 100vh;" >
<swiper :current="current" :duration="1000" style="height: 100%;" @change="changeCurrent">
<swiper-item v-for="(t,tIndex) in tabs" :key="tIndex">
<scroll-view @scrolltolower="reachBottom(t)" scroll-y="true"
style="height: 100%;padding-top: 20rpx;">
<course-list :item="item" v-for="(item,index) in t.list " :key="index" :type="t.type">
</course-list>
<!-- 加载loading -->
<uni-load-more :status="t.loadStatus"></uni-load-more>
</scroll-view>
</swiper-item>
</swiper>
</view>
</view>
</template>
<script>
export default {
data() {
return {
current: 0,
tabs: [{
name: '课程',
loadStatus: 'more',
type: 'one',
list: [],
page: 1
},
{
name: '专栏',
loadStatus: 'more',
type: 'two',
list: [],
page: 1
}
],
limit: 10,
searchValue: ''
}
},
onNavigationBarSearchInputClicked() {
uni.navigateBack()
},
onLoad(e) {
this.searchValue = e.value
this.getData()
},
methods: {
getData() {
const currentTab = this.tabs[this.current]
const data = {
keyword: this.searchValue,
type: this.current == 0 ? 'course' : 'column',
page: currentTab.page
}
currentTab.loadStatus = 'loading'
this.$api.getSearchList(data).then(res => {
currentTab.list = currentTab.page === 1 ? res.rows : [...currentTab.list, ...res.rows],
console.log(currentTab.list, 'currentTab.list');
if (res.rows.length < this.limit) {
currentTab.loadStatus = 'noMore'
} else if (res.rows.length === this.limit) {
currentTab.loadStatus = 'more'
}
})
},
changeTab(e) {
this.current = e
},
changeCurrent(e) {
this.current = e.detail.current
const currentTab = this.tabs[this.current]
if (currentTab.loadStatus === 'more' && currentTab.page === 1) {
this.getData()
}
},
// 滚动底部事件
reachBottom(t) {
console.log('触发底部事件');
const currentTab = this.tabs[this.current]
if (currentTab.loadStatus !== 'more') return
currentTab.page++
this.getData()
}
}
}
</script>
<style scoped lang="less">
page {
height: 100%;
.result {
height: 100%;
}
}
</style>
3、专栏、课程详情页
1、课程详情页:
1、基本思路
uni.setNavigationBarTitle修改标题名
富文本插件:mp-html https://ext.dcloud.net.cn/plugin?id=805 直接导入项目即可
1.1、对购买状态进行判断,根据数据做对应渲染 (购买后顶部图片、简介、购买按钮不显示,将课程简介转为课程内容)
1.2、加载时会出现白色页面一闪而过(骨架屏)
2、视频组件 video
3、音频组件 创建f-audio公共组件
3.1、uni的滑动选择器组件 uslider
3.2、使用uni.createInnerAudioContext创建音频对象 并赋值给data内的_audioContext,在created钩子中对音频对象进行操作,
data中定义:_audioContext存储音频对象,isplaying播放状态,playEnd播放结束状态,currenTime当前时间, duration总时长,isChangeing拖动中状态
3.3、tool.js中转换时分秒方法
3.1、课程详情页(专栏详情页与其类似)
// course.vue
<template>
<view>
<view class="position-relative">
<image :src="detail.cover" style="width: 100%;height: 420rpx;" class="bg-light"></image>
<view class="text-white font-sm p-1" style="position: absolute;right: 20rpx;bottom: 20rpx;background-color: rgba(0,0,0,0.4);">
专栏
</view>
</view>
<!-- 活动条 -->
<active-bar v-if="activeData && !detail.isbuy" :end_time="activeData.data.end_time" :price="activeData.data.price" :t_price="detail.price">
<text v-if="activeData.type == 'group'">{{ activeData.data.p_num }}人拼团</text>
<text v-else>{{ activeData.data.used_num }}人已枪/剩{{ activeData.data.s_num - activeData.data.used_num}}名</text>
</active-bar>
<tabs :tabs="tabs" :current="current" @change="clickTab"></tabs>
<!-- 简介 -->
<view v-if="current == 0" class="animate__animated animate__fadeIn animate__faster">
<view v-if="firstLoad" class="flex flex-column p-3">
<text class="mb-1" style="font-size: 38rpx;">{{ detail.title }}</text>
<view class="flex align-center justify-between">
<text class="font-sm text-light-muted">{{ detail.sub_count }} 人学过</text>
</view>
<view v-if="!detail.isbuy" class="flex mt-2 align-end">
<text class="text-danger font-lg">¥{{ detail.price }}</text>
<text class="font-sm text-light-muted ml-1 text-through">¥{{ detail.t_price }}</text>
</view>
</view>
<view v-else class="flex flex-column p-3">
<skeleton width="600rpx" height="75rpx" oClass="mb-2"></skeleton>
<skeleton width="150rpx" height="70rpx"></skeleton>
<view class="flex mt-2 align-end">
<skeleton width="150rpx" height="70rpx"></skeleton>
<skeleton width="150rpx" height="40rpx" oClass="ml-1"></skeleton>
</view>
</view>
<view class="divider"></view>
<uni-card title="专栏简介" isFull>
<group-works v-if="!detail.isbuy" ref="groupWorks" @updateData="getData"></group-works>
<mp-html :content="detail.content">
<view class="flex justify-center py-3 text-muted">
加载中...
</view>
</mp-html>
</uni-card>
</view>
<!-- 目录 -->
<view v-else class="animate__animated animate__fadeIn animate__faster">
<view class="p-3">
<view class="border rounded bg-light text-muted p-2">
共 {{ list.length }} 节
</view>
</view>
<menu-item v-for="(item,index) in list" :key="index" :title="item.title" :index="index" @click="openPlay(item)">
<view class="flex">
<text class="border text-danger rounded border-danger font-small px-1 mt-1 mr-1">
{{ item.type | formatType}}
</text>
<text v-if="item.price == 0" class="border text-danger rounded border-danger font-small px-1 mt-1">
免费试看
</text>
</view>
</menu-item>
</view>
<template v-if="!detail.isbuy && firstLoad">
<view style="height: 75px;"></view>
<view class="fixed-bottom p-2 border-top bg-white">
<main-button @click="submit">{{ btn }}</main-button>
</view>
</template>
</view>
</template>
<script>
export default {
filters: {
formatType(t) {
let c = {
media:"图文",
audio:"音频",
video:"视频"
}
return c[t];
}
},
data() {
return {
firstLoad:false,
current:0,
tabs:[{
name:"简介",
},{
name:"目录",
}],
detail:{
id: 0,
title: "",
cover: "",
try: "",
price: "",
t_price: "",
type: "media",
sub_count: 0,
content: "",
isbuy: false,
isfava:false
},
list:[],
group_id:0,
// 拼团/秒杀详情
activeData:null,
flashsale_id:0
}
},
computed:{
btn(){
if(this.detail.flashsale){
return '立即秒杀¥'+this.detail.flashsale.price
}
if(this.detail.group){
return '立即拼团¥'+this.detail.group.price
}
if(this.detail.price == 0){
return '立即学习'
}
return '立即订购¥'+this.detail.price
}
},
onLoad(e) {
this.detail.id = e.id
if(!this.detail.id){
this.$toast('非法参数')
setTimeout(()=>{
uni.navigateBack({ delta: 1 });
},700)
return
}
if(e.group_id){
this.group_id = e.group_id
}
if(e.flashsale_id){
this.flashsale_id = e.flashsale_id
}
},
onShow(){
this.getData()
},
methods: {
submit(){
// 立即拼团
if(this.group_id){
uni.showLoading({
title: '发起拼团中...',
mask: true
})
this.$api.createOrder({
group_id:this.group_id,
},'group').then(res=>{
// H5支付
// #ifdef H5
uni.navigateTo({
url: '../h5pay/h5pay?no='+res.no,
});
// #endif
// app端支付
// #ifdef APP-PLUS || MP
$tool.wxpay(res.no,()=>{
this.getData()
})
// #endif
}).catch(err=>{
console.log(err);
}).finally(()=>{
uni.hideLoading()
})
return
}
// 立即学习
if(this.detail.price == 0){
uni.showLoading({
title: '加载中...',
mask: false
});
this.$api.learn({
goods_id:this.detail.id,
type:"column"
}).then(res=>{
this.getData()
}).finally(()=>{
uni.hideLoading()
})
return
}
// 创建订单
let type = "column"
let id = this.detail.id
if(this.detail.flashsale){
type = 'flashsale'
id = this.flashsale_id
}
console.log('创建订单');
this.$authJump(`/pages-order/creat-order/creat-order?id=${id}&type=${type}`)
},
openPlay(item){
if(item.price != 0 && !this.detail.isbuy){
return this.$toast('请先购买该专栏')
}
this.$authJump(`/pages-media/course/course?id=${item.id}&column_id=${this.detail.id}`)
},
clickTab(index){
this.current = index
},
getData(){
this.$api.readColumn({
id:this.detail.id,
group_id:this.group_id,
flashsale_id:this.flashsale_id
}).then(res=>{
this.detail = res
console.log(this.detail,'detail');
if(res.group){
this.activeData = {
type:"group",
data:res.group
}
this.$refs.groupWorks.init(this.group_id)
}
if(res.flashsale){
this.activeData = {
type:"flashsale",
data:res.flashsale
}
}
this.list = res.column_courses
console.log(this.activeData,'activeData');
uni.setNavigationBarTitle({
title:this.detail.title
})
}).catch(err=>{
if(err == '该记录不存在'){
setTimeout(()=>{
uni.navigateBack({ delta: 1 });
},700)
}
}).finally(()=>{
this.firstLoad = true
})
}
}
}
</script>
3.2 、音频组件封装
1、created钩子触发createAudio事件
1.1、事件内将uni.createInnerAudioContext()赋值给this._audioContext,由this._audioContext对音频的各种钩子进行监听操作
1.2、播放进度:slider组件拖拽时,在change事件内要修改播放进度.seek(跳转到指定位置),以及需要监听拖拽中事件,data中定义一个状态记录是否为拖动中changing,如果处于拖动中则changing为true,在播放事件的播放中判断changing值,为true则表明为拖动中则要暂停播放时间
1.3、组件销毁之前钩子内判断要停止播放状态
1.4、循环播放:this._audioContext.loop设为true
<template>
<view style="background-color: #f5f5f3;" class="pb-4" v-if="dataStatus">
<view class="" style="padding: 50rpx;padding-bottom: 20rpx;">
<image :src="list.cover" mode="aspectFilla" style="width: 655rpx;height: 400rpx;background-color: red;">
</image>
</view>
<view class="px-3">
<slider @changing="changingSlider" @change="changeSlider" :value="position" :max="duration" :block-size="20"
block-color="#5ccc84" activeColor="#5ccc84" />
<view class="flex justify-between font" style="color:#5ccc84;">
<text>{{currentTime | formatTime }}</text>
<text>{{duration | formatTime}}</text>
</view>
<view class="flex justify-center pt-3 icons align-center">
<text class="iconfont icon-ziyuan11" @click="loop" :style="loopStatus ? 'color: #5ccc84;':''"></text>
<text class="iconfont " :class="isPlaying ? 'icon-tianchongxing-':'icon-bofang2'" @click="play"></text>
<text class="iconfont icon-shoucang1" @click="collect"></text>
</view>
</view>
</view>
</template>
<script>
import tool from "@/common/tool.js"
export default {
name: "f-audio",
props: {
list: Object,
src: String,
},
computed: {
position() {
return this.isPlayEnd ? '0' : this.currentTime
}
},
filters: {
formatTime(s) {
if (!s) return "00:00:00"
return tool.formatSeconds(s);
}
},
data() {
return {
_audioContext: null,
// 播放状态
isPlaying: false,
// 播放结束状态
isPlayEnd: false,
// 当前时间
currentTime: 0,
// 总时长
duration: 0,
// 拖动中
changing: false,
// 循环状态
loopStatus: false,
// 加载状态
dataStatus:false
};
},
beforeDestroy() {
if (this._audioContext !== null && this.isPlaying) {
this.stop()
}
},
created() {
this.createAudio()
},
methods: {
createAudio() {
this._audioContext = uni.createInnerAudioContext()
this._audioContext.autoplay = false
this._audioContext.src = this.src
// 播放
this._audioContext.onPlay(() => {
console.log('开始播放');
});
// 音频进入可以播放状态
this._audioContext.onCanplay(() => {
this.duration = this._audioContext.duration
})
// 音频播放进度更新事件
this._audioContext.onTimeUpdate((e) => {
if (this.changing) return
this.currentTime = this._audioContext.currentTime
// 获取播放进度,传给父组件
if (this.duration > 0) {
this.$emit('onProgress', ((this.currentTime / this.duration) * 100).toFixed(2))
}
});
// 播放结束
this._audioContext.onEnded(() => {
this.currentTime = 0
this.isPlaying = false
this.isPlayEnd = true
});
// 播放错误
this._audioContext.onError(() => {
this.isPlaying = false
});
this.dataStatus =true
console.log(this.dataStatus,'加载状态',this.duration,'总时长');
},
// 播放事件
play() {
if (!this.src) return this.$toast('数据有误,请联系管理员')
if (this.isPlaying) {
return this.pause()
}
this.isPlaying = true
this._audioContext.play()
this.isPlayEnd = false
},
// 暂停事件
pause() {
console.log('暂停');
this.isPlaying = false
this._audioContext.pause()
},
stop() {
this.isPlaying = false
this._audioContext.stop()
},
// 循环播放
loop() {
this.loopStatus = !this.loopStatus
let toast = this.loopStatus ? '开启循环' : '关闭循环'
this._audioContext.loop = this.loopStatus
this.$toast(toast)
},
// 拖动事件
changeSlider(e) {
// console.log(e.detail.value,'e');
this._audioContext.seek(e.detail.value)
this.changing = false
},
// 拖动中事件
changingSlider(e) {
this.changing = true
this.isPlaying = false
this.currentTime = e.detail.value
},
collect() {
this.$toast('暂无此功能')
}
},
}
</script>
<style scoped lang="less">
.icons {
text {
&:first-child,
&:last-child {
font-size: 30px;
color: #ccc;
}
&:nth-child(2) {
font-size: 50px;
margin: 0 50rpx;
color: #5ccc84;
}
}
}
</style>
6、学习进度开发
1、图文进度
1、监听滚动钩子onPageScroll(只监听图文类型),拿到滚动距离scrollTop
2、只保存滚动最大值(滚动值大于保存值 且判断是否购买)
3、在mp-html的ready事件内,拿到课程内容的高度
4、获取窗口高度 let windowHeight = uni.getSystemInfoSync().windowHeight
onMediaReady(){
const Query = uni.createSelectorQuery().in(this)
Query.select('#media').boundingClientRect(data=>{
this.mediaHeight = parseInt(data.height)
this.sumMediaProgress()
}).exec()
},
// 计算图文课程学习进度
sumMediaProgress(){
if(this.mediaHeight > 0){
this.progress = (((this.scrollTop + windowHeight)/this.mediaHeight)*100).toFixed(2)
this.progress = this.progress > 100 ? 100 : this.progress
console.log(this.progress);
}
},
2、视频类型进度
1、video组件上 @timeupdate监听视频进度 获取当前时间和总时长
2、 当前时长/总时长 *100 =学习进度
3、音频类型进度
1、f-audio组件的onTimeUpdate事件内 获取当前时间和结束时间
2、 当前时长/总时长 *100 =学习进度
7、直播模块(live)
思路笔记:
1、course-list 判断一下让其跳转至live页
2、直播模块没有type值,在获取列表时给live添加个type值
3、课程详情页与live页相似,直接copy
西瓜直播(h5直播)使用
npm install xgplayer
npm install xgplayer-flv --save
// live-play
<template>
<view>
<!-- #ifdef H5 -->
<view id="video"></view>
<!-- #endif -->
<scroll-view scroll-y="true" class="ff0000" :style="'height: '+scrollH+'px;'">
<view class="font text-danger p-2">
系统提示:直播内容及互动评论须严格遵守直播规范,严禁传播违法违规、低俗血暴、吸烟酗酒、造谣诈骗等不良有害信息。
</view>
<view :id="'live_'+item.id" class="p-2 font" v-for="(item,index) in danmuList" :key="index">
<text class="text-muted">{{ item.name }}:</text>
{{ item.content }}
</view>
</scroll-view>
<!-- 点击弹出评论 -->
<view style="height: 50px;"></view>
<view style="height: 50px;z-index: 1;" class="fixed-bottom bg-white flex align-center px-3">
<view class="border rounded flex-1 px-2 py-1 text-light-muted bg-light mr-2" @click="openComment()">说一句吧
</view>
</view>
<!-- 弹窗组件 -->
<comment-popup ref="comment" @send="sendComment"></comment-popup>
</view>
</template>
<script>
// #ifdef H5
import 'xgplayer'
import FlvPlayer from "xgplayer-flv"
// #endif
export default {
name: "live-play",
props: {
detail: Object
},
data() {
return {
scrollH: 500,
videoContext: null,
danmuList: [],
scrollInto: "",
currentTime: 0
};
},
mounted() {
this.getData()
},
created() {
let res = uni.getSystemInfoSync()
this.scrollH = res.windowHeight - uni.upx2px(420) - 50
},
beforeDestroy() {
// #ifdef H5
this.videoContext.off('timeupdate', this.handleTimeUpdate)
// #endif
},
methods: {
getData() {
this.$api.getLiveComment({
page: 1,
limit:500,
live_id: this.detail.id
}).then(res => {
console.log(res,'res');
// #ifdef H5
this.initH5Video(res.rows)
// #endif
})
},
// 创建播放器
initH5Video(comments = []) {
// 获取弹幕信息,并封装成FlvPlayer可以接收的结构
comments = comments.map(item => {
return {
duration: 5000,
id: item.id,
start: item.time,
txt: `${item.name}: ${item.content}`,
style: {
color: item.color,
borderRadius: '50px',
padding: '5px 5px',
backgroundColor: 'rgba(255, 255, 255, 0.1)'
}
}
})
this.videoContext = new FlvPlayer({
id: 'video',
url:this.detail.playUrl,
isLive: true,
playsinline: true,
width: window.inderWidth,
height: uni.upx2px(420),
danmu: {
panel: true, //弹幕面板
comments, //弹幕数组
area: { //弹幕显示区域
start: 0, //区域顶部到播放器顶部所占播放器高度的比例
end: 1 //区域底部到播放器顶部所占播放器高度的比例
},
closeDefaultBtn: false, //开启此项后不使用默认提供的弹幕开关,默认使用西瓜播放器提供的开关
defaultOff: false //开启此项后弹幕不会初始化,默认初始化弹幕
}
})
// 监听视频播放时间
this.videoContext.on('timeupdate', this.handleTimeUpdate)
},
handleTimeUpdate(e) {
this.currentTime = e.currentTime
},
openComment() {
this.$refs.comment.open()
},
sendComment(content) {
if (content == '') {
return this.$toast("弹幕内容不能为空")
}
uni.showLoading({
title: '发送中...',
mask: false
});
this.$api.sendLiveComment({
live_id: this.detail.id,
content,
time: parseInt(this.currentTime * 1000),
color: this.getRandomColor()
}).then(res => {
this.danmuList.push(res)
setTimeout(() => {
this.scrollInto = 'live_' + res.id
}, 300)
// 同步弹幕到视频中
// #ifdef H5
this.videoContext.danmu.sendComment({
duration: 5000,
id: res.id,
start: res.time,
txt: `${res.name}: ${res.content}`,
style: {
color: res.color,
borderRadius: '50px',
padding: '5px 5px',
backgroundColor: 'rgba(255, 255, 255, 0.1)'
}
})
// #endif
}).finally(() => {
uni.hideLoading()
})
},
// 随机颜色
getRandomColor() {
const rgb = []
for (let i = 0; i < 3; ++i) {
let color = Math.floor(Math.random() * 256).toString(16)
color = color.length == 1 ? '0' + color : color
rgb.push(color)
}
return '#' + rgb.join('')
}
},
}
</script>
<style>
</style>
新东西:
scroll 组件:scroll-into-view=xx容器id (滚动到xx容器id处)
8、多端兼容
视频播放组件(live-play.vue)
<template>
<view>
<!-- #ifdef H5 -->
<view id="video"></view>
<!-- #endif -->
<!-- #ifdef MP -->
<live-player
:src="detail.playUrl"
autoplay
@statechange="statechange"
@error="error"
style="width: 750rpx; height: 420rpx;"
/>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<video id="video" v-if="showAppVideo" :src="detail.playUrl" controls autoplay style="width: 750rpx; height: 420rpx;" danmu-btn enable-danmu :danmu-list="appDanmuList"></video>
<view v-else class="flex align-center justify-center bg-dark" style="width: 750rpx; height: 420rpx;">
<text class="text-white">加载中...</text>
</view>
<!-- #endif -->
<scroll-view scroll-y="true" class="ff0000" :style="'height: '+scrollH+'px;'">
<view class="font text-danger p-2">
系统提示:直播内容及互动评论须严格遵守直播规范,严禁传播违法违规、低俗血暴、吸烟酗酒、造谣诈骗等不良有害信息。
</view>
<view :id="'live_'+item.id" class="p-2 font" v-for="(item,index) in danmuList" :key="index">
<text class="text-muted">{{ item.name }}:</text>
{{ item.content }}
</view>
</scroll-view>
<!-- 点击弹出评论 -->
<view style="height: 50px;"></view>
<view style="height: 50px;z-index: 1;" class="fixed-bottom bg-white flex align-center px-3">
<view class="border rounded flex-1 px-2 py-1 text-light-muted bg-light mr-2" @click="openComment()">说一句吧
</view>
</view>
<!-- 弹窗组件 -->
<comment-popup ref="comment" @send="sendComment"></comment-popup>
</view>
</template>
<script>
// #ifdef H5
import 'xgplayer'
import FlvPlayer from "xgplayer-flv"
// #endif
export default {
name: "live-play",
props: {
detail: Object
},
data() {
return {
scrollH: 500,
videoContext: null,
danmuList: [],
scrollInto: "",
currentTime: 0,
appDanmuList:[],
showAppVideo:false
};
},
created() {
let res = uni.getSystemInfoSync()
this.scrollH = res.windowHeight - uni.upx2px(420) - 50
// 获取弹幕列表
this.getData()
},
beforeDestroy() {
// #ifdef H5
this.videoContext.off('timeupdate', this.handleTimeUpdate)
// #endif
},
methods: {
// #ifdef MP
statechange(e){
console.log('live-player code:', e.detail.code)
},
error(e){
console.error('live-player error:', e.detail.errMsg)
},
// #endif
getData() {
this.$api.getLiveComment({
page: 1,
limit:500,
live_id: this.detail.id
}).then(res => {
// #ifdef H5
this.initH5Video(res.rows)
// #endif
// #ifdef APP-PLUS
this.initAppVideo(res.rows)
// #endif
})
},
initAppVideo(comments = []){
this.appDanmuList = comments.map(o=>{
return {
text:`${o.name}: ${o.content}`,
color: o.color,
time:parseInt(o.time/1000),
}
})
this.showAppVideo = true
this.$nextTick(()=>{
this.videoContext = uni.createVideoContext("video", this)
})
},
// 创建播放器
initH5Video(comments = []){
comments = comments.map(o=>{
return {
duration: 5000,
id: o.id,
start: o.time,
txt: `${o.name}: ${o.content}`,
style: {
color: o.color,
borderRadius: '50px',
padding: '5px 5px',
backgroundColor: 'rgba(255, 255, 255, 0.1)'
}
}
})
console.log(comments);
this.videoContext = new FlvPlayer({
id: 'video',
url: this.detail.playUrl,
isLive: true,
playsinline: true,
height: uni.upx2px(420),
width: window.innerWidth,
danmu: {
panel: true, //弹幕面板
comments, //弹幕数组
area: { //弹幕显示区域
start: 0, //区域顶部到播放器顶部所占播放器高度的比例
end: 1 //区域底部到播放器顶部所占播放器高度的比例
},
closeDefaultBtn: false, //开启此项后不使用默认提供的弹幕开关,默认使用西瓜播放器提供的开关
defaultOff: false //开启此项后弹幕不会初始化,默认初始化弹幕
}
});
this.videoContext.on('timeupdate',this.handleTimeUpdate)
},
handleTimeUpdate(e) {
this.currentTime = e.currentTime
},
openComment() {
this.$refs.comment.open()
},
sendComment(content) {
if (content == '') {
return this.$toast("弹幕内容不能为空")
}
uni.showLoading({
title: '发送中...',
mask: false
});
this.$api.sendLiveComment({
live_id: this.detail.id,
content,
time: parseInt(this.currentTime * 1000),
color: this.getRandomColor()
}).then(res => {
this.danmuList.push(res)
setTimeout(() => {
this.scrollInto = 'live_' + res.id
}, 300)
// 同步弹幕到视频中
// #ifdef H5
this.videoContext.danmu.sendComment({
duration: 5000,
id: res.id,
start: res.time,
txt: `${res.name}: ${res.content}`,
style: {
color: res.color,
borderRadius: '50px',
padding: '5px 5px',
backgroundColor: 'rgba(255, 255, 255, 0.1)'
}
})
// #endif
// #ifdef APP-PLUS
this.videoContext.sendDanmu({
text:`${res.name}: ${res.content}`,
color: res.color,
})
// #endif
}).finally(() => {
uni.hideLoading()
})
},
// 随机颜色
getRandomColor() {
const rgb = []
for (let i = 0; i < 3; ++i) {
let color = Math.floor(Math.random() * 256).toString(16)
color = color.length == 1 ? '0' + color : color
rgb.push(color)
}
return '#' + rgb.join('')
}
},
}
</script>
搜索页兼容(search.vue)
<template>
<view>
<!-- 配置的搜索栏不兼容小程序 -->
<!-- #ifdef MP -->
<search-bar v-model="searchValue" @confirm="handleSearchEvent()"></search-bar>
<!-- #endif -->
<view class="p-2 flex justify-between align-center" v-if="list.length">
<text class="font-md font-weight-bold">历史记录</text>
<text class="font-sm text-secondary" @click="clearHistory">清除全部</text>
</view>
<view class="flex flex-wrap p-2">
<view v-for="(item,index) in list" :key="index" class="border font-sm mr-2 mb-2 p-2"
style="border-radius: 4rpx;background-color: #f8f8f8;" @click="goResult(index)">{{item}}</view>
</view>
</view>
</template>
登录页兼容(login.vue)
<template>
<view>
<!-- 返回按钮 -->
<!-- #ifndef MP -->
<view class="login-back" @click="back">
<uni-icons type="arrowleft" size="20" color="#FFFFFF"></uni-icons>
</view>
<!-- #endif -->
<view class="login-bg"></view>
<view class="login">
<view class="flex">
<text class="title">{{ type == 'login' ? '登 录' : '注 册' }}</text>
</view>
<view class="login-form">
<uni-icons type="person"></uni-icons>
<input type="text" placeholder="请输入用户名" class="rounded font-md" v-model="form.username" />
</view>
<view class="login-form">
<uni-icons type="locked"></uni-icons>
<input type="text" placeholder="请输入密码" class="rounded font-md" v-model="form.password" />
</view>
<view class="login-form" v-if="type == 'reg'">
<uni-icons type="locked"></uni-icons>
<input type="text" placeholder="请输入确认密码" class="rounded font-md" v-model="form.repassword" />
</view>
<view class="bg-main btn" hover-class="bg-main-hover" @click="submit">{{ type == 'login' ? '登 录' : '注 册' }}
</view>
<view class="flex align-center justify-between my-3 font">
<text class="py-3 text-main" @click="changeType">{{ type == 'login' ? '注册账号' : '去登录' }}</text>
<text class="py-3 text-light-muted" @click="openForget">忘记密码?</text>
</view>
<view class="flex align-center justify-center wechatlogin">
<!-- #ifndef MP -->
<uni-icons type="weixin" size="25" color="#5ccc84" @click="wxLogin"></uni-icons>
<!-- #endif -->
<!-- #ifdef MP -->
<button type="default" open-type="getUserInfo" @getuserinfo="mpWxLogin">
<uni-icons type="weixin" size="25" color="#5ccc84"></uni-icons>
</button>
<!-- #endif -->
</view>
<checkbox-group v-if="type == 'login'" class="flex align-center justify-center mt-4"
@change="handleCheckboxChange">
<label class="text-light-muted">
<checkbox value="1" color="#7fd49e" style="transform: scale(0.7);" :checked="confirm" /><text class="font"
@click.stop="userNeed">已阅读并同意用户协议&隐私声明</text>
</label>
</checkbox-group>
</view>
</view>
</template>
创建订单支付兼容(creat-order.vue)
<template>
<view>
<course-list :item="item" type="one" :disable ="true"></course-list>
<uni-list>
<uni-list-item title="优惠券" showArrow="true" clickable @click="goCouponList">
<view slot='footer'>
<view class="font-sm font-weight-bold">
{{couponShow}}
</view>
</view>
</uni-list-item>
<uni-list-item title="支付方式">
<view slot='footer'>
<view class="font text-success ">
微信支付
</view>
</view>
</uni-list-item>
</uni-list>
<view style="height: 75px;" />
<view class="buy">
<view class=" p-2 border-top fixed-bottom bg-white">
<main-button v-if="item.price" @click="submit">立即购买{{price}}</main-button>
</view>
</view>
</view>
</template>
<script>
import $tool from "@/common/tool.js"
export default {
computed: {
couponShow() {
if (this.coupon_price) {
return `减${this.coupon_price}元`
}
return this.couponCount ? `请选择优惠券 (${this.couponCount}张)` : '暂无优惠券'
},
price() {
let p = ((this.item.price * 1000 - this.coupon_price * 1000) / 1000).toFixed(2)
return p
}
},
data() {
return {
type: '',
id: 0,
item: {
"id": 0,
"title": "",
"cover": "",
"price": 0,
"type": "video"
},
couponCount: 0,
coupon_price: 0,
user_coupon_id: 0
}
},
onLoad(e) {
if (!e.type || !e.id) {
return this.$toast('参数错误!')
}
this.type = e.type
this.id = e.id
this.getData()
uni.$on('chooseCoupon', this.handleChooseCoupon)
},
beforeDestroy() {
uni.$off('chooseCoupon', this.handleChooseCoupon)
},
methods: {
getData() {
uni.showLoading({
title: 'loading···'
})
this.$api.getGoodsList({
type: this.type,
id: this.id
}).then(res => {
this.item = res
console.log(this.item, 'this.item');
}).finally(() => {
uni.hideLoading()
this.getCouponList()
})
},
goCouponList() {
if (!this.couponCount) return
this.$authJump(`/pages-user/my-coupon/my-coupon?type=${this.type}&goods_id=${this.id}`)
},
getCouponList() {
uni.showLoading({
title: 'loading···'
})
this.$api.getUsableCoupon({
type: this.type == 'course' ? 'course' : "column",
goods_id: this.id
}).then(res => {
this.couponCount = res
}).finally(() => {
uni.hideLoading()
})
},
handleChooseCoupon({
user_coupon_id,
price
}) {
this.user_coupon_id = user_coupon_id
this.coupon_price = price
},
submit() {
uni.showLoading({
title: '创建订单中···'
})
let data ={
goods_id: this.id,
type: this.type,
user_coupon_id: this.user_coupon_id
}
let type ='save'
console.log(data,'data');
if(this.type === 'flashsale') {
data ={
flashsale_id :this.id
}
type ='flashsale'
}
this.$api.createOrder(data).then(res => {
// h5支付
// #ifdef H5
this.$navigateTo('/pages/h5pay/h5pay?no=' + res.no)
// #endif
// app端||小程序 支付
// #ifdef APP-PLUS || MP
$tool.wxpay(res.no, () => {
uni.navigateBack({
delta: 1
});
})
// #endif
}).finally(() => {
uni.hideLoading()
})
}
}
}
</script>
9、微信支付
基本逻辑:
1、创建订单页后会生成一个订单号 ,携带订单号跳转至h5支付页
2.1、支付页首先判断是否在微信环境
2.2、调登录方法 会跳转至微信登录页,获取路径中的 登录凭证code
3、拿到支付相应参数,调用支付接口即可
// 创建订单页 submit为点击创建订单后的回调方法
submit() {
uni.showLoading({
title: '创建订单中···'
})
this.$api.createOrder({
goods_id: this.id,
type: this.type,
user_coupon_id: this.user_coupon_id
}).then(res => {
// h5支付
// #ifdef H5
this.$navigateTo('/pages/h5pay/h5pay?no=' + res.no) //res.no 为订单号
// #endif
}).finally(() => {
uni.hideLoading()
})
}
// h5支付页
<template>
<view>
<view class="text-center my-5">{{ statusOptions[status] }}</view>
</view>
</template>
<script>
import tool from '@/common/tool.js';
export default {
data() {
return {
status:"pendding",
statusOptions:{
pendding:"支付中...",
success:"支付成功",
fail:"支付失败"
}
}
},
async onLoad(e) {
// 1、判断是否在微信浏览器中
if(!tool.isInWechat()){
uni.showModal({
content: '请在微信浏览器中打开',
showCancel: false,
success: res => {
if(res.confirm){
location.href = '/'
}
},
});
}
// 3、登录后会生成一个code值 获取路径中的code值
let code = tool.getUrlCode("code")
if(!code){
// 2、没有code值则表示未登录 调用小程序登录方法
tool.getH5Code()
return
}
// 4、请求支付
try{
let orderInfo = await this.$api.wxpay({
no:e.no,
code,
type:"h5"
})
console.log(orderInfo);
// {
// appId: "wxf0d98abcc66aab61",
// nonceStr: "urN2FLRDvsrJuKiQ",
// package: "prepay_id=wx06040120387050015fc250c8479f9d0000",
// paySign: "1898E6AFC91C27DFF5677F505FD24058",
// signType: "MD5",
// timeStamp: "1633464080",
// timestamp: "1633464080"
// }
// H5支付
this.wxH5Pay(orderInfo,(s)=>{
console.log(s);
this.status = 'fail'
})
}catch(err){
//TODO handle the exception
if(err.indexOf('code been used') != -1){
tool.getH5Code()
} else {
this.status = 'fail'
this.$toast(err)
}
}
},
methods: {
// 微信h5支付 官方方法
wxH5Pay(data, callback){
/**
data:{
"appId": "wx2421b1c4370ecxxx", //公众号ID,由商户传入
"timeStamp": "1395712654", //时间戳,自1970年以来的秒数
"nonceStr": "e61463f8efa94090b1f366cccfbbb444", //随机串
"package": "prepay_id=up_wx21201855730335ac86f8c43d1889123400",
"signType": "RSA", //微信签名方式:
"paySign": "oR9d8PuhnIc+YZ8cBHFCwfgpaK9gd7vaRvkYD7rthRAZ\/X+QBhcCYL21N7cHCTUxbQ+EAt6Uy+lwSN22f5YZvI45MLko8Pfso0jm46v5hqcVwrk6uddkGuT+Cdvu4WBqDzaDjnNa5UK3GfE1Wfl2gHxIIY5lLdUgWFts17D4WuolLLkiFZV+JSHMvH7eaLdT9N5GBovBwu5yYKUR7skR8Fu+LozcSqQixnlEZUfyE55feLOQTUYzLmR9pNtPbPsu6WVhbNHMS3Ss2+AehHvz+n64GDmXxbX++IOBvm2olHu3PsOUGRwhudhVf7UcGcunXt8cqNjKNqZLhLw4jq\/xDg==" //微信签名
}
**/
function onBridgeReady() {
WeixinJSBridge.invoke('getBrandWCPayRequest',data,(res)=> {
callback(res)
})
}
if (typeof WeixinJSBridge == "undefined") {
if ( document.addEventListener ) {
document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false)
} else if (document.attachEvent) {
document.attachEvent('WeixinJSBridgeReady', onBridgeReady);
document.attachEvent('onWeixinJSBridgeReady', onBridgeReady)
}
} else {
onBridgeReady()
}
},
}
}
</script>
// tool.js 公共方法页
import $api from "@/api/api.js"
export default {
// 微信支付
async wxpay(no,success = false,fail = false){
// app端支付
// #ifdef APP-PLUS
let orderInfo = await $api.wxpay({ no, type:"app"})
console.log(orderInfo);
uni.requestPayment({
"provider": "wxpay",
"orderInfo": orderInfo,
success:(res2)=> {
uni.showToast({
title: '支付成功',
icon: 'none'
});
if(success && typeof success == 'function'){
success()
}
},
fail:(err)=> {
console.log(err);
if(fail && typeof fail == 'function'){
fail(err)
}
uni.showModal({
content: '支付失败',
showCancel:false
});
}
})
// #endif
// 小程序支付
// #ifdef MP
let [err,e] = await uni.login({
provider:"weixin"
})
if(err){
return uni.showModal({
content: '支付失败,原因是:'+err.errMsg,
showCancel: false,
});
}
let code = e.code
let orderInfo = await $api.wxpay({ no, type:"mp", code })
console.log(orderInfo,'orderInfo');
uni.requestPayment({
provider: 'wxpay',
timeStamp: orderInfo.timeStamp,
nonceStr: orderInfo.nonceStr,
package: orderInfo.package,
signType: orderInfo.signType,
paySign: orderInfo.paySign,
success: (res)=> {
uni.showToast({
title: '支付成功',
icon: 'none'
});
if(success && typeof success == 'function'){
success()
}
},
fail: (err)=> {
uni.showModal({
content: '支付失败,原因是:'+err.errMsg,
showCancel: false,
});
}
});
// #endif
},
// 是否在微信浏览器中
isInWechat(){
return String(navigator.userAgent.toLowerCase().match(/MicroMessenger/i)) === "micromessenger"
},
// 获取路径中的参数
getUrlCode(name) {
return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.href) ||
[, ''
])[1]
.replace(/\+/g, '%20')) || null
},
// 微信登录
getH5Code() {
// 微信公众号的appid
let appid = 'wxc6491f5743c52eef'
let href = window.location.href
if (href.indexOf('?code') != -1) {
let h = href.split('#/')
h[0] = window.location.protocol + "//" + window.location.host
href = h[0] + '/#/' + h[1]
}
let local = encodeURIComponent(href);
const url =
`https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${local}&response_type=code&scope=snsapi_userinfo&state=STATE&connect_redirect=1#wechat_redirect`;
location.href = url
}
}
10、小程序分包配置/子包预加载
主包pages放启动必要页面,分包subPackages放其他页面,包预加载preloadRule
// pages.json页
{
// 主包
"pages": [{
"path": "pages/tabbar/index/index",
"style": {
"app-plus": {
// 隐藏导航栏
"titleNView": false
},
// 下拉刷新
"enablePullDownRefresh": true
}
}, {
"path": "pages/tabbar/learn/learn"
}, {
"path": "pages/tabbar/home/home",
"style": {
"enablePullDownRefresh": false, // 刷新
"navigationBarBackgroundColor": "#5ccc84", //导航栏背景色
"navigationBarTextStyle": "white", // 文字颜色
"app-plus": {
"titleNView": { // 自定义导航栏
"titleAlign": "left",
"titleText": "我的",
"buttons": [{ // 自定义按钮
"type": "menu"
}]
}
}
}
},
{
"path": "pages/login/login",
"style": {
"app-plus": {
"titleNView": false
}
}
}, {
"path": "pages/userNeedKnow/userNeedKnow",
"style": {
"app-plus": {
"titleNView": false
},
"enablePullDownRefresh": false
}
}, {
"path": "pages/search/search",
"style": {
"enablePullDownRefresh": false,
"app-plus": {
"titleNView": {
"searchInput": {
"placeholder": "请输入关键词搜索",
"autoFocus": true,
"align": "left",
"backgroundColor": "#f8f8f8",
"borderRadius": "50px"
},
"buttons": [{
"text": "搜索",
"fontSize": "15px"
}]
}
},
// 小程序不兼容配置搜索框
"mp-weixin": {
"navigationStyle": "custom"
}
}
}, {
"path": "pages/search-result/search-result",
"style": {
"enablePullDownRefresh": false,
"app-plus": {
"titleNView": {
"searchInput": {
"placeholder": "请输入关键词搜索",
"disabled": true,
"align": "left",
"backgroundColor": "#f8f8f8",
"borderRadius": "50px"
}
}
}
}
}, {
"path": "pages/list/list",
"style": {
"navigationBarTitleText": "列表页",
"enablePullDownRefresh": true
}
},
{
"path": "pages/update-password/update-password",
"style": {
"navigationBarTitleText": "修改密码",
"enablePullDownRefresh": false
}
},
{
"path": "pages/webview/webview",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false
}
}
],
// 分包
"subPackages": [{
"root": "pages-book",
"pages": [{
"path": "my-book/my-book",
"style": {
"navigationBarTitleText": "我的电子书",
"enablePullDownRefresh": true
}
}]
},
{
"root": "pages-media",
"pages": [{
"path": "live/live",
"style": {
"navigationBarTitleText": "直播详情",
"enablePullDownRefresh": false
}
}, {
"path": "course/course",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false
}
}, {
"path": "column/column",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false
}
}]
},
{
"root": "pages-order",
"pages": [{
"path": "creat-order/creat-order",
"style": {
"navigationBarTitleText": "创建订单",
"enablePullDownRefresh": false
}
}, {
"path": "h5pay/h5pay",
"style": {
"navigationBarTitleText": "微信h5支付",
"enablePullDownRefresh": false
}
},
{
"path": "order-list/order-list",
"style": {
"navigationBarTitleText": "我的订单",
"enablePullDownRefresh": true,
"onReachBottomDistance": 100
}
}
]
},
{
"root": "pages-test",
"pages": [{
"path": "test-list/test-list",
"style": {
"navigationBarTitleText": "考试列表",
"enablePullDownRefresh": true
}
}, {
"path": "test-detail/test-detail",
"style": {
"navigationBarTitleText": "开始考试",
"enablePullDownRefresh": false
}
}, {
"path": "my-test/my-test",
"style": {
"navigationBarTitleText": "我的考试",
"enablePullDownRefresh": true
}
}]
},
{
"root": "pages-user",
"pages": [{
"path": "setting/setting",
"style": {
"navigationBarTitleText": "我的设置",
"backgroundColor": "#fff",
"enablePullDownRefresh": false
}
}, {
"path": "my-coupon/my-coupon",
"style": {
"navigationBarTitleText": "我的优惠券",
"enablePullDownRefresh": true
}
}, {
"path": "user-info/user-info",
"style": {
"navigationBarTitleText": "编辑资料",
"enablePullDownRefresh": false
}
},
{
"path": "bind-phone/bind-phone",
"style": {
"app-plus": {
"titleNView": false
},
"enablePullDownRefresh": false
}
}, {
"path": "forget-password/forget-password",
"style": {
"app-plus": {
"titleNView": false
},
"enablePullDownRefresh": false
}
}
]
}
],
// 分包预载配置
"preloadRule": {
"pages-user/my-coupon/my-coupon": {
"network": "all",
"packages": ["__APP__"]
}
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uniApp在线教育",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#ffffff",
"app-plus": {
"background": "#efeff4"
}
},
"tabBar": {
"color": "#BBBAC7",
"selectedColor": "#2c2c2c",
"borderStyle": "black",
"list": [{
"pagePath": "pages/tabbar/index/index",
"iconPath": "/static/tabbar/index1.png",
"selectedIconPath": "/static/tabbar/index1_selected.png",
"text": "首页"
},
{
"pagePath": "pages/tabbar/learn/learn",
"iconPath": "/static/tabbar/learn.png",
"selectedIconPath": "/static/tabbar/learn_selected.png",
"text": "学习"
},
{
"pagePath": "pages/tabbar/home/home",
"iconPath": "/static/tabbar/home.png",
"selectedIconPath": "/static/tabbar/home_selected.png",
"text": "我的"
}
]
},
"condition": { //模式配置,仅开发期间生效
"current": 0, //当前激活的模式(list 的索引项)
"list": [{
"name": "", //模式名称
"path": "", //启动页面,必选
"query": "" //启动参数,在页面的onLoad函数里面得到
}]
}
}