管理端:商品管理
- 简介
- 进入管理端
- 初始页面
-
- Tabbar
-
- 调整位置
- 功能划分
- 图标无法显示时的处理
- 个人信息页面
- 商品管理
-
- 入口UI实现
- 操作数据库
- 添加商品
-
- 获取用户输入
- 图片选取与展示
- 云存储基础
- 图片批量上传
- 返回添加的商品信息
- 商品列表
-
- 只更新部分列表项
- 避免tabbar与其他组件重合
- 删除商品
- 编辑商品
-
- 添加、删除图片时处理
- 下一步
简介
这次将实现管理端的主页面,以及其中个人信息、商品管理功能。涉及tabbar、浮动组件、运行时组件大小位置设置、自定义组件、页面数据交换、云函数、云数据库、云存储等知识。我们在miniprogram文件夹下新建admin文件夹用于搁置管理端的代码实现,自定义组件将放置在工程原有的components文件夹中:
在admin文件夹下新建一个index页面作为管理员功能的初始页面。查看app.json,工具会自动注册新页面:
"pages": ["mainPage/index","pages/index/index","pages/userConsole/userConsole","pages/storageConsole/storageConsole","pages/databaseGuide/databaseGuide","pages/addFunction/addFunction","pages/deployFunctions/deployFunctions","pages/chooseLib/chooseLib","pages/openapi/openapi","pages/openapi/serverapi/serverapi","pages/openapi/callback/callback","pages/openapi/cloudid/cloudid","pages/im/im","pages/im/room/room","admin/index/index"],
在云控制台中的云数据库中新建goods数据集用于存放商品信息:
在云存储中新建goods目录用于存放商品图片:
进入管理端
页面跳转可通过wx.navigateTo实现。它能以URL的方式附带数据单向传递,比如,从主页面进入管理端后,需要使用用户的openid(考虑后续管理员可能不止一人),就可按此方式传递。
<button class="simple-button" wx:if="{
{isAdmin}}" bindtap="onSelectedAdmin">管理员</button>
onSelectedAdmin: function (e) {
wx.navigateTo({
url: '../admin/index/index?openid=' + this.data._openid })},
可通过新页面加载时的输出来观察数据传递的具体情况:
onLoad: function (options) {
console.debug("option:", options)},
从开发工具的控制台观察到数据是以Json对象传递的:
获取帮助:在微信开发者工具中,当鼠标悬停在平台接口或者CSS元素、JS函数时,会给出简明参考以及链接。
初始页面
当我们从主程序页面选择进入管理端的时候,期望会有一个看起来“有内容”的默认界面:
要实现此功能,需要考虑以下问题:
- 工具条实现
- 页面布局
- 个人信息获取
Tabbar
官方对自定义 tabBar进行了介绍,但确实有点麻烦。前面引入的weui组件库提供的的Tabbar控件就派上用场了。在主页面的index.json文件中引入这个控件:
{"usingComponents": {"mp-tabbar": "/miniprogram_npm/weui-miniprogram/tabbar/tabbar"}
}
注意:以扩展库方式引入weui要按照上面的路径组合方式引入组件。对于组件路径,官方变更了几次,所以在网上搜索时回答也是各种各样。但目前的版本已经统一为上述路径组合方式了。
调整位置
默认该控件显示在页面上方,也不符合期望。因此为页面中tabbar指定下面的样式:
#admin-index-tabber {
position:fixed; bottom:0;width:100%;left:0;right:0;
}
别忘了在wxml中将该组件的id设置为admin-index-tabber
功能划分
不考虑实用性或者是否合理,我们将该区域分为四个部分:
这样我们需要提供四个功能的对应图标,以及功能被选中时的图标。
另外需要考虑,添加商品的浮动按钮只有在“商品”功能项选中时才处于显示状态。可以借助wx:if开关来实现。
修改界面wxml文件:
<view><mp-tabbar id="admin-index-tabber" list="{
{functions}}" bindchange="onTabChanged"></mp-tabbar>
</view>
data: {functions: [{"text": "我","iconPath": "/admin/images/home.png","selectedIconPath": "/admin/images/selected.png",},{"text": "订单","iconPath": "/admin/images/order.png","selectedIconPath": "/admin/images/selected.png",},{"text": "商品","iconPath": "/admin/images/goods.png","selectedIconPath": "/admin/images/selected.png",},{"text": "管理","iconPath": "/admin/images/settings.png","selectedIconPath": "/admin/images/selected.png",},],pageIndex : 0,},
在onTabChanged函数实现中会更新当前选项卡索引pageIndex。另外说一下,很多时候我们不知道也记不住事件函数中的参数对象的属性,比如onTabChanged的参数对象的那个属性才是索引值,这个时候加一行代码控制台打印代码,将事件对象打印出来就很容易观察了,没必要查询文档。
图标无法显示时的处理
可能会遇到图片不显示的问题,如果发生在模拟器中,一般是路径问题。最好使用绝对路径,虽然官方文档也说了可以使用相对于组件所在目录的相对路径。如果开发工具的模拟中显示正常,但是上传后无法显示图标,一般是 图片格式或者大小问题。如果使用其不支持的格式(比如icon)或者大小超过40KB(也可能是其他值,但开发者没权限设置)。
对于icon格式的图标文件,有个办法可以绕过腾讯的格式检查,就是简单的修改图片文件的后缀,比如png。如果文件还是尺寸超标,就删除icon中的不使用的类型。比如下面的图标,红圈括起来的明显占用了大部分尺寸空间,只保留一个自己需要的尺寸就行了(我使用visual studio 作为图标编辑器)。
个人信息页面
对于我这么个没有任何web基础的人来说,简单的页面也不可能独立完成,都是在通过官方的demo熟悉平台的过程中发掘哪些页面能拿来使用。该页面是从官方小程序示例的组件->开放能力->open-data功能页面copy而来,目前展示的数据没有实际意义,将它安插为管理端的初始页面完全是因为不想展示一个空白页面。另外,通过将其包装为一个自定义组件,也能借此了解相关知识。将demo中得页面作为自定义组件移植到程序中,虽然能正常获取用户信息数据,但布局错乱,如下图:
这是组件样式隔离导致的。默认情况下,自定义组件的样式只受到自定义组件 wxss 的影响。可以通过将样式隔离指定为共享模式来解决:
options: {
"styleIsolation": "apply-shared"},
商品管理
当点击【商品】选项卡后,期望会有如下的功能界面。可以列表方式查看商品,点击商品后进入编辑模式,点击浮动的+进入新建商品模式,通过滑动商品项删除商品,如下:
这会涉及功能实现的先后问题。但并非先出现的功能一定要优先实现,而是谁能为其他功能提供数据谁就先实现。对于商品信息,手动录入作为测试数据不是个好办法。可以先做添加商品这项功能。但是在此之前,需要提供进入这个功能的基本UI,类似下图,点击+号后进入添加商品功能:
入口UI实现
上面的+始终要在页面的其他元素之上,而且位置要固定。我们找个有表现力的图标(我也知道这个图标很丑,以后有时间会换个别的)加入工程:
另外,只有我们点击了商品选项卡(第三个)时该浮动按钮才能显示,在页面展示中给它加个显示开关:
<view class="add-wrap" wx:if="{
{pageIndex == 2}}" bindtap="onAddGoods"><image src="../../images/add.png"></image>
</view>
其中的pageIndex为当前选项卡索引,当选项卡被点击时更新。
其余的可通过类似下面的css代码来控制,按自己需求修改后加入index.wxss即可:
.add-wrap {
height: 81rpx;width: 81rpx;border-radius: 100%;position: fixed;bottom: 150rpx;right: 20rpx;display: flex;align-items: center;justify-content: center;z-index: 999;background-color: rgba(0, 255, 255, 0.548);
}.add-wrap image {
width: 16px;height: 16px;
}
操作数据库
增、删、改、查是数据库的基本操作。鉴于腾讯对免费版的云函数个数也做了限制,我们通过新建一个goods-op云函数来实现所有商品管理的功能。虽然在客户端也可以直接调用云数据库接口,但我老感觉前端还是不要有直接数据操作的情况存在。这里给出部分实现:
注意,如果要调用返回类型为Promise的函数,记得调用前加上await关键字。我因为没有用过Javascript,没这方面的意识,第一次操作数据库时一直无法正确返回数据。另外,在分支处理中使用Promise或者Callback风格的方式调用接口对于不熟悉js或者类似语法的人真的是个坑,一不留神就在它们的匿名函数中return结果了。
// 云函数入口文件
const cloud = require('wx-server-sdk')
cloud.init({
env: cloud.DYNAMIC_CURRENT_ENV
})
const db = cloud.database()
const _ = db.command
// 云函数入口函数
exports.main = async (request) => {
//为了使用await,函数需要使用async修饰console.log("requst:", request)const wxContext = cloud.getWXContext()const collection = db.collection('goods')let success = falseswitch (request.cmd) {
case "set":var data = request.datadata.openid = wxContext.OPENIDif (data.date != null) {
data.date = new Date(data.date)}await collection.add({
//注意这里的awaitdata: data}).then(result => {
success = truedata = result._id}).catch(err => {
console.error(err)data = {
err }})return {
success: success ? true : false,data: data}case "update": //TODOcase "delete": //TODOcase "get": {
//TODO}default:return {
success: false,msg: "不支持的操作"};}
}
添加商品
商品的图片虽然也可以通过转换为字符串保存在JSON数据库中,但转换过程本身就是非必要的计算消耗,而且这个免费的数据库的容量有限。因此,比较妥当的做法是将图片上传到云存储中,在JSON数据库中仅保留图片在云存储中的标识符。
界面如下:
获取用户输入
获取控件中用户输入数据的方式有很多,但最简洁的方式就是简易双向绑定。
<mp-cell prop="goods_name" title="名称" ext-class=""><input data-field="name" class="weui-input" placeholder="商品名称" model:value="{
{goodsName}}" />
</mp-cell>
图片选取与展示
wx.chooseImage可以从本地相册选择图片或使用相机拍照。而且可以通过参数设置为“压缩”模式,这将大大减少图片的体积。因为我们只是将商品图片作为图标类的展示,而不是在图片中包含具体详细的文字描述,启用压缩模式是必要的。
wx.chooseImage({
count: 1,sizeType: ['original', 'compressed'],sourceType: ['album', 'camera'],success (res) {
// tempFilePath可以作为img标签的src属性显示图片const tempFilePaths = res.tempFilePaths}
})
实际处理时,不但需要保存图片的本地路径,也需要为它指定云存储的路径:
相比使用UUID来给每个图片设置文件名,使用时间+索引后缀也能保证其唯一性,而且没那么麻烦。
onSelectImg: function () {
let self = thiswx.chooseImage({
count: 9,sizeType: ['compressed'],//启用压缩模式sourceType: ['album', 'camera'],success: function (res) {
//使用时间+后缀的方式来区分不同图片let now = Date.now();for (var index = 0; index < res.tempFilePaths.length; index++) {
const cloudPath = "goods/" + now + "[" + index + "]" + res.tempFilePaths[index].match(/\.[^.]+?$/)[0]self.data.imageUrls.push({
path: cloudPath,file: res.tempFilePaths[index]})self.data.previewImageUrls.push(res.tempFilePaths[index])}self.setData({
imageUrls: self.data.imageUrls})},fail: e => {
console.error(e)}})},
gallery可实现对一组图片的预览,并可以实现如删除之类的操作。先在页面的json文件中引入这个组件:
"mp-gallery": "/miniprogram_npm/weui-miniprogram/gallery/gallery"
这个组件默认处于隐藏状态,当点击商品图片时,设置预览模式,将显示该组件:
<mp-gallery class="gallery" hide-on-click="{
{true}}" show-delete="{
{true}}" show="{
{isPreviewing}}" binddelete="onDeletePic" img-urls="{
{previewImageUrls}}" current="{
{previewCurrent}}"/>
图片选择部分的UI其实就是一个+号图片跟在一系列按行排列的图片后面:
<view class="goods-image-list"><block wx:for="{
{imageUrls}}" wx:key="path"><view bindtap="onPreviewImage" data-index="{
{index}}"><image class='goods-image' mode="aspectFit" src="{
{item.file}}" /></view></block><mp-icon icon="add" color="black" size="{
{48}}" bindtap="onSelectImg" />
</view>
.goods-image{
height: 48px;width: 48px;margin-right: 5px;
}
.goods-image-list{
display:flex;flex-direction:row;flex-wrap:wrap;
}
对于预览和删除功能处理:
onPreviewImage: function (e) {
//TODOthis.setData({
isPreviewing: true,previewCurrent: e.currentTarget.dataset.index,previewImageUrls: this.data.previewImageUrls})},onDeletePic: function (e) {
//TODOthis.setData({
imageUrls: this.data.imageUrls.splice(e.detail.index, 1),previewImageUrls: this.data.previewImageUrls.splice(e.detail.index, 1)})},
云存储基础
关于云存储详细的介绍,请参考链接。
上传文件的时候,需要指定路径。但是访问时却不能以上传时的云存储路径作为参数,而是以上传成功后返回的唯一标识符fileID作为参数。
wx.cloud.uploadFile({
cloudPath: 'example.png', // 上传至云端的路径filePath: '', // 小程序临时文件路径success: res => {
// 返回文件 IDconsole.log(res.fileID)},fail: console.error
})
也能通过这个唯一标识符来获取临时性的网络链接:
wx.cloud.getTempFileURL({
fileList: ['cloud://xxx.png'],success: res => {
// fileList 是一个有如下结构的对象数组// [{
// fileID: 'cloud://xxx.png', // 文件 ID// tempFileURL: '', // 临时文件网络链接// maxAge: 120 * 60 * 1000, // 有效期// }]console.log(res.fileList)},fail: console.error
})
图片批量上传
云存储不支持批量上传文件,这在要上传多个图片的场景就不方便。如果上传文件没有成功,通常的应对措施是重复上传,直到成功或者失败达到一定的次数。
上传失败后多次尝试再次上传看起来是一种严谨的应对方式。但在实践中,如果出现了上传失败,多试几次通常也不会成功。失败的原因以下情况占了绝大多数:手系统、网络、服务。 手机网络出问题的几乎没遇到过,提供服务的服务器出问题的几率也几乎为0,但系统出问题了大概率要重启解决。
暂时我的处理方式是,所有的图片尝试上传一次后,别管成功还是失败,都要接着写商品信息到云数据库(大不了没图片的商品后续采用默认的类别图片代替)。如果商品信息写数据库失败,还要删除已经成功上传的商品图片。这块代码比较多,只展示流程,具体实现请参考github上的代码:
var imgsID = [];let uploaded = 0;for (var index in this.data.imageUrls) {
let filePath = this.data.imageUrls[index].fileif (filePath.startsWith("cloud:")) {
uploaded++;imgsID.push(filePath)continue// todo}wx.showLoading({
title: `上传图片${
index}/${
this.data.imageUrls.length}`,})let cloudPath = this.data.imageUrls[index].pathvar res = await wx.cloud.uploadFile({
cloudPath,filePath})if (res.fileID != null) {
uploaded++imgsID.push(res.fileID)console.debug('[上传文件] :', cloudPath, filePath)}}
没有Javascript使用经验的人需要注意,使用callback风格时,complete是无论成功或者失败都要执行的:
wx.cloud.uploadFile({
cloudPath,filePath,success: res => {
},fail: e => {
},complete: async (res) => {
//如果要在这里处理循环上传后再次调用promise函数(比如写数据库),需要使用async修饰这个匿名函数}})
返回添加的商品信息
添加商品后,商品列表就发生了变化。可以将添加的商品信息暂存起来,等返回商品列表页面时更新。具体实现就是,在商品列表页面跳转添加商品页面时,增加商品添加事件处理,而在退出添加商品页面时,将保存的已添加商品信息列表返回给调用页面:
onAddGoods: function (e) {
let self = thiswx.navigateTo({
url: '../add-item/add-item',events: {
goodsAdded: function (result) {
if (result.num == 0) {
console.debug("没有添加商品")return}//处理添加的商品信息TODO...}}})},
添加商品页面卸载时,将发送goodsAdded事件,添加的商品信息将作为参数传递给调用页面:
onUnload: function () {
const eventChannel = this.getOpenerEventChannel()eventChannel.emit('goodsAdded', {
num: this.data.goodsItems.length,items : this.data.goodsItems})},
商品列表
如果商品数量太多,列表过长,会不会影响响应速度,进而影响用户体验?对,它会的。但这个潜在的问题留在后续的优化阶段来处理。一般来说,没必要一次将商品数据全部取出,可在下滑事件处理时分批请求数据。另外,考虑UI刷新效率,可以使用recycle-view来代替listview。但此时,我们先让功能可正常运行。
只更新部分列表项
当添加或者删除某个商品后,返回商品列表时需要更新列表。但没必要将整个列表刷新,可以只刷新list中指定索引数据:
//只刷新必要的部分视图this.setData({
['goods_items[' + item_index + ']']: new_item_data})
避免tabbar与其他组件重合
扩展UI库中的tabbar是透明的,其他组件计算可视区域时并不会自动“避开”它,如不处理就会像下图:
要解决这个问题可以使用窗口高度减去tabbar的高度后作为其他控件的可视区域高度:
onLoad: function (options) {
var query = wx.createSelectorQuery();query.select('#admin-index-tabber').boundingClientRect();let self = thisquery.exec(function (res) {
var new_height = wx.getSystemInfoSync().windowHeight - res[0].heightconsole.debug("new height:", new_height)if (self.data.viewHight != new_height) {
self.setData({
viewHight: new_height})}})},
<scroll-view scroll-y style="overflow:hidden;height:{
{
viewHight}}px;">
...
</scroll-view>
删除商品
滑动后显示删除按钮(或者图标)是常见的操作。
要实现上述效果,我们需要引入slideview控件:
"mp-slideview": "/miniprogram_npm/weui-miniprogram/slideview/slideview",
现在完整的页面部分代码如下:
如果要问这个页面布局怎么回事,我只能回答:从腾讯的小程序demo中剽窃加工的
<view ><user-info wx:if="{
{pageIndex == 0}}" openid="{
{_openid}}" /><block wx:if="{
{pageIndex == 2}}"><scroll-view scroll-y style="overflow:hidden;height:{
{
viewHight}}px;"><view class="weui-slidecells" wx:for="{
{goods_items}}" wx:key="_id"><mp-slideview show="{
{false}}" buttons="{
{item.buttons}}" bindbuttontap="onDeleteItem"><view class="weui-slidecell weui-media-box weui-media-box_appmsg" bindtap="onShowDetail"data-index="{
{index}}"><view class="weui-media-box__hd weui-media-box__hd_in-appmsg"><image class="weui-media-box__thumb" mode="aspectFit" src="{
{item.imgs[0]}}" /></view><view class="weui-media-box__bd weui-media-box__bd_in-appmsg"><view class="weui-media-box__title">{
{item.name}}</view><view class="weui-media-box__desc">{
{item.des}}</view></view></view></mp-slideview></view></scroll-view><view class="add-wrap" bindtap="onAddGoods"><image src="../images/add.ico"></image></view></block><mp-tabbar id="admin-index-tabber" list="{
{functions}}" bindchange="onTabChanged" /></view>
在商品选项卡被激活时获取商品信息,然后给每个商品信息对象追加buttons属性:
该属性类型为数组,也就是说可以设置多个操作按钮。此处只考虑删除功能。
onGetGoods: async function () {
// 调用云函数wx.showLoading({
title: "正在加载资源",})var res = await wx.cloud.callFunction({
name: 'goods-op',data: {
cmd: 'get'}})let items = res.result.data;for (var index = 0; index < items.length; index++) {
items[index].buttons = [{
text: '删除',type: 'warn',data: items[index]._id,//通过ID删除该条数据}]}this.setData({
goods_items: items})wx.hideLoading({
complete: (res) => {
},})},
注意,在OnDeleteItem中处理删除操作时,别忘了不但要删除数据表中的商品项,还要删除云存储中的商品图片。
编辑商品
很多时候,进入编辑状态并不代表一定更改数据。就算更改数据也不代表最终更改的数据与初始数据有别。原来考虑过编辑商品信息时先保存原始商品数据,更新时再进行比较,看商品信息是否真的发生了更改。但这样做也是有问题的,比如更换了商品图片,也许最终的图片和初始图片一样,但它们的云端ID肯定是不同的,这就很难判断商品信息是否真的发生了更改。但如果每次编辑页面退出后都更新也是不必要的开销,最后决定还是将更新数据的责任留给使用者,点更新就更新,不点不更新。
编辑和新建共用页面,只是通过调用页面时传递参数来做状态区分,如果是编辑模式,会传递op=edit:
wx.navigateTo({
url: '../add-item/add-item?op=edit',events: {
updated: async function (data) {
console.debug("item:", data.items)//更新后只刷新必要的部分视图 },success: function (res) {
//进入编辑状态时,会传递当前的商品数据项res.eventChannel.emit("showItem", self.data.goods_items[e.currentTarget.dataset.index])}})
页面加载时,也要检查参数,并作相应的处理,如做状态标识以及保存通过事件通道传递的商品数据:
onLoad: function (options) {
let self = thisif (options.op == "edit") {
const eventChannel = this.getOpenerEventChannel()eventChannel.on('showItem', function (data) {
console.debug(data) //TODO...}},
在该页面卸载时,也要根据模式来进行相应的处理。如果是编辑模式就发送updated事件,如果是新增商品就会触发goodsAdded事件:
onUnload: function () {
const eventChannel = this.getOpenerEventChannel()if (this.data._editting) {
eventChannel.emit('updated', {
items: this.data._goodsItems})} else {
eventChannel.emit('goodsAdded', {
num: this.data._goodsItems.length,items: this.data._goodsItems})console.debug(this.data._goodsItems)}},
添加、删除图片时处理
无论是编辑模式还是新建模式,都使用同样的流程处理数据更新。但在编辑模式下,原有的云图片不需要上传(也不能上传)。可以在上传图片时判断图片文件路径字符串是否以[cloud://]开头来判断该文件是本地文件还是云存储中得文件:
for (var index in this.data.imageUrls) {
let filePath = this.data.imageUrls[index].fileif (filePath.startsWith("cloud:")) {
uploaded++;imgsID.push(filePath)continue// todo}
在处理用户删除图片的操作时,可以将要删除的图片ID先保存在某个变量中,在更新商品信息到云数据库成功后再执行删除云存储中的图片文件的操作:
注意,以下滑线为前缀命名的变量setData时不影响界面刷新。或者应该说,如果一个变量不影响界面展示,命名时应该以下划线作为前缀。
res = await wx.cloud.callFunction({
name: 'goods-op',data: data})if (res.result.success) {
if (self.data._editting) {
var res = await wx.cloud.deleteFile({
fileList: this.data._deletedImgs })console.debug("del:", res)}}
下一步
管理端实现了基本的商品信息管理功能,下一步可以以此为基础实现客户端的商品浏览功能。