一、前言
本篇文章的篇幅会很长,但是如果你正在学习或者准备学习微信小程序开发,请认真看下去,会对你有很大的帮助。
文章会通过一个项目案例,完整的将小程序+微信云开发+CloudBase CMS(后台)的基础使用展现出来,可以让你在短时间内入门微信小程序开发。
文末有完整代码包下载,但是我希望你看文章自己动手实现,而不是直接拿着代码包导入后作为自己的项目,只有自己动手敲的,才是你的。
看不懂不要紧,如果能够提起你的兴趣就好,看多几次,不懂的或者有问题,评论区等你。
二、案例介绍
案例简介
本次实践的项目是开发一个一键绘制国庆头像的小程序,有部分同学们可能已经使用过这类的小程序了,简单来说就是获取用户的微信头像,然后给用户生成一个带国旗或者其他一些带有国庆元素的新头像,用户可以保存下来使用。
目前的热度以及新鲜度没有前两年刚刚出来时那么的高了,但是作为一个实践案例还是很值得去玩一玩的,难度中等,涉及的接口也比较常用,也具有一定的娱乐性和实用性。
技术栈介绍
前端:原生微信小程序
服务端:微信云开发
管理端:CloudBase CMS(微信云开发提供的内容管理系统)
小程序案例展示
接下来我们先看看最终要实现的效果。
因为案例代码目前还没有重写,所以我先拿公司的线上项目作为效果展示。
项目最终实现功能基本一致,部分功能会去除(例如关注公众号的)。
这里附上公司的线上小程序码,有兴趣的同学可以前往查看。
首页
申请获取用户头像
绘制
保存、转发等操作
内容管理后台(CMS)展示
三、项目准备工作
准备工作
前往下载项目所需要用到的相关资源。电脑浏览器里点击前往下载
新建云开发小程序项目
这个没什么难度,如果没有注册过小程序账号的话,自行百度先注册一个,APPID不能使用测试号的,因为需要开通云开发。
已经有APPID的同学,直接新建一个项目就行,后端服务记得勾选开通微信云开发。
创建相关数据库表
创建好后,进入云开发控制台,创建两个数据库表。
1、makeAvatarBg:存储头像框数据。
2、makeAvatarType:存储头像框分类数据。
修改一下数据库表的读写权限。这里很重要,别漏了,不然小程序端将请求不到数据。
创建CMS
进入创建内容管理系统。
因为我已经开通过了,所以这是开通成功的页面,如果没有开启过的,会显示开启的按钮,点击等待初始化完成就行。
初始化需要一点时间,耐心等待即可。
搭建CMS
创建完成后,点击CMS的访问地址,前往浏览器打开后输入设置的账号密码,点击登录。
创建一个CMS的管理项目。
创建完成后,点击进入,开始搭建一下CMS。
导入模型
如果你想自己手动创建也可以,自己研究吧。
模型的文件在这里。
上传数据
因为项目比较简单,需求也已经清晰了,所以我们直接上数据,小程序编写的时候,数据直接从数据库取。
前往云控制台,上传一下页面的素材,页面素材加起来有500多K,小程序单包大小最大是1M,所以素材需要放到云存储里,避免随着项目的迭代而超出限制。
四、项目开发
目录结构
代码实现
json代码
这里要引入一个组件,用来绘制头像。
{
"usingComponents": {
"painter":"/components/painter/painter"
},
"navigationStyle": "custom",
"disableScroll": true
}
JS代码
代码里面都已经额外添加了很多注释,同学们可以下载代码包进行查看。
// pages/makeAvatar/makeAvatar.js
const db = wx.cloud.database()
const posterViewjs = require("../../posterViewjs/posterView")
Page({
/**
* 页面的初始数据
*/
data: {
paintPallette: null, // 绘制头像的的数据
// 页面素材网址,记得修改成自己上传时的素材地址
leftTop: "cloud://cloud1-3gpkfcgu3d6c85da.636c-cloud1-3gpkfcgu3d6c85da-1309850448/makeAvatar/left-top.png",
rightTop: "cloud://cloud1-3gpkfcgu3d6c85da.636c-cloud1-3gpkfcgu3d6c85da-1309850448/makeAvatar/right-top.png",
leftCenter: "cloud://cloud1-3gpkfcgu3d6c85da.636c-cloud1-3gpkfcgu3d6c85da-1309850448/makeAvatar/left-center.png",
rightBottom: "cloud://cloud1-3gpkfcgu3d6c85da.636c-cloud1-3gpkfcgu3d6c85da-1309850448/makeAvatar/right-bottom.png",
typeList: [], // 分类列表
nowTypeIndex: 0, // 当前选择的分类下标
nowSelectAvatarBg: "", // 当前选择的头像框url
avatarBgDocId: null, // 当前头像框的id
userAvatarUrl: null, // 用户微信头像url
},
/**
* 生命周期函数--监听页面加载
*/
onLoad() {
this.initData()
},
/**
* 初始化数据
*/
async initData() {
wx.showLoading({
title: "加载中",
mask: true
})
const nowTypeIndex = this.data.nowTypeIndex
// 获取头像框分类数据并初始化分类列表
const typeList = await this.getTypeData()
for (let i = 0; i < typeList.length; i++) {
typeList[i].avatarBgList = []
}
// 获取当前选择分类的头像框数据
const nowTypeName = typeList[nowTypeIndex].typeName
const avatarBgList = await this.getAvatarBgData(nowTypeName)
typeList[nowTypeIndex].avatarBgList = avatarBgList
this.data.avatarBgDocId = avatarBgList[0]._id
// 动态绑定数据
this.setData({
typeList: typeList,
nowSelectAvatarBg: avatarBgList[0].avatarBg,
})
wx.hideLoading()
},
/**
* 分类栏点击监听
*/
async typeBarTap(res) {
// 获取当前点击的分类下标
const index = res.currentTarget.dataset.index
const nowIndex = this.data.nowTypeIndex
// 判断是否点击同一个分类
if (index === nowIndex) return
this.setData({
nowTypeIndex: index
})
const typeList = this.data.typeList
let avatarBgList = typeList[index].avatarBgList
// 判断该分类是否已经获取过头像框数据,如果已经获取了,就不再重复获取
if (avatarBgList.length > 0) return
// 获取新分类中的头像框数据
const typeName = typeList[index].typeName
wx.showLoading({
title: "获取中",
mask: true
})
avatarBgList = await this.getAvatarBgData(typeName)
const key = "typeList[" + index + "].avatarBgList"
this.setData({
[key]: avatarBgList
})
wx.hideLoading()
},
/**
* 头像框点击监听
*/
avatarBgTap(res) {
const {
docid,
bgurl
} = res.currentTarget.dataset
this.data.avatarBgDocId = docid
this.setData({
nowSelectAvatarBg: bgurl
})
},
/**
* 从图库选择图片
* 因为有使用最近(202210)的新接口(图片剪裁wx.cropImage())
* 需要基础库>=2.26.0才能用
* 且android中有BUG,只能在iOS上使用,所以有做系统以及基础库的判断
* 期待后续微信小程序团队的修复,如果你看这份代码的时候
* BUG已经修复了,可以将系统判断代码去掉,仅判断基础库版本即可
*/
async getPhotoTap() {
// 获取系统相关信息
const systemInfo = wx.getSystemInfoSync()
const system = systemInfo.system // 手机操作系统
const SDKVersion = systemInfo.SDKVersion // 基础库版本
let tempFilePath = null // 临时图片路径
// android或基础库版本 < 2.26.0 执行以下逻辑
if (!system.includes("iOS") || this.compareVersion("2.26.0", SDKVersion) > 0) {
// 弹出手动剪裁的提示
const modalRes = await this.showAndroidTips()
if (!modalRes) return
// 选择相册图片
const tempObj = await this.chooseImg(1, ["original"], ["album"])
if (!tempObj) return
tempFilePath = tempObj.tempFilePaths[0]
this.setData({
userAvatarUrl: tempFilePath
})
return
}
// 系统为iOS且基础库 >= 2.26.0 执行以下逻辑
// 选择相册图片
const tempObj = await this.chooseImg(1, ["original"], ["album"])
if (!tempObj) return
tempFilePath = tempObj.tempFilePaths[0]
const that = this
// 剪裁图片
wx.cropImage({
src: tempFilePath,
cropScale: '1:1',
success(res) {
tempFilePath = res.tempFilePath
that.setData({
userAvatarUrl: tempFilePath
})
},
fail(err) {
console.log(err)
},
})
},
/**
* 获取用户头像点击监听
*/
async getUserProfileTap() {
wx.showLoading({
title: "获取中",
mask: true
})
// 获取用户头像信息
const userProfileRes = await this.getUserProfile()
console.log("获取用户信息结果 ===>", userProfileRes)
wx.hideLoading()
if (userProfileRes.errMsg !== "getUserProfile:ok") {
wx.showToast({
title: "获取头像失败,请重试",
icon: "none"
})
return
}
let avatarUrl = userProfileRes.userInfo.avatarUrl
console.log("转换前的头像网址 ===>", avatarUrl)
// 默认返回132*132尺寸的图片,将其转换成640*640, 不然很模糊
const avatarUrlPrefix = avatarUrl.substring(0, avatarUrl.length - 3)
avatarUrl = avatarUrlPrefix + "0"
console.log("转换后的头像网址 ===>", avatarUrl)
this.setData({
userAvatarUrl: avatarUrl
})
},
/**
* 获取分类信息
*/
async getTypeData() {
const typeDataObj = await this.getDBDataByWhere("makeAvatarType", {
isShow: "show"
}, {}, 20, 0, "index", "asc")
return typeDataObj.data
},
/**
* 获取头像框数据
* @param {String} typeName 分类名称
* @param {Number} limit 获取数量
* @param {Number} skip 跳过的文档数
*/
async getAvatarBgData(typeName, limit = 20, skip = 0) {
const avatarBgObj = await this.getDBDataByWhere("makeAvatarBg", {
typeName: typeName,
isShow: "show",
}, {}, limit, skip, "index", "asc")
return avatarBgObj.data
},
/** 绘制完成后的回调函数*/
onImgOK(res) {
const docId = this.data.avatarBgDocId
wx.hideLoading()
// 这个路径就可以作为保存图片时的资源路径
console.log("新头像临时路径", res.detail.path)
// 保存、收藏、转发头像
wx.showShareImageMenu({
path: res.detail.path,
success(res) {
console.log(res)
this.callCloudFun("makeAvatarFun", {
type: "saveCount",
docId: docId
})
},
fail(err) {
console.log(err)
},
})
},
/**
* 绘制头像
*/
makeAvatar() {
if (!this.data.userAvatarUrl) {
wx.showToast({
title: "请先获取头像",
icon: "none"
})
return
}
wx.showLoading({
title: "生成中",
})
// 更新头像框的使用次数
const docId = this.data.avatarBgDocId
this.callCloudFun("makeAvatarFun", {
type: "useCount",
docId: docId
})
// 绘制海报所用到JSON数据
const userAvatarUrl = this.data.userAvatarUrl
const nowSelectAvatarBg = this.data.nowSelectAvatarBg
const viewList = posterViewjs.getPosterView01(userAvatarUrl, nowSelectAvatarBg)
this.setData({
paintPallette: viewList
})
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
return {
title: "一键制作国庆专属头像",
path: "pages/makeAvatar/makeAvatar"
}
},
/**
* 显示剪裁提示的弹窗
*/
showAndroidTips() {
return new Promise((resolve, reject) => {
wx.showModal({
title: "系统提示",
content: "选取图片时,请将图片剪切为1:1的正方形,否则会影响生成的效果",
confirmText: "好的",
cancelText: "取消",
success(res) {
if (res.confirm) {
resolve(true)
} else {
resolve(false)
}
}
})
})
},
/**
* 执行头像框使用或保存的次数更新
* @param {String} cloudFunName 云函数名称
* @param {Object} eventData 云函数入参
*/
callCloudFun(cloudFunName, eventData) {
wx.cloud.callFunction({
name: cloudFunName,
data: eventData
})
.then(res => {
console.log(`云函数${cloudFunName}执行成功===>`, res)
})
.catch(err => {
console.error(`云函数${cloudFunName}执行失败===>`, err)
})
},
/**
* 获取数据库数据的封装函数
* @param {String} collectionName 集合名
* @param {Object} whereObj 查询条件
* @param {Object} field 过滤
* @param {Number} limit 查询文档数(小程序端最多一次查询20条)
* @param {Number} skip 跳过文档数
* @param {String} orderByName 排序字段名
* @param {String} orderByType 排序方式
*/
async getDBDataByWhere(collectionName, whereObj, field = {}, limit = 20, skip = 0, orderByName = "_id", orderByType = "desc") {
return await db.collection(collectionName)
.orderBy(orderByName, orderByType)
.where(whereObj)
.field(field)
.limit(limit)
.skip(skip)
.get()
.then(res => {
return res
})
.catch(err => {
return err
})
},
/**
* 选择图片封装函数
* @param {Number} count 可选择的照片数量, 默认可选择1张
* @param {Array} sizeType 照片的质量, 默认 ['original', 'compressed']
* @param {Array} sourceType 照片来源, 默认 ['album', 'camera']
* wx.chooseImage接口在2.21.0废弃不再维护
* 大家如果可以,自己换成新的接口,当一个小作业
*/
async chooseImg(count = 1, sizeType = ['original', 'compressed'], sourceType = ['album', 'camera']) {
return new Promise((resolve) => {
wx.chooseImage({
count: count,
sizeType: sizeType,
sourceType: sourceType,
success(res) {
resolve(res)
},
fail(err) {
console.log(err)
resolve(false)
}
})
})
},
/**
* 版本比较
* v1 >= v2 返回 0或1 否则 -1
* @param {String} v1
* @param {String} v2
*/
compareVersion(v1, v2) {
v1 = v1.split('.')
v2 = v2.split('.')
const len = Math.max(v1.length, v2.length)
while (v1.length < len) {
v1.push('0')
}
while (v2.length < len) {
v2.push('0')
}
for (let i = 0; i < len; i++) {
const num1 = parseInt(v1[i])
const num2 = parseInt(v2[i])
if (num1 > num2) {
return 1
} else if (num1 < num2) {
return -1
}
}
return 0
},
/**
* 获取用户头像、昵称信息封装函数
* @param {String} desc 获取头像的理由,实际上没有任何意义,只是接口必须要传
*/
async getUserProfile(desc = "获取用户头像") {
return new Promise((resolve, reject) => {
wx.getUserProfile({
desc: desc,
success(res) {
resolve(res)
},
fail(err) {
resolve(err)
}
})
})
},
})
WXML代码
<!--pages/makeAvatar/makeAvatar.wxml-->
<view class="page-box">
<view class="page-bg-box">
<view class="left-top-box">
<image style="height: 500rpx; width: 500rpx;" src="{{leftTop}}" mode="scaleToFill"></image>
</view>
<view class="right-top-box">
<image style="height: 800rpx; width: 800rpx;" src="{{rightTop}}" mode="scaleToFill"></image>
</view>
<view class="left-center-box">
<image style="height: 850rpx; width: 850rpx;" src="{{leftCenter}}" mode="scaleToFill"></image>
</view>
<view class="right-bottom-box">
<image style="width: 800rpx;height: 800rpx;" src="{{rightBottom}}" mode="scaleToFill"></image>
</view>
</view>
</view>
<view class="avatar-type-box">
<scroll-view class="type-bar-scroll-box" scroll-x="true">
<view class="type-bar-box flex">
<view class="type-item flex-ac {{nowTypeIndex == index ? 'type-item-select' : ''}}" wx:for="{{typeList}}" wx:key="_id" bindtap="typeBarTap" data-index="{{index}}">
{{item.typeName}}
</view>
</view>
</scroll-view>
<scroll-view class="avatar-bg-scroll" scroll-y="true" wx:if="{{typeList[nowTypeIndex].avatarBgList.length > 0}}">
<view class="avatar-bg-box flex-jb">
<view class="avatar-bg-img-box flex-jc-ac {{nowSelectAvatarBg == item.avatarBg ? 'avatar-bg-img-box-select':''}}" wx:for="{{typeList[nowTypeIndex].avatarBgList}}" wx:key="_id" bindtap="avatarBgTap" data-docid="{{item._id}}" data-bgurl="{{item.avatarBg}}">
<image src="{{item.avatarBg}}" class="avatar-bg-img" mode="widthFix"></image>
</view>
<view wx:key="index" wx:for="{{typeList[nowTypeIndex].avatarBgList.length%4}}" style="height: 135rpx; width: 135rpx;"></view>
</view>
</scroll-view>
<view wx:else>
<view class="no-data-tips flex-jc-ac">插画师正在加班加点设计中...</view>
</view>
</view>
<view class="make-avatar-box flex-jb-ac">
<view class="perview-avatar-box">
<image class="bg-avatar-img" src="{{nowSelectAvatarBg}}" mode="widthFix"></image>
<image class="user-avatar-img" src="{{userAvatarUrl}}" mode="scaleToFill"></image>
</view>
<view class="make-avatar-btn-box">
<view class="btn-class flex-jc-ac" bindtap="getUserProfileTap">获取微信头像</view>
<view class="btn-class flex-jc-ac" bindtap="getPhotoTap">从相册中选择</view>
<view class="btn-class flex-jc-ac" bindtap="makeAvatar">保存新头像</view>
</view>
</view>
<view class="tips-text">温馨提示:如果生成的头像比较模糊,可以从相册中选择图片进行生成</view>
<!-- canvas隐藏 -->
<painter customStyle='position: absolute; left: -9999rpx;' palette="{{paintPallette}}" bind:imgOK="onImgOK" use2D="{{true}}" scaleRatio="2" />
<!-- canvas隐藏 -->
WXSS代码
/* pages/makeAvatar/makeAvatar.wxss */
.page-box {
position: fixed;
width: 100vw;
height: 100vh;
top: 0rpx;
left: 0rpx;
z-index: -10;
}
.page-bg-box {
position: relative;
width: 100vw;
height: 100vh;
background-image: linear-gradient(to right bottom,
#ff8b67,
#ff895c,
#fe8a54,
#fb7e4c,
#f47150,
#ec5944,
#e65642,
#d0402c);
}
.left-top-box {
position: absolute;
top: 0rpx;
left: -70rpx;
}
.right-top-box {
position: absolute;
top: 0rpx;
right: -50rpx;
}
.left-center-box {
position: absolute;
left: -230rpx;
margin-top: 200rpx;
}
.right-bottom-box {
position: absolute;
bottom: 0rpx;
right: -50rpx;
}
.avatar-type-box {
background-color: rgba(255, 255, 255, 0.5);
width: 90%;
height: 350rpx;
margin: auto;
margin-top: 30%;
border-radius: 20rpx;
}
.type-bar-scroll-box {
width: 100%;
height: 75rpx;
white-space: nowrap;
}
.type-bar-box {
height: 100%;
}
.type-item {
height: 100%;
padding: 0rpx 25rpx;
font-size: 28rpx;
}
.type-item-select {
font-size: 30rpx;
font-weight: bold;
color: #d04a35;
}
.avatar-bg-scroll {
height: 75%;
width: 90%;
margin: auto;
}
.avatar-bg-box {
flex-wrap: wrap;
width: 100%;
height: 100%;
}
.avatar-bg-img-box {
background-color: #ffffff;
width: 135rpx;
height: 135rpx;
border-radius: 10rpx;
margin-top: 15rpx;
}
.avatar-bg-img-box-select{
box-shadow: rgba(231, 86, 66, 0.9) 0rpx 10rpx 10rpx;
}
.avatar-bg-img {
width: 125rpx;
height: 125rpx;
}
.make-avatar-box {
margin: auto;
margin-top: 55rpx;
padding: 25rpx 35rpx;
padding-bottom: 35rpx;
background-color: rgba(255, 255, 255, 0.6);
width: 80%;
border-radius: 20rpx;
}
.perview-avatar-box {
position: relative;
}
.bg-avatar-img {
width: 220rpx;
height: 220rpx;
position: absolute;
top: 0rpx;
left: 0rpx;
}
.user-avatar-img {
width: 220rpx;
height: 220rpx;
border-radius: 20rpx;
background-color: #ffffff;
}
.btn-class {
width: 280rpx;
height: 85rpx;
border-radius: 20rpx;
margin-top: 25rpx;
background-image: linear-gradient(to right bottom,
#ff8b67,
#ff895c,
#fe8a54,
#fb7e4c,
#f47150,
#ec5944,
#e65642,
#d0402c);
font-size: 30rpx;
font-weight: bold;
color: #f6f6f6;
}
.tips-text {
font-size: 26rpx;
color: #f6f6f6;
margin-top: 15rpx;
padding: 0rpx 35rpx;
}
.about-public-box {
background-color: rgba(255, 255, 255, 0.6);
width: 90%;
margin: auto;
margin-top: 25rpx;
padding: 15rpx 25rpx;
border-radius: 20rpx;
}
.public-title {
font-size: 30rpx;
font-weight: bold;
}
.public-desc {
font-size: 24rpx;
color: #515151;
margin-left: 20rpx;
}
.public-btn {
font-size: 26rpx;
font-weight: bold;
width: 300rpx;
height: 75rpx;
margin: auto;
margin-top: 25rpx;
background-image: linear-gradient(to right bottom,
#ff8b67,
#ff895c,
#fe8a54,
#fb7e4c,
#f47150,
#ec5944,
#e65642,
#d0402c);
color: #f6f6f6;
border-radius: 10rpx;
}
.no-data-tips{
font-size: 28rpx;
margin-top: 35rpx;
}
/* 以下部分其实我是封装起来的,但是为了降低代码的耦合度,我就抽出来,略显冗余 */
.flex,
.flex-jc,
.flex-ac,
.flex-jb,
.flex-jc-ac,
.flex-jb-ac,
.flex-jc-col,
.flex-je-ac,
.flex-jc-ae,
.flex-jc-ac-col {
display: flex;
}
.flex-jc,
.flex-jc-ac,
.flex-jc-col,
.flex-jc-ae,
.flex-jc-ac-col {
justify-content: center;
}
.flex-jb {
justify-content: space-between;
}
.flex-ac,
.flex-jc-ac,
.flex-jb-ac,
.flex-je-ac,
.flex-jc-ac-col {
align-items: center;
}
.flex-je-ac {
justify-content: flex-end;
}
.flex-jb-ac {
justify-content: space-between;
}
.flex-jc-col,
.flex-jc-ac-col {
flex-flow: column;
}
.flex-jc-ae{
align-items: flex-end;
}
绘制头像的一些额外代码
其实单独抽出来有点难理解,但是直接跟着目录结构,创建文件夹以及文件,粘贴代码进去就行。(主要是因为原项目中很多地方都用到了绘制海报、头像之类的功能,所以会抽离出来)
/**
*
* @param {*} avatarUrl 用户头像网址
* @param {*} avatarBgUrl 头像框网址
*/
const getPosterView01 = (avatarUrl, avatarBgUrl) => {
const poster = {
"width": "1000rpx",
"height": "1000rpx",
"background": avatarUrl,
"views": [
{
"type": "image",
"url": avatarBgUrl,
"css": {
"width": "1000rpx",
"height": "1000rpx",
"top": "0px",
"left": "0px",
"rotate": "0",
"borderRadius": "",
"borderWidth": "",
"borderColor": "#000000",
"shadow": "",
"mode": "scaleToFill"
}
},
]
}
return poster
}
module.exports = {
getPosterView01,
}
云函数代码
云函数名称:makeAvatarFun
如果没有的话,新建一下。
如果是直接下载代码导入的话,就上传一下这个云函数。
可能会出现的报错
因为小程序请求第三方链接需要配置一下,所以可能会报下面这个错误。
解决方法:
前往微信公众管理平台,进行域名配置。
输入网址:https://thirdwx.qlogo.cn
这个是用户微信头像的一级网址。
保存并提交后,刷新一下项目配置然后重新编译一下项目即可。
五、项目上线
上线前请确保通过真机的测试,没有出现异常。
小程序上线的细节自行搜索相关的文章,文章篇幅太长,就不详细讲了。
简单讲一下两个步骤。
上传代码
提交审核
六、结语
项目中做了很多封装,虽然在这个小项目里面显得有点多余,大家在做正式项目的时候,可以自己学着封装一些公共的函数到模块里,这样可以减少很多重复代码,特别是数据库请求的代码。
最后来一下常规结语:
分享的是思维不是技术。所以很多地方写得并不是很严谨,仅仅是把逻辑跑了一遍。(大佬们手下留情,谢谢)
实际开发中的其他逻辑就不写了,这里只是最简单的实现。
有任何疑问可以在评论区留下。我每天都会进行回复,私聊不回。(为了刷积分)
以上均是本人开发过程中的一些经验总结与领悟,如果有什么不正确的地方,希望大佬们评论区斧正。
💥最后!!!不管这篇文章对你有没有用,既然都看到最后了。
👍赞一个!!!
🤩当然,顺带收藏就最好了。
😎欢迎转载,原创不易,转载请注明出处✍。
😊如果你对小程序开发有兴趣或者正在学习小程序开发,可以关注我。每一篇都是原创,每一篇都是干货噢~。
————————————————
版权声明:本文为CSDN博主「super–Yang」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_44702572/article/details/127293443