uniapp介绍

#uniapp介绍

#什么是快应用?

快应用是指用户无需下载安装,即点即用,享受原生性能体验的应用,例如:微信小程序,支付宝小程序,百度小程序等。

快应用的优势:

快应用的缺点:

快应用的发展趋势:平台底层支持,扫码即用,无需安装微信、支付宝等平台,可以参看,链接(opens new window)

为什么PWA不香了?

#背景介绍

#技术人想偷懒

困境:

#各个平台之间的对比

test-frame-11.png

数据源:链接 (opens new window), 2020年横评:链接(opens new window)

结论:

TIP

没有最好的,只有最适合的。

#应用场景

#什么是uniapp?

uni-app 是一个使用 Vue.js (opens new window)开发所有前端应用的框架,开发者编写一套代码,可发布到iOS、Android、Web(响应式)、以及各种小程序(微信/支付宝/百度/头条/QQ/钉钉/淘宝)、快应用等多个平台。

TIP

uni-app与mpvue的渊源:uni-app在初期借鉴了mpvue,实现了微信小程序端的快速兼容,参考链接 (opens new window)

#开发规范

为了实现多端兼容,综合考虑编译速度、运行性能等因素,uni-app 约定了如下开发规范:

#目录结构

一个uni-app工程,默认包含如下目录及文件:

┌─uniCloud              云空间目录,阿里云为uniCloud-aliyun,腾讯云为uniCloud-tcb(详见uniCloud)
│─components 符合vue组件规范的uni-app组件目录
│ └─comp-a.vue 可复用的a组件
├─hybrid App端存放本地html文件的目录,详见
├─platforms 存放各平台专用页面的目录,详见
├─pages 业务页面文件存放的目录
│ ├─index
│ │ └─index.vue index页面
│ └─list
│ └─list.vue list页面
├─static 存放应用引用的本地静态资源(如图片、视频等)的目录,注意:静态资源只能存放于此
├─uni_modules 存放uni_module规范的插件。
├─wxcomponents 存放小程序组件的目录,详见
├─main.js Vue初始化入口文件
├─App.vue 应用配置,用来配置App全局样式以及监听 应用生命周期
├─manifest.json 配置应用名称、appid、logo、版本等打包信息
└─pages.json 配置页面路由、导航条、选项卡等页面类信息

TIP

#导入静态资源

#模板内引入静态资源

template内引入静态资源,如imagevideo等标签的src属性时,可以使用相对路径或者绝对路径,形式如下

<!-- 绝对路径,/static指根目录下的static目录,在cli项目中/static指src目录下的static目录 -->
<image class="logo" src="/static/logo.png"></image>
<image class="logo" src="@/static/logo.png"></image>
<!-- 相对路径 -->
<image class="logo" src="../../static/logo.png"></image>

特别说明:

TIP

#js文件引入

js文件或script标签内(包括renderjs等)引入js文件时,可以使用相对路径和绝对路径,形式如下

// 绝对路径,@指向项目根目录,在cli项目中@指向src目录
import add from '@/common/add.js'
// 相对路径
import add from '../../common/add.js'

WARNING

js文件不支持使用/开头的方式引入

#css引入静态资源

  1. css文件或style标签内引入css文件时(scss、less文件同理),可以使用相对路径或绝对路径(HBuilderX 2.6.6
/* 绝对路径 */
@import url('/common/uni.css');
@import url('@/common/uni.css');
/* 相对路径 */
@import url('../../common/uni.css');

WARNING

HBuilderX 2.6.6起支持绝对路径引入静态资源,旧版本不支持此方式

  1. css文件或style标签内引用的图片路径可以使用相对路径也可以使用绝对路径,需要注意的是,有些小程序端css文件不允许引用本地文件(请看注意事项)。
/* 绝对路径 */
background-image: url(/static/logo.png);
background-image: url(@/static/logo.png);
/* 相对路径 */
background-image: url(../../static/logo.png);

注意事项:

TIP

#搭建开发环境

#集成scss/sass编译

为了方便编写样式(例如<style lang="scss">),建议大家安装sass/scss编译插件,插件的下载地址:scss/sass编译(opens new window)

image-20210419163056984

登录账号 -> 无账号,即注册(邮箱验证) -> 再次点击安装插件 -> 打开HBuilderX

#自定义主题、快捷键等

  1. 快捷键切换

工具 -> 预设快捷键方案切换 中可以切换自己喜欢的快捷键方案,对HBuilderX进行自定义:

image-20210419163655412

  1. 设置主题

image-20210419163851076

  1. 字号设置

macOS的快捷键是 Command + ,,windows的快捷键是Ctrl + ,

image-20210419164115182

常见配置:

{
"editor.colorScheme" : "Default",
"editor.fontFamily" : "Consolas",
"editor.fontSize" : 14,
"editor.insertSpaces" : true,
"editor.lineHeight" : "1.5",
"editor.mouseWheelZoom" : true,
"editor.onlyHighlightWord" : false,
"editor.tabSize" : 2,
"editor.wordWrap" : true,
"editor.codeassist.px2rem.enabel": false,
"editor.codeassist.px2upx.enabel": false
}

#创建项目

#使用HBuilderX

步骤:

#使用vue-cli命令行(VSCode)

  1. 初始化项目
// 全局安装 vue-cli 3.x(如已安装请跳过此步骤)
npm install -g @vue/cli

// 通过 CLI 创建 uni-app 项目
vue create -p dcloudio/uni-preset-vue my-project

img

  1. 安装组件语法提示

组件语法提示是uni-app的亮点,其他框架很少能提供。

npm i @dcloudio/uni-helper-json

如果是HBuilderX的项目,可以使用

npm i @types/uni-app @types/html5plus -D

另外,uni-app 项目下的 manifest.json、pages.json 等文件可以包含注释。vscode 里需要改用 jsonc 编辑器打开。

#配置AppID

image-20210419171015789

#ESLint与代码格式化

TIP

ESLint与代码保存即自动格式化,仅在VSCode上有效

#使用第三方npm包

uni-app支持使用npm安装第三方包。

此文档要求开发者们对npm有一定的了解,因此不会再去介绍npm的基本功能。如若之前未接触过npm,请翻阅NPM官方文档 (opens new window)进行学习。

1. 初始化npm工程

若项目之前未使用npm管理依赖(项目根目录下无package.json文件),先在项目根目录执行命令初始化npm工程:

npm init -y

cli项目默认已经有package.json了。HBuilderX创建的项目默认没有,需要通过初始化命令来创建。

2. 安装依赖

在项目根目录执行命令安装npm包:

npm install packageName --save

3. 使用

安装完即可使用npm包,js中引入npm包:

TIP

#初始化ESLint

# 初始化npm包管理
npm init -y

# 安装eslint依赖
npm i -D eslint eslint-config-standard eslint-plugin-import eslint-plugin-node eslint-plugin-promise eslint-plugin-vue

package.json文件配置如下:

"devDependencies": {
"eslint": "^7.24.0",
"eslint-config-standard": "^16.0.2",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.3.1",
"eslint-plugin-vue": "^7.8.0"
}

新建 两个文件,.eslintrc.js

module.exports = {
env: {
browser: true,
commonjs: true,
es2021: true,
node: true
},
extends: ['eslint:recommended', 'standard', 'plugin:vue/essential'],
parserOptions: {
ecmaVersion: 12
},
plugins: ['vue'],
rules: {
// 这里有一些自定义配置
'no-console': [
'warn',
{
allow: ['warn', 'error']
}
],
'no-eval': 'error',
'no-alert': 'error'
},
globals: {
uni: 'readonly',
plus: 'readonly',
wx: 'readonly'
}
}

创建.eslintignore文件:

node_modules
.hbuilderx
static
uni_modules
unpackage

#配置vscode自动修复功能

安装vetureslint插件

打开vscode的首选项配置,settings.json文件

{
// ... 你自己的配置
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.format.enable": true,
//autoFix默认开启,只需输入字符串数组即可
"eslint.validate": ["javascript", "vue", "html"],

// 关闭vue文件的自动格式化工具, vetur,使用eslint
"[vue]": {
"editor.defaultFormatter": "octref.vetur"
},

"vetur.format.defaultFormatter.ts": "none",
"vetur.format.defaultFormatter.js": "none",

// ...
}

#下载官方代码提示

点击 下载地址 (opens new window),放到项目目录下的 .vscode 目录即可拥有和 HBuilderX 一样的代码块。

img

#首页模块

#首页tabBar

#代码依赖分析

可以在基本信息中选择代码依赖分析

img

查看本地代码与分包大小:

img

#导入静态资源

#布局与样式

完成效果:

img

创建tabBar的步骤:

创建页面HBuilderx方式:

img

创建页面的VSCode插件方式:

image-20210428194346415

快速创建4个页面

image-20210428194614781

并调整pages.json

{
"pages": [
{
"path": "pages/home/home"
},
{
"path": "pages/msg/msg"
},
{
"path": "pages/hot/hot"
},
{
"path": "pages/center/center"
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8",
"app-plus": {
"background": "#efeff4"
}
}
}

新建tabBar属性:

"tabBar": {
"color": "#999",
"backgroundColor": "#fafafa",
"selectedColor": "#02D199",
"borderStyle": "white",
"list": [
{
"text": "首页",
"pagePath": "pages/home/home",
"iconPath": "static/images/tab_home_no.png",
"selectedIconPath": "static/images/tab_home_yes.png"
},
{
"text": "消息",
"pagePath": "pages/msg/msg",
"iconPath": "static/images/tab_news_no.png",
"selectedIconPath": "static/images/tab_news_yes.png"
},
{
"text": "热门",
"pagePath": "pages/hot/hot",
"iconPath": "static/images/tab_popular_no.png",
"selectedIconPath": "static/images/tab_popular_yes.png"
},
{
"text": "我的",
"pagePath": "pages/center/center",
"iconPath": "static/images/tab_my_no.png",
"selectedIconPath": "static/images/tab_my_yes.png"
}
],
"position": "bottom"
}

配置package.json中的eslint修复命令:

"lint": "eslint --ext vue --ext js pages --fix"

底部的阴影:

<view class="bottom-line"></view>

<style lang="scss">
.bottom-line {
position: fixed;
bottom: -5px;
left: 0;
width: 100vw;
height: 5px;
background: transparent;
box-shadow: 0 -5px 5px rgba(0, 0, 0, 0.05);
}
</style>

#导入uView UI

准备工作

// 安装sass依赖
npm i node-sass sass-loader@10 -D

// 安装uView
npm install uview-ui

main.js中引入:

import uView from 'uview-ui'
Vue.use(uView)

在项目根目录的uni.scss中引入样式文件:

/* uni.scss */
@import 'uview-ui/theme.scss';

调整App.vue的样式

<style lang="scss">
/* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
@import "uview-ui/index.scss";
</style>

配置pages.json

// ....
"easycom": {
"^u-(.*)": "uview-ui/components/u-$1/u-$1.vue"
}

#首页Tabs

#布局与样式

home.vue添加tabs

<u-tabs ref="uTabs" :is-scroll="true" active-color="#02D199" height="88" gutter="50"></u-tabs>

添加tabs数据

<u-tabs ref="uTabs" :list="tabs" :name="'value'" :current="current" :is-scroll="true" active-color="#02D199" height="88" gutter="50"></u-tabs>
<script>
export default {
data: () => ({
tabs: [
{
key: '',
value: '首页'
},
{
key: 'ask',
value: '提问'
},
{
key: 'share',
value: '分享'
},
{
key: 'discuss',
value: '讨论'
},
{
key: 'advise',
value: '建议'
},
{
key: 'advise',
value: '公告'
},
{
key: 'advise',
value: '动态'
}
],
// 因为内部的滑动机制限制,请将tabs组件和swiper组件的current用不同变量赋值
current: 0, // tabs组件的current值,表示当前活动的tab选项
swiperCurrent: 0, // swiper组件的current值,表示当前那个swiper-item是活动的
})
}
</script>

完成效果

image-20210526225831609

#添加事件

tabs添加切换事件tabsChange

<u-tabs ref="uTabs" :list="tabs" :name="'value'" :current="current" @change="tabsChange" :is-scroll="true" active-color="#02D199" height="88" gutter="50"></u-tabs>

<script>
export default {
...
methods: {
// tabs通知swiper切换
tabsChange (index) {
this.current = index
}
}
}
</script>

#接口封装

问题:小程序原生提供了request请求,但是是callback的使用方式,并不支持Promise,见链接 (opens new window)

image-20210714162901877

需求:我们前端里面写网络请求,习惯了Axios + async/await的书写风格,那在小程序中怎么去实现接口请求的封装呢?

拆分需求:

#小程序API Promise化

扩展微信小程序api支持promise

首先,本地小程序npm初始化

npm init -y

// 安装依赖包
npm install --save miniprogram-api-promise

在小程序入口(app.js)调用一次promisifyAll,只需要调用一次。

import { promisifyAll, promisify } from 'miniprogram-api-promise';

const wxp = {}
// promisify all wx's api
promisifyAll(wx, wxp)
console.log(wxp.getSystemInfoSync())
wxp.getSystemInfo().then(console.log)
wxp.showModal().then(wxp.openSetting())

// compatible usage
wxp.getSystemInfo({success(res) {console.log(res)}})

// promisify single api
promisify(wx.getSystemInfo)().then(console.log)

建议的写法:

import { promisifyAll, promisify } from 'miniprogram-api-promise';

const wx.p = {}

promisifyAll(wx, wx.p)

// 全局使用wx.p
wx.p.getSystemInfo().then(console.log)

async/await支持的方法比较简单:开户将 JS 代码编译成 ES5即可:链接(opens new window)

在工具 1.05.2106091 版本之后,原有的ES6 转 ES5增强编译 选项统一合并为将 JS 代码编译成 ES5,此功能和原有的增强编译逻辑一致。如需了解旧版本的文档,请点此查看 (opens new window)

支持async/await语法,按需注入regeneratorRuntime,目录位置与辅助函数一致

image-20210714164109331

平时开发的时候,一定要注意该选项有没有打开哦!!

#uniapp小程序请求封装

接口请求封装

TIP

uniapp中已经支持Promise + async/await,但是,对于request请求的错误的处理需要自己来处理。

请求工具js:

// 这个文件我们使用uni.request进行封装
// 同样适合于原生小程序的request封装 -> Promise API

// 需求分析

const errorHandle = (err) => {
if (err.statusCode === 401) {
// todo 4.业务 —> refreshToken -> 请求响应401 -> 刷新token
} else {
// 其他的错误
// showToast提示用户
// 3.对错误进行统一的处理 -> showToast
const { data: { msg } } = err
uni.showToast({
icon: 'none',
title: msg || '请求异常,请重试',
duration: 2000
})
}
}

export const request = (options = {}) => {
const { success, fail } = options
// 1.在头部请求的时候,token带上 -> 请求拦截器
const publicArr = [/\/public/, /\/login/]
// local store -> uni.getStorageSync('token')
let isPublic = false
publicArr.forEach(path => {
isPublic = isPublic || path.test(options.url)
})
const token = uni.getStorageSync('token')
if (!isPublic && token) {
options.header = Object.assign({},
{
Authorization: 'Bearer ' + token
}, options.header)
}
return new Promise((resolve, reject) => {
uni.request(Object.assign({}, options, {
success: (res) => {
// 响应拦截器
if (res.statusCode >= 200 && res.statusCode < 300) {
if (success && typeof success === 'function') {
// 2.在响应的时候,处理data数据
success(res.data)
return
}
// 请求成功
// 2.在响应的时候,处理data数据
resolve(res.data)
} else {
// 请求失败
errorHandle(res)
reject(res)
}
},
fail: (err) => {
if (fail && typeof fail === 'function') {
fail(err)
return
}
errorHandle(err)
reject(err)
}
}))
})
}

#uview中请求封装

工作原理:

uView提供了 http封装:

平台差异说明

App H5 微信小程序 支付宝小程序 百度小程序 头条小程序 QQ小程序

由于某些小程序平台的限制:

语法:

get | post | put | delete(url, params, header).then(res => {}).catch(res => {})

但是如果直接按照上面的写会报错,由于没有设置baseURL: image-20210716134642927

官方文档也有说明:

image-20210716134734477

设置baseURL的方法:配置参数的时候,需要调用$u.http.setConfig(),打开main.js

Vue.prototype.$u.http.setConfig({
baseUrl: 'http://localhost:3000'
})

修改接口:

this.$u.get('/public/getCaptcha?sid=123123123'
).then(res => {
console.log(res)
}).catch(err => {
console.log('err', err)
})

然后就可以正常的请求了:

image-20210716134858589

uview对于拦截器(请求/响应)的支持:

Vue.prototype.$u.http.interceptor.request = (config) => {
console.log('config)
return config
}

Vue.prototype.$u.http.interceptor.response = (data) => {
console.log('data)
return data
}

请求封装:

config.js配置文件:

// export const baseUrl = 'https://mp.toimc.com'
export const baseUrl =
process.env.NODE_ENV === 'development'
? 'http://localhsot:3000'
: 'https://mp.toimc.com'

common/request.js文件

// console.log(process.env)
import store from '@/store'
import { authNav } from '@/common/checkAuth'
import { baseUrl } from '@/config'
import { simpleHttp } from '@/common/utils/simple-http'

export const config = {
baseUrl: baseUrl, // 请求的本域名
// baseUrl: 'https://mp.toimc.com', // 请求的本域名
// 设置为json,返回后会对数据进行一次JSON.parse()
dataType: 'json',
showLoading: true, // 是否显示请求中的loading
loadingText: '请求中...', // 请求loading中的文字提示
loadingTime: 800, // 在此时间内,请求还没回来的话,就显示加载中动画,单位ms
originalData: false, // 是否在拦截器中返回服务端的原始数据
loadingMask: true, // 展示loading的时候,是否给一个透明的蒙层,防止触摸穿透
// 配置请求头信息
header: {
'content-type': 'application/json;charset=UTF-8'
},
}

const install = (Vue) => {
const http = Vue.prototype.$u.http
http.setConfig(config)

http.interceptor.request = (config) => {
let isPublic = false
const publicPath = [/^\/public/, /^\/login/]
publicPath.forEach((path) => {
isPublic = isPublic || path.test(config.url)
})
const token = store.state.token
// if (token) {
if (!isPublic && token) {
config.header.Authorization = 'Bearer ' + token
}
return config
// 如果return一个false值,则会取消本次请求
// if(config.url == '/user/rest') return false; // 取消某次请求
}

http.interceptor.response = (data) => {
return data
}
}

export default {
install
}

问题是如何进行统一的错误处理?

具体做法,修改uview的源码,并把uview的代码移动到项目的代码中,利用hbuilder&uniapp的按需加载,按需打包的机制,使用uview中的组件。

步骤:

#合并uview请求

目的:

思路:

目录结构:

img

其中index.js代码:

const req = require.context('./modules', false, /\.js$/)

const install = (Vue) => {
let api = Vue.prototype.$u.api || {}
req.keys().forEach(item => {
const module = req(item)
// 1.取得所有的方法 -> key值,
const keys = Object.keys(module)
keys.forEach(key => {
api = {
...api,
// 2.取得所有方法对应的function -> value值
// 3.把上面的对象 -> $u.api
[key]: module[key]
}
})
// 4.把它封装成为一个插件,以便在后面的方法中使用 this.$u.api.方法名(参数).then(res) ....
})
Vue.prototype.$u.api = api
}

export default {
install
}

修改main.js

import apis from '@/api'

Vue.use(apis)

#内容列表

测试图片地址:链接(opens new window)

#布局与样式

添加list-item组件

<template>
<view class="list-item">
<view class="list-head">
<!-- 标题部分 -->
<text class="type" :class="['type-'+item.catalog]">{{tabs.filter(o => o.key === item.catalog)[0].value}}</text>
<text class="title">{{item.title}}</text>
</view>
<!-- 用户部分 -->
<view class="author u-flex u-m-b-18">
<u-image :src="item.uid.pic" class="head" width="40" height="40" shape="circle" error-icon="/static/images/header.jpg"></u-image>
<text class="name u-m-l-10">{{item.uid.name}}</text>
</view>
<!-- 摘要部分 + 右侧的图片 -->
<view class="list-body u-m-b-30 u-flex u-col-top">
<view class="info u-m-r-20 u-flex-1">{{item.content}}</view>
<image class="fmt" :src="item.snapshot" v-if="item.snapshot" mode="aspectFill" />
</view>
<!-- 回复 + 文章发表的时间 -->
<view class="list-footer u-flex">
<view class="left">
<text class="reply-num u-m-r-25">{{item.answer}} 回复</text>
<text class="timer">{{item.created | moment}}</text>
</view>
</view>
</view>
</template>

<script>
import { tabs } from '@/config/const'
export default {
props: {
item: {
type: Object,
default: () => ({})
}
},
data: () => ({
tabs
})
}
</script>

<style lang="scss" scoped>
.list-item {
background: #fff;
margin-top: 20rpx;
padding: 30rpx;
}

.list-head {
margin-bottom: 18rpx;
.type {
display: inline-block;
height: 36rpx;
width: 72rpx;
text-align: center;
line-height: 36rpx;
white-space: nowrap;
margin-right: 10rpx;
font-size: 24rpx;
border-radius: 18rpx;
border-bottom-left-radius: 0;
color: #fff;
background-color: red;
position: relative;
top: -4rpx;
transform: scale(0.9);
}
.type-share {
background-color: #feb21e;
}
.type-ask {
background-color: #02d199;
}
.type-discuss {
background-color: #fe1e1e;
}
.type-advise {
background-color: #0166f8;
}
.type-notice {
background-color: #00a3b8;
}
.type-logs {
background-color: #33cb61;
}
.title {
color: #333;
font-size: 32rpx;
line-height: 44rpx;
font-weight: bold;
}
}

.author {
color: #666;
font-size: 24rpx;
}

.list-body {
.info {
font-size: 28rpx;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.fmt {
width: 192rpx;
height: 122rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
}

.list-footer {
color: #999;
font-size: 24rpx;
}
</style>

home.vue添加内容展示区

<template>
<view class="home">
<view class="fixed-top">
<search @click="handle()"></search>
<u-tabs name="value" :list="tabs" :is-scroll="true" :current="current" @change="change" gutter="50" height="88" active-color="#02D199"></u-tabs>
</view>
<!-- 首页的列表 -->
<view class="content" :style="{'padding-top': offsetTop + 'px'}">
<view class="wrapper">
<view class="list-box" v-for="(item,index) in lists" :key="index">
<list-item :item="item" v-if="item.status === '0' && item.title && item.catalog"></list-item>
</view>
<view class="end">{{msg}}</view>
</view>
</view>
<view class="bottom-line"></view>
</view>
</template>

<script>
// import { request } from '@/common/http'
import { tabs } from '@/config/const'
export default {
components: {},
data: () => ({
current: 0,
offsetTop: 50,
page: {
page: 0,
limit: 10,
catalog: '',
sort: 'created'
},
lists: [],
loading: false,
pullDown: false,
isEnd: false,
tabs
}),
methods: {
handle () {
uni.navigateTo({
url: '/subcom-pkg/search/search'
})
},
change (index) {
this.isEnd = false
this.current = index
this.page = {
page: 0,
limit: 10,
catalog: this.tabs[index].key,
sort: 'created'
}
this.lists = []
this.getList()
},
async getList () {
this.loading = true
const { data } = await this.$u.api.getList(this.page)
if (data.length < this.page.limit) {
this.isEnd = true
}
this.lists = [...this.lists, ...data]
this.loading = false
this.page.page++
}
},
mounted () {
this.getList()
},
computed: {
msg () {
let str = ''
if (this.loading) {
str = '加载中...'
} else {
if (this.lists.length > 0) {
// 说明有数据
if (this.isEnd) {
str = '您已经到底啦~没有更多了!'
}
} else {
// 说明没有数据
str = '暂无内容,请下拉刷新'
}
}
return str
}
},

// 页面周期函数--监听页面加载
onLoad () {},
// 页面周期函数--监听页面初次渲染完成
onReady () {
const query = uni.createSelectorQuery().in(this)
query.select('.fixed-top').boundingClientRect((data) => {
this.offsetTop = data.height
}).exec()
},
// 页面周期函数--监听页面显示(not-nvue)
onShow () {},
// 页面周期函数--监听页面隐藏
onHide () {},
// 页面周期函数--监听页面卸载
onUnload () {},
// 页面处理函数--监听用户下拉动作
onPullDownRefresh () {
this.pullDown = true
this.change(this.current)
// setTimeout(() => {
// console.log(2222)
// uni.stopPullDownRefresh()
// }, 2000)
},
}
</script>

<style lang="scss">
.fixed-top {
position: fixed;
left: 0;
top: 0;
width: 100vw;
z-index: 999;
}

.search {
width: 70%;
}

.content {
background: #f5f6f7;
.wrapper {
padding-bottom: 24rpx;
}
}

.end {
color: #999;
text-align: center;
background: #fff;
padding: 25rpx 0;
// background: transparent;
}
</style>

监听页面加载,处理内容列表容器的padding

onLoad () {
const query = uni.createSelectorQuery().in(this)
query.select('.fixed-top').boundingClientRect(data => {
this.offsetTop = data.height
}).exec()
// const { windowHeight } = uni.getSystemInfoSync()
// const query = uni.createSelectorQuery().in(this)
// query.select('#tabs').boundingClientRect(data => {
// }).exec()
}

#获取数据

在通过后台接口获取数据前,我们需要对request和api进行简单的封装。

创建common文件夹,新建 request.js

// 1.请求拦截器
// 2.在响应的时候,处理data数据
// 3.统一的错误处理
// 这里的vm,就是我们在vue文件里面的this,所以我们能在这里获取vuex的变量,比如存放在里面的token变量
const install = (Vue, vm) => {
// 此为自定义配置参数,具体参数见上方说明
Vue.prototype.$u.http.setConfig({
baseUrl: 'http://localhost:3000',
dataType: 'json',
showLoading: true, // 是否显示请求中的loading
loadingText: '请求中...', // 请求loading中的文字提示
loadingTime: 800, // 在此时间内,请求还没回来的话,就显示加载中动画,单位ms
originalData: false, // 是否在拦截器中返回服务端的原始数据
loadingMask: true, // 展示loading的时候,是否给一个透明的蒙层,防止触摸穿透
// 配置请求头信息
header: {
'content-type': 'application/json;charset=UTF-8'
},
errorHandle: (err) => {
if (err.statusCode === 401) {
// todo 4.业务 —> refreshToken -> 请求响应401 -> 刷新token
uni.showToast({
icon: 'none',
title: '鉴权失败,请重新登录',
duration: 2000
})
} else {
// 其他的错误
// showToast提示用户
// 3.对错误进行统一的处理 -> showToast
const { data: { msg } } = err
uni.showToast({
icon: 'none',
title: msg || '请求异常,请重试',
duration: 2000
})
}
}
})

// 请求拦截,配置Token等参数
Vue.prototype.$u.http.interceptor.request = (config) => {
// 引用token
// 1.在头部请求的时候,token带上 -> 请求拦截器
const publicArr = [/\/public/, /\/login/]
// local store -> uni.getStorageSync('token')
let isPublic = false
publicArr.forEach(path => {
isPublic = isPublic || path.test(config.url)
})
const token = uni.getStorageSync('token')
if (!isPublic && token) {
config.header = Object.assign({},
{
Authorization: 'Bearer ' + token
}, config.header)
}
// 最后需要将config进行return
return config
// 如果return一个false值,则会取消本次请求
// if(config.url === '/user/rest') return false; // 取消某次请求
}

// 响应拦截,判断状态码是否通过
Vue.prototype.$u.http.interceptor.response = (res) => {
console.log('🚀 ~ file: request.js ~ line 46 ~ install ~ res', res)
return res
}
}

export default {
install
}

创建api文件夹,新建 index.js,按模块进行接口划分

image-20210526235920712

index.js

const req = require.context('./modules', false, /\.js$/)

const install = (Vue) => {
let api = Vue.prototype.$u.api || {}
req.keys().forEach(item => {
const module = req(item)
// 1.取得所有的方法 -> key值,
const keys = Object.keys(module)
keys.forEach(key => {
api = {
...api,
// 2.取得所有方法对应的function -> value值
// 3.把上面的对象 -> $u.api
[key]: module[key]
}
})
// 4.把它封装成为一个插件,以便在后面的方法中使用 this.$u.api.方法名(参数).then(res) ....
})
Vue.prototype.$u.api = api
}

export default {
install
}

public.js

import Vue from 'vue'

const HttpRequest = Vue.prototype.$u

// ---------------------------------------首页----------------------------------------- //
// 获取首页列表数据
export const getContentList = params => HttpRequest.get('/public/list', params)

main.js中引入

import apis from '@/api/index'
import interceptors from '@/common/request'

Vue.use(interceptors)
Vue.use(apis)

下面我们来获取首页列表数据,创建getList方法

methods: {
async getList () {
this.loading = true
const { data } = await this.$u.api.getList(this.page)
if (data.length < this.page.limit) {
this.isEnd = true
}
this.lists = [...this.lists, ...data]
this.loading = false
this.page.page++
}
},

最后在onshow周期请求数据

onShow () {
this.getList()
}

完成效果:

image-20210527003502507

#分页与切换

上拉分页加载列表数据

watch: {
loading (newval) {
if (this.pullDown && !newval) {
this.pullDown = false
uni.stopPullDownRefresh()
}
}
},
// 页面处理函数--监听用户上拉触底
onReachBottom () {
// 1. 触发条件的设置,距离底部多远的时候触发 - 50
// console.log('reach bottom')
// 2. 当没有更多的时候,需要给用户一个提示,同时设置页面样式,不再发请新的请求
if (this.isEnd || this.loading) return
this.getList()
},

同时在tab切换时加载不同分类的数据,在tabs切换方法tabsChange中添加 getList 方法

tabsChange (index) {
this.current = index
this.page = {
page: 0,
limit: 10,
catalog: this.tabs[this.current].key || '',
sort: 'created'
}
this.getList()
}

#日期过滤器

注意:用法与vue中的过滤器一致

  1. 安装依赖:
npm i dayjs
  1. 添加filter.js文件:
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'

dayjs.extend(relativeTime)

const moment = (date) => {
// 超过7天,显示日期
if (dayjs(date).isBefore(dayjs().subtract(7, 'days'))) {
return dayjs(date).format('YYYY-MM-DD')
} else {
// 1小前,xx小时前,X天前
return dayjs(date).locale('zh-cn').from(dayjs())
}
}

const hours = (date) => {
if (dayjs(date).isBefore(dayjs(dayjs().format('YYYY-MM-DD 00:00:00')))) {
return dayjs(date).format('YYYY-MM-DD')
} else {
// 1天内
return dayjs(date).format('HH:mm:ss')
}
}

// ...

export default {
moment,
hours
}
  1. main.js中使用:
import filters from '@/common/filter'

Object.keys(filters).forEach((key) => {
Vue.filter(key, filters[key])
})

#搜索

#首页搜索按钮

关于设计:

参考:链接1 (opens new window)链接2(opens new window)

官方的文档目前没有对右侧按钮的说明:

image-20210712184845616

在旧的文档中有说明:

image-20210712184922032

从图中分析,我们可以得到如下信息:

关于px,em与pt单位的说明:

px是像素单位,em是相对单位,pt是绝对单位。

它们各自的好处是:

使用wx.getSystemInfoSync()可以在console中快速查看设备的信息。

两种方式创建uni的easycom组件:

  1. 创建components/组件名同名文件夹/组件名.vue
  2. 使用hbuilderx来创建组件,勾选创建目录

image-20210712193718265

创建search组件:

<template>
<view class="search" :style="{'padding-top': barHeight + 'px'}" @click="$emit('click')">
<view class="search-box">
<u-icon name="search" color="#CCC" size="28" class="icon"></u-icon>
<text>搜索社区内容</text>
</view>
</view>
</template>

<script>
export default {
name: 'search',
data () {
return {
barHeight: 0
}
},
beforeMount () {
this.getNavBarHeight()
},
methods: {
getNavBarHeight () {
uni.getSystemInfo({
success: (result) => {
console.log('🚀 ~ file: home.vue ~ line 24 ~ getNavBarHeight ~ result', result)
const statusBarHeight = result.statusBarHeight
const isiOS = result.system.indexOf('iOS') > -1
if (isiOS) {
this.barHeight = statusBarHeight + 5
} else {
this.barHeight = statusBarHeight + 8
}
}
})
}
}
}
</script>

<style lang="scss" scoped>
.search {
position: relative;
width: 100vw;
background: #fff;
padding: 0 32rpx 12rpx;
z-index: 999;
display: flex;
justify-content: flex-start;
align-items: center;
color: #ccc;
.search-box {
position: relative;
width: 60%;
@media screen and (max-width: 320px) {
width: 50%;
}
background: #f3f3f3;
height: 64rpx;
border-radius: 32rpx;
line-height: 64rpx;
columns: #ccc;
font-size: 26rpx;
padding-left: 74rpx;
}
.icon {
position: absolute;
left: 32rpx;
top: 19rpx;
}
}
</style>

注意:

#添加分包

在uniapp中添加分包

注意:

分包的作用是为了提升小程序的加载性能,首页tabs相关的页面是不能放置在分包中的。

#布局与样式

<template>
<view class="search">
<div class="search-box">
<u-search :focus="true"></u-search>
</div>
<!-- 搜索建议列表 -->
<view class="list" v-if="searchResults.length !== 0">
<view class="item" v-for="(item, i) in searchResults" :key="i">
<view class="name">{{item.name}}</view>
<u-icon type="arrow-right" size="16"></u-icon>
</view>
</view>
<!-- 搜索历史 -->
<view class="history-box" v-else>
<!-- 标题区域 -->
<view class="history-title" v-if="historyList.length !== 0">
<text>搜索历史</text>
<u-icon type="trash" size="17" @click="clean"></u-icon>
</view>
<!-- 列表区域 -->
<view class="history-list">
<uni-tag :text="item" v-for="(item, i) in historyList" :key="i"></uni-tag>
</view>
</view>
<!-- 热门推荐 -->
<view class="history-box">
<!-- 标题区域 -->
<view class="history-title" v-if="hotList.length !== 0">
<text>热门推荐</text>
</view>
<!-- 列表区域 -->
<view class="history-list">
<uni-tag :text="item" v-for="(item, i) in hotList" :key="i"></uni-tag>
</view>
</view>
</view>
</template>

<style lang="scss" scoped>
.search {
padding: 24rpx;
}

.search-box {
position: sticky;
top: 0;
z-index: 999;
padding-bottom: 50rpx;
}

.list {
padding: 0 5px;
.item {
font-size: 12px;
padding: 13px 0;
border-bottom: 1px solid #efefef;
display: flex;
align-items: center;
justify-content: space-between;
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 3px;
}
}
}

.history-box {
padding: 0 10rpx 50rpx;

.history-title {
display: flex;
justify-content: space-between;
align-items: center;
height: 40px;
font-size: 16px;
font-weight: bold;
}

.history-list {
display: flex;
flex-wrap: wrap;
::v-deep .uni-tag {
margin-top: 5px;
margin-right: 5px;
border-radius: 25rpx;
}
}
}
</style>

pages.json中配置分包:

"subPackages": [
{
"root": "subpkg",
"pages": [
{
"path": "search/search",
"style": {
"navigationBarTitleText": "搜索"
}
},
// ...
]
}
]

使用easycom,添加search.vue组件:

<template>
<view :style="{'padding-top': barHeight + 'px'}" class="search" @click="$emit('click')">
<view class="search-box">
<u-icon name="search" color="#CCCCCC" size="28" class="icon"></u-icon>
<text>搜索社区内容</text>
</view>
</view>
</template>

<script>

export default {
props: {},
data: () => ({
barHeight: 80
}),
computed: {},
methods: {
getNavBarHeight () {
uni.getSystemInfo({
success: (result) => {
const statusBarHeight = result.statusBarHeight
const isiOS = result.system.indexOf('iOS') > -1
if (isiOS) {
this.barHeight = statusBarHeight + 5
} else {
this.barHeight = statusBarHeight + 7
}
// getApp().globalData.barHeight = this.barHeight
// 存储至store中
// uni.setStorage({
// key: 'setBarHeight',
// data: this.barHeight
// })
},
fail: () => {},
complete: () => {}
})
}
},
beforeMount () {
this.getNavBarHeight()
}
}
</script>

<style lang="scss" scoped>
// search
.search {
// padding-top: 25px;
position: relative;
background: #fff;
width: 100vw;
padding: 0 32rpx 12rpx;
z-index: 999;
.search-box {
position: relative;
width: 70%;
@media (max-width: 320px) {
width: 60%;
}
height: 64rpx;
line-height: 64rpx;
background: #f3f3f3;
border-radius: 32rpx;
color: #ccc;
font-size: 26rpx;
padding-left: 74rpx;
}
.icon {
position: absolute;
left: 32rpx;
top: 19rpx;
}
}
</style>

home.vue添加导航:

<search @click="gotoSearch"></search>

完成效果:

image-20210523231442459

#添加事件

home.vue添加gotoSearch方法,完成页面跳转。

gotoSearch () {
uni.navigateTo({
url: '/subcom-pkg/search/search'
})
}

完成效果:

iShot2021-05-23 23.36.17

#搜索历史功能

本地数据缓存:

data () {
return {
// ...
historyList: uni.getStorageSync('historyList') || [],
}
},

添加对应的事件:

// 添加本地缓存
addHis (value) {
// 标签去重
const index = this.historyList.indexOf(value)
if (index !== -1) {
this.historyList.splice(index, 1)
}
// 最近搜索的标签,显示在最前端
this.historyList.unshift(value)
// 本地缓存
uni.setStorageSync('historyList', this.historyList)
},
clearSearch () {
// 当用户点击搜索框右侧的清空按钮的逻辑
this.showResult = false
this.searchResults = []
this.page = {
title: '',
page: 0,
limit: 20
}
this.loading = false
},

#搜索建议(列表)

结构部分:

<template>
<view class="search">
<!-- 搜索框 -->
<view class="search-box">
<u-search v-model="searchValue" @clear="clearSearch"></u-search>
</view>
<!-- 搜索建议列表 -->
<view class="list" v-if="searchResults.length !== 0">
<view class="item" v-for="(item) in searchResults" :key="item._id" @click="gotoDetail(item)">
<view class="name">{{item.title}}</view>
<u-icon name="arrow-right" size="25"></u-icon>
</view>
</view>
<view class="list no-result" v-else-if="searchResults.length === 0 && showResult">
这里空空如也~
</view>
<!-- 搜索历史 -->
<view class="history-box" v-else-if="historyList.length !== 0 && searchResults.length === 0">
<view class="history-title">
<text>搜索历史</text>
<u-icon name="trash" size="32" @click="clearStorage"></u-icon>
</view>
<!-- 标签列表区域 -->
<view class="history-list">
<uni-tag :text="item" v-for="(item,i) in historyList" :key="i" @click="quickSearch(item)"></uni-tag>
</view>
</view>
<!-- 热门推荐 -->
<view class="history-box" v-if="!showResult">
<view class="history-title">
<text>热门推荐</text>
</view>
<!-- 标签列表区域 -->
<view class="history-list">
<uni-tag :text="item" v-for="(item,i) in hotList" :key="i" @click="quickSearch(item)"></uni-tag>
</view>
</view>
</view>
</template>

样式部分:

// ...
.list {
padding: 0 5px;
.item {
font-size: 12px;
padding: 13px 0;
border-bottom: 1px solid #efefef;
display: flex;
align-items: center;
justify-content: space-between;
&:last-child {
border-bottom: none;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 3px;
}
}
&.no-result {
text-align: center;
color: #666;
font-size: 28rpx;
}
}

methods部分:

// 发起请求
async getList () {
if (this.loading) return
this.loading = true
this.showResult = true
const { data } = await this.$u.api.getList(this.page)
this.searchResults = [...this.searchResults, ...data]
this.loading = false
},
// 点击热门推荐,快速搜索
quickSearch (item) {
this.searchValue = item
this.page.title = item
// 添加到搜索历史
this.addHis(item)
// 发送搜索请求 -> 请求文件列表
this.getList()
},

#搜索按钮事件绑定

<u-search v-model="searchValue" @clear="clearSearch" @search="search" @custom="search"></u-search>

添加对应的search方法:

search (value) {
if (value.trim() === '') {
uni.showToast({
icon: 'error',
title: '关键词不得为空',
duration: 2000
})
return
}
this.page.title = value
this.quickSearch(value)
},

#整体代码

<template>
<view class="search">
<!-- 搜索框 -->
<view class="search-box">
<u-search v-model="searchValue" @clear="clearSearch" @search="search" @custom="search"></u-search>
</view>
<!-- 搜索建议列表 -->
<view class="list" v-if="searchResults.length !== 0">
<view class="item" v-for="(item) in searchResults" :key="item._id" @click="gotoDetail(item)">
<view class="name">{{item.title}}</view>
<u-icon name="arrow-right" size="25"></u-icon>
</view>
</view>
<view class="list no-result" v-else-if="searchResults.length === 0 && showResult">
这里空空如也~
</view>
<!-- 搜索历史 -->
<view class="history-box" v-else-if="historyList.length !== 0 && searchResults.length === 0">
<view class="history-title">
<text>搜索历史</text>
<u-icon name="trash" size="32" @click="clearStorage"></u-icon>
</view>
<!-- 标签列表区域 -->
<view class="history-list">
<uni-tag :text="item" v-for="(item,i) in historyList" :key="i" @click="quickSearch(item)"></uni-tag>
</view>
</view>
<!-- 热门推荐 -->
<view class="history-box" v-if="!showResult">
<view class="history-title">
<text>热门推荐</text>
</view>
<!-- 标签列表区域 -->
<view class="history-list">
<uni-tag :text="item" v-for="(item,i) in hotList" :key="i" @click="quickSearch(item)"></uni-tag>
</view>
</view>
</view>
</template>

<script>
export default {
data () {
return {
searchValue: '',
historyList: uni.getStorageSync('historyList') || [],
hotList: ['前端', 'vue', 'node', '面试', 'react', 'devops', 'flutter', 'MySQL', 'gitlab', 'redis', 'git', 'typescript', '升职', 'B站'],
page: {
title: '',
page: 0,
limit: 20
},
loading: false,
searchResults: [],
showResult: false
}
},
methods: {
search (value) {
if (value.trim() === '') {
uni.showToast({
icon: 'error',
title: '关键词不得为空',
duration: 2000
})
return
}
this.page.title = value
this.quickSearch(value)
},
addHis (value) {
// 标签去重
const index = this.historyList.indexOf(value)
if (index !== -1) {
this.historyList.splice(index, 1)
}
// 最近搜索的标签,显示在最前端
this.historyList.unshift(value)
// 本地缓存
uni.setStorageSync('historyList', this.historyList)
},
async getList () {
if (this.loading) return
this.loading = true
this.showResult = true
const { data } = await this.$u.api.getList(this.page)
this.searchResults = [...this.searchResults, ...data]
this.loading = false
},
// 点击热门推荐,快速搜索
quickSearch (item) {
this.searchValue = item
this.page.title = item
// 添加到搜索历史
this.addHis(item)
// 发送搜索请求 -> 请求文件列表
this.getList()
},
clearSearch () {
// 当用户点击搜索框右侧的清空按钮的逻辑
this.showResult = false
this.searchResults = []
this.page = {
title: '',
page: 0,
limit: 20
}
this.loading = false
},
gotoDetail (item) {
// 文章详情
console.log('🚀 ~ file: search.vue ~ line 99 ~ gotoDetail ~ item', item)
},
clearStorage () {
this.historyList = []
uni.setStorageSync('historyList', [])
}
}
}
</script>

<style lang="scss" scoped>
.search {
padding: 24rpx;
}

.search-box {
padding-bottom: 50rpx;
}

.history-box {
padding: 0 10rpx 50rpx;
.history-title {
display: flex;
justify-content: space-between;
align-items: center;
height: 40px;
font-size: 16px;
font-weight: bold;
}

.history-list {
display: flex;
flex-wrap: wrap;
::v-deep .uni-tag {
margin-top: 5px;
margin-right: 5px;
border-radius: 25rpx;
}
}
}

.list {
padding: 0 5px;
.item {
font-size: 12px;
padding: 13px 0;
border-bottom: 1px solid #efefef;
display: flex;
align-items: center;
justify-content: space-between;
&:last-child {
border-bottom: none;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 3px;
}
}
&.no-result {
text-align: center;
color: #666;
font-size: 28rpx;
}
}
</style>

#发帖入口

#布局与样式

home.vue添加发帖入口

<image class="add-post" src="/static/images/add-post.png" />

修改add-post样式

.add-post {
position: fixed;
width: 150rpx;
height: 150rpx;
bottom: 30rpx;
right: 10rpx;
z-index: 999;
}

完成效果

image-20210527004752928

#添加事件

添加点击事件,点击跳转发帖页面

newContent () {
uni.navigateTo({
url: '/subcom-pkg/post/post'
})
}

#小程序鉴权登录

#需求分析

流程分析:

img

说明:

通过分析,我们获取用户的信息用于创建用户,并通过自己的服务器返回用户的登录态,即token信息与用户信息。

用户信息需要跨页面进行共享,同时,也需要持久化,所以惯性的思考到了 vuex + 本地缓存的方案。

#Vuex集成uniapp

#初始化store

重要:uniapp中内置vuex,所以直接按照vue中使用vuex的步骤进行集成。

创建文件store/index.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex);//vue的插件机制

//Vuex.Store 构造器选项
const store = new Vuex.Store({
state:{
//存放状态
// ...
}
})
export default store

main.js 中导入文件:

import Vue from 'vue'
import App from './App'
import store from './store'

// 把 store 对象提供给 “store” 选项,这可以把 store 的实例注入所有的子组件
const app = new Vue({
store,
...App
})
app.$mount()

state使用上的区别:

正确的打开姿势:

// store.js
const state = {
count: 0,
msg: 'hello Vuex'
}


// 组件中
computed: {
// 方法一:辅助函数
// ...mapState(['count']),
// ...mapState({
// msg2: (state) => state.msg
// }),
// 方法二:computed
count1 () {
return this.$store.state.count
},
}

#如何进行调试

import Vue from 'vue'
import Vuex from 'vuex'
import createLogger from 'vuex/dist/logger'

// 获取执行环境变量
const debug = process.env.NODE_ENV === 'development'
// console.log('🚀 ~ file: index.js ~ line 5 ~ debug', debug)

Vue.use(Vuex)

const state = {
count: 0,
msg: 'hello Vuex'
}

const mutations = {
// 测试Mutation
add (state, payload) {
state.count += payload
}
}

const actions = {}

const getters = {}

// 添加vuex日志插件
const plugins = debug ? [createLogger()] : []

const store = new Vuex.Store({
state,
mutations,
actions,
getters,
plugins
})

export default store

当我们在页面触发mutation的时候:

mounted () {
this.getList()
setTimeout(() => {
this.$store.commit('add', 100)
}, 2000)
// console.log(this.$store)
},

即可以收到console中的打印日志:

image-20210723135408789

#微信官方服务相关

#获取AppID与AppSecret

登录小程序的后台 https://mp.weixin.qq.com/(opens new window)

image-20210724102639423

#unionid与openid

#获取用户OpenData

登录凭证校验,通过 wx.login (opens new window)接口获得临时登录凭证 code 后传到开发者服务器调用此接口完成登录流程。更多使用方法详见 小程序登录 (opens new window)

// 记录微信相关的接口, API项目 -> 微信官方服务器
import axios from 'axios'
import config from '@/config'

const instance = axios.create({
timeout: 10000
})

export const wxGetOpenData = async (code) => {
const res = await instance.get(`https://api.weixin.qq.com/sns/jscode2session?appid=${config.AppID}&secret=${config.AppSecret}&js_code=${code}&grant_type=authorization_code`)
console.log('🚀 ~ file: WxUtils.js ~ line 11 ~ wxGetOpenData ~ res', res)
}

#鉴权页面样式

<template>
<div class="auth">
<view class="title">
<image src="/static/images/logo.jpg" mode="aspectFill" />
<text>toimc技术社区</text>
</view>
<view class="container">
<u-button type="primary" @click="login" hover-class="none">
<u-icon name="weixin-fill" size="32" color="#fff"></u-icon>
<text>微信登录</text>
</u-button>
<u-button plain :custom-style="customStyle" hover-class="none" @click="goto">手机号登录</u-button>
</view>
<view class="forbid" @click="leave">暂不登录</view>
<u-toast ref="uToast" />
</div>
</template>

<script>
import { mapMutations, mapGetters } from 'vuex'
import auth from '@/mixins/auth'

export default {
components: {},
data: () => ({
customStyle: {
'margin-top': '40rpx',
'background-color': '#fff',
color: '#02d199'
}
}),
mixins: [auth],
computed: {
...mapGetters(['isLogin'])
},
methods: {
...mapMutations(['setIsLogin', 'setWxInfo', 'setToken', 'setUserInfo']),
goto () {
uni.navigateTo({
url: '/subcom-pkg/auth/mobile-login'
})
},
login () {
// 获取用户信息
// todo
},
leave () {
// todo
uni.navigateBack()
}
},
watch: {}
}
</script>

<style lang="scss">
.title {
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
height: 500rpx;
width: 100vw;
image {
width: 168rpx;
height: 168rpx;
border-radius: 50%;
box-shadow: 0 0 10px rgba($color: #000000, $alpha: 0.1);
}
text {
margin-top: 32rpx;
font-size: 32rpx;
color: #333;
font-weight: 500;
}
}

.container {
padding: 32rpx;
}

.forbid {
position: fixed;
bottom: 60px;
left: 0;
width: 100%;
text-align: center;
font-size: 28rpx;
text-decoration: underline;
}
</style>

#解密微信数据

#用户信息的解密

官方说明链接(opens new window)

img

代码示例:

微信解密工具js:

import crypto from 'crypto'

function WXBizDataCrypt (appId, sessionKey) {
this.appId = appId
this.sessionKey = sessionKey
}

WXBizDataCrypt.prototype.decryptData = function (encryptedData, iv) {
// base64 decode
let sessionKey = Buffer.from(this.sessionKey, 'base64')
encryptedData = Buffer.from(encryptedData, 'base64')
iv = Buffer.from(iv, 'base64')
let decoded
try {
// 解密
let decipher = crypto.createDecipheriv('aes-128-cbc', sessionKey, iv)
// 设置自动 padding 为 true,删除填充补位
decipher.setAutoPadding(true)
decoded = decipher.update(encryptedData, 'binary', 'utf8')
decoded += decipher.final('utf8')

decoded = JSON.parse(decoded)
} catch (err) {
throw new Error('Illegal Buffer')
}

if (decoded.watermark.appid !== this.appId) {
throw new Error('Illegal Buffer')
}

return decoded
}

export default WXBizDataCrypt

微信工具js的封装:

// 记录微信相关的接口, API项目 -> 微信官方服务器
import axios from 'axios'
import config from '@/config'
import crypto from 'crypto'
import WXBizDataCrypt from './WXBizDataCrypt'

const instance = axios.create({
timeout: 10000
})

// 获取session_key,openid 等OpenData数据
export const wxGetOpenData = async (code) => {
// sessin_key, openid, unoinid
const res = await instance.get(`https://api.weixin.qq.com/sns/jscode2session?appid=${config.AppID}&secret=${config.AppSecret}&js_code=${code}&grant_type=authorization_code`)
// console.log('🚀 ~ file: WxUtils.js ~ line 11 ~ wxGetOpenData ~ res', res)
return res.data
}

// 获取解密用户的信息
export const wxGetUserInfo = async (user, code) => {
// 1.获取用户的openData -> session_key
const data = await wxGetOpenData(code)
const sessionKey = data.session_key
// 2.用户数据进行签名校验 -> sha1 -> session_key + rawData + signature
const { rawData, signature, encryptedData, iv } = user
const sha1 = crypto.createHash('sha1')
sha1.update(rawData)
sha1.update(sessionKey)
if (sha1.digest('hex') !== signature) {
// 校验失败
return Promise.reject(
new Error({
code: 500,
msg: '签名校验失败'
})
)
}
const wxBizDataCrypt = new WXBizDataCrypt(config.AppID, sessionKey)
// 3.用户加密数据的解密
const userInfo = wxBizDataCrypt.decryptData(encryptedData, iv)
return { ...userInfo, ...data }
}

小程序侧:

async login () {
uni.login({
success: (e) => {
this.code = e.code
}
})
uni.getUserProfile({
lang: 'zh_CN',
desc: '用于完善会员资料',
success: async (e) => {
console.log('🚀 ~ file: auth.vue ~ line 37 ~ test ~ e', e)
await this.$u.api.wxLogin({
code: this.code,
user: e
})
},
fail: (e) => {
console.log('🚀 ~ file: auth.vue ~ line 40 ~ test ~ e', e)
}
})
}

需要注意的点:

#用户登录凭证维护

防止code失效,而请求失败。

解决办法:前端设置定时任务,每隔<5分钟的时间,请求一次uni.login刷新登录凭证。

<script>

export default {
components: {},
data: () => ({
code: '',
ctrl: null
}),
computed: {},
created () {
this.getNewCode()
this.setCron()
},
onShow () {
this.getNewCode()
this.setCron()
},
onHide () {
clearTimeout(this.ctrl)
},
// 用户离开当前页面
onUnload () {
clearTimeout(this.ctrl)
},
methods: {
// 获取code
getNewCode () {
uni.login({
success: (e) => {
this.code = e.code
}
})
},
// 设置定时任务
setCron () {
clearTimeout(this.ctrl)
// 定时刷新code的方法
this.ctrl = setTimeout(() => {
this.getNewCode()
// 重新进行cron,保证code的有效性
this.setCron()
}, 4 * 60 * 1000)
},
},
}
</script>

#用户登录接口

接口部分:

产生token:

// 生成 token 返回给客户端
const generateToken = (payload, expire = '1h') => {
if (payload) {
return jwt.sign(
{
...payload,
},
config.JWT_SECRET,
{ expiresIn: expire }
)
} else {
throw new Error('生成token失败!')
}
}

随机用户名:

const rand = (len = 8) => {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let text = ''
for (let i = 0; i < len; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length))
}
return text
}

const getTempName = () => {
// 返回用户邮箱
return 'toimc_' + rand() + '@toimc.com'
}

微信解密用户信息的wxUtils.js

// 获取解密用户的信息
export const wxGetUserInfo = async (user, code) => {
// 1.获取用户的openData -> session_key
const data = await wxGetOpenData(code)
const { session_key: sessionKey } = data
if (sessionKey) {
// 2.用户数据进行签名校验 -> sha1 -> session_key + rawData + signature
const { rawData, signature, encryptedData, iv } = user
const sha1 = crypto.createHash('sha1')
sha1.update(rawData)
sha1.update(sessionKey)
if (sha1.digest('hex') !== signature) {
// 校验失败
return Promise.reject(
new Error({
code: 500,
msg: '签名校验失败'
})
)
}
const wxBizDataCrypt = new WXBizDataCrypt(config.AppID, sessionKey)
// 3.用户加密数据的解密
const userInfo = wxBizDataCrypt.decryptData(encryptedData, iv)
return { ...userInfo, ...data, errcode: 0 }
} else {
// data -> errcode非0 ,请求失败
return data
}
}

查询并创建用户User.js

findOrCreateByUnionid: function (user) {
return this.findOne({
unionid: user.unionid
// openid: user.openid
}, {
unionid: 0, password: 0
}).then(obj => {
return (
obj || this.create({
openid: user.openid,
unionid: user.unionid,
username: getTempName(),
name: user.nickName,
roles: ['user'],
gender: user.gender,
pic: user.avatarUrl,
location: user.city
})
)
})
},

LoginController.js微信登录接口:

// 微信登录
async wxLogin (ctx) {
// 1.解密用户信息
const { body } = ctx.request
// console.log('🚀 ~ file: LoginController.js ~ line 223 ~ LoginController ~ wxLogin ~ body', body)
const { user, code } = body
if (!code) {
ctx.body = {
code: 500,
data: '没有足够参数'
}
return
}
const res = await wxGetUserInfo(user, code)
if (res.errcode === 0) {
// 2.查询数据库 -> 判断用户是否存在
// 3.如果不存在 —> 创建用户
// 4.如果存在 -> 获取用户信息
const tmpUser = await User.findOrCreateByUnionid(res)
// 5.产生token,获取用户的签到状态
const token = generateToken({ _id: tmpUser._id })
const userInfo = await addSign(tmpUser)
ctx.body = {
code: 200,
data: userInfo,
token
}
} else {
ctx.throw(501, res.errcode === 40163 ? 'code已失效,请刷新后重试' : '获取用户信息失败,请重试')
}
}

获取access_token,并按两小时维护:

export const wxGetAccessToken = async (flag = false) => {
// https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
let accessToken = await getValue('accessToken')
if (!accessToken || flag) {
try {
const result = await instance.get(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${config.AppID}&secret=${config.AppSecret}`
)
if (result.status === 200) {
// 说明请求成功
await setValue(
'accessToken',
result.data.access_token,
result.data.expires_in
)
accessToken = result.data.access_token
// {"errcode":40013,"errmsg":"invalid appid"}
if (result.data.errcode && result.data.errmsg) {
logger.error(
`Wx-GetAccessToken Error: ${result.data.errcode} - ${result.data.errmsg}`
)
}
}
} catch (error) {
logger.error(`GetAccessToken Error: ${error.message}`)
}
}
return accessToken
}

维护access_token数据:

import { CronJob } from 'cron'
import { wxGetAccessToken } from './WxUtils'

const job = new CronJob('* 55 */1 * * *', () => {
wxGetAccessToken()
})

job.start()

#手机登录

#业务流程图

img

img

#如何选择短信服务商

推荐腾讯云、阿里云,下面以腾讯云为示例:

image-20210726203051548

基本的使用步骤:

  1. 创建云平台账号 -> 实名 -> 推荐公司实名
  2. 够买短信包 -> 添加短信模板
  3. 集成SDK发送短信

腾讯云的两种集成方式:

  1. qcloudsms_js,参考github仓库(opens new window)
  2. Tencentcloud-sdk-nodejs,参考官方文档(opens new window)

#登录页面样式

auth/mobile-login.vue页面

<template>
<view class="wrapper">
<view class="title">登录注册更精彩</view>
<view class="info">未注册的手机号验证后自动创建账号</view>
<view class="form">
<u-form label-width="120">
<u-form-item label="手机号">
<u-input v-model="mobile" placeholder="请输入手机号"></u-input>
<template v-slot:right>
<u-button type="primary" shape="circle" size="mini" hover-class="none">获取手机号</u-button>
</template>
</u-form-item>
</u-form>
<view class="btn" :class="{'deactive': !/^1[3-9]\d{9}$/.test(mobile)}">
<u-button type="primary" hover-class="none" @click="getCode">获取验证码</u-button>
</view>
</view>
<navigator open-type="navigateBack" hover-class="none">
<view class="footer u-flex u-col-center u-row-center">
<u-icon name="weixin-circle-fill" size="120" color="#1BB723"></u-icon>
<text>微信登录</text>
</view>
</navigator>
</view>
</template>

<script>
import auth from '@/mixins/auth'
export default {
mixins: [auth],
data: () => ({
mobile: '',
phoneNumber: '',
loading: false
}),
methods: {
async getCode () {
// 发送短信验证码
},
}
}
</script>

<style lang="scss" scoped>
.wrapper {
padding: 32rpx;
.title {
color: #333;
font-size: 48rpx;
font-weight: bold;
padding-top: 50rpx;
}
.info {
color: #666;
line-height: 28rpx;
padding-top: 24rpx;
}
.form {
padding-top: 40rpx;
}
::v-deep .btn {
padding-top: 80rpx;
&.deactive {
.u-btn--primary {
background-color: #ccc;
}
}
}
}
.footer {
position: absolute;
bottom: 120rpx;
width: 100vw;
left: 0;
text-align: center;
flex-direction: column;
text {
color: #666;
font-size: 28rpx;
padding-top: 14rpx;
}
}
</style>

mobile-code.vue页面:

<template>
<view class="wrapper">
<view class="title">输入验证码</view>
<view class="info">验证码已发送至 {{mobile.substr(0,3)}}****{{mobile.substr(7,10)}} </view>
<view class="inputs">
<u-message-input :maxlength="6" mode="bottomLine" active-color="#02d199" inactive-color="#DDD" width="90" :bold="false" :breathe="false" :focus="true"></u-message-input>
</view>
<view class="resend" :class="{'disabled': sending}" @click="resend()">{{msg}}</view>
</view>
</template>

<script>
import { mapMutations } from 'vuex'
export default {
data: () => ({
mobile: '',
count: 60,
ctrl: null,
sending: false
}),
onLoad (options) {
// console.log('🚀 ~ file: mobile-code.vue ~ line 15 ~ onLoad ~ options', options)
this.mobile = options.mobile
this.setCron()
},
methods: {
...mapMutations(['setToken', 'setUserInfo']),
setCron () {
clearInterval(this.ctrl)
this.sending = true
this.ctrl = setInterval(() => {
this.count--
if (this.count === 0) {
this.count = 60
this.sending = false
clearInterval(this.ctrl)
}
}, 1000)
},
async resend () {
if (this.sending) return
const res = await this.$u.api.sendCode({
phone: this.mobile
})
// console.log('🚀 ~ file: mobile-code.vue ~ line 75 ~ resend ~ res', res)
this.setCron()
}
},
computed: {
msg () {
let str = '重新获取'
if (this.sending) {
str += ('(' + (this.count + '').padStart(2, '0') + 's)')
}
return str
}
}
}
</script>

<style lang="scss" scoped>
.wrapper {
padding: 32rpx;
.title {
color: #333;
font-size: 48rpx;
font-weight: bold;
padding-top: 50rpx;
}
.info {
color: #666;
line-height: 28rpx;
padding-top: 24rpx;
}
.inputs {
padding: 60rpx 0 40rpx;
}
.resend {
font-size: 26rpx;
font-weight: 300;
color: #02d199;
&.disabled {
color: #ddd;
}
}
}
</style>

#小程序获取手机号

小程序侧逻辑:

设置open-type为getPhoneNumber

<u-button type="primary" shape="circle" size="mini" hover-class="none" open-type="getPhoneNumber" @getphonenumber="getPhoneNumber">获取手机号</u-button>

getPhoneNumber方法:

async getPhoneNumber (value) {
const { encryptedData, iv } = value.detail
// value.detail + code
const { code, data } = await this.$u.api.getMobile({
code: this.code,
encryptedData,
iv
})
if (code === 200) {
const { phoneNumber, purePhoneNumber, countryCode } = data
if (countryCode !== '86') {
uni.showToast({
title: 'Please use a cell phone number from mainland China',
duration: 2000
})
return
}
this.phoneNumber = phoneNumber
this.mobile = purePhoneNumber
} else {
uni.showToast({
icon: 'error',
title: '获取手机号失败,请重试',
duration: 2000
})
}
// console.log('🚀 ~ file: mobile-login.vue ~ line 48 ~ getPhoneNumber ~ res', res)
}

API接口解密数据:

设置路由:

router.post('/getMobile', loginController.getMobile)

获取手机号LoginController.js

// 获取用户手机号
async getMobile (ctx) {
const { body } = ctx.request
const { code, encryptedData, iv } = body
if (!code) {
ctx.body = {
code: 500,
data: '没有足够参数'
}
return
}
const { session_key: sessionKey } = await wxGetOpenData(code)
const wxBizDataCrypt = new WXBizDataCrypt(config.AppID, sessionKey)
// 3.用户加密数据的解密
const data = wxBizDataCrypt.decryptData(encryptedData, iv)
// console.log('🚀 ~ file: LoginController.js ~ line 274 ~ LoginController ~ getMobile ~ data', data)
ctx.body = {
code: 200,
data,
msg: '获取手机号成功'
}
}

#前后端逻辑&联调

常见问题:

小程序侧接口:

// 获取用户手机号
const getMobile = (data) => axios.post('/login/getMobile', data)

// 发送短信
const sendCode = params => axios.get('/public/sendCode', params)

完成的手机发送验证码页面

<template>
<view class="wrapper">
<view class="title">登录注册更精彩</view>
<view class="info">未注册的手机号验证后自动创建账号</view>
<view class="form">
<u-form label-width="120">
<u-form-item label="手机号">
<u-input v-model="mobile" placeholder="请输入手机号"></u-input>
<template v-slot:right>
<u-button type="primary" shape="circle" size="mini" hover-class="none" open-type="getPhoneNumber" @getphonenumber="getPhoneNumber">获取手机号</u-button>
</template>
</u-form-item>
</u-form>
<view class="btn" :class="{'deactive': !/^1[3-9]\d{9}$/.test(mobile)}">
<u-button type="primary" hover-class="none" @click="getCode">获取验证码</u-button>
</view>
</view>
<navigator open-type="navigateBack" hover-class="none">
<view class="footer u-flex u-col-center u-row-center">
<u-icon name="weixin-circle-fill" size="120" color="#1BB723"></u-icon>
<text>微信登录</text>
</view>
</navigator>
</view>
</template>

<script>
import auth from '@/mixins/auth'
export default {
mixins: [auth],
data: () => ({
mobile: '',
phoneNumber: '',
loading: false
}),
methods: {
async getCode () {
// 发送短信验证码
// 防止用户频繁发送
if (this.loading) return
this.loading = true
try {
const res = await this.$u.api.sendCode({
mobile: this.mobile
})
this.loading = false
if (res.code === 200) {
// 提示用户
uni.showToast({
icon: 'none',
title: '发送成功',
duration: 2000
})
// 延迟跳转到输入验证码的页面
setTimeout(() => {
uni.navigateTo({
url: '/subcom-pkg/auth/mobile-code?mobile=' + this.mobile
})
}, 2000)
} else {
uni.showToast({
icon: 'error',
title: '短信发送失败',
duration: 2000
})
}
} catch (error) {
this.loading = false
}
},
async getPhoneNumber (value) {
const { encryptedData, iv } = value.detail
// value.detail + code
const { code, data } = await this.$u.api.getMobile({
code: this.code,
encryptedData,
iv
})
if (code === 200) {
const { phoneNumber, purePhoneNumber, countryCode } = data
if (countryCode !== '86') {
uni.showToast({
title: 'Please use a cell phone number from mainland China',
duration: 2000
})
return
}
this.phoneNumber = phoneNumber
this.mobile = purePhoneNumber
} else {
uni.showToast({
icon: 'error',
title: '获取手机号失败,请重试',
duration: 2000
})
}
}
}
}
</script>

<style lang="scss" scoped>
.wrapper {
padding: 32rpx;
.title {
color: #333;
font-size: 48rpx;
font-weight: bold;
padding-top: 50rpx;
}
.info {
color: #666;
line-height: 28rpx;
padding-top: 24rpx;
}
.form {
padding-top: 40rpx;
}
::v-deep .btn {
padding-top: 80rpx;
&.deactive {
.u-btn--primary {
background-color: #ccc;
}
}
}
}
.footer {
position: absolute;
bottom: 120rpx;
width: 100vw;
left: 0;
text-align: center;
flex-direction: column;
text {
color: #666;
font-size: 28rpx;
padding-top: 14rpx;
}
}
</style>

校验验证码的页面:

<template>
<view class="wrapper">
<view class="title">输入验证码</view>
<view class="info">验证码已发送至 {{mobile.substr(0,3)}}****{{mobile.substr(7,10)}} </view>
<view class="inputs">
<u-message-input :maxlength="6" mode="bottomLine" active-color="#02d199" inactive-color="#DDD" width="90" :bold="false" :breathe="false" :focus="true" @change="change"></u-message-input>
</view>
<view class="resend" :class="{'disabled': sending}" @click="resend()">{{msg}}</view>
</view>
</template>

<script>
import { mapMutations } from 'vuex'
export default {
data: () => ({
mobile: '',
count: 60,
ctrl: null,
sending: false
}),
onLoad (options) {
this.mobile = options.mobile
this.setCron()
},
methods: {
...mapMutations(['setToken', 'setUserInfo']),
setCron () {
clearInterval(this.ctrl)
this.sending = true
this.ctrl = setInterval(() => {
this.count--
if (this.count === 0) {
this.count = 60
this.sending = false
clearInterval(this.ctrl)
}
}, 1000)
},
async change (val) {
if (/\d{6}/.test(val)) {
const res = await this.$u.api.loginByPhone({
mobile: this.mobile,
code: val
})
if (res.code === 200) {
uni.showToast({
icon: 'none',
title: '登录成功,2s后跳转',
duration: 2000
})
const { token, data } = res
this.setToken(token)
this.setUserInfo(data)
setTimeout(() => {
uni.navigateBack({
delta: 3
})
}, 2000)
} else {
uni.showToast({
icon: 'none',
title: res.msg,
duration: 2000
})
}
}
},
async resend () {
if (this.sending) return
const res = await this.$u.api.sendCode({
mobile: this.mobile
})
if (res.code === 200) {
this.setCron()
} else {
uni.showToast({
icon: 'none',
title: res.msg || '短信发送失败,请稍后重试',
duration: 2000
})
}
}
},
computed: {
msg () {
let str = '重新获取'
if (this.sending) {
str += ('(' + (this.count + '').padStart(2, '0') + 's)')
}
return str
}
}
}
</script>

<style lang="scss" scoped>
.wrapper {
padding: 32rpx;
.title {
color: #333;
font-size: 48rpx;
font-weight: bold;
padding-top: 50rpx;
}
.info {
color: #666;
line-height: 28rpx;
padding-top: 24rpx;
}
.inputs {
padding: 60rpx 0 40rpx;
}
.resend {
font-size: 26rpx;
font-weight: 300;
color: #02d199;
&.disabled {
color: #ddd;
}
}
}
</style>

后端接口LoginController.js

// 手机号登录
async loginByPhone (ctx) {
const { body } = ctx.request
// mobile + code
const { mobile, code } = body
// 验证手机号与短信验证码的正确性
const sms = await getValue(mobile)
if (sms && sms === code) {
await delValue(mobile)
// 查询并创建用户
const user = await User.findOrCreateByMobile({
mobile
})
// 查看用户是否签到
const userObj = await addSign(user)
// 响应用户
ctx.body = {
code: 200,
token: generateToken({ _id: userObj._id }),
data: userObj
}
} else {
ctx.body = {
code: 500,
msg: '手机号与验证码不匹配'
}
}
}

查询并创建手机用户User.js

findOrCreateByMobile: function (user) {
return this.findOne({ mobile: user.mobile }, {
unionid: 0, password: 0
}).then(res => {
return res || this.create({
mobile: user.mobile,
username: getTempName(),
name: getTempName(),
roles: ['user']
})
})
},

发送验证码逻辑PublicController.js

// 发送手机验证码
async sendCode (ctx) {
// 1.获取手机号 phone
const { mobile } = ctx.query
// 2.查询redis -> 判断是否验证码过期
if (await getValue(mobile)) {
ctx.body = {
code: 501,
msg: '短信正在发送中,请勿重新发送'
}
return
}
// 3.产生随机的6位数字
const sms = String(Math.random()).slice(-6)
// 4.发送短信 -> 设置redis -> sms, expire -> key:phone
const res = await sendSms(mobile, sms)
if (res.result === 0) {
setValue(mobile, sms, 10 * 60)
// 5.响应
ctx.body = {
code: 200,
msg: '发送成功',
data: res
}
} else {
ctx.throw(500, '发送短信失败' + res.errmsg || '')
}
}

需要注意的点:

#统一的用户授权登录

用户授权登录是经常需要使用的功能,封装到common/checkAuth.js中:

import store from '@/store'

export const checkSession = async () => {
try {
await uni.checkSession()
return true
} catch (error) {
return false
}
}

export const checkToken = async () => {
let flag = true
const token = uni.getStorageSync('token')
const checked = await checkSession()
if (!store.state.token || !token || !checked) {
flag = false
uni.showModal({
title: '您未登录',
content: '需要登录才能操作,确定登录吗?',
success: function (res) {
if (res.confirm) {
uni.navigateTo({
url: '/subcom-pkg/auth/auth'
})
}
}
})
}
return flag
}

#消息&热门&个人中心

消息、热门、个人中心这三块的内容重点:

#消息模块

最终 完成效果:

img

#页面布局和样式

<template>
<view class="msg">
<view class="msg" v-if="isLogin">
<u-sticky>
<view class="tabs box-shadow">
<!-- tabs -->
<u-tabs :list="tabs" :name="'value'" :is-scroll="false" active-color="#02D199" inactive-color="#666" height="88" @change="tabsChange" :current="current"></u-tabs>
</view>
<u-sticky>
<view>
<!-- 评论列表 -->
<!-- 点赞列表 -->
</view>
</view>
<view class="info u-flex u-row-center u-col-center flex-column" v-else>
<view class="center">
登录过后查看评论&点赞消息
</view>
<u-button type="primary" hover-class="none">去登录</u-button>
</view>
<view class="bottom-line"></view>
</view>
</template>

<script>
export default {
props: {},
data: () => ({
current: 0,
tabs: [
{
key: 'comments',
value: '评论'
},
{
key: 'like',
value: '点赞'
}
],
}),
methods: {
tabsChange (i) {
this.current = i
this.$store.commit('setType', i === 0 ? 'comment' : 'hands')
this.checkType()
},
}
}
</script>

<style lang="scss" scoped>
.flex-column {
flex-flow: column nowrap;
}

.info {
flex-flow: column nowrap;
height: 100vh;
width: 100vw;
.center {
color: #666;
font-size: 32rpx;
line-height: 50px;
}
}
</style>

#自定义吸顶组件

吸顶效果最关键的属性:

.t-sticky {
position: sticky;
top: 0;
}

可以自行创建components/t-sticky/t-sticky.vue组件:

<template>
<view class="t-sticky" :style="{'top': top + 'px'}">
<slot></slot>
</view>
</template>

<script>

export default {
props: {
top: {
default: 0,
type: Number
}
},
data: () => ({})
}
</script>

<style lang="scss" scoped>
.t-sticky {
position: sticky;
// top: 0;
}
</style>

使用方法:

<t-sticky :top="距离顶部的值">
// ... 组件
</t-sticky>

#消息模块的样式

<template>
<view class="msg">
<view class="msg" v-if="isLogin">
<u-sticky>
<view class="tabs box-shadow">
<!-- tabs -->
<u-tabs :list="tabs" :name="'value'" :is-scroll="false" active-color="#02D199" inactive-color="#666" height="88" @change="tabsChange" :current="current"></u-tabs>
</view>
</u-sticky>
<view>
<!-- 评论列表 -->
<view v-if="current === 0">
<view v-for="(item, index) in comments" :key="index">
<view class="box">
<!-- 评论用户卡片 -->
<view class="user u-flex">
<u-image class="phone" :src="item.cuid.pic" width="72" height="72" shape="circle" error-icon="/static/images/header.jpg"></u-image>
<view class="user-column u-flex-1 u-flex flex-column u-col-top">
<text class="name">{{ item.cuid.name }}</text>
<text class="label">{{ item.created | moment }} 回复了你</text>
</view>
<view class="reply u-flex u-row-center">
<image src="/static/images/advice.png" mode="aspectFit" />
回复
</view>
</view>
<!-- 评论内容 -->
<view class="comment">{{ item.content }}</view>
<view class="post">
<view>
<!-- 封面图 -->
<view v-if="item.tid.shotpic">
<view class="img">
<u-image :src="item.tid.shotpic" width="192" height="122"></u-image>
</view>
</view>
<!-- 文章标题 + 摘要 -->
<view class="post-content u-flex flex-column u-col-top">
<text class="title">{{ item.tid.title }}</text>
<text class="content">{{ item.tid.content }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 点赞列表 -->
<view v-else>
<view v-for="(item, index) in handUsers" :key="index">
<view class="box">
<view class="user u-flex">
<u-image class="pic" :src="item.huid.pic" width="72" height="72" shape="circle" error-icon="/static/images/header.jpg" />
<view class="user-column u-flex-1 u-flex flex-column u-col-top">
<span class="name">{{ item.huid.name }}</span>
<span class="label">{{ item.created | moment }}</span>
</view>
</view>
</view>
<view class="comment">赞了你的评论 {{item.cid.content}}</view>
</view>
</view>
</view>
</view>
<view class="info u-flex u-row-center u-col-center flex-column" v-else>
<view class="center">
登录过后查看评论&点赞消息
</view>
<u-button type="primary" hover-class="none" @click="navTo">去登录</u-button>
</view>
<view class="bottom-line"></view>
</view>
</template>

<script>
import { mapGetters } from 'vuex'
import { checkAuth } from '@/common/checkAuth'
export default {
props: {},
data: () => ({
current: 0,
tabs: [
{
key: 'comments',
value: '评论'
},
{
key: 'like',
value: '点赞'
}
],
comments: [
],
handUsers: [
],
pageMsg: {
page: 0,
limit: 10
},
pageHands: {
page: 0,
limit: 10
}
}),
computed: {
...mapGetters(['isLogin'])
},
methods: {
navTo () {
uni.navigateTo({
url: '/subcom-pkg/auth/auth'
})
},
tabsChange (i) {
this.current = i
this.$store.commit('setType', i === 0 ? 'comment' : 'hands')
this.checkType()
},
async getMsg () {
const { data, code } = await this.$u.api.getMsg(this.pageMsg)
if (code === 200) {
this.comments = data
}
},
async getHands () {
const { data, code } = await this.$u.api.getHands(this.pageHands)
if (code === 200) {
this.handUsers = data
}
},
async checkType () {
if (!this.isLogin) return
const flag = await checkAuth()
if (!flag) {
return
}
if (this.$store.state.type === 'hands') {
// 这里肯定已经登录
this.current = 1
this.getHands()
} else {
this.current = 0
this.getMsg()
}
}
},
watch: {},
// 页面周期函数--监听页面显示(not-nvue)
onShow () {
this.checkType()
},
// 页面周期函数--监听页面隐藏
onHide () {
this.current = 0
}
}
</script>

<style lang="scss" scoped>
.flex-column {
flex-flow: column nowrap;
}

.info {
flex-flow: column nowrap;
height: 100vh;
width: 100vw;
.center {
color: #666;
font-size: 32rpx;
line-height: 50px;
}
}
.user {
margin: 20rpx;
.name {
margin-block: 20rpx;
margin-bottom: 10rpx;
font-size: 28rpx;
font-weight: bold;
color: rgba(51, 51, 51, 1);
}
.phone {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
}
.user-column {
margin-left: 20rpx;
}
.label {
font-size: 22rpx;
font-weight: 500;
color: rgba(153, 153, 153, 1);
}
}

.reply {
color: rgba(153, 153, 153, 1);
margin-right: 40rpx;
font-size: 24rpx;
font-weight: 500;
line-height: 40rpx;
image {
width: 30rpx;
height: 30rpx;
margin-right: 10rpx;
}
}

.comment {
margin: 0 20rpx 20rpx;
}

.post {
margin: 0 20rpx 20rpx;
padding: 20rpx;
background-color: #f3f3f3;
border-radius: 15rpx;
.title {
margin-bottom: 10rpx;
font-size: 26rpx;
font-weight: bold;
color: rgba(51, 51, 51, 1);
display: -webkit-box;
-webkit-line-clamp: 1; /*这个数字是设置要显示省略号的行数*/
-webkit-box-orient: vertical;
overflow: hidden;
}
.content {
font-size: 24rpx;
font-weight: 400;
color: rgba(102, 102, 102, 1);
line-height: 30rpx;
display: -webkit-box;
-webkit-line-clamp: 2; /*这个数字是设置要显示省略号的行数*/
-webkit-box-orient: vertical;
overflow: hidden;
}
}
</style>

前端接口api/modules/user.js

// 获取点赞数据
const getHands = params => axios.get('/user/getHands', params)

// 获取用户未读消息
const getMsg = (data) => axios.get('/user/getmsg', data)

#接口对接与联调

调整src/model/CommentsHands.js

import mongoose from '../config/DBHelpler'

const Schema = mongoose.Schema

const CommentsSchema = new Schema({
cid: { type: String, ref: 'comments' }, // 评论id
huid: { type: String, ref: 'users' }, // 被点赞用户的id
uid: { type: String, ref: 'users' }, // 点赞用户id
created: { type: Date }
})

CommentsSchema.pre('save', function (next) {
this.created = new Date()
next()
})

CommentsSchema.post('save', function (error, doc, next) {
if (error.name === 'MongoError' && error.code === 11000) {
next(new Error('There was a duplicate key error'))
} else {
next(error)
}
})

CommentsSchema.statics = {
findByCid: function (id) {
return this.find({ cid: id })
},
getHandsByUid: function (id, page, limit) {
return this.find({ uid: id })
.populate({
path: 'huid',
select: '_id name pic'
})
.populate({
path: 'cid',
select: '_id content'
})
.skip(page * limit)
.limit(limit)
.sort({ created: -1 })
}
}

const CommentsHands = mongoose.model('comments_hands', CommentsSchema)

export default CommentsHands

获取历史消息src/api/UserController.js

// 获取历史消息
// 记录评论之后,给作者发送消息
async getHands (ctx) {
const params = ctx.query
const page = params.page ? params.page : 0
const limit = params.limit ? parseInt(params.limit) : 0
// 方法一: 嵌套查询 -> aggregate
// 方法二: 通过冗余换时间
const obj = await getJWTPayload(ctx.header.authorization)
const result = await CommentsHands.getHandsByUid(obj._id, page, limit)

ctx.body = {
code: 200,
data: result
}
}

完成效果(点赞):

image-20210527031025147

完成效果(评论):

image-20210527030947602

#热门模块

这个部分利旧接口,所以只用开发前端页面即可:

#页面布局和样式

<template>
<view>
<u-sticky>
<view class="tabs box-shadow">
<u-tabs :list="tabs" :name="'value'" :current="current" @change="tabsChange" :is-scroll="false" active-color="#02D199" inactive-color="#666" height="88"></u-tabs>
</view>
</u-sticky>
<view class="content">
<view class="tags">
<uni-tag :text="item.value" v-for="(item, i) in types[tabs[current].key]" :class="{ active: tagCur === i }" :key="i" @click="tagsChange(i)"></uni-tag>
</view>
<HotPostList :lists="lists" v-if="tabs[current].key === 'posts'" @click="postDetail"></HotPostList>
<HotCommentsList :lists="lists" :type="types.comments[tagCur].key" v-else-if="tabs[current].key === 'comments'" @click="commentDetail"></HotCommentsList>
<HotSignList :lists="lists" :type="types.sign[tagCur].key" v-else></HotSignList>
</view>
<view class="bottom-line"></view>
</view>
</template>

<script>
import HotPostList from './components/HotPostList'
import HotCommentsList from './components/HotCommentsList'
import HotSignList from './components/HotSignList'

export default {
components: {
HotPostList,
HotCommentsList,
HotSignList
},
data: () => ({
tabs: [
{
key: 'posts',
value: '热门帖子'
},
{
key: 'comments',
value: '热门评论'
},
{
key: 'sign',
value: '签到排行'
}
],
types: {
posts: [
{
key: '3',
value: '全部'
},
{
key: '0',
value: '3日内'
},
{
key: '1',
value: '7日内'
},
{
key: '2',
value: '30日内'
}
],
comments: [
{
key: '1',
value: '最新评论'
},
{
key: '0',
value: '热门评论'
}
],
sign: [
{
key: '0',
value: '总签到榜'
},
{
key: '1',
value: '今日签到榜'
}
]
},
current: 0,
tagCur: 0,
lists: [
],
page: {
page: 0,
limit: 50
}
}),
onLoad (options) {
const { scene } = options
if (scene) {
this.tabsChange(scene)
} else {
this.getHotPost()
}
},
onShow () {
this.hanldeChange()
},
methods: {
async getHotPost () {
const { data } = await this.$u.api.getHotPost({
...this.page,
index: this.types.posts[this.tagCur].key
})
this.lists = data
},
async getHotComments () {
const { data } = await this.$u.api.getHotComments({
...this.page,
index: this.types.comments[this.tagCur].key
})
this.lists = data
},
async getHotSignRecord () {
const { data } = await this.$u.api.getHotSignRecord({
...this.page,
index: this.types.sign[this.tagCur].key
})
this.lists = data
},
tabsChange (value) {
this.current = value
this.tagCur = 0
this.page = {
page: 0,
limit: 50
}
this.hanldeChange()
},
tagsChange (value) {
this.tagCur = value
this.page = {
page: 0,
limit: 50
}
this.hanldeChange()
},
hanldeChange () {
if (this.current === 0) {
// 热门帖子
this.getHotPost()
} else if (this.current === 1) {
// 热门评论
this.getHotComments()
} else {
// 签到排行
this.getHotSignRecord()
}
},
// 跳转文章详情
postDetail (item) {
uni.navigateTo({
url: '/subcom-pkg/detail/detail?tid=' + item._id
})
},
// 评论详情
commentDetail (item) {
uni.navigateTo({
url: `/subcom-pkg/detail/detail?tid=${item.tid}&cid=${item._id}`
})
}
},
async onPullDownRefresh () {
this.hanldeChange()
uni.stopPullDownRefresh()
},
// 页面处理函数--监听用户上拉触底
onReachBottom () {}
// 页面处理函数--监听页面滚动(not-nvue)
/* onPageScroll(event) {}, */
// 页面处理函数--用户点击右上角分享
/* onShareAppMessage(options) {}, */
}
</script>

<style lang="scss" scoped>
.tags {
display: flex;
padding: 20rpx 25rpx;
width: 100vw;
background-color: #fff;
z-index: 200;
::v-deep .uni-tag {
// margin-top: 20rpx;
margin-right: 25rpx;
border-radius: 25rpx;
text {
color: #999;
white-space: nowrap;
font-size: 26rpx;
}
}
.active {
::v-deep .uni-tag {
background-color: #d6f8ef;
text {
color: #02d199;
font-weight: bold;
}
}
}
}

::v-deep .list {
z-index: 100;
padding: 0 30rpx 60rpx 30rpx;
.list-item {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #ddd;
}
.num {
font-size: 36rpx;
font-weight: bold;
&.first {
color: #ed745e;
}
&.second {
color: #e08435;
}
&.third {
color: #f1ae37;
}
&.common {
color: #999;
}
}
.user {
width: 90rpx;
height: 90rpx;
border-radius: 50%;
margin-left: 20rpx;
}
.column {
flex: 1;
display: flex;
flex-flow: column nowrap;
justify-content: space-between;
height: 186rpx;
padding: 30rpx 24rpx;

&.no-between {
justify-content: center;
.title {
padding-bottom: 16rpx;
}
}
.title {
color: #333;
font-size: 32rpx;
font-weight: bold;
}
.read {
font-size: 26rpx;
color: #999;
text {
color: #333;
font-weight: bold;
padding-right: 10rpx;
}
}
}
.img {
width: 200rpx;
height: 125rpx;
border-radius: 12rpx;
overflow: hidden;
img {
width: 100%;
height: 100%;
}
}
}
</style>

自定义三个组件,热门帖子HotPostList.vue

<template>
<view>
<view class="list" v-for="(item,index) in lists" :key="index">
<view class="list-item" @click="gotoDetail(item)">
<view class="num first" v-if="index === 0">01</view>
<view class="num second" v-else-if="index === 1">02</view>
<view class="num third" v-else-if="index === 2">03</view>
<view class="num common" v-else-if="index < 9">{{ '0' + (index+1) }}</view>
<view class="num common" v-else-if="index < 50 && index >=9">{{ index+1 }}</view>
<view class="num" v-else></view>
<view class="column">
<view class="title">{{item.title}}</view>
<view class="read">{{parseInt(item.answer) > 1000?parseInt(item.answer/1000).toFixed(1) + 'k': item.answer}} 评论</view>
</view>
<view class="img" v-if="item.shotpic">
<image :src="item.shotpic" mode="aspectFill" />
</view>
</view>
</view>
</view>
</template>

<script>
export default {
props: {
lists: {
type: Array,
default: () => []
}
},
methods: {
gotoDetail (item) {
this.$emit('click', item)
}
}
}
</script>

<style lang="scss" scoped>
</style>

热门评论HotCommentsList.vue

<template>
<view>
<view class="list" v-for="(item,index) in lists" :key="index">
<!-- 评论 -->
<view class="list-item" @click="gotoDetail(item)">
<view class="num first" v-if="index === 0">01</view>
<view class="num second" v-else-if="index === 1">02</view>
<view class="num third" v-else-if="index === 2">03</view>
<view class="num common" v-else-if="index < 9">{{ '0' + (index+1) }}</view>
<view class="num common" v-else-if="index < 50 && index >=9">{{ index+1 }}</view>
<view class="num" v-else></view>
<u-image width="88" height="88" class="user" :src="item.cuid? item.cuid.pic : ''" mode="aspectFit" shape="circle" error-icon="/static/images/header.jpg" />
<view class="column no-between">
<view class="title">{{item.cuid && item.cuid.name? item.cuid.name : 'imooc'}}</view>
<view class="read" v-if="parseInt(type) === 0">
<text>{{item.count}}</text> 条评论
</view>
<view class="read" v-else>{{item.created | moment}} 发表了评论</view>
</view>
</view>
</view>
</view>
</template>

<script>
export default {
props: {
lists: {
type: Array,
default: () => []
},
type: {
type: [Number, String],
default: 0
}
},
methods: {
gotoDetail (item) {
this.$emit('click', item)
}
}
}
</script>

<style lang="scss" scoped>
</style>

签到排行HotSignList.vue

<template>
<view>
<view class="list" v-for="(item, index) in lists" :key="index">
<!-- 签到 -->
<view
class="list-item"
v-if="item.count && item.count > 0"
@click="gotoDetail(item)"
>
<view class="num first" v-if="index === 0">01</view>
<view class="num second" v-else-if="index === 1">02</view>
<view class="num third" v-else-if="index === 2">03</view>
<view class="num common" v-else-if="index < 9">{{
'0' + (index + 1)
}}</view>
<view class="num common" v-else-if="index < 50 && index >= 9">{{
index + 1
}}</view>
<view class="num" v-else></view>
<u-image
width="88"
height="88"
class="user"
:src="parseInt(type) === 0 ? item.pic : item.uid.pic"
mode="aspectFit"
shape="circle"
error-icon="/static/images/header.jpg"
/>
<view class="column no-between">
<view class="title">{{
(item.uid ? item.uid.name : item.name) || 'imooc'
}}</view>
<view class="read" v-if="parseInt(type) === 0">
已经连续签到
<span>{{ item.count }}</span> 天
</view>
<view class="read" v-else>{{ item.created | hours }}</view>
</view>
</view>
<view class="list-item" v-else>
<view class="num first" v-if="index === 0">01</view>
<view class="num second" v-else-if="index === 1">02</view>
<view class="num third" v-else-if="index === 2">03</view>
<view class="num common" v-else-if="index < 9">{{
'0' + (index + 1)
}}</view>
<view class="num common" v-else-if="index < 50 && index >= 9">{{
index + 1
}}</view>
<view class="num" v-else></view>
<u-image
width="88"
height="88"
class="user"
:src="parseInt(type) === 0 ? item.pic : item.uid.pic"
mode="aspectFit"
shape="circle"
error-icon="/static/images/header.jpg"
/>
<view class="column no-between">
<view class="title">{{ item.uid ? item.uid.name : 'imooc' }}</view>
<view class="read">
今日签到时间<span class="text">{{ item.created | hours }}</span>
</view>
<!-- <view class="read" v-else>{{item.created | hours}}</view> -->
</view>
</view>
</view>
</view>
</template>

<script>
export default {
props: {
lists: {
type: Array,
default: () => []
},
type: {
type: [Number, String],
default: 0
}
},
methods: {
gotoDetail (item) {
this.$emit('click', item)
}
}
}
</script>

<style lang="scss" scoped>
.text {
padding-left: 15rpx;
}
</style>

#接口对接与联调

PublicController.js文件:

async getHotPost (ctx) {
// page limit
// type index 0-3日内, 1-7日内, 2-30日内, 3-全部
const params = ctx.query
const page = params.page ? parseInt(params.page) : 0
const limit = params.limit ? parseInt(params.limit) : 10
const index = params.index ? params.index : '0'
let startTime = ''
let endTime = ''
if (index === '0') {
startTime = moment().subtract(2, 'day').format('YYYY-MM-DD 00:00:00')
} else if (index === '1') {
startTime = moment().subtract(6, 'day').format('YYYY-MM-DD 00:00:00')
} else if (index === '2') {
startTime = moment().subtract(29, 'day').format('YYYY-MM-DD 00:00:00')
}
endTime = moment().add(1, 'day').format('YYYY-MM-DD 00:00:00')
const result = await Post.getHotPost(page, limit, startTime, endTime)
const total = await Post.getHotPostCount(page, limit, startTime, endTime)
ctx.body = {
code: 200,
total,
data: result,
msg: '获取热门文章成功'
}
}

async getHotComments (ctx) {
// 0-热门评论,1-最新评论
const params = ctx.query
const page = params.page ? parseInt(params.page) : 0
const limit = params.limit ? parseInt(params.limit) : 10
const index = params.index ? params.index : '0'
const result = await Comments.getHotComments(page, limit, index)
const total = await Comments.getHotCommentsCount(index)
ctx.body = {
code: 200,
data: result,
total,
msg: '获取热门评论成功'
}
}

async getHotSignRecord (ctx) {
// 0-总签到榜,1-最新签到
const params = ctx.query
const page = params.page ? parseInt(params.page) : 0
const limit = params.limit ? parseInt(params.limit) : 10
const index = params.index ? params.index : '0'
let result
let total = 0
if (index === '0') {
// 总签到榜
result = await User.getTotalSign(page, limit)
total = await User.getTotalSignCount()
} else if (index === '1') {
// 今日签到
result = await SignRecord.getTopSign(page, limit)
total = await SignRecord.getTopSignCount()
} else if (index === '2') {
// 最新签到
result = await SignRecord.getLatestSign(page, limit)
total = await SignRecord.getSignCount()
}
ctx.body = {
code: 200,
data: result,
total,
msg: '获取签到排行成功'
}
}

完成效果(热门帖子):

image-20210527032522285

完成效果(热门评论):

image-20210527032608703

完成效果(签到排行):

image-20210527032652755

#个人中心模块

#页面布局和样式

整体的页面分为:

<template>
<view>
<view class="grey">
<view class="bg"></view>
<view class="wrapper">
<!-- 个人信息卡片 -->
<view class="profile">
<view class="info">
<u-image class="pic" :src="isLogin ? userInfo.pic: ''" width="120" height="120" shape="circle" error-icon="/static/images/header.jpg" />
<!-- 用户昵称 + VIP -->
<view class="user" @click="navTo">
<view class="name">{{isLogin ?userInfo.name : '请登录'}}</view>
<view class="fav">
<!-- <van-icon name="fav2" class-prefix="iconfont" size="14"></van-icon> -->
积分:{{userInfo && userInfo.favs ? userInfo.favs:0}}
</view>
</view>
<view class="link" @click="gotoGuard('/sub-pkg/user-info/user-info')">个人主页 ></view>
</view>
<!-- 统计信息 -->
<view class="stats" v-if="isLogin">
<view class="item">
<navigator :url="'/sub-pkg/posts/posts?uid=' + uid + '&type=p'">
<view>{{ countMyPost }}</view>
<view class="title">我的帖子</view>
</navigator>
</view>
<view class="item">
<navigator :url="'/sub-pkg/posts/posts?uid=' + uid+ '&type=c'">
<view>{{ countMyCollect }}</view>
<view class="title">收藏夹</view>
</navigator>
</view>
<view class="item">
<navigator :url="'/sub-pkg/posts/posts?uid=' + uid+ '&type=h'">
<view>{{ countMyHistory }}</view>
<view class="title">最近浏览</view>
</navigator>
</view>
</view>
</view>
</view>
<!-- 功能区 -->
<view class="center-wraper">
<view class="center-list first">
<li v-for="(item,index) in lists" :key="index">
<view @click="gotoGuardHandler(item)">
<i :class="item.icon"></i>
<span>{{item.name}}</span>
</view>
</li>
</view>
<!-- 首页 -> 分类标签 快速跳转 -->
<view class="center-list">
<li v-for="(item,index) in routes" :key="index" @click="gotoHome(item.tab)">
<i :class="item.icon"></i>
<span>{{item.name}}</span>
</li>
</view>
</view>
</view>
<view class="bottom-line"></view>
</view>
</template>

<script>
import { gotoGuard } from '@/common/checkAuth'
import { mapGetters, mapState, mapMutations } from 'vuex'
export default {
data: () => ({
lists: [
{
name: '我的帖子',
icon: 'icon-teizi',
routeName: '/sub-pkg/posts/posts'
},
{
name: '修改设置',
icon: 'icon-setting',
routeName: '/sub-pkg/settings/settings'
},
{
name: '签到中心',
icon: 'icon-qiandao',
routeName: '/sub-pkg/sign/sign'
},
{
name: '电子书',
icon: 'icon-book',
routeName: '/sub-pkg/books/books'
},
{
name: '关于我们',
icon: 'icon-about',
routeName: '/sub-pkg/about/about'
},
{
name: '人工客服',
icon: 'icon-support',
routeName: '/sub-pkg/suggest/suggest'
},
{
name: '意见反馈',
icon: 'icon-lock2',
routeName: '/sub-pkg/suggest/survey'
}
],
routes: [
{
name: '提问',
icon: 'icon-question',
tab: 'ask'
},
{
name: '分享',
icon: 'icon-share',
tab: 'share'
},
{
name: '讨论',
icon: 'icon-taolun',
tab: 'discuss'
},
{
name: '建议',
icon: 'icon-advise',
tab: 'advise'
}
],
countMyPost: 0,
countMyCollect: 0,
countMyHistory: 0
// isLogin: true
}),
computed: {
...mapGetters(['isLogin']),
...mapState(['userInfo']),
uid () {
return this.userInfo._id
}
},
onShow () {
},
methods: {
...mapMutations(['setUserInfo']),
gotoGuard,
gotoGuardHandler (item) {
const { name, routeName } = item
if (name === '我的帖子') {
gotoGuard(routeName + `?uid=${this.uid}&type=p`)
} else {
gotoGuard(routeName)
}
},
gotoHome (tab) {
uni.switchTab({
url: '/pages/home/home'
})
},
navTo () {
if (!this.isLogin) {
uni.navigateTo({
url: '/subcom-pkg/auth/auth'
})
}
},

}

}
</script>

<style lang="scss">
.grey {
position: fixed;
width: 100%;
height: 100%;
left: 0;
top: 0;
z-index: 30;
}
a {
color: #666;
text-decoration: none;
}
.bg {
background-image: url("/static/images/my_bg.png");
background-repeat: no-repeat;
background-size: contain;
position: relative;
left: 0;
top: 0;
width: 100%;
height: 280rpx;
background-position: 0 0;
z-index: 100;
}
.wrapper {
width: 100%;
height: 370rpx;
padding: 25rpx;
position: absolute;
left: 0;
top: 0;
z-index: 100;
box-sizing: border-box;
color: #333;
.profile {
background: #fff;
border-radius: 12rpx;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 100%;
height: 100%;
padding: 30rpx;
box-sizing: border-box;
.name {
font-size: 36rpx;
font-weight: 700;
margin-bottom: 10rpx;
margin-top: 0;
width: 370rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link {
font-size: 24rpx;
color: #999;
}
.fav {
display: inline-block;
padding: 8px 12rpx;
background: rgba(2, 209, 153, 0.16);
border-radius: 12rpx;
color: #02d199;
margin: 0;
font-size: 22rpx;
.icon-fav {
padding-right: 10rpx;
}
}
.info,
.stats {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
}
.info {
margin-bottom: 24rpx;
}
.stats {
justify-content: space-around;
}
.user {
flex: 1;
padding-left: 20rpx;
}
.pic {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
}
.item {
text-align: center;
position: relative;
p {
margin-top: 14rpx;
margin-bottom: 0;
}
&:after {
width: 2rpx;
height: 80rpx;
background: #ddd;
content: "";
position: absolute;
right: -60rpx;
top: 20rpx;
}
&:last-child {
&:after {
width: 0;
}
}
.title {
color: #666;
}
}
}
}
.center-wraper {
background: #f6f5f8;
position: relative;
width: 100%;
// height: 100%;
z-index: 10;
.center-list {
background: #fff;
margin-bottom: 30rpx;
display: flex;
flex-flow: wrap;
padding-top: 40rpx;
&.first {
padding-top: 100rpx;
}
li {
width: 25%;
text-align: center;
color: #666;
margin-bottom: 40rpx;
font-size: 26rpx;
}
i {
display: block;
margin: 0 auto;
font-size: 40rpx;
width: 56rpx;
height: 56rpx;
margin-bottom: 20rpx;
color: #888;
background-size: contain;
}
.icon-teizi {
background-image: url("/static/images/teizi@2x.png");
}
.icon-setting {
background-image: url("/static/images/setting@2x.png");
}
.icon-lock2 {
background-image: url("/static/images/lock2@2x.png");
}
.icon-support {
background-image: url("/static/images/support.png");
}
.icon-qiandao {
background-image: url("/static/images/sign1.png");
}
.icon-book {
background-image: url("/static/images/books.png");
}
.icon-record {
background-image: url("/static/images/record@2x.png");
}
.icon-about {
background-image: url("/static/images/about.png");
}
// 快捷访问
.icon-question {
background-image: url("/static/images/question@2x.png");
}
.icon-share {
background-image: url("/static/images/share@2x.png");
}
.icon-taolun {
background-image: url("/static/images/taolun@2x.png");
}
.icon-advise {
background-image: url("/static/images/advice@2x.png");
}
}
}
</style>

#最近浏览功能

创建PostHistory模型:

import mongoose from '../config/DBHelpler'

const Schema = mongoose.Schema

const PostHistorySchema = new Schema(
{
uid: { type: String, ref: 'user', required: true },
tid: { type: String, ref: 'post', required: true }
},
{ timestamps: { createdAt: 'created', updatedAt: 'updated' } }
)

PostHistorySchema.statics = {
queryCount (options) {
return this.find(options).countDocuments()
},
addOrUpdate (uid, tid) {
// 增加浏览记录,如果有则更新浏览时间
return this.findOne({ uid, tid }).exec((err, doc) => {
if (err) {
console.log(err)
}
if (doc) {
doc.created = new Date()
doc.save()
} else {
this.create({ uid, tid })
}
})
},
delOne (uid, tid) {
return this.deleteOne({ uid, tid })
},
getListByUid (uid, skip, limit) {
// 获取用户的浏览记录
return this.find({ uid })
.populate({
path: 'tid',
select: 'uid catalog title content answer created',
populate: {
path: 'uid',
select: 'name pic'
}
})
.sort({ created: -1 })
.skip(skip)
.limit(limit)
},
deleteByPostId: function (tid) {
return this.deleteMany({ tid })
}
}

const PostHistory = mongoose.model('post_history', PostHistorySchema)

export default PostHistory

创建src/api/StaticsController.js

// import { getJWTPayload } from '@/common/Utils'
import Post from '@/model/Post'
import PostHistory from '@/model/PostHistory'
import User from '@/model/User'
import UserCollect from '@/model/UserCollect'
import Comments from '@/model/Comments'
import CommentsHands from '@/model/CommentsHands'
import SignRecord from '@/model/SignRecord'

/**
* 统计相关的 api 放在这里
*/
class StatisticsController {
// 统计数据:最近浏览、我的帖子、收藏夹、我的评论、我的点赞、获赞、个人积分
async wxUserCount (ctx) {
const body = ctx.query
// const obj = await getJWTPayload(ctx.header.authorization)
const { _id: uid } = ctx
const { reqAll } = body
// console.log('🚀 ~ file: StatisticsController.js ~ line 20 ~ StatisticsController ~ wxUserCount ~ reqAll', reqAll)
if (uid) {
const countMyHistory =
(body.reqHistory || reqAll) && (await PostHistory.countDocuments({ uid })) // 最近浏览
const countMyPost =
(body.reqPost || reqAll) && (await Post.countDocuments({ uid })) // 我的帖子
const countMyCollect =
(body.reqCollect || reqAll) &&
(await UserCollect.countDocuments({ uid })) // 收藏夹
const countMyComment =
(body.reqComment || reqAll) &&
(await Comments.countDocuments({ cuid: uid })) // 我的评论
const countMyHands =
(body.reqHands || reqAll) &&
(await CommentsHands.countDocuments({ uid })) // 我的点赞
const countHandsOnMe =
(body.reqHandsOnMe || reqAll) &&
(await CommentsHands.countDocuments({ huid: uid })) // 获赞
const countFavs = (body.reqFavs || reqAll) && (await User.getFavs(uid)) // 个人积分
const countSign =
(body.reqSign || reqAll) && (await SignRecord.countDocuments({ uid })) // 签到次数
const lastSigned =
(body.reqLastSigned || reqAll) && (await SignRecord.countDocuments({ uid })) // 获取用户最新的签到日期

ctx.body = {
code: 200,
data: {
countMyPost,
countMyCollect,
countMyComment,
countMyHands,
countHandsOnMe,
countMyHistory,
countFavs,
lastSigned,
countSign
}
}
} else {
ctx.body = {
code: 500,
msg: '查询失败'
}
}
}
}

export default new StatisticsController()

更新用户获取文章详情的方法getPostDetail,在文件src/api/ContentController.js中:

// 获取文章详情
async getPostDetail (ctx) {
const params = ctx.query
if (!params.tid) {
ctx.body = {
code: 500,
msg: '文章id为空'
}
return
}
const post = await Post.findByTid(params.tid)
if (!post) {
ctx.body = {
code: 200,
data: {},
msg: '查询文章详情成功'
}
return
}
let isFav = 0
// 判断用户是否传递Authorization的数据,即是否登录
if (
typeof ctx.header.authorization !== 'undefined' &&
ctx.header.authorization !== ''
) {
const obj = await getJWTPayload(ctx.header.authorization)
const userCollect = await UserCollect.findOne({
uid: obj._id,
tid: params.tid
})
if (userCollect && userCollect.tid) {
isFav = 1
}
await PostHistory.addOrUpdate(ctx._id, params.tid) // 添加浏览记录
}
const newPost = post.toJSON()
newPost.isFav = isFav
// 更新文章阅读记数
const result = await Post.updateOne(
{ _id: params.tid },
{ $inc: { reads: 1 } }
)
if (post._id && result.ok === 1) {
ctx.body = {
code: 200,
data: newPost,
msg: '查询文章详情成功'
}
} else {
ctx.body = {
code: 500,
msg: '获取文章详情失败'
}
}
}

#接口对接与联调

前端添加接口:

// 个人中心的统计数字
const wxUserCount = params => axios.get('/user/wxUserCount', params)

前端页面添加请求:

async getUserCount () {
const { _id: uid } = this.userInfo
if (!uid) return
await this.getUserInfo()
const { data, code } = await this.$u.api.wxUserCount({ uid, reqAll: 1 })
if (code === 200) {
const { countMyPost, countMyCollect, countMyHistory } = data
this.countMyPost = countMyPost
this.countMyCollect = countMyCollect
this.countMyHistory = countMyHistory
}
}

完整的页面代码:

<template>
<view>
<view class="grey">
<view class="bg"></view>
<view class="wrapper">
<!-- 个人信息卡片 -->
<view class="profile">
<view class="info">
<u-image class="pic" :src="isLogin ? userInfo.pic: ''" width="120" height="120" shape="circle" error-icon="/static/images/header.jpg" />
<!-- 用户昵称 + VIP -->
<view class="user" @click="navTo">
<view class="name">{{isLogin ?userInfo.name : '请登录'}}</view>
<view class="fav">
<!-- <van-icon name="fav2" class-prefix="iconfont" size="14"></van-icon> -->
积分:{{userInfo && userInfo.favs ? userInfo.favs:0}}
</view>
</view>
<view class="link" @click="gotoGuard('/sub-pkg/user-info/user-info')">个人主页 ></view>
<!-- <navigator class="link" url="/subcom-pkg/auth/auth">个人主页 ></navigator> -->
<!-- <navigator class="link" url="/sub-pkg/user-info/user-info">个人主页 ></navigator> -->
</view>
<view class="stats" v-if="isLogin">
<view class="item">
<navigator :url="'/sub-pkg/posts/posts?uid=' + uid + '&type=p'">
<view>{{ countMyPost }}</view>
<view class="title">我的帖子</view>
</navigator>
</view>
<view class="item">
<navigator :url="'/sub-pkg/posts/posts?uid=' + uid+ '&type=c'">
<view>{{ countMyCollect }}</view>
<view class="title">收藏夹</view>
</navigator>
</view>
<view class="item">
<navigator :url="'/sub-pkg/posts/posts?uid=' + uid+ '&type=h'">
<view>{{ countMyHistory }}</view>
<view class="title">最近浏览</view>
</navigator>
</view>
</view>
</view>
</view>
<view class="center-wraper">
<view class="center-list first">
<li v-for="(item,index) in lists" :key="index">
<view @click="gotoGuardHandler(item)">
<i :class="item.icon"></i>
<span>{{item.name}}</span>
</view>
</li>
</view>
<view class="center-list">
<li v-for="(item,index) in routes" :key="index" @click="gotoHome(item.tab)">
<i :class="item.icon"></i>
<span>{{item.name}}</span>
</li>
</view>
</view>
</view>
<view class="bottom-line"></view>
</view>
</template>

<script>
import { gotoGuard } from '@/common/checkAuth'
import { mapGetters, mapState, mapMutations } from 'vuex'
export default {
data: () => ({
lists: [
{
name: '我的帖子',
icon: 'icon-teizi',
routeName: '/sub-pkg/posts/posts'
},
{
name: '修改设置',
icon: 'icon-setting',
routeName: '/sub-pkg/settings/settings'
},
{
name: '签到中心',
icon: 'icon-qiandao',
routeName: '/sub-pkg/sign/sign'
},
{
name: '电子书',
icon: 'icon-book',
routeName: '/sub-pkg/books/books'
},
{
name: '关于我们',
icon: 'icon-about',
routeName: '/sub-pkg/about/about'
},
{
name: '人工客服',
icon: 'icon-support',
routeName: '/sub-pkg/suggest/suggest'
},
{
name: '意见反馈',
icon: 'icon-lock2',
routeName: '/sub-pkg/suggest/survey'
}
],
routes: [
{
name: '提问',
icon: 'icon-question',
tab: 'ask'
},
{
name: '分享',
icon: 'icon-share',
tab: 'share'
},
{
name: '讨论',
icon: 'icon-taolun',
tab: 'discuss'
},
{
name: '建议',
icon: 'icon-advise',
tab: 'advise'
}
],
countMyPost: 0,
countMyCollect: 0,
countMyHistory: 0
// isLogin: true
}),
computed: {
...mapGetters(['isLogin']),
...mapState(['userInfo']),
uid () {
return this.userInfo._id
}
},
onShow () {
this.getUserCount()
},
methods: {
...mapMutations(['setTab', 'setUserInfo']),
gotoGuard,
gotoGuardHandler (item) {
const { name, routeName } = item
if (name === '我的帖子') {
gotoGuard(routeName + `?uid=${this.uid}&type=p`)
} else {
gotoGuard(routeName)
}
},
gotoHome (tab) {
this.setTab(tab)
uni.switchTab({
url: '/pages/home/home'
})
},
navTo () {
if (!this.isLogin) {
uni.navigateTo({
url: '/subcom-pkg/auth/auth'
})
}
},
async getUserInfo () {
const { _id: uid } = this.userInfo
const { code, data } = await this.$u.api.getBasic({ uid })
if (code === 200) {
this.setUserInfo(data)
}
},
async getUserCount () {
const { _id: uid } = this.userInfo
if (!uid) return
await this.getUserInfo()
const { data, code } = await this.$u.api.wxUserCount({ uid, reqAll: 1 })
if (code === 200) {
const { countMyPost, countMyCollect, countMyHistory } = data
this.countMyPost = countMyPost
this.countMyCollect = countMyCollect
this.countMyHistory = countMyHistory
}
}
}

}
</script>

<style lang="scss">
.grey {
position: fixed;
width: 100%;
height: 100%;
left: 0;
top: 0;
z-index: 30;
}
a {
color: #666;
text-decoration: none;
}
// .bg {
// height: 260rpx;
// // 4个参数: 左上 右上 右下 左下
// border-radius: 0 0 50% 50%;
// background-color: #16d1a2;
// position: relative;
// z-index: 50;
// }
.bg {
background-image: url("/static/images/my_bg.png");
background-repeat: no-repeat;
background-size: contain;
position: relative;
left: 0;
top: 0;
width: 100%;
height: 280rpx;
background-position: 0 0;
z-index: 100;
}
.wrapper {
width: 100%;
height: 370rpx;
padding: 25rpx;
position: absolute;
left: 0;
top: 0;
z-index: 100;
box-sizing: border-box;
color: #333;
.profile {
background: #fff;
border-radius: 12rpx;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 100%;
height: 100%;
padding: 30rpx;
box-sizing: border-box;
.name {
font-size: 36rpx;
font-weight: 700;
margin-bottom: 10rpx;
margin-top: 0;
width: 370rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link {
font-size: 24rpx;
color: #999;
}
.fav {
display: inline-block;
padding: 8px 12rpx;
background: rgba(2, 209, 153, 0.16);
border-radius: 12rpx;
color: #02d199;
margin: 0;
font-size: 22rpx;
.icon-fav {
padding-right: 10rpx;
}
}
.info,
.stats {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
}
.info {
margin-bottom: 24rpx;
}
.stats {
justify-content: space-around;
}
.user {
flex: 1;
padding-left: 20rpx;
}
.pic {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
}
.item {
text-align: center;
position: relative;
p {
margin-top: 14rpx;
margin-bottom: 0;
}
&:after {
width: 2rpx;
height: 80rpx;
background: #ddd;
content: "";
position: absolute;
right: -60rpx;
top: 20rpx;
}
&:last-child {
&:after {
width: 0;
}
}
.title {
color: #666;
}
}
}
}
.center-wraper {
background: #f6f5f8;
position: relative;
width: 100%;
// height: 100%;
z-index: 10;
.center-list {
background: #fff;
margin-bottom: 30rpx;
display: flex;
flex-flow: wrap;
padding-top: 40rpx;
&.first {
padding-top: 100rpx;
}
li {
width: 25%;
text-align: center;
color: #666;
margin-bottom: 40rpx;
font-size: 26rpx;
}
i {
display: block;
margin: 0 auto;
font-size: 40rpx;
width: 56rpx;
height: 56rpx;
margin-bottom: 20rpx;
color: #888;
background-size: contain;
}
.icon-teizi {
background-image: url("/static/images/teizi@2x.png");
}
.icon-setting {
background-image: url("/static/images/setting@2x.png");
}
.icon-lock2 {
background-image: url("/static/images/lock2@2x.png");
}
.icon-support {
background-image: url("/static/images/support.png");
}
.icon-qiandao {
background-image: url("/static/images/sign1.png");
}
.icon-book {
background-image: url("/static/images/books.png");
}
.icon-record {
background-image: url("/static/images/record@2x.png");
}
.icon-about {
background-image: url("/static/images/about.png");
}
// 快捷访问
.icon-question {
background-image: url("/static/images/question@2x.png");
}
.icon-share {
background-image: url("/static/images/share@2x.png");
}
.icon-taolun {
background-image: url("/static/images/taolun@2x.png");
}
.icon-advise {
background-image: url("/static/images/advice@2x.png");
}
}
}
</style>

最终效果:

image-20210801235142625

#RefreshToken机制

#需求分析

用户token不能设置太长的有效时间,这样可以提高接口部分的安全性。

refreshToken

说明:

#创建refreshToken接口

添加src/routes/modules/loginRouter.js路由:

// refresh
router.post('/refresh', loginController.refresh)

调整登录接口src/api/LoginController.js

// 微信登录
async wxLogin (ctx) {
// 1.解密用户信息
const { body } = ctx.request
const { user, code } = body
if (!code) {
ctx.body = {
code: 500,
data: '没有足够参数'
}
return
}
const res = await wxGetUserInfo(user, code)
if (res.errcode === 0) {
// 2.查询数据库 -> 判断用户是否存在
// 3.如果不存在 —> 创建用户
// 4.如果存在 -> 获取用户信息
const tmpUser = await User.findOrCreateByUnionid(res)
// 5.产生token,获取用户的签到状态
const token = generateToken({ _id: tmpUser._id })
const userInfo = await addSign(tmpUser)
ctx.body = {
code: 200,
data: userInfo,
token,
// 新增refreshToken
refreshToken: generateToken({ _id: tmpUser._id }, '7d')
}
} else {
ctx.throw(501, res.errcode === 40163 ? 'code已失效,请刷新后重试' : '获取用户信息失败,请重试')
}
}

// refreshToken
async refresh (ctx) {
ctx.body = {
code: 200,
token: generateToken({ _id: ctx._id }, '60m'),
msg: '获取token成功'
}
}

#前端页面逻辑

#登录失败后处理缓存

src/store/index.js中新增关于清除token及用户信息的actions

const actions = {
logout ({ commit }) {
commit('setToken', '')
commit('setUserInfo', {})
commit('setRefreshToken', '')
uni.clearStorage()
}
}

最终测试与效果:

image-20210802192754262

#封装Simple.js

simple.js封装新的request补全,用于在默认实例请求401的情况下,发送refreshToken的请求:

export const simpleHttp = (options, { header = {}, callback }) => {
const result = new Promise((resolve, reject) => {
uni.request(Object.assign({
timeout: 10 * 1000
}, options, {
header,
success: (res) => {
// 请求成功
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
} else {
reject(res)
}
},
fail: (err) => {
reject(err)
},
complete: () => {
callback && callback()
}
}))
})
return result
}

#调整errorHandle 401逻辑

import { simpleHttp } from './request/simple'

// 调整errorHandle处理流程:
errorHandle: async (err, { options, instance }) => {
if (err.statusCode === 401) {
// 4.业务 —> refreshToken -> 请求响应401 -> 刷新token
try {
const { code, token } = await simpleHttp({
method: 'POST',
url: baseUrl + '/login/refresh'
}, {
header: {
Authorization: 'Bearer ' + uni.getStorageSync('refreshToken')
}
})
if (code === 200) {
// refreshToken请求成功
// 1.设置全局的token
store.commit('setToken', token)
// 2.重新发起请求
const newResult = await instance.request(options)
return newResult
}
} catch (error) {
// 代表refreshToken已经失效
// 清除本地的token
store.dispatch('logout')
// 导航到用户的登录页面
authNav()
}
uni.showToast({
icon: 'none',
title: '鉴权失败,请重新登录',
duration: 2000
})
} else {
// 其他的错误
// showToast提示用户
// 3.对错误进行统一的处理 -> showToast
const { data: { msg } } = err
uni.showToast({
icon: 'none',
title: msg || '请求异常,请重试',
duration: 2000
})
}
}

#请求拦截器

要注意请求拦截器中token的设置方式,可以使用config.header.Authorization = 'Bearer ' + token进行赋值:

// 请求拦截,配置Token等参数
Vue.prototype.$u.http.interceptor.request = (config) => {
// 引用token
// 1.在头部请求的时候,token带上 -> 请求拦截器
const publicArr = [/\/public/, /\/login/]
// local store -> uni.getStorageSync('token')
let isPublic = false
publicArr.forEach(path => {
isPublic = isPublic || path.test(config.url)
})
const token = uni.getStorageSync('token')
if (!isPublic && token) {
config.header.Authorization = 'Bearer ' + token
}
// 最后需要将config进行return
return config
// 如果return一个false值,则会取消本次请求
// if(config.url === '/user/rest') return false; // 取消某次请求
}

#调整工具类request.js

原先Request.js封装中,errorhandle的部分只会返回错误的内容。

而我们在errorHandle中需要发送旧的请求,即需要:

思路使用callback回调的方式,把参数传递出来:

const errCallback = { options, instance: this }

// ...
errorHandle(response, errCallback)

完整的代码:

import deepMerge from '../function/deepMerge'
// import validate from '../function/test'
class Request {
// 设置全局默认配置
setConfig (customConfig) {
// 深度合并对象,否则会造成对象深层属性丢失
this.config = deepMerge(this.config, customConfig)
}

// 主要请求部分
request (options = {}) {
// 检查请求拦截
if (this.interceptor.request && typeof this.interceptor.request === 'function') {
const interceptorRequest = this.interceptor.request(options)
if (interceptorRequest === false) {
// 返回一个处于pending状态中的Promise,来取消原promise,避免进入then()回调
return new Promise(() => { })
}
this.options = interceptorRequest
}
options.dataType = options.dataType || this.config.dataType
options.responseType = options.responseType || this.config.responseType
options.url = options.url || ''
options.params = options.params || {}
options.header = Object.assign({}, this.config.header, options.header)
options.method = options.method || this.config.method
const errCallback = { options, instance: this }
return new Promise((resolve, reject) => {
options.complete = (response) => {
const { errorHandle } = this.config
// 请求返回后,隐藏loading(如果请求返回快的话,可能会没有loading)
uni.hideLoading()
// 清除定时器,如果请求回来了,就无需loading
clearTimeout(this.config.timer)
this.config.timer = null
// 判断用户对拦截返回数据的要求,如果originalData为true,返回所有的数据(response)到拦截器,否则只返回response.data
if (this.config.originalData) {
// 判断是否存在拦截器
if (this.interceptor.response && typeof this.interceptor.response === 'function') {
const resInterceptors = this.interceptor.response(response)
// 如果拦截器不返回false,就将拦截器返回的内容给this.$u.post的then回调
if (resInterceptors !== false) {
resolve(resInterceptors)
} else {
// 如果拦截器返回false,意味着拦截器定义者认为返回有问题,直接接入catch回调
errorHandle(response, errCallback)
reject(response)
}
} else {
// 如果要求返回原始数据,就算没有拦截器,也返回最原始的数据
resolve(response)
}
} else {
if (response.statusCode === 200) {
if (this.interceptor.response && typeof this.interceptor.response === 'function') {
const resInterceptors = this.interceptor.response(response.data)
if (resInterceptors !== false) {
resolve(resInterceptors)
} else {
errorHandle(response, errCallback)
reject(response.data)
}
} else {
// 如果不是返回原始数据(originalData=false),且没有拦截器的情况下,返回纯数据给then回调
resolve(response.data)
}
} else {
// 不返回原始数据的情况下,服务器状态码不为200,modal弹框提示
// if(response.errMsg) {
// uni.showModal({
// title: response.errMsg
// });
// }
errorHandle(response, errCallback)
reject(response)
}
}
}

// 判断用户传递的URL是否/开头,如果不是,加上/,这里使用了uView的test.js验证库的url()方法
// 情景一:如果是使用的官方的uview组件库 -> 创建新的reg -> url
// 情况二:如果是自己定义的request工具js -> 替换正则
options.url = /^https?:\/\/.*/.test(options.url)
? options.url
: (this.config.baseUrl + (options.url.indexOf('/') === 0
? options.url
: '/' + options.url))
// console.log('🚀 ~ file: index.js ~ line 82 ~ Request ~ returnnewPromise ~ options.url', options.url, /^https?:\/\/.*/.test(options.url))

// 是否显示loading
// 加一个是否已有timer定时器的判断,否则有两个同时请求的时候,后者会清除前者的定时器id
// 而没有清除前者的定时器,导致前者超时,一直显示loading
if (this.config.showLoading && !this.config.timer) {
this.config.timer = setTimeout(() => {
uni.showLoading({
title: this.config.loadingText,
mask: this.config.loadingMask
})
this.config.timer = null
}, this.config.loadingTime)
}
uni.request(options)
})
// .catch(res => {
// // 如果返回reject(),不让其进入this.$u.post().then().catch()后面的catct()
// // 因为很多人都会忘了写后面的catch(),导致报错捕获不到catch
// return new Promise(()=>{});
// })
}

constructor () {
this.config = {
baseUrl: '', // 请求的根域名
// 默认的请求头
header: {},
method: 'POST',
// 设置为json,返回后uni.request会对数据进行一次JSON.parse
dataType: 'json',
// 此参数无需处理,因为5+和支付宝小程序不支持,默认为text即可
responseType: 'text',
showLoading: true, // 是否显示请求中的loading
loadingText: '请求中...',
loadingTime: 800, // 在此时间内,请求还没回来的话,就显示加载中动画,单位ms
timer: null, // 定时器
originalData: false, // 是否在拦截器中返回服务端的原始数据,见文档说明
loadingMask: true // 展示loading的时候,是否给一个透明的蒙层,防止触摸穿透
}

// 拦截器
this.interceptor = {
// 请求前的拦截
request: null,
// 请求后的拦截
response: null
}

// get请求
this.get = (url, data = {}, header = {}) => {
return this.request({
method: 'GET',
url,
header,
data
})
}

// post请求
this.post = (url, data = {}, header = {}) => {
return this.request({
url,
method: 'POST',
header,
data
})
}

// put请求,不支持支付宝小程序(HX2.6.15)
this.put = (url, data = {}, header = {}) => {
return this.request({
url,
method: 'PUT',
header,
data
})
}

// delete请求,不支持支付宝和头条小程序(HX2.6.15)
this.delete = (url, data = {}, header = {}) => {
return this.request({
url,
method: 'DELETE',
header,
data
})
}
}
}
export default new Request()

#文章详情

完成效果:

image-20210527020457329

#页面布局与样式

基本的步骤

配置pages.json

"subPackages": [
{
"root": "subcom-pkg",
"pages": [
...
{
"path": "detail/detail",
"style": {
"navigationBarTitleText": "文章详情"
}
}
]
}
]

页面样式与基础逻辑

<template>
<view class="detail" v-show="page._id" @click.stop="showReply = false">
<view class="header title">
{{page.title}}
</view>
<view class="content">
<view class="user u-flex">
<u-image class="photo" :src="page.uid.pic" error-icon="/static/images/header.jpg" width="72" height="72" />
<view class="user-column u-flex-1">
<span class="name">{{page.uid.name}}</span>
<span class="label">{{ page.created | moment }}</span>
</view>
</view>
<u-parse :html="page.content">
<view class="title"></view>
</u-parse>
</view>
<view class="comments" :style="{'padding-bottom': paddingHeight + 'px'}">
<view class="title">评论</view>
<view class="item" v-for="(item) in comments" :key="item._id">
<view class="u-flex u-col-center">
<view class="user u-flex-1">
<u-image class="photo" :src="item.cuid.pic" error-icon="/static/images/header.jpg" width="72" height="72" />
<view class="user-column u-flex-1">
<span class="name">{{ item.cuid.name }}</span>
<span class="label">{{ item.created | moment }} 回复了你</span>
</view>
</view>
<view class="u-flex u-col-center add-hand">
<view class="reply" :class="{'active': item.handed === '1'}" @click="hand(item)">
<u-icon name="thumb-up-fill" size="30" v-if="item.handed === '1'"></u-icon>
<u-icon name="thumb-up" size="30" v-else></u-icon>
<text>{{item.hands}}</text>
</view>
<view v-if="isOwner">
<view class="caina" v-if="item.isBest === '1'">
<u-icon name="yicaina" custom-prefix="iconfont" size="70" color="#58a571"></u-icon>
</view>
<view class="setBest" v-else-if="parseInt(page.isEnd) === 0 && parseInt(item.isBest) === 0" @click="setBest(item)">
<u-icon name="caina" custom-prefix="iconfont" size="32"></u-icon>
</view>
</view>
</view>
</view>
<view class="comments-content">{{item.content}}</view>
</view>
<view v-if="comments.length === 0">
<view v-if="!loading" class="info">
暂无评论,赶紧来抢沙发吧~~~
</view>
<view class="flex-center-center loading" v-else>
<u-loading class="loading-icon" mode="circle"></u-loading>
<text class="loading-text">加载中...</text>
</view>
</view>
</view>
<view class="footer">
<view class="box u-flex u-col-center" v-if="!showReply">
<view class="add-comment" @click.stop="reply()">
<u-icon name="edit-pen" size="32" color="#cccccc"></u-icon>
<text class="text">写评论</text>
</view>
<view class="ctrls u-flex u-col-center u-row-between">
<view class="comment u-flex flex-column">
<u-icon name="chat" size="45"></u-icon>
<text>评论{{ page.answer > 0 ? page.answer : ''}}</text>
</view>
<view class="fav u-flex flex-column" :class="{'active': page.isFav === 1}" @click="setCollect">
<u-icon name="star-fill" size="45" v-if="page.isFav === 1"></u-icon>
<u-icon name="star" size="45" v-else></u-icon>
<text>{{page.isFav === 1 ? '已收藏': '收藏'}}</text>
</view>
<view class="like u-flex flex-column" :class="{'active': page.isHand === 1}" @click="handsPost">
<u-icon name="thumb-up-fill" size="45" v-if="page.isHand === 1"></u-icon>
<u-icon name="thumb-up" size="45" v-else></u-icon>
<text>{{page.isHand === 1 ? '已点赞' : '点赞'}}</text>
</view>
</view>
</view>
<view class="box u-flex u-col-center" v-else>
<u-input v-model="content" class="reply" placeholder="请输入评论内容" focus @clear="clear"></u-input>
<button type="primary" plain size="mini" @click.stop="send">发送</button>
</view>
</view>
</view>
</template>

<script>
import { mapGetters, mapState } from 'vuex'
import { checkToken } from '@/common/checkAuth'

export default {
components: {},
data: () => ({
page: {},
comments: [],
params: {
page: 0,
limit: 10,
tid: ''
},
content: '',
showReply: false,
height: 0,
paddingHeight: 60,
loading: false
}),
computed: {
...mapState(['userInfo']),
...mapGetters(['isLogin']),
isCollect () {
return typeof this.page.isFav !== 'undefined' && this.page.isFav === 1
},
isOwner () {
let flag = false
if (this.page.uid && typeof this.page.uid !== 'undefined' && typeof this.userInfo._id !== 'undefined') {
flag = this.page.uid._id === this.userInfo._id
}
return flag
}
},
methods: {
check: checkToken,
async getReply () {
const { data } = await this.$u.api.getComents(this.params)
const arr = data.reverse()
if (this.params.page === 0) {
this.comments = arr
} else {
this.comments = [...this.comments, ...arr]
}
this.page.answer = this.comments.length || 0
},
async handsPost () {
// 文章点赞
},
async hand (item) {
// 评论点赞
if (!this.check()) return
const { msg, data, code } = await this.$u.api.setHands({ cid: item._id })
if (code === 200 && data) {
item.handed = '1'
item.hands++
} else {
uni.showToast({
icon: 'none',
title: msg,
duration: 2000
})
}
},
async setCollect () {
// 设置收藏
if (!this.check()) return
const { msg, isCollect } = await this.$u.api.addCollect({ tid: this.params.tid, isFav: this.isCollect ? 1 : 0 })
if (isCollect) {
this.page.isFav = 1
} else {
this.page.isFav = 0
}
uni.showToast({
icon: 'none',
title: msg,
duration: 2000
})
},
reply () {
if (!this.check()) return
this.showReply = true
},
async send () {
// 微信评论
},
setBest (item) {
// 设置最佳
},
onShareAppMessage () {
// 微信分享
}
},
watch: {},
// 页面周期函数--监听页面加载
async onLoad (options) {
this.loading = true
const { tid } = options
this.params.tid = tid
const { data } = await this.$u.api.getDetail({ tid })
this.page = data
await this.getReply()
this.loading = false
},
// 页面周期函数--监听页面初次渲染完成
onPullDownRefresh () {
uni.stopPullDownRefresh()
},
// 页面处理函数--监听用户上拉触底
onReachBottom () {}
// 页面处理函数--监听页面滚动(not-nvue)
/* onPageScroll(event) {}, */
// 页面处理函数--用户点击右上角分享
/* onShareAppMessage(options) {}, */
}
</script>

<style lang="scss">
.detail {
background: #f4f6f8;
min-height: 100vh;
}

.header,
.content,
.comments {
background: #fff;
padding: 32rpx;
}

.header,
.content {
margin-bottom: 24rpx;
box-shadow: 0 5rpx 5px rgba($color: black, $alpha: 0.1);
}

.add-hand {
position: relative;
.caina {
position: absolute;
right: 100rpx;
top: -20rpx;
}
.setBest {
padding-left: 25rpx;
}
}

.footer {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
padding: 20rpx 32rpx;
background-color: #fff;
// height: 100rpx;
box-shadow: 0 -5rpx 5px rgba($color: black, $alpha: 0.1);
.box {
width: 100%;
}
.reply {
flex: 1;
border: 1px solid #eee;
padding: 0 15rpx;
margin-right: 15rpx;
}
}

.title {
font-size: 32rpx;
color: #333;
font-weight: bold;
}

.user {
display: flex;
align-items: center; /* 垂直居中 */
margin-right: 20rpx;
.name {
margin-bottom: 10rpx;
font-size: 28rpx;
font-family: PingFang SC;
font-weight: bold;
color: rgba(51, 51, 51, 1);
white-space: nowrap;
max-width: 420rpx;
overflow: hidden;
text-overflow: ellipsis;
}
.photo {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
}
.user-column {
display: flex;
flex-direction: column;
margin-left: 20rpx;
}
.label {
font-size: 22rpx;
font-family: PingFang SC;
font-weight: 500;
color: rgba(153, 153, 153, 1);
}
}

.comments {
.item {
padding: 24rpx 0;
.comments-content {
padding-top: 32rpx;
}
.reply {
text {
padding-left: 10rpx;
}
&.active {
color: $u-type-primary;
}
}
}
.info {
font-size: 28rpx;
color: #666;
line-height: 90rpx;
text-align: center;
}
}

.ctrls {
color: #999;
font-size: 22rpx;
width: 35%;
.fav,
.like {
&.active {
color: $u-type-primary;
}
}
}

.add-comment {
background: #f3f3f3;
height: 64rpx;
border-radius: 32rpx;
line-height: 64rpx;
padding: 0 32rpx;
width: 65%;
margin-right: 40rpx;
color: #ccc;
.text {
padding-left: 10rpx;
}
}

.loading {
height: 50px;
.loading-text {
padding-left: 15rpx;
}
}
</style>

App.vue页面中添加公共样式:

.flex-column {
flex-direction: column;
}

调整api接口:

// 获取文章中的评论列表
const getComents = (params) => {
const token = store.state.token
let headers = {}
if (token !== '') {
headers = {
headers: {
Authorization: 'Bearer ' + store.state.token
}
}
}
return axios.get('/public/comments', params, headers)
}

// 获取文章详情
const getDetail = (data) => {
const token = store.state.token
let headers = {}
if (token !== '') {
headers = {
headers: {
Authorization: 'Bearer ' + store.state.token
}
}
}
return axios.get('/public/content/detail', data, headers)
}

#长屏适配方案

安全区域指的是一个可视窗口范围,处于安全区域的内容不受圆角(corners)、齐刘海(sensor housing)、小黑条(Home Indicator)影响,如下图蓝色区域:

img

也就是说,我们要做好适配,必须保证页面可视、可操作区域是在安全区域内。

解决方案env() 和 constant():

iOS11 新增特性,Webkit 的一个 CSS 函数,用于设定安全区域与边界的距离,有四个预定义的变量:

这里我们只需要关注 safe-area-inset-bottom 这个变量,因为它对应的就是小黑条的高度(横竖屏时值不一样)。

注意:当 viewport-fit=contain 时 env() 是不起作用的,必须要配合 viewport-fit=cover 使用。对于不支持env() 的浏览器,浏览器将会忽略它。

在这之前,笔者使用的是 constant(),后来,官方文档加了这么一段注释(坑):

The env() function shipped in iOS 11 with the name constant(). Beginning with Safari Technology Preview 41 and the iOS 11.2 beta, constant() has been removed and replaced with env(). You can use the CSS fallback mechanism to support both versions, if necessary, but should prefer env() going forward.

这就意味着,之前使用的 constant() 在 iOS11.2 之后就不能使用的,但我们还是需要做向后兼容,像这样:

padding-bottom: constant(safe-area-inset-bottom); /* 兼容 iOS < 11.2 */
padding-bottom: env(safe-area-inset-bottom); /* 兼容 iOS >= 11.2 */

注意:env() 跟 constant() 需要同时存在,而且顺序不能换。

更详细说明,参考文档: Designing Websites for iPhone X(opens new window)

#页面分享设置

与页面分享相关的uniapp的API有两个:

小程序中用户点击分享后,在 js 中定义 onShareAppMessage 处理函数(和 onLoad 等生命周期函数同级),设置该页面的分享信息。

微信小程序平台的分享管理比较严格,请参考 小程序分享指引 (opens new window)

在详情页面中加入onShareAppMessage方法:

onShareAppMessage () {
// 微信分享 -> 这个分享单一的好友
return {
title: this.page.title,
path: '/subcom-pkg/detail/detail?tid=' + this.params.tid
}
}

目前,微信官方没有提供正式的朋友圈分享的功能,只是在android设备上进行beta测试,参考说明:https://developers.weixin.qq.com/miniprogram/dev/reference/api/Page.html#onShareTimeline

image-20210802212601310

#富文本显示

#高亮方案一:自定义的highlight组件

步骤:

安装依赖:

npm i prismjs

自定义的highlight.vue组件

<template>
<view class="highlight">
<scroll-view scroll-x>
<view v-for="(line,i) in tokenLines" :key="i" class="lines">
<!-- 行数 -->
<text class="line-number">{{i+1}}</text>
<!-- 代码块 -->
<text v-for="(token,index) in line" :key="index" :class="'token--' + token.type">{{token.content}}</text>
</view>
</scroll-view>
</view>
</template>

<script>
import Prism from 'prismjs'
import normalize from '@/common/utils/normalize'

// const code = `
// <template>
// <view class="list-item">
// <view class="list-head">
// <!-- 标题部分 -->
// <text class="type" :class="['type-'+item.catalog]">{{tabs.filter(o => o.key === item.catalog)[0].value}}</text>
// <text class="title">{{item.title}}</text>
// </view>
// <!-- 用户部分 -->
// <view class="author u-flex u-m-b-18">
// <u-image :src="item.uid.pic" class="head" width="40" height="40" shape="circle" error-icon="/static/images/header.jpg"></u-image>
// <text class="name u-m-l-10">{{item.uid.name}}</text>
// </view>
// <!-- 摘要部分 + 右侧的图片 -->
// <view class="list-body u-m-b-30 u-flex u-col-top">
// <view class="info u-m-r-20 u-flex-1">{{item.content}}</view>
// <image class="fmt" :src="item.snapshot" v-if="item.snapshot" mode="aspectFill" />
// </view>
// <!-- 回复 + 文章发表的时间 -->
// <view class="list-footer u-flex">
// <view class="left">
// <text class="reply-num u-m-r-25">{{item.answer}} 回复</text>
// <text class="timer">{{item.created | moment}}</text>
// </view>
// </view>
// </view>
// </template>
// `
export default {
props: {
code: {
type: String,
default: ''
},
language: {
type: String,
default: 'js'
}
},
data: () => ({
}),
computed: {
tokenLines () {
const result = normalize(Prism.tokenize(this.code, Prism.languages[this.language]))
// console.log('🚀 ~ file: highlight.vue ~ line 54 ~ tokenLines ~ result', result)
return result
}
},
methods: {}
}
</script>

<style lang="scss" scoped>
.intro {
margin: 30px;
text-align: center;
}

:host {
text-align: left;
font-family: consolas, monospace;
line-height: 1.44;
white-space: nowrap;
}

.lines {
display: flex;
flex-flow: row nowrap;
width: 100%;
align-items: center;
justify-content: flex-start;
}

.line-number {
display: inline-block;
flex-shrink: 0;
border-right: 4px solid #d8d8d8;
min-width: 55px;
text-align: right;
margin-right: 10px;
padding-right: 10px;
color: #888;
&.max {
width: 110px;
}
}

.token {
color: #333;
white-space: pre;
}

.token--plain,
.token--string {
white-space: pre;
}

.token--keyword {
color: #00f;
}

.token--number {
color: #09885a;
}

.token--string {
color: #a31515;
}

.token--regex {
color: #811f3f;
}

.token--comment {
color: #008000;
}
</style>

#高亮方案二:wxParse

效果展示:

img img

@import "../utils/prism.wxss";
var Prism = require('../utils/prism.js')
function highlight(data, that) {
// Prism 所支持的代码语言数组
let langArr = new Array();
langArr = listLanguages();
// console.log('all-language:'+langArr)
let html = data;
//匹配到的所有标签<\/code>
let tagArr = data.match(/<\/?code[^>]*>/g);
if (tagArr == null) {
return html;// 如果没有 pre 标签就直接返回原来的内容,不做代码高亮处理
}
//记录每一个 code 标签在data中的索引位置
let indexArr = [];
//计算索引位置
for (let i = 0; i < tagArr.length; i++) {
//添加索引值
if (i == 0) {
indexArr.push(data.indexOf(tagArr[i]));
}
else {
indexArr.push(data.indexOf(tagArr[i], indexArr[i - 1]));
}
}

//记录基本的class信息
let cls;

// 开始循环处理 code 标签
let i = 0;
while (i < tagArr.length - 1) {// 这里减一是因为不处理最后的 code 标签
// 调用函数来获取 class 信息
getStartInfo(tagArr[i])
// 获取标签的值
var label = tagArr[i].match(/<*([^> ]*)/)[1];
// console.log('label:'+label)
if (tagArr[i + 1] === '</' + label + '>') {//判断紧跟它的下一个标签是否为它的闭合标签
if (label === 'code') {
// 代码语言判断,根据类进行判断,自定义,比如 lang-语言,language-语言。
let lang = cls.split(' ')[0];
if (/lang-(.*)/i.test(lang)) {// 代码语言定义是 lang-XXX 的样式
lang = lang.replace(/lang-(.*)/i, '$1');
}
else if (/languages?-(.*)/i.test(lang)) {
lang = lang.replace(/languages?-(.*)/i, '$1');// 代码语言定义是 language(s)-XXX 的样式
}
// 如果代码语言不在 Prism 存在的语言,或者 class 值是 null,则不执行代码高亮函数
if (langArr.indexOf(lang) == -1 || lang == null || lang == 'none' || lang == 'null') {
}
else {
// 获取代码段内容为 code
let code = data.substring(indexArr[i], indexArr[i + 1]).replace(/<code[^>]*>/, '');

// 执行 Prism 的代码高亮函数
let hcode = Prism.highlight(code, Prism.languages[lang], lang);
html = html.replace(code, hcode);
}

}
// 指向下一个标签(闭合标签)索引
i++;
} else {
//onsole.log('不是闭包')
}
// 指向下一个标签(开始标签)的索引
i++;
}
return html;

function getStartInfo(str) {
cls = matchRule(str, 'class');
}

//获取部分属性的值
function matchRule(str, rule) {
let value = '';
let re = new RegExp(rule + '=[\'"]?([^\'"]*)');
//console.log('regexp:'+re)
if (str.match(re) !== null) {
value = str.match(re)[1];
//console.log('value:'+value)
}
return value;
}


// 列出当前 Prism.js 中已有的代码语言,可以自己在 Prism 的下载页面选择更多的语言。
function listLanguages() {
var langs = new Array();
let i = 0;
for (let language in Prism.languages) {
if (Object.prototype.toString.call(Prism.languages[language]) !== '[object Function]') {
langs[i] = language;
i++;
}
}
return langs;
}
}

module.exports = {
highlight: highlight
};
//引用`highlight.js`工具类
var highlight = require('./highlight.js');

function html2json(html, bindName, that) {
html = removeDOCTYPE(html);
html = trimHtml(html);
html = wxDiscode.strDiscode(html);
//引用高亮函数
html = highlight.highlight(html, that);

//省略了后续代码
...

}

参考链接 :

https://blog.sunriseydy.top/technology/server-blog/wordpress/wordpress-miniapp-code-highlight

https://blog.csdn.net/qq_41107410/article/details/89042212

#安全域名相关

微信小程序的安全域名必须是HTTPS,同时必须是在国内ICP备案的域名,本章来介绍如何使用acme.sh+nginx配置https与申请SSL证书。

#SSL证书申请

#前置准备

#整体架构

配置架构图:

image-20210810144327946

#基本步骤

借助acme.sh (opens new window)进行申请SSL证书,以便微信接口部分的使用,下面演示的是DNSpod进行证书申请的过程。

主要步骤:

前置:使用ssh命令连接到服务器 ->

  1. 安装 acme.sh

  2. 生成证书 ——推荐DNS的方式(opens new window)

    支持的DNS列表:https://github.com/acmesh-official/acme.sh/tree/master/dnsapi(opens new window)

  3. copy 证书到 nginx/apache 或者其他服务

  4. 更新证书

  5. 更新 acme.sh

#acme.sh安装

安装很简单, 一个命令:

curl  https://get.acme.sh | sh -s email=my@example.com

image-20210810145930893

再次打开一个新的ssh终端,使用crontab -e(Centos)查看acme.sh自动创建的定时任务:

image-20210810150236024

输入i进入编辑模式,调整如上图所示,使用:wq退出。

PS:上面的配置是每天的0点23分去执行一次脚本。

#配置DNS密钥

下面介绍了DNSpod、阿里云、Cloudflare的配置过程,大家可以根据自己的云服务商选择,过程类似。

#DNSpod

配置密钥:

export DPI_Id="1234"
export DPI_Key="sADDsdasdgdsf"

#阿里云

点击:https://ak-console.aliyun.com/#/accesskey (opens new window),申请密钥:

image-20210810151001761

选择编程访问

image-20210810151252035

生成accessKey与密钥:

image-20210810151319187

配置过程:

export Ali_Key="sdfsdfsdfljlbjkljlkjsdfoiwje"
export Ali_Secret="jlsdflanljkljlfdsaklkjflsa"

#Cloudflare

配置页面 (opens new window)

image-20210810151518194

配置过程:

export CF_Key="sdfsdfsdfljlbjkljlkjsdfoiwje"
export CF_Email="xxxx@sss.com"

#产生证书

推荐域名通配符的写法:example.com 与 *.example.com,那么二级子域名,全部都可以使用https。

过程1:

image-20210810152354761

上面的图片是展示出在对域名进行校验。

过程2:

image-20210810152415177

证书申请成功,证书所在位置。

cer 自签
key 密钥
CA 中间证书
把CA与cert链接到一起的证书,这个是后续放在nginx上的,用于服务器之间的协商

#Nginx配置

#前置准备

image-20210810205823556

步骤:

#证书安装命令

Apache配置:

acme.sh --install-cert -d example.com \
--cert-file /path/to/certfile/in/apache/cert.pem \
--key-file /path/to/keyfile/in/apache/key.pem \
--fullchain-file /path/to/fullchain/certfile/apache/fullchain.pem \
--reloadcmd "service apache2 force-reload"

Nginx配置:

acme.sh --install-cert -d example.com \
--key-file /path/to/keyfile/in/nginx/key.pem \
--fullchain-file /path/to/fullchain/nginx/cert.pem \
--reloadcmd "service nginx force-reload"

--reloadcmd可以指定docker容器进行重启,或者是指定运行shell脚本。

比如:

--reloadcmd "docker restart some-nginx"

#dhparam.pem证书

创建一个目录:/home/keys

# 生成 dhparam.pem 文件, 在命令行执行任一方法:

# 方法1: 很慢
openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048

# 方法2: 较快
# 与方法1无明显区别. 2048位也足够用, 4096更强
openssl dhparam -dsaparam -out /etc/nginx/ssl/dhparam.pem 4096

把dhparam.pem复制到个人SSL证书安装相同的目录/home/keys

#docker配置

version: "3"
services:
web:
image: nginx:latest
container_name: "some-nginx"
restart: always
volumes:
- /home/nginx/nginx.conf:/etc/nginx/nginx.conf
- /home/nginx/conf.d:/etc/nginx/conf.d
- /home/keys:/home/keys
# blog
# - /home/blog:/var/www
ports:
- "80:80"
- "443:443"

# docker network create https
networks:
default:
external:
name: https

#nginx.conf配置

user nginx;
worker_processes auto;
pid /run/nginx.pid;
worker_rlimit_nofile 65535;

events {
# 设置事件驱动模型,是内核2.6以上支持
use epoll;
worker_connections 65535;
accept_mutex off;
multi_accept off;
}

http {
# Basic Settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
send_timeout 120;
keepalive_timeout 300;
client_body_timeout 300;
client_header_timeout 120;

proxy_read_timeout 300;
proxy_send_timeout 300;
#tcp_nopush on;
types_hash_max_size 4096;
client_header_buffer_size 16m;
client_max_body_size 4096m;

# 添加nginx的配置文件目录 -> 用户后期添加vhost文件
include /etc/nginx/mime.types;
include /etc/nginx/conf.d/*.conf;

default_type application/octet-stream;
# Logging Settings
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

# 开启gzip
gzip on;
# 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
gzip_min_length 1k;
# gzip 压缩级别,1-10,数字越大压缩的越好,也越占用CPU时间,后面会有详细说明
gzip_comp_level 2;
# 进行压缩的文件类型。javascript有多种形式。其中的值可以在 mime.types 文件中找到。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png font/ttf font/otf image/svg+xml;
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
# 禁用IE 6 gzip
gzip_disable "MSIE [1-6]\.";
}

#安装证书启动nginx

image-20210810211832312

测试Nignx的配置文件:

docker exec -it some-nginx nginx -t

image-20210810220403001

#课程福利nginx配置

nginx的配置文件地址:github(opens new window)

#场景一:配置博客应用

添加宿主机的docker目录映射:

version: "3"
services:
web:
image: nginx:latest
container_name: "some-nginx"
restart: always
volumes:
- /home/nginx/nginx.conf:/etc/nginx/nginx.conf
- /home/nginx/conf.d:/etc/nginx/conf.d
- /home/keys:/home/keys
# blog
- /home/blog:/var/www
ports:
- "80:80"
- "443:443"

# docker network create https
networks:
default:
external:
name: https

nginx的配置文件 conf.d/vhost.conf

# listen on HTTP2/SSL
server {
listen 443 ssl http2;
server_name www.wayearn.com;
# ssl certs from letsencrypt
# ssl on;
# 这里要注意目录是docker里面的目录,所以建议大家把容器里面的目录与宿主机的目录映射一致
ssl_certificate /home/keys/certs.pem;
ssl_certificate_key /home/keys/key.pem;
# dhparam.pem
ssl_dhparam /home/keys/dhparam.pem;

ssl_session_cache shared:SSL:50m;
ssl_session_timeout 30m;
ssl_session_tickets off;

ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
# ciphers chosen for forward secrecy and compatibility
# http://blog.ivanristic.com/2013/08/configuring-apache-nginx-and-openssl-for-forward-secrecy.html
ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';

ssl_prefer_server_ciphers on;

add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload";

location / {
root /var/www/;
index index.html;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

# redirect HTTP and handle let's encrypt requests
server {
listen 80;
server_name www.wayearn.com;
location / {
return 301 https://$host$request_uri;
}
}

检测ssl网站的评级 myssl.com:

image-20210810220929142

#场景二:配置小程序API接口

步骤:

小程序的docker-compose文件:

version: "3"
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
Version: ${Version}
image: api_online:${tag}
container_name: api_online
restart: always
# env_file: .env
environment:
- DB_USER=$DB_USER
- DB_PASS=$DB_PASS
- DB_HOST=$DB_HOST
- DB_PORT=$DB_PORT
- DB_NAME=$DB_NAME
- REDIS_HOST=$REDIS_HOST
- REDIS_PORT=$REDIS_PORT
- REDIS_PASS=$REDIS_PASS
ports:
- "${PORT}:3000"
- "${WS_PORT}:3001"
volumes:
- /home/imooc/online:/app/public

# 关键就是这里,配置API项目的网络,连接到https网络
# 然后,使用vhost配置,让Nginx进行代理
networks:
default:
external:
name: https

可选方案:提供Mongo、redis环境的docker-compose.yml文件:

version: "3"
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
Version: 1.0
image: api_online:1.0
container_name: api_online
restart: always
# env_file: .env
environment:
- DB_USER=toimc
- DB_PASS=long_random_pass_mongo
- DB_HOST=mongo
- DB_PORT=27017
- DB_NAME=community
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASS=long_random_pass_redis
ports:
- "10030:3000"
- "10031:3001"
volumes:
- /home/imooc/online:/app/public

mongo:
image: mongo
container_name: "mongodb"
restart: always
volumes:
- /home/imooc/db:/data/db
- /home/imooc/db/initdb.d:/docker-entrypoint-initdb.d/
# .sh & .js
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
MONGO_INITDB_DATABASE: testdb
MONGO_INITDB_USERNAME: toimc
MONGO_INITDB_PASSWORD: long_random_pass_mongo

redis:
image: redis
container_name: "redis"
restart: always
ports:
- "6379:6379"
command: redis-server --requirepass long_random_pass_redis

networks:
default:
external:
name: https

MongoDB的初始化文件init.d/mongo-init.sh文件:

mongo -- "$MONGO_INITDB_DATABASE" <<EOF
var rootUser = '$MONGO_INITDB_ROOT_USERNAME';
var rootPassword = '$MONGO_INITDB_ROOT_PASSWORD';
var initDB = '$MONGO_INITDB_DATABASE'
var admin = db.getSiblingDB(initDB);
admin.auth(rootUser, rootPassword);

var user = '$MONGO_INITDB_USERNAME';
var passwd = '$MONGO_INITDB_PASSWORD';
db.createUser({ user: user, pwd: passwd, roles: ["dbOwner"] });
EOF

手动部署API服务:

#排查问题

# 查看防火墙的运行状态
firewall-cmd --state

firewall-cmd --add-port=443/tcp --permanenet

firewall-cmd --reload

华为云示例:

image-20210810221145314

腾讯云:

image-20210810221210594

#小程序接口联调

请求测试:

image-20210810224507919

下面即可以在微信开发者工具中进行测试了,取消不校验合法域名

image-20210810224714543

#订阅消息

#介绍

消息能力是小程序能力中的重要组成,我们为开发者提供了订阅消息能力,以便实现服务的闭环和更优的体验。

intro

#消息类型

1. 一次性订阅消息

一次性订阅消息用于解决用户使用小程序后,后续服务环节的通知问题。用户自主订阅后,开发者可不限时间地下发一条对应的服务消息;每条消息可单独订阅或退订。

2. 长期订阅消息

一次性订阅消息可满足小程序的大部分服务场景需求,但线下公共服务领域存在一次性订阅无法满足的场景,如航班延误,需根据航班实时动态来多次发送消息提醒。为便于服务,我们提供了长期性订阅消息,用户订阅一次后,开发者可长期下发多条消息。

目前长期性订阅消息仅向政务民生、医疗、交通、金融、教育等线下公共服务开放,后期将逐步支持到其他线下公共服务业务。

#使用步骤

#封装订阅消息工具js

作用:

export default (arr, cb, mute = false) => {
uni.requestSubscribeMessage({
tmplIds: arr.length > 3 ? arr.splice(0, 3) : arr,
// 在调试工具中,无论订阅成功还是取消,都可以在complete取到状态
// 调试工具中,无法直接测试关闭订阅的状态
// 在真机上,可以获取用户拒绝订阅的状态
// 而且在complete部分可以获取到success/fail的回调内容
complete: (res) => {
// 2.1 如果用户未订阅,并未拒绝,正常发起订阅
// 2.2 如果用户拒绝了订阅,需要给用户一个轻提示 -> 手动打开订阅消息的
// wx.openSetting
if (arr.includes(item => res[item] === 'reject') || res.errCode === 20004) {
uni.showModal({
title: '您关闭了订阅通知',
content: '需要打开设置进行手动设置吗?',
success: function (res) {
if (res.confirm) {
uni.openSetting()
} else if (res.cancel) {
uni.showToast({
icon: 'error',
title: '您取消了订阅',
duration: 2000
})
}
}
})
} else if (!arr.some(item => res[item] === 'reject')) {
!mute && uni.showToast({
icon: 'none',
title: '您已经订阅了该消息',
duration: 1500
})
} else if (res.errCode === 10002 || res.errCode === 10003) {
uni.showToast({
title: '网络问题订阅失败,请重新订阅',
duration: 1500
})
} else {
// 其他的逻辑 https://developers.weixin.qq.com/miniprogram/dev/api/open-api/subscribe-message/wx.requestSubscribeMessage.html
}
cb && cb()
}
})
}

#前端主动订阅

在App.vue中添加判断用户订阅逻辑:

export default {
onLaunch: function () {
// console.warn('当前组件仅支持 uni_modules 目录结构 ,请升级 HBuilderX 到 3.1.0 版本以上!')
// console.log('App Launch')
uni.getSetting({
withSubscriptions: true,
success: async (res) => {
const app = getApp()
app.globalData.subscriptionsSetting = res.subscriptionsSetting
const arr = [
'S7zrpjN9Kq05-4ZG_nlTAYxnARMLWlSW09h54A2JCZo',
'ANN2-LhDgrhdFjs7jHOLdTnaxWpQU1LqS3kDIMF9GDs',
'FSQZganmBgaRRoNNlelQ1Qm2u4gx6pVSt69EJfkLbPA',
'g9FFU43_deHRuez-2FcrASorTSITsJJPYx-GhzvHEIU'
]
// 1. 获取用户已经订阅的消息
const { itemSettings: keys, mainSwitch } = res.subscriptionsSetting
// 相当于用户未打开订阅开关
if (!mainSwitch) {
return
}
// 用户开启订阅消息 -> 如果未设置任何消息
if (!keys) {
app.globalData.tmplIds = arr
} else {
// 用户开启了订阅消息 -> 已经有部分订阅 -> reject, accept
const keysArr = Object.keys(keys)
app.globalData.tmplIds = arr.filter((item) => keysArr.indexOf(item) === -1)
}
}
})
},
onShow: function () {
console.log('App Show')
},
onHide: function () {
console.log('App Hide')
}
}

在需要订阅的位置,加入订阅逻辑,比如在个人中心中,点击去登录时:

import sub from '@/common/utils/subscribe'

// ...
const app = getApp()
const tmplIds = app.globalData.tmplIds || []
sub(tmplIds.splice(0, 3), () => {
if (!this.isLogin) {
uni.navigateTo({
url: '/subcom-pkg/auth/auth'
})
}
}, true)

注意:订阅消息一次最多只可以订阅3条,所以需要加入一个splice方法截断未订阅的消息。

也可以自己在指定的业务部分,给sub方法,传递指定的模板ID。

#订阅消息模板API

应用场景:

前端请求模板API:

// 获取模板id
const getSubIds = () => axios.get('/public/subids')

调整App.vue中的模板IDs的判断逻辑,处理:

// 模板ID
// 改装成一个API接口 -> key:value -> value, key -> 业务场景
const { code, data } = await this.$u.api.getSubIds()
let arr
if (code === 200) {
// {key: value, key1: value1} => [value, value1]
arr = Object.entries(data).map(o => o[1])
} else {
// 默认的前端模板数据
arr = [
'S7zrpjN9Kq05-4ZG_nlTAYxnARMLWlSW09h54A2JCZo',
'ANN2-LhDgrhdFjs7jHOLdTnaxWpQU1LqS3kDIMF9GDs',
'FSQZganmBgaRRoNNlelQ1Qm2u4gx6pVSt69EJfkLbPA',
'g9FFU43_deHRuez-2FcrASorTSITsJJPYx-GhzvHEIU'
]
}

后端创建路由与接口:

路由:

// 获取微信模板id
router.get('/subids', publicController.getSubIds)

接口:

async getSubIds (ctx) {
ctx.body = {
code: 200,
data: config.subIds
}
}

#维护accessToken

后端发送订阅消息需要accessToken:

POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=ACCESS_TOKEN

见官方文档:链接(opens new window)

由于acessToken的有效时间是2小时,而且每天微信请求的限制为2000次(链接 (opens new window)),如下图:

image-20210812102933432

所以,需要自己定时维护accessToken:

常见问题,如果不小心accessToken请求超限怎么办:

img

#后台发送订阅消息

发送订阅消息:官方文档(opens new window)

调用方式:

#HTTPS调用接口说明

POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=ACCESS_TOKEN

请求参数:

属性 类型 默认值 必填 说明
access_token / cloudbase_access_token string 接口调用凭证(opens new window)
touser string 接收者(用户)的 openid
template_id string 所需下发的订阅模板id
page string 点击模板卡片后的跳转页面,仅限本小程序内的页面。支持带参数,(示例index?foo=bar)。该字段不填则模板无跳转。
data Object 模板内容,格式形如 { “key1”: { “value”: any }, “key2”: { “value”: any } }
miniprogram_state string 跳转小程序类型:developer为开发版;trial为体验版;formal为正式版;默认为正式版
lang string 进入小程序查看”的语言类型,支持zh_CN(简体中文)、en_US(英文)、zh_HK(繁体中文)、zh_TW(繁体中文),默认为zh_CN

说明一下关于data

例如,模板的内容为

姓名: {{name01.DATA}}
金额: {{amount01.DATA}}
行程: {{thing01.DATA}}
日期: {{date01.DATA}}

则对应的json为

{
"touser": "OPENID",
"template_id": "TEMPLATE_ID",
"page": "index",
"data": {
"name01": {
"value": "某某"
},
"amount01": {
"value": "¥100"
},
"thing01": {
"value": "广州至北京"
} ,
"date01": {
"value": "2018-01-01"
}
}
}

这里的data有内容限制(举例如下表),更多查看官方文档详情:

参数类别 参数说明 参数值限制 说明
thing.DATA 事物 20个以内字符 可汉字、数字、字母或符号组合
number.DATA 数字 32位以内数字 只能数字,可带小数
letter.DATA 字母 32位以内字母 只能字母
symbol.DATA 符号 5位以内符号 只能符号
character_string.DATA 字符串 32位以内数字、字母或符号 可数字、字母或符号组合
time.DATA 时间 24小时制时间格式(支持+年月日),支持填时间段,两个时间点之间用“~”符号连接 例如:15:01,或:2019年10月1日 15:01
date.DATA 日期 年月日格式(支持+24小时制时间),支持填时间段,两个时间点之间用“~”符号连接 例如:2019年10月1日,或:2019年10月1日 15:01
amount.DATA 金额 1个币种符号+10位以内纯数字,可带小数,结尾可带“元” 可带小数
phone_number.DATA 电话 17位以内,数字、符号 电话号码,例:+86-0766-66888866
car_number.DATA 车牌 8位以内,第一位与最后一位可为汉字,其余为字母或数字 车牌号码:粤A8Z888挂
name.DATA 姓名 10个以内纯汉字或20个以内纯字母或符号 中文名10个汉字内;纯英文名20个字母内;中文和字母混合按中文名算,10个字内
phrase.DATA 汉字 5个以内汉字 5个以内纯汉字,例如:配送中

返回的 JSON 数据包

属性 类型 说明
errcode number 错误码
errmsg string 错误信息

errcode 的合法值

说明
40003 touser字段openid为空或者不正确
40037 订阅模板id为空不正确
43101 用户拒绝接受消息,如果用户之前曾经订阅过,则表示用户取消了订阅关系
47003 模板参数不准确,可能为空或者不满足规则,errmsg会提示具体是哪个字段出错
41030 page路径不正确,需要保证在现网版本小程序中存在,与app.json保持一致

#创建发送消息工具js

export const wxSendMessage = async (options) => {
// POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=ACCESS_TOKEN
let accessToken = await wxGetAccessToken()
try {
const { data } = await instance.post(`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`, options)
return data
} catch (error) {
logger.error(`wxSendMessage error: ${error.message}`)
}
}

举例:

在用户登录之后,发送订阅消息通知:

// 推送消息
// 字段限制:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/subscribe-message/subscribeMessage.send.html
const notify = await wxSendMessage({
touser: tmpUser.openid,
template_id: 'FSQZganmBgaRRoNNlelQ1Qm2u4gx6pVSt69EJfkLbPA',
data: {
phrase1: {
value: '登录安全'
},
date2: {
value: moment().format('YYYY年MM月DD HH:mm')
},
thing4: {
value: '通过微信授权登录成功,请注意信息安全'
}
},
miniprogram_state: process.env.NODE_ENV === 'development' ? 'developer' : 'formal'
})

#前后端联调

联调需要注意的点:

image-20210812111502555

#内容安全

#微信安全检测

微信官方提供了可疑用户与危险接口的检查:

image-20210815144637652

接口安全扫描:

image-20210815144649621

这两个功能在后台即可操作,平坦可以交由运营小姐姐来进行操作一下。

#第三方内容安全推荐

#文本内容安全

#整体思路

#官方介绍

检查一段文本是否含有违法违规内容,下面介绍的接口:官方链接(opens new window)

1.0 版本接口文档【点击查看】(opens new window)

应用场景举例:

  1. 用户个人资料违规文字检测;
  2. 媒体新闻类用户发表文章,评论内容检测;
  3. 游戏类用户编辑上传的素材(如答题类小游戏用户上传的问题及答案)检测等。 *频率限制:单个 appId 调用上限为 4000 次/分钟,2,000,000 次/天**

调用方式:

#工具js封装

请求地址

POST https://api.weixin.qq.com/wxa/msg_sec_check?access_token=ACCESS_TOKEN

请求参数

属性 类型 默认值 必填 说明
access_token / cloudbase_access_token string 接口调用凭证(opens new window)
version string 接口版本号,2.0版本为固定值2
openid string 用户的openid(用户需在近两小时访问过小程序)
scene number 场景枚举值(1 资料;2 评论;3 论坛;4 社交日志)
content string 需检测的文本内容,文本字数的上限为2500字
nickname string 用户昵称
title string 文本标题
signature string 个性签名,该参数仅在资料类场景有效(scene=1)

返回的 JSON 数据包

属性 类型 说明
errcode number 错误码
errmsg string 错误信息
trace_id string 唯一请求标识,标记单次请求
result object 综合结果
detail array 详细检测结果

逻辑:

// 文本内容安全
// content - 内容
// title - 标题 可选
// signature - 签名 也是可选
export const wxMsgCheck = async (content, {
user: {
openid,
name: nickname,
remark: signature
},
scene,
title
} = {
user: {},
scene: 3,
title: ''
}) => {
// POST https://api.weixin.qq.com/wxa/msg_sec_check?access_token=ACCESS_TOKEN
let accessToken = await wxGetAccessToken()
let res
try {
// 1.过滤掉一些如Html,自定义的标签内容
content = content.replace(/<[^>]+>/g, '').replace(/\sface\[\S{1,}]/g, '').replace(/img\[\S+\]/g, '').replace(/\sa\(\S+\]/g, '').replace(/\[\/?quote\]/g, '').replace(/\[\/?pre\]/g, '').replace(/\[\/?hr\]/g, '').replace(/[\r\n|\n|\s]/g, '')
// 2.如果content内容超过了2500词,需要进行分段处理
if (content.length > 2500) {
// 分段 —> arr -> method1: for , method2: reg
let arr = content.match(/[\s\S]{1,2500}/g) || []
// 多次请求接口
let mulResult = []
for (let i = 0; i < arr.length; i++) {
// 获取所有接口的返回结果 -> 结果判断 -> 返回
res = await instance.post(`https://api.weixin.qq.com/wxa/msg_sec_check?access_token=${accessToken}`, {
version: 2,
openid: openid || 'ooTjn5YPpogMWLtEQ_PxyUJkIp2I',
scene,
content: arr[i],
nickname: nickname,
title,
signature: scene === 1 ? signature : null
})
mulResult.push(res)
}
// 判断mulResult
console.log(mulResult)
const arrTemp = mulResult.filter(item => {
const { status, data: { errcode, result } } = item
return status !== 200 || errcode !== 0 || (result && result.suggest !== 'pass')
})
return !(arrTemp.length > 0)
} else {
res = await instance.post(`https://api.weixin.qq.com/wxa/msg_sec_check?access_token=${accessToken}`, {
version: 2,
openid: openid || 'ooTjn5YPpogMWLtEQ_PxyUJkIp2I',
scene,
content,
nickname: nickname,
title,
signature: scene === 1 ? signature : null
})
const { status, data: { errcode, result } } = res
return status === 200 && errcode === 0 && result && result.suggest === 'pass'
}
} catch (error) {
logger.error(`wxMsgCheck error: ${error.message}`)
}
}

#自定义安全敏感词汇

在小程序管理后台,开发管理 -> 安全中心 -> 内容风控:

image-20210815144815314

在这里添加的分值越高,越可能被屏蔽。

#测试接口工具js

#图片内容安全

#整体思路

#官方介绍

请求地址

POST https://api.weixin.qq.com/wxa/img_sec_check?access_token=ACCESS_TOKEN

请求参数

属性 类型 默认值 必填 说明
access_token / cloudbase_access_token string 接口调用凭证(opens new window)
media FormData 要检测的图片文件,格式支持PNG、JPEG、JPG、GIF,图片尺寸不超过 750px x 1334px

返回的 JSON 数据包

属性 类型 说明
errcode number 错误码
errmsg string 错误信息

errcode 的合法值

说明 最低版本
0 内容正常
87014 内容可能潜在风险

#工具js封装

安装依赖:

npm i make-dir form-date sharp del

其中sharp (opens new window)需要配置加速:

npm config set sharp_binary_host "https://npm.taobao.org/mirrors/sharp"
npm config set sharp_libvips_binary_host "https://npm.taobao.org/mirrors/sharp-libvips"

工具js:

// 获取头部属性
export const getHeaders = (form) => {
return new Promise((resolve, reject) => {
form.getLength((err, length) => {
if (err) {
reject(err)
}
const headers = Object.assign({
'Content-Length': length
}, form.getHeaders())
resolve(headers)
})
})
}

// 删除文件
export const checkAndDelFile = async (path) => {
try {
accessSync(path, constants.R_OK | constants.W_OK)
await del(path)
} catch (err) {
// console.error('no access!')
}
}

// 图片内容安全
export const wxImgCheck = async (file) => {
// POST https://api.weixin.qq.com/wxa/img_sec_check?access_token=ACCESS_TOKEN
const accessToken = await wxGetAccessToken()
// 1.保证图片 -> 判断分辨率 -> sharp 750 * 1334
let newPath = file.path
const tmpPath = path.resolve('./tmp')
try {
const img = sharp(newPath)
const meta = await img.metadata()
if (meta.width > 750 || meta.height > 1334) {
// 判断临时路径是否存在,并创建
await mkdir(tmpPath)
// uuid -> 指定临时的文件名称
newPath = path.join(tmpPath, uuidv4() + path.extname(newPath) || '.jpg')
await img.resize(750, 1334, {
fit: 'inside'
}).toFile(newPath)
}
const stream = fs.createReadStream(newPath)
// 2.FormData类型的数据准备
const form = new FormData()
form.append('media', stream)
const headers = await getHeaders(form)
// 3.请求接口 -> 返回结果
const result = await instance.post(`https://api.weixin.qq.com/wxa/img_sec_check?access_token=${accessToken}`, form, { headers })
// 校验成功 -> 删除tmp数据 -> 判断路径中的文件是否存在
console.log('🚀 ~ file: WxUtils.js ~ line 232 ~ wxImgCheck ~ result', result)
await checkAndDelFile(newPath)
return result.status === 200 && result.data && result.data.errcode === 0
// if (result.status === 200 && result.data && result.data.errcode === 0) {
// // errcode 0 - 内容正常,否则 - 异常
// return true
// } else {
// return false
// }
} catch (error) {
await checkAndDelFile(newPath)
logger.error(`wxImgCheck error: ${error.message}`)
}
}

#测试接口工具js

注意:

image-20210815154746020

#发贴评论功能

#创建分包

在uniapp中添加分包,减少主包体积,提升页面加载的性能:

pages.json中配置分包:

"subPackages": [
{
"root": "subcom-pkg",
"pages": [
...
{
"path": "post/post",
"style": {
"navigationBarTitleText": "发贴"
}
}
]
}
]

#表单校验

使用uview中的form (opens new window)表单组件:

<template>
<view class="container">
<u-form :model="form" ref="uForm" label-width="0">
<u-form-item prop="title">
<u-input v-model="form.title" placeholder="请输入帖子标题" clearable></u-input>
</u-form-item>
<u-form-item prop="content">
<u-input v-model="form.content" placeholder="请输入帖子内容" type="textarea"></u-input>
</u-form-item>

<view class="upload-img">
<view class="prev">设置封面图片:</view>
<!-- 上传图片 -->
</view>
<u-form-item :label-position="labelPosition" label="发贴类型" label-width="140" prop="catalog">
<u-input class="right-text" type="select" :select-open="show1" v-model="form.catalog" placeholder="请选择发贴类型" @click="show1=true"></u-input>
<u-select v-model="show1" :list="list" @confirm="confirmType"></u-select>
</u-form-item>
<!-- </view> -->
<u-form-item label="奖励积分" label-width="140" prop="fav">
<u-input class="right-text" type="select" :select-open="show" v-model="form.fav" placeholder="请选择奖励积分" @click="show=true"></u-input>
<u-select v-model="show" :list="tempFavs" @confirm="confirmFav"></u-select>
</u-form-item>
</u-form>
<view class="btn">
<u-button size="default" type="primary" hover-class="none">发布</u-button>
</view>
</view>
</template>

加入表单校验:

<script>
export default {
components: {},
data: () => ({
show: false,
show1: false,
form: {
title: '',
content: '',
catalog: '',
fav: '',
snapshot: ''
},
rules: {
title: [
{
required: true,
message: '请输入标题',
// 可以单个或者同时写两个触发验证方式
trigger: ['blur']
}
],
content: [
{
required: true,
message: '请输入文章内容',
trigger: 'blur'
}
],
catalog: [
{
required: true,
message: '请选择发贴类型',
// 触发器可以同时用blur和change
trigger: ['change', 'blur']
}
],
fav: [
{
required: true,
message: '请选择积分',
trigger: ['change', 'blur']
}
]
},
list: [
{
value: '',
label: '请选择'
},
{
value: 'ask',
label: '提问'
},
{
value: 'share',
label: '分享'
},
{
value: 'discuss',
label: '讨论'
},
{
value: 'advise',
label: '建议'
}
],
listIndex: 0,
tempFavs: [
{
label: '请选择',
value: ''
},
{
label: '20',
value: 20
},
{
label: '30',
value: 30
},
{
label: '50',
value: 50
},
{
label: '100',
value: 100
}
],
favIndex: 0,
fileList: [],
disabledButton: true
}),
methods: {
confirmType (e) {
const index = this.list.findIndex(item => item.value === e[0].value)
this.listIndex = index
this.form.catalog = e[0].label
},
confirmFav (e) {
const index = this.tempFavs.findIndex(item => item.value === e[0].value)
this.favIndex = index
this.form.fav = e[0].label
},
addPost () {
this.$refs.uForm.validate(async valid => {
if (valid) {
// to do
} else {
console.log('验证失败')
}
})
}
},
onReady () {
this.$refs.uForm.setRules(this.rules)
}
}
</script>

#图片上传

#上传接口

上传formdata类型的数据,需要使用uni.uploadFile API,封装成一个promise谅,在complete中加入callback,方便后续的处理:

// 图片上传接口
const uploadImg = (params, callback) => {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: baseUrl + '/content/upload', // 仅为示例,非真实的接口地址
filePath: params,
name: 'file',
header: {
'Content-Type': 'multipart/form-data',
authorization: 'Bearer ' + store.state.token
},
formData: {
// 'user': 'test'
},
success: (uploadFileRes) => {
resolve(uploadFileRes)
},
fail: (err) => {
reject(err)
},
complete: (res) => {
callback && callback(res)
}
})
})
}

// 发贴接口
const addPost = (data) => axios.post('/content/wxAdd', data)

#页面结构

可以使用u-upload (opens new window)组件来轻松实现图片上传:

<template>
<view class="container">
<u-form :model="form" ref="uForm" label-width="0">
<u-form-item prop="title">
<u-input v-model="form.title" placeholder="请输入帖子标题" clearable></u-input>
</u-form-item>
<u-form-item prop="content">
<u-input v-model="form.content" placeholder="请输入帖子内容" type="textarea"></u-input>
</u-form-item>

<view class="upload-img">
<view class="prev">设置封面图片:</view>
<u-upload ref="uUpload" :file-list="fileList" action="#" :auto-upload="false" @on-list-change="uploadImg" multiple :max-size="5 * 1024 * 1024" max-count="1" />
</view>
<u-form-item :label-position="labelPosition" label="发贴类型" label-width="140" prop="catalog">
<u-input class="right-text" type="select" :select-open="show1" v-model="form.catalog" placeholder="请选择发贴类型" @click="show1=true"></u-input>
<u-select v-model="show1" :list="list" @confirm="confirmType"></u-select>
</u-form-item>
<!-- </view> -->
<u-form-item label="奖励积分" label-width="140" prop="fav">
<u-input class="right-text" type="select" :select-open="show" v-model="form.fav" placeholder="请选择奖励积分" @click="show=true"></u-input>
<u-select v-model="show" :list="tempFavs" @confirm="confirmFav"></u-select>
</u-form-item>
</u-form>
<view class="btn">
<u-button size="default" type="primary" @click="addPost" hover-class="none">发布</u-button>
</view>
</view>
</template>

<script>
import { authNav } from '@/common/checkAuth'
export default {
data: () => ({
// ...
fileList: [],
}),
methods: {
// ....
async uploadImg (lists, name) {
if (lists.length > 0) {
const res = await this.$u.api.uploadImg(lists[0].url)
if (res.statusCode === 401) {
await authNav('登录已失效,图片上传失败,请登录后重传!')
this.$refs.uUpload.clear()
}
if (res.statusCode === 200) {
const { data } = res
const { code, msg, data: url } = JSON.parse(data)
if (code === 200) {
this.form.snapshot = url
}
uni.showToast({
icon: 'none',
title: msg,
duration: 2000
})
}
}
},
// ...
},
}
</script>

#完成效果

<template>
<view class="container">
<u-form :model="form" ref="uForm" label-width="0">
<u-form-item prop="title">
<u-input v-model="form.title" placeholder="请输入帖子标题" clearable></u-input>
</u-form-item>
<u-form-item prop="content">
<u-input v-model="form.content" placeholder="请输入帖子内容" type="textarea"></u-input>
</u-form-item>

<view class="upload-img">
<view class="prev">设置封面图片:</view>
<u-upload ref="uUpload" :file-list="fileList" action="#" :auto-upload="false" @on-list-change="uploadImg" multiple :max-size="5 * 1024 * 1024" max-count="1" />
</view>
<u-form-item :label-position="labelPosition" label="发贴类型" label-width="140" prop="catalog">
<u-input class="right-text" type="select" :select-open="show1" v-model="form.catalog" placeholder="请选择发贴类型" @click="show1=true"></u-input>
<u-select v-model="show1" :list="list" @confirm="confirmType"></u-select>
</u-form-item>
<!-- </view> -->
<u-form-item label="奖励积分" label-width="140" prop="fav">
<u-input class="right-text" type="select" :select-open="show" v-model="form.fav" placeholder="请选择奖励积分" @click="show=true"></u-input>
<u-select v-model="show" :list="tempFavs" @confirm="confirmFav"></u-select>
</u-form-item>
</u-form>
<view class="btn">
<u-button size="default" type="primary" @click="addPost" hover-class="none">发布</u-button>
</view>
</view>
</template>

<script>
import { mapMutations } from 'vuex'
import { authNav } from '@/common/checkAuth'
export default {
components: {},
data: () => ({
show: false,
show1: false,
form: {
title: '',
content: '',
catalog: '',
fav: '',
snapshot: ''
},
rules: {
title: [
{
required: true,
message: '请输入标题',
// 可以单个或者同时写两个触发验证方式
trigger: ['blur']
}
],
content: [
{
required: true,
message: '请输入文章内容',
trigger: 'blur'
}
],
catalog: [
{
required: true,
message: '请选择发贴类型',
// 触发器可以同时用blur和change
trigger: ['change', 'blur']
}
],
fav: [
{
required: true,
message: '请选择积分',
trigger: ['change', 'blur']
}
]
},
list: [
{
value: '',
label: '请选择'
},
{
value: 'ask',
label: '提问'
},
{
value: 'share',
label: '分享'
},
{
value: 'discuss',
label: '讨论'
},
{
value: 'advise',
label: '建议'
}
],
listIndex: 0,
tempFavs: [
{
label: '请选择',
value: ''
},
{
label: '20',
value: 20
},
{
label: '30',
value: 30
},
{
label: '50',
value: 50
},
{
label: '100',
value: 100
}
],
favIndex: 0,
fileList: [],
disabledButton: true
}),
computed: {
},
methods: {
...mapMutations(['setPage']),
confirmType (e) {
const index = this.list.findIndex(item => item.value === e[0].value)
this.listIndex = index
this.form.catalog = e[0].label
},
confirmFav (e) {
const index = this.tempFavs.findIndex(item => item.value === e[0].value)
this.favIndex = index
this.form.fav = e[0].label
},
async uploadImg (lists, name) {
if (lists.length > 0) {
const res = await this.$u.api.uploadImg(lists[0].url)
if (res.statusCode === 401) {
await authNav('登录已失效,图片上传失败,请登录后重传!')
this.$refs.uUpload.clear()
}
if (res.statusCode === 200) {
const { data } = res
const { code, msg, data: url } = JSON.parse(data)
if (code === 200) {
this.form.snapshot = url
}
uni.showToast({
icon: 'none',
title: msg,
duration: 2000
})
}
}
},
addPost () {
this.$refs.uForm.validate(async valid => {
if (valid) {
const data = {
...this.form,
catalog: this.list[this.listIndex].value,
fav: this.tempFavs[this.favIndex].value
}
const { code, msg, data: res } = await this.$u.api.addPost(data)
// console.log('🚀 ~ file: post.vue ~ line 157 ~ addPost ~ res', res)
if (code === 200 && res._id) {
uni.showToast({
icon: 'none',
title: msg,
duration: 2000
})
uni.navigateBack()
} else {
// 内容审核提示
if (code === 500 && /内容安全/.test(msg)) {
uni.showModal({
title: '注意文明用语',
content: '发布内容没有通过内容审核,请检查后重新提效',
showCancel: false,
success: function (res) {
console.log(res)
}
})
return
}
uni.showToast({
icon: 'none',
title: msg,
duration: 2000
})
}
} else {
console.log('验证失败')
}
})
}
},
onReady () {
this.$refs.uForm.setRules(this.rules)
}
}
</script>

<style lang="scss" scoped>
.container {
padding: 32rpx;
}

::v-deep .edit-post {
position: relative;
textarea {
max-height: 400rpx;
}
.u-clear-icon {
position: absolute;
right: 10rpx;
bottom: 30rpx;
}
}

.btn {
margin-top: 60rpx;
}

::v-deep .right-text {
.u-input__input {
text-align: end;
padding-right: 15rpx;
}
}

.prev {
padding: 15rpx 0 30rpx;
}
</style>

完成效果:

image-20210527014805079

#发帖服务端逻辑

#图片上传

// 上传图片
async uploadImg (ctx) {
const file = ctx.request.files.file
// 这里加入内容安全
const flag = await wxImgCheck(file)
if (!flag) {
ctx.body = {
code: 500,
msg: '内容安全校验失败,请检查'
}
return
}
// 图片名称、图片格式、存储的位置,返回前台一可以读取的路径
const ext = file.name.split('.').pop()
const dir = `${config.uploadPath}/${moment().format('YYYYMMDD')}`
// 判断路径是否存在,不存在则创建
await mkdir(dir)
// 存储文件到指定的路径
// 给文件一个唯一的名称
const picname = uuidv4()
const destPath = `${dir}/${picname}.${ext}`
const reader = fs.createReadStream(file.path)
const upStream = fs.createWriteStream(destPath)
const filePath = `/${moment().format('YYYYMMDD')}/${picname}.${ext}`
// method 1
reader.pipe(upStream)

ctx.body = {
code: 200,
msg: '图片上传成功',
data: filePath
}
}

#发帖接口

调整整体的逻辑,删除验证码的逻辑部分,并添加内容安全校验:

async addWxPost (ctx) {
const { body } = ctx.request
// 校验用户传递的参数与内容是否通过内容安全的校验
// 判断用户的积分数是否 > fav,否则,提示用户积分不足发贴
// 用户积分足够的时候,新建Post,减除用户对应的积分
const user = await User.findByID({ _id: ctx._id })
const flag = await wxMsgCheck(body.content || '', { user: user, title: body.title })
if (!flag) {
ctx.body = {
code: 500,
msg: '内容安全校验失败,请检查'
}
return
}
if (user.favs < body.fav) {
ctx.body = {
code: 501,
msg: '积分不足'
}
return
} else {
await User.updateOne({ _id: ctx._id }, { $inc: { favs: -body.fav } })
}
const newPost = new Post(body)
newPost.uid = ctx._id
const result = await newPost.save()
ctx.body = {
code: 200,
msg: '成功的保存的文章',
data: result
}
}

添加路由:

// 小程序发表新贴
router.post('/wxAdd', contentController.addWxPost)

#评论功能

#创建接口

// 评论接口
const addComment = (data) => axios.post('/comments/reply', data)

#fixed底部定位问题

在小程序中,使用了fixed定位的底部元素可能被遮挡:

解决方案:

<!-- fixed: 1.static/absolute 2.点击跳转新页面 3.cursor-spacing -->
<view class="box u-flex u-col-center" v-else>
<u-input v-model="content" class="reply" placeholder="请输入评论内容" focus @clear="clear" :cursor-spacing="10"></u-input>
<button type="primary" plain size="mini" @click.stop="send">发送</button>
</view>

推荐:使用cursor-spacing官方链接(opens new window)

image-20210817235100442

如果不设置,默认是0,也是为什么会浮动不准确的原因。

#页面样式与事件

<template>
<view class="detail" :class="{'ipx': ipxFlag}" v-show="page._id" @click.stop="showReply = false">
// .....
<view class="footer">
<view class="box u-flex u-col-center" v-if="!showReply">
<view class="add-comment" @click.stop="reply()">
<u-icon name="edit-pen" size="32" color="#cccccc"></u-icon>
<text class="text">写评论</text>
</view>
<view class="ctrls u-flex u-col-center u-row-between">
<view class="comment u-flex flex-column">
<u-icon name="chat" size="45"></u-icon>
<text>评论{{ page.answer > 0 ? page.answer : ''}}</text>
</view>
<view class="fav u-flex flex-column" :class="{'active': page.isFav === 1}" @click="setCollect">
<u-icon name="star-fill" size="45" v-if="page.isFav === 1"></u-icon>
<u-icon name="star" size="45" v-else></u-icon>
<text>{{page.isFav === 1 ? '已收藏': '收藏'}}</text>
</view>
<view class="like u-flex flex-column" :class="{'active': page.isHand === 1}" @click="handsPost">
<u-icon name="thumb-up-fill" size="45" v-if="page.isHand === 1"></u-icon>
<u-icon name="thumb-up" size="45" v-else></u-icon>
<text>{{page.isHand === 1 ? '已点赞' : '点赞'}}</text>
</view>
</view>
</view>
<!-- fixed: 1.static/absolute 2.点击跳转新页面 3.cursor-spacing -->
<view class="box u-flex u-col-center" v-else>
<u-input v-model="content" class="reply" placeholder="请输入评论内容" focus @clear="clear" :cursor-spacing="10"></u-input>
<button type="primary" plain size="mini" @click.stop="send">发送</button>
</view>
</view>
</view>
</template>

<script>
import { mapGetters, mapState } from 'vuex'
import { checkToken } from '@/common/checkAuth'
import formatHTML from '@/common/utils/formatHTML'

export default {
components: {},
data: () => ({
// ....
content: '',
showReply: false,
// ...
}),
// ....
methods: {
// ....
reply () {
if (!this.check()) return
this.showReply = true
},
async send () {
const { code, msg } = await this.$u.api.addReply({ tid: this.params.tid, content: this.content })
if (code === 200) {
uni.$success(msg)
} else {
if (code === 500 && /内容安全/.test(msg)) {
uni.showModal({
title: '注意文明用语',
content: '发布内容没有通过内容审核,请检查后重新提效',
showCancel: false
})
return
}
uni.$error(msg)
}
await this.getReply()
this.content = ''
this.showReply = false
},
},
// ...
}
</script>

<style lang="scss">
.detail {
background: #f4f6f8;
height: 100vh;
&.ipx {
.comments,
.footer {
padding-bottom: constant(safe-area-inset-bottom); // 兼容iOS < 11.2
padding-bottom: env(safe-area-inset-bottom);
}
}
}

.header,
.content,
.comments {
background: #fff;
padding: 32rpx;
}

.header,
.content {
margin-bottom: 24rpx;
box-shadow: 0 5rpx 5px rgba($color: black, $alpha: 0.1);
}

.add-hand {
position: relative;
.caina {
position: absolute;
right: 100rpx;
top: -20rpx;
}
.setBest {
padding-left: 25rpx;
}
}

.footer {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
padding: 10px 32rpx;
background-color: #fff;
// height: 100rpx;
box-shadow: 0 -5rpx 5px rgba($color: black, $alpha: 0.1);
.box {
width: 100%;
}
.reply {
flex: 1;
border: 1px solid #eee;
padding: 0 15rpx;
margin-right: 15rpx;
}
}

.title {
font-size: 32rpx;
color: #333;
font-weight: bold;
}
.add-comment {
background: #f3f3f3;
height: 64rpx;
border-radius: 32rpx;
line-height: 64rpx;
padding: 0 32rpx;
width: 65%;
margin-right: 40rpx;
color: #ccc;
.text {
padding-left: 10rpx;
}
}

.loading {
height: 50px;
.loading-text {
padding-left: 15rpx;
}
}

.layui-elem-quote {
margin-bottom: 10rpx;
padding: 15rpx;
line-height: 14px;
border-left: 2px solid #009688;
border-radius: 0 2px 2px 0;
background-color: #f2f2f2;
}
</style>

#评论服务端逻辑

#需求分析

#评论接口

路由:

// 微信评论回复
router.post('/wxreply', commentsController.wxAddComment)

逻辑代码:

const canReply = async (ctx) => {
let result = false
// const obj = await getJWTPayload(ctx.header.authorization)
if (typeof ctx._id === 'undefined') {
return result
} else {
const user = await User.findByID(ctx._id)
if (user && user.status === '0') {
result = true
}
return result
}
}

async wxAddComment(ctx) {
// 查看用户是否被禁言
const check = await canReply(ctx)
if (!check) {
ctx.body = {
code: 500,
msg: '用户已被禁言!',
}
return
}
const { body } = ctx.request
const result = await wxMsgCheck(body.content)
if (result && ctx._id) {
// const obj = await getJWTPayload(ctx.header.authorization)
const user = await User.findByID(ctx._id)
const newComment = new Comments(body)
newComment.cuid = ctx._id
// 添加文章评论记数
await Post.updateOne({ _id: body.tid }, { $inc: { answer: 1 } })
const post = await Post.findByPostId(body.tid)
newComment.uid = post.uid._id // 保存帖子作者的id
const comment = await newComment.save()

// 调用微信订阅消息api
// 1、发帖的作者必须是用微信登录的才可以接收订阅消息
// 2、自己不能给自己发订阅消息
// if (post.uid.openid) {
if (ctx._id !== post.uid._id.toString() && post.uid.openid) {
const notice = await wxSendMessage({
touser: post.uid.openid,
template_id: 'ANN2-LhDgrhdFjs7jHOLdTnaxWpQU1LqS3kDIMF9GDs',
data: {
thing1: {
value: getShort(post.title),
},
thing4: {
value: getCatalog(post.catalog),
},
thing2: {
value: getShort(comment.content),
},
name6: {
value: user.name.substr(0, 10),
},
date3: {
value: moment().format('YYYY年MM月DD HH:mm'),
},
},
page: '/subcom-pkg/detail/detail?tid=' + post._id,
})
console.log(notice)
}

ctx.body = {
msg: '回帖成功',
code: 200,
data: comment,
}
} else {
ctx.body = {
code: 500,
msg: '内容安全:' + result.errmsg,
}
}
}

#发布上线

#分包机制

某些情况下,开发者需要将小程序划分成不同的子包,在构建时打包成不同的分包,用户在使用时按需进行加载。

image-20210818000627795

#普通分包

在构建小程序分包项目时,构建会输出一个或多个分包。

每个使用分包小程序必定含有一个主包

所谓的主包,即放置默认启动页面/TabBar 页面,以及一些所有分包都需用到公共资源/JS 脚本;而分包则是根据开发者的配置进行划分。

在小程序启动时,默认会下载主包并启动主包内页面,当用户进入分包内某个页面时,客户端会把对应分包下载下来,下载完成后再进行展示。

目前小程序分包大小有以下限制:

对小程序进行分包,可以优化小程序首次启动的下载时间,以及在多团队共同开发时可以更好的解耦协作。

#独立分包

独立分包是小程序中一种特殊类型的分包,可以独立于主包和其他分包运行。从独立分包中页面进入小程序时,不需要下载主包。当用户进入普通分包或主包内页面时,主包才会被下载。

独立分包属于分包的一种,普通分包的所有限制都对独立分包有效(2M大小)。独立分包中插件、自定义组件的处理方式同普通分包。

此外,使用独立分包时要注意:

应用场景:活动页面、登录注册相关页面…

大多数独立分包的场景,使用分包也可以很好的完成对应的功能,而且可以共享主包的样式。

#分包预加载

开发者可以通过配置,在进入小程序某个页面时,由框架自动预下载可能需要的分包,提升进入后续分包页面时的启动速度。

预下载分包行为在进入某个页面时触发,通过在 app.json 增加 preloadRule 配置来控制。

"preloadRule": {
"进入的页面": {
"network": "all",
"packages": ["加载的包中的页面", "或者加载整个目录"]
},
"sub1/index": {
"packages": ["hello", "sub3"]
},
"sub3/index": {
"packages": ["path/to"]
},
"indep/index": {
"packages": ["__APP__"]
}
}

preloadRule 中,key 是页面路径,value 是进入此页面的预下载配置,每个配置有以下几项:

字段 类型 必填 默认值 说明
packages StringArray 进入页面后预下载分包的 rootname__APP__ 表示主包。
network String wifi 在指定网络下预下载,可选值为: all: 不限网络 wifi: 仅wifi下预下载

说明:

不是必要,不是非常影响用户的交互体验,不要占用预下载分包的份额,不要使用大于40k的图片与资源。

#检查分包大小

点击查看详情,用于排查哪些比较大的资源:

image-20210818001614038

#基本流程

img

#成员说明

小程序成员管理包括对小程序项目成员及体验成员的管理。

不同项目成员拥有不同的权限,从而保证小程序开发安全有序。

权限 运营者 开发者 数据分析者
开发者权限
体验者权限
登录
数据分析
微信支付
推广
开发管理
开发设置
暂停服务
解除关联公众号
腾讯云管理
小程序插件
游戏运营管理

配置路径:登录https://mp.weixin.qq.com/,选择管理 -> 成员管理 -> 添加项目成员,最多100个;体验成员->最多100个(已认证);

image-20210818123401415

#版本说明

权限 说明
开发版本 使用开发者工具,可将代码上传到开发版本中。 开发版本只保留每人最新的一份上传的代码。 点击提交审核,可将代码提交审核。开发版本可删除,不影响线上版本和审核中版本的代码。
体验版本 可以选择某个开发版本作为体验版,并且选取一份体验版。
审核中版本 只能有一份代码处于审核中。有审核结果后可以发布到线上,也可直接重新提交审核,覆盖原审核版本。
线上版本 线上所有用户使用的代码版本,该版本代码在新版本代码发布后被覆盖更新。

#注意事项

#配置BaseURL

export const baseUrl = process.env.NODE_ENV === 'development' ? 'http://192.168.31.132:3000' : 'https://yourdomain.com'

uniapp内置的打包工具即是webpack

说明:

#使用HBuilder生产打包方式

一定要注意,在uniapp中开发的时候,启动的是调试进程,这时的代码中有很多调试代码,也未进行压缩。

步骤:

#打包上线

#小程序打包

检查分包大小,注意这里的项目名称并非小程序的项目名称。

小程序的名称在注册小程序的时候,就已经确定下来了,这里只是一个项目别名。

请检查小程序的AppID。

#上传代码

image-20210818123801710

上传代码是用于提交体验或者审核使用的。

点击开发者工具顶部操作栏的上传按钮,填写版本号以及项目备注,需要注意的是,这里版本号以及项目备注是为了方便管理员检查版本使用的,开发者可以根据自己的实际要求来填写这两个字段。

image-20210818124047238

#审核与发布

#审核版本

上传成功之后,登录小程序管理后台 (opens new window)- 开发管理 - 开发版本 就可以找到刚提交上传的版本了。

可以将这个版本设置 体验版 或者是 提交审核。

image-20210818124719121

然后点击下一步:

image-20210818124806867

说明:

审核中:

image-20210818125910968

#关于灰度发布

审核通过后,需要在页面中点击发布,选择全量发布或者指定用户进行发布(灰度测试)。

点击发布后,即可发布小程序。小程序提供了两种发布模式:全量发布和分阶段发布。全量发布是指当点击发布之后,所有用户访问小程序时都会使用当前最新的发布版本。

分阶段发布是指分不同时间段来控制部分用户使用最新的发布版本,分阶段发布我们也称为灰度发布。一般来说,普通小程序发布时采用全量发布即可,当小程序承载的功能越来越多,使用的用户数越来越多时,采用分阶段发布是一个非常好的控制风险的办法。

#支付专题

支付作为工作中最常见的一个核心业务场景,前端同学其实需要做的东西有限,基本的流程、逻辑与难点都是在服务端。为了让前端同学,也能自己后续开发支付功能(Node.js),在本篇介绍了详细的从企业主体 -> 支付必要条件 ->微信小程序支付完整的闭环。

支付业务支撑:

支付应用场景:

支付的技术难点:

常见的问题:

#企业注册与税务

开办企业需要注意:

无论是支付宝还是微信,能够支持的支付主体只有企业、个体工商户和政府及事业单位等,共同点是非个人

个人开发者,如果需要接入支付功能,也可以选择第三方服务商:

  • PayJS:开通费用300+手续费0.38%+服务费2%
  • PaysApi:月付手续费30-199+单笔费率从0.3~0.1%不等
  • PayBob:开通费用300+手续费0.38%+服务费1-2%
  • xorpay:月付手续费0-60+手续费0.38%+单笔手续费1.2%~0.5%不等

了解企业的注册流程及税务相关的知识,有助于学习支付相关的内容,扩展知识面。

企业注册比较麻烦的地方:

但是,开办企业也是有好处的,举例:

#注册企业流程

图片 1

企业注册流程中,需要注意的点:

#个人工商户与企业区别

共同点:

不同点:

#自己注册vs代注册

自己注册:

优点:

缺点:

代注册:

优点:

缺点:

#报税&开票

大多数企业会找一个代账会计或者找一个代账公司,而企业前期是无需会计的,完全可以零报税,操作流程非常的简单。

如果不清楚报税的流程,可以在税务机关处进行现场报税。

代账费用:从200元到300元/月不等。

找了代账的企业需要会看如下的几个表格,了解概念:

除了找代账公司以外,Brain更推荐自己在前期进行记账与报税,流程不复杂,而且软件非常的智能,只用填入自己的收入与支出,财务软件可以自动形成报表。

财务软件推荐:

云平台推荐:

季度+年度报税:3个月报季报,照着软件填

#企业日常的开销

主要分为如下几类:

从上面的分类来看,注册企业没有什么费用,反而是维持一个企业的运转是需要大量费用的。大家在准备企业开办之前,需要有一定的准备。

不打没有准备的仗,也不要什么都准备好了,才开始!!

#支付前置必要条件

下面以微信支付为例,来介绍,开发支付功能需要准备的前置条件:

微信支付:

支付宝支付:

image-20210830134004706

说明:

#小程序支付流程

#开发技巧

如果是学习,也可以从0到1的开发,了解整个支付的流程。

#流程图

学会看时序图:

img

重点步骤说明:

步骤3:用户下单发起支付,商户可通过JSAPI下单 (opens new window)创建支付订单。

步骤8: 用户可通过小程序调起支付API (opens new window)调起微信支付,发起支付请求。

步骤15:用户支付成功后,商户可接收到微信支付支付结果通知支付通知API (opens new window)

步骤20:商户在没有接收到微信支付结果通知的情况下需要主动调用查询订单API (opens new window)查询支付结果。

#搭建开发环境

#支付准备

image-20210830135731466

按照上面的流程,准备相关的开发环境,参考指引:官方链接(opens new window)

#配置Https+域名解析

说明:

#配置API密钥

商户平台 (opens new window)-> 账户中心 -> API安全,分别配置API商户证书 + APIv3密钥

image-20210830140522534

#配置frp内网穿透

为了方便测试微信支付通知,有两种方案:

配置过程如下:

如果运行失败,可以在官方网站 (opens new window)上查询失败的原因:

#APIv3 vs APIv2

为了在 保证支付 安全的前提下,带给商户 简单、一致且易用的开发体验,我们推出了全新的微信支付API v3。

相较于之前的微信支付API,主要区别是:

APIv3的文档:官方链接(opens new window)

两个接口的对接图:

V3 规则差异 V2
JSON 参数格式 XML
POST、GET 或 DELETE 提交方式 POST
AES-256-GCM加密 回调加密 无需加密
RSA 加密 敏感加密 无需加密
UTF-8 编码方式 UTF-8
非对称密钥SHA256-RSA 签名方式 MD5 或 HMAC-SHA256

推荐:在没有接触v2的情况下,直接上手v3;如果有老旧业务,也可以对接到v3,或者不动原有的业务,直至v2的证书快过期,需要更换时,再切换到v3。

#JSAPI统一下单

#签名生成

官方文档(opens new window)

商户可以按照下述步骤生成请求的签名,微信支付API v3 要求商户对请求进行签名,微信支付会在收到请求后进行签名的验证。

如果签名验证不通过,微信支付API v3将会拒绝处理请求,并返回401 Unauthorized

签名生成的步骤有:

第一步比较好实现:

// HTTP请求方法\n
// URL\n https://www.imooc.com/path1/path2/?query1=value
// 请求时间戳\n
// 请求随机串\n
// 请求报文主体\n
const tmpUrl = new URL(url)
const nonceStr = rand.generate(16)
const pathname = /http/.test(url) ? tmpUrl.pathname : url
const timestamp = Math.floor(Date.now() / 1000)
const message = `${method.toUpperCase()}\n${pathname + tmpUrl.search
}\n${timestamp}\n${nonceStr}\n${body ? JSON.stringify(body) : ''}\n`

第二步的RSA的算法实现思路:

如果 以上都没有,那么就要自己造轮子了。但是这样的场景少之有少,哈哈,还轮不上大家自己上。

export const rsaSign = (message) => {
const keyPem = fs.readFileSync(
path.join(__dirname, 'keys/apiclient_key.pem'),
'utf-8'
)
const signature = crypto
.createSign('RSA-SHA256')
.update(message, 'utf-8')
.sign(keyPem, 'base64')
return signature
}

然后使用上面合成的message进行签名即可:

const signature = rsaSign(message)

如何验证呢?

方案一:

Linux或者mac直接使用openssl来进行验证

echo -n -e \
"GET\n/v3/certificates\n1554208460\n593BEC0C930BF1AFEB40B4A08C8FB242\n\n" \
| openssl dgst -sha256 -sign apiclient_key.pem \
| openssl base64 -A

方案二:

使用老师给大家准备的docker镜像进行验证lw96/libressl

# 1.创建容器
docker run -itd --name ssl lw96/libressl

# 2.拷贝证书
docker cp 证书目录 容器id:/tmp

# 3.进入容器
docker exec -it ssl sh

# 4.使用上面的一样的命令
echo -n -e \
"GET\n/v3/certificates\n1554208460\n593BEC0C930BF1AFEB40B4A08C8FB242\n\n" \
| openssl dgst -sha256 -sign /tmp/apiclient_key.pem \
| openssl base64 -A

#Authentication头部密钥串

微信支付商户API v3要求请求通过HTTP Authorization头来传递签名。Authorization认证类型签名信息两个部分组成。

下面我们使用命令行演示如何生成签名。

Authorization: 认证类型 签名信息

具体组成为:

1.认证类型,目前为WECHATPAY2-SHA256-RSA2048

2.签名信息

Authorization 头的示例如下:(注意,示例因为排版可能存在换行,实际数据应在一行)

Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900009191",nonce_str="593BEC0C930B

最终我们可以组一个包含了签名的HTTP请求了。

$ curl https://api.mch.weixin.qq.com/v3/certificates -H 'Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900009191",nonce_str="593BEC0C930BF1AFEB40B4A08C8FB242",signature="uOVRnA4qG/MNnYzdQxJanN+zU+lTgIcnU9BxGw5dKjK+VdEUz2FeIoC+D5sB/LN+nGzX3hfZg6r5wT1pl2ZobmIc6p0ldN7J6yDgUzbX8Uk3sD4a4eZVPTBvqNDoUqcYMlZ9uuDdCvNv4TM3c1WzsXUrExwVkI1XO5jCNbgDJ25nkT/c1gIFvqoogl7MdSFGc4W4xZsqCItnqbypR3RuGIlR9h9vlRsy7zJR9PBI83X8alLDIfR1ukt1P7tMnmogZ0cuDY8cZsd8ZlCgLadmvej58SLsIkVxFJ8XyUgx9FmutKSYTmYtWBZ0+tNvfGmbXU7cob8H/4nLBiCwIUFluw==",timestamp="1554208460",serial_no="1DDE55AD98ED71D6EDD4A4A16996DE7B47773A8C"'

代码示例:

// config.js
const mchid = 'xxxxx'

const serialNo = 'xxxxx'

export default {
// ...
mchid,
serialNo
}

// WxPay.js
export const getSignHeaders = (url, method, body) => {
// HTTP请求方法\n
// URL\n https://www.imooc.com/path1/path2/?query1=value
// 请求时间戳\n
// 请求随机串\n
// 请求报文主体\n
const tmpUrl = new URL(url)
const nonceStr = rand.generate(16)
const pathname = /http/.test(url) ? tmpUrl.pathname : url
const timestamp = Math.floor(Date.now() / 1000)
const message = `${method.toUpperCase()}\n${pathname + tmpUrl.search
}\n${timestamp}\n${nonceStr}\n${body ? JSON.stringify(body) : ''}\n`
// const keyPem = fs.readFileSync(path.join(__dirname, 'keys/apiclient_key.pem'), 'utf-8')
// const signature = crypto.createSign('RSA-SHA256').update(message, 'utf-8').sign(keyPem, 'base64')
const signature = rsaSign(message)
// 1.解决问题:windows上无openssl -> lw96/libressl
// 2.需要传递apiclient_key.pem给镜像 -> 因为只有在容器里面才能执行openssl
// 3.method1: docker cp method2: -v
// 4.使用openssl进行签名 -> 对比crypto产生的base64串

return {
headers: `WECHATPAY2-SHA256-RSA2048 mchid="${config.mchid}",nonce_str="${nonceStr}",signature="${signature}",timestamp="${timestamp}",serial_no="${config.serialNo}"'`,
nonceStr,
timestamp
}
}

#接口说明

接口文档:JSAPI(opens new window)

请求URL:https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi

请求方式:POST

形成随机的商户订单号:

export const getTradeNo = () => {
// 服务端侧:out_trade_no -> 订单号 -> timestamp + type + id
// 1.Date.now() 2.Moment/dayjs
// 2. 01-小程序
return (
dayjs().format('YYYYMMDDHHmmssSSS') +
'01' +
Math.random().toString().substr(-10)
)
}

最终,统一订单的接口:

export const wxJSPAY = async (params) => {
const {
description,
goodsTag,
total,
user: { openid },
detail,
sceneInfo,
settleInfo
} = params
// https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi
// 小程序用户侧: description,amount:{total} -> token -> id -> openid

// 参数准备
const wxParams = {
appid: config.AppID,
mchid: config.mchid,
description,
out_trade_no: getTradeNo(),
time_expire: dayjs().add(30, 'm').format(),
attach: '',
notify_url: 'https://test1.toimc.com/public/notify',
goods_tag: goodsTag,
amount: {
total: parseInt(total),
currency: 'CNY'
},
payer: {
openid
},
detail,
scene_info: sceneInfo,
settle_info: settleInfo
}
const url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi'
// 头部签名
const { headers, nonceStr, timestamp } = getSignHeaders(
url,
'post',
wxParams
)
try {
const result = await instance.post(url, wxParams, {
headers: {
Authorization: headers
}
})
console.log('🚀 ~ file: WxPay.js ~ line 53 ~ wxJSPAY ~ result', result)
const { status, data } = result
if (status === 200) {
return { prepayId: data.prepay_id, nonceStr, timestamp }
} else {
logger.error(`wxJSPAY error: ${result}`)
}
} catch (error) {
logger.error(`wxJSPAY error: ${error.message}`)
}
}

#用户支付

#支付步骤

前端步骤:

后端步骤:

#准备支付参数

第一步:

造签名串

签名串一共有四行,每一行为一个参数。行尾以\n(换行符,ASCII编码值为0x0A)结束,包括最后一行。
如果参数本身以\n结束,也需要附加一个\n

参与签名字段及格式:

小程序appId
时间戳
随机字符串
订单详情扩展字符串

第二步:

计算签名:

echo -n -e \
"wx8888888888888888\n1414561699\n5K8264ILTKCH16CQ2502SI8ZNMTM67VS\nprepay_id=wx201410272009395522657a690389285100\n" \
| openssl dgst -sha256 -sign apiclient_key.pem \
| openssl base64 -A

参数:

参数名 变量 类型[长度限制] 必填 描述
时间戳 timeStamp string[1,32] 当前的时间,其他详见时间戳规则 (opens new window)。 示例值:1414561699
随机字符串 nonceStr string[1,32] 随机字符串,不长于32位。 示例值:5K8264ILTKCH16CQ2502SI8ZNMTM67VS
订单详情扩展字符串 package string[1,128] 小程序下单接口返回的prepay_id参数值,提交格式如:prepay_id=*** 示例值:prepay_id=wx201410272009395522657a690389285100
签名方式 signType string[1,32] 签名类型,默认为RSA,仅支持RSA。 示例值:RSA
签名 paySign string[1,512] 签名,使用字段appId、timeStamp、nonceStr、package计算得出的签名值 示例值

#后端接口开发

创建路由:

// 微信用户下单
router.post('/wxOrder', userController.wxOrder)

创建wxOrder下单方法:

async wxOrder (ctx) {
const { body } = ctx.request
// 为什么订单的total即商品的金额信息不能从前端传?
// 从前端传商品的id -> 在后端查询对应id的商品价格
const { description, total } = body
const user = await User.findByID(ctx._id)
const params = {
description,
total,
user
}
// 1. 发起wxPay -> prepay_id
const { prepayId, nonceStr, timestamp } = await wxJSPAY(params)
// 小程序appId
// 时间戳
// 随机字符串
// 订单详情扩展字符串
const paySign = rsaSign(`${config.AppID}\n${timestamp}\n${nonceStr}\nprepay_id=${prepayId}\n`)
// 2. 拼接数据返回前端
ctx.body = {
code: 200,
data: {
appId: config.AppID,
timestamp,
nonceStr,
package: `prepay_id=${prepayId}`,
signType: 'RSA',
paySign
}
}
}

#前端模拟下单&支付

async order () {
const res = await this.$u.api.orderGoods({
description: 'toimc测试商品',
total: 1 // 单位分
})
// console.log('🚀 ~ file: order.vue ~ line 23 ~ order ~ res', res)
const { code, data } = res
if (code === 200) {
this.orderParams = data
uni.showToast({
icon: 'none',
title: '下单成功',
duration: 2000
})
}
},
pay () {
uni.requestPayment({
provider: 'weixin',
orderInfo: {
description: 'toimc测试商品',
total: 1 // 单位分
},
timeStamp: this.orderParams.timestamp + '',
nonceStr: this.orderParams.nonceStr,
package: this.orderParams.package,
signType: this.orderParams.signType,
paySign: this.orderParams.paySign,
complete: function (res) {
console.log('🚀 ~ file: order.vue ~ line 47 ~ pay ~ res', res)
// errMsg: "requestPayment:ok" -> 支付成功
// errMsg: "requestPayment:fail cancel" -> 取消
}
})
}

#订单查询

用户支付成功后,需要接受微信平台的被动通知或者是主动查询订单的支付状态。

原因:可能用户支付成功后,微信后台已经给了用户侧反馈,但是由于网络问题,商户平台可能未收到通知。

image-20210830173043224

#微信主动通知

frp转发通知,只需要创建对应的接口,然后在JSAPI统一下单的接口中指定回调的域名即可。

请求方式:POST

回调URL:该链接是通过基础下单接口中的请求参数“notify_url”来设置的,要求必须为https地址。请确保回调URL是外部可正常访问的,且不能携带后缀参数,否则可能导致商户无法接收到微信的回调通知信息。回调URL示例: “https://pay.weixin.qq.com/wxpay/pay.action”

通知规则

用户支付完成后,微信会把相关支付结果和用户信息发送给商户,商户需要接收处理该消息,并返回应答。

对后台通知交互时,如果微信收到商户的应答不符合规范或超时,微信认为通知失败,微信会通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微信不保证通知最终能成功。(通知频率为15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m)

通知报文

支付结果通知是以POST 方法访问商户设置的通知url,通知的数据以JSON 格式通过请求主体(BODY)传输。通知的数据包括了加密的支付结果详情。

下面详细描述对通知数据进行解密的流程:

  1. 用商户平台上设置的APIv3密钥【微信商户平台 (opens new window)—>账户设置—>API安全—>设置APIv3密钥】,记为key;
  2. 针对resource.algorithm中描述的算法(目前为AEAD_AES_256_GCM),取得对应的参数nonce和associated_data;
  3. 使用key、nonce和associated_data,对数据密文resource.ciphertext进行解密,得到JSON形式的资源对象;

注: AEAD_AES_256_GCM算法的接口细节,请参考rfc5116 (opens new window)。微信支付使用的密钥key长度为32个字节,随机串nonce长度12个字节,associated_data长度小于16个字节并可能为空。

#主动通知后端接口

解密方法:

export const decryptByApiV3 = ({
associate, // 加密参数 - 类型
nonce, // 加密参数 - 随机数
ciphertext // 加密密文
} = {}) => {
ciphertext = decodeURIComponent(ciphertext)
ciphertext = Buffer.from(ciphertext, 'base64')

const authTag = ciphertext.slice(ciphertext.length - 16)
const data = ciphertext.slice(0, ciphertext.length - 16)

const decipher = crypto.createDecipheriv(
'aes-256-gcm',
config.apiV3Key,
nonce
)
decipher.setAuthTag(authTag)
decipher.setAAD(Buffer.from(associate))

let decryptedText = decipher.update(data, null, 'utf8')
decryptedText += decipher.final()
return decryptedText
}

创建接口:

// 获取支付通知
router.post('/notify', adminController.wxNotify)

接口详情wxNotify

// 微信支付回调通知
async wxNotify (ctx) {
const { body } = ctx.request
const { resource_type: type, resource } = body
if (type === 'encrypt-resource') {
const { ciphertext, associated_data: associate, nonce } = resource
const str = decryptByApiV3({
associate,
nonce,
ciphertext
})
console.log('🚀 ~ file: AdminController.js ~ line 326 ~ AdminController ~ wxNotify ~ str', str)
// todo 入库,并修改订单的支付成功的状态
}
console.log(
'🚀 ~ file: AdminController.js ~ line 294 ~ AdminController ~ wxNotify ~ body',
body
)
ctx.body = {
code: 200
}
}

收到订单通知后,需要保存通知中的状态与数据(微信订单号)。

#订单查询接口

官方文档(opens new window)

有两种方案:

请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}

请求方式:GET

请求参数

参数名 变量 类型[长度限制] 必填 描述
直连商户号 mchid string[1,32] query 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109
商户订单号 out_trade_no string[6,32] path 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一。 特殊规则:最小字符长度为6 示例值:1217752501201407033233368018

示例:

https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/1217752501201407033233368018?mchid=1230000109

这里要特别注意,url中有路径参数与query参数

后端代码:

import qs from 'qs'

// 微信支付订单查询 out_trade_no
// https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}
export const getNofityByTradeNo = async (id) => {
try {
let url = `https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/${id}?`
const params = {
mchid: config.mchid
}
url += qs.stringify(params)
const { headers, nonceStr, timestamp } = getSignHeaders(url, 'get')
const result = await instance.get(url, {
headers: {
Authorization: headers
}
})
console.log(
'🚀 ~ file: WxPay.js ~ line 147 ~ getNofityByTradeNo ~ timestamp',
timestamp
)
console.log(
'🚀 ~ file: WxPay.js ~ line 147 ~ getNofityByTradeNo ~ nonceStr',
nonceStr
)
console.log('🚀 ~ file: WxPay.js ~ line 53 ~ wxJSPAY ~ result', result)
// todo result.data -> trade_state trade_type -> 存储订单的其他信息
} catch (error) {
logger.error(`getNofityByTradeNo error: ${error.message}`)
}
}

至此,完成的小程序支付的一个完整的闭环。

关于退款与退款通知与下单&订单通知是类似,不再提供示例。