lilei 4 سال پیش
والد
کامیت
d96f728198

+ 110 - 0
almost-utils/almost-utils.js

@@ -0,0 +1,110 @@
+/**
+ * 存储 localStorage 数据
+ * @param {String} name - 缓存数据的标识
+ * @param {any} content - 缓存的数据内容
+ */
+export const setStore = (name, content) => {
+  if (!name) return
+  if (typeof content !== 'string') {
+    content = JSON.stringify(content)
+  }
+	uni.setStorageSync(name, content)
+}
+
+/**
+ * 获取 localStorage 数据
+ * @param {String} name - 缓存数据的标识
+ */
+export const getStore = (name) => {
+  if (!name) return
+  return uni.getStorageSync(name)
+}
+
+/**
+ * 清除 localStorage 数据
+ * @param {String} name - 缓存数据的标识
+ */
+export const clearStore = (name) => {
+  if (name) {
+		uni.removeStorageSync(name)
+  } else {
+    console.log('清理本地全部缓存')
+    uni.clearStorageSync()
+  }
+}
+
+/**
+ * 下载文件,并返回临时路径
+ * @return {String}  临时路径
+ * @param {String} fileUrl - 网络地址
+*/
+export const downloadFile = (fileUrl) => {
+  return new Promise((resolve) => {
+    uni.downloadFile({
+      url: fileUrl,
+      success: (res) => {
+        // #ifdef MP-ALIPAY
+        if (res.errMsg === 'downloadFile:ok') {
+          resolve({
+            ok: true,
+            tempFilePath: res.tempFilePath
+          })
+        } else {
+          resolve({
+            ok: false,
+            msg: '图片下载失败'
+          })
+        }
+        // #endif
+				// #ifndef MP-ALIPAY
+				if (res.statusCode === 200) {
+				  resolve({
+				    ok: true,
+				    tempFilePath: res.tempFilePath
+				  })
+				} else {
+				  resolve({
+				    ok: false,
+				    msg: '图片下载失败'
+				  })
+				}
+				// #endif
+      },
+      fail: (err) => {
+        resolve({
+          ok: false,
+          msg: `图片下载失败,${err}`
+        })
+      }
+    })
+  })
+}
+
+/**
+ * 清理应用已缓存的文件
+*/
+export const clearCacheFile = () => {
+	// #ifndef H5
+	uni.getSavedFileList({
+		success: (res) => {
+			let fileList = res.fileList
+			if (fileList.length) {
+				for (let i = 0; i < fileList.length; i++) {
+					uni.removeSavedFile({
+						filePath: fileList[i].filePath,
+						complete: () => {
+							console.log('清除缓存已完成')
+						}
+					})
+				}
+			}
+		},
+		fail: (err) => {
+			console.log('getSavedFileList Fail')
+		}
+	})
+	// #endif
+	// #ifdef H5
+	clearStore()
+	// #endif
+}

+ 164 - 0
almost-utils/image-tools.js

@@ -0,0 +1,164 @@
+// 图像转换工具,可用于图像和base64的转换
+// https://ext.dcloud.net.cn/plugin?id=123
+
+const getLocalFilePath = (path) => {
+  if (
+    path.indexOf('_www') === 0 ||
+    path.indexOf('_doc') === 0 ||
+    path.indexOf('_documents') === 0 ||
+    path.indexOf('_downloads') === 0
+  ) return path
+
+  if (path.indexOf('/storage/emulated/0/') === 0) return path
+	
+  if (path.indexOf('/storage/sdcard0/') === 0) return path
+
+  if (path.indexOf('/var/mobile/') === 0) return path
+
+  if (path.indexOf('file://') === 0) return path
+
+  if (path.indexOf('/') === 0) {
+		// ios 无法获取本地路径
+    let localFilePath = plus.os.name === 'iOS' ? path : plus.io.convertLocalFileSystemURL(path)
+    if (localFilePath !== path) {
+      return localFilePath
+    } else {
+      path = path.substring(1)
+    }
+  }
+	
+  return '_www/' + path
+}
+
+export const pathToBase64 = (path) => {
+	return new Promise((resolve, reject) => {
+		if (typeof window === 'object' && 'document' in window) {
+			if (typeof FileReader === 'function') {
+				let xhr = new XMLHttpRequest()
+				xhr.open('GET', path, true)
+				xhr.responseType = 'blob'
+				xhr.onload = function() {
+					if (this.status === 200) {
+						let fileReader = new FileReader()
+						fileReader.onload = function(e) {
+							resolve(e.target.result)
+						}
+						fileReader.onerror = reject
+						fileReader.readAsDataURL(this.response)
+					}
+				}
+				xhr.onerror = reject
+				xhr.send()
+				return
+			}
+			let canvas = document.createElement('canvas')
+			let c2x = canvas.getContext('2d')
+			let img = new Image
+			img.onload = function() {
+				canvas.width = img.width
+				canvas.height = img.height
+				c2x.drawImage(img, 0, 0)
+				resolve(canvas.toDataURL())
+				canvas.height = canvas.width = 0
+			}
+			img.onerror = reject
+			img.src = path
+			return
+		}
+		
+		if (typeof plus === 'object') {
+			let tempPath = getLocalFilePath(path)
+			plus.io.resolveLocalFileSystemURL(tempPath, (entry) => {
+				entry.file((file) => {
+					let fileReader = new plus.io.FileReader()
+					fileReader.onload = function(data) {
+						resolve(data.target.result)
+					}
+					fileReader.onerror = function(error) {
+						console.log(error)
+						reject(error)
+					}
+					fileReader.readAsDataURL(file)
+				}, (error) => {
+					reject(error)
+				})
+			}, (error) => {
+				reject(error)
+			})
+			return
+		}
+		
+		if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
+			wx.getFileSystemManager().readFile({
+				filePath: path,
+				encoding: 'base64',
+				success: (res) => {
+					resolve('data:image/png;base64,' + res.data)
+				},
+				fail: (error) => {
+					reject(error)
+				}
+			})
+			return
+		}
+		reject(new Error('not support'))
+	})
+}
+
+export const base64ToPath = (base64) => {
+	return new Promise((resolve, reject) => {
+		if (typeof window === 'object' && 'document' in window) {
+			base64 = base64.split(',')
+			let type = base64[0].match(/:(.*?);/)[1]
+			let str = atob(base64[1])
+			let n = str.length
+			let array = new Uint8Array(n)
+			while (n--) {
+				array[n] = str.charCodeAt(n)
+			}
+			return resolve((window.URL || window.webkitURL).createObjectURL(new Blob([array], {
+				type: type
+			})))
+		}
+		let extName = base64.match(/data\:\S+\/(\S+);/)
+		if (extName) {
+			extName = extName[1]
+		} else {
+			reject(new Error('base64 error'))
+		}
+		let fileName = Date.now() + '.' + extName
+		if (typeof plus === 'object') {
+			let bitmap = new plus.nativeObj.Bitmap('bitmap' + Date.now())
+			bitmap.loadBase64Data(base64, () => {
+				let filePath = '_doc/uniapp_temp/' + fileName
+				bitmap.save(filePath, {}, () => {
+					bitmap.clear()
+					resolve(filePath)
+				}, (error) => {
+					bitmap.clear()
+					reject(error)
+				})
+			}, (error) => {
+				bitmap.clear()
+				reject(error)
+			})
+			return
+		}
+		if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
+			let filePath = wx.env.USER_DATA_PATH + '/' + fileName
+			wx.getFileSystemManager().writeFile({
+				filePath: filePath,
+				data: base64.replace(/^data:\S+\/\S+;base64,/, ''),
+				encoding: 'base64',
+				success: () => {
+					resolve(filePath)
+				},
+				fail: (error) => {
+					reject(error)
+				}
+			})
+			return
+		}
+		reject(new Error('not support'))
+	})
+}

+ 716 - 0
components/almost-lottery/almost-lottery.vue

@@ -0,0 +1,716 @@
+<template>
+  <view class="almost-lottery" :style="{ width: canvasWidth + 40 + 'px', height: canvasHeight + 40 + 'px'}">
+    <view class="almost-lottery__wrap" :style="{width: canvasWidth + canvasMarginTotal + 'px', height: canvasHeight + canvasMarginTotal + 'px'}">
+      <!-- #ifdef MP-ALIPAY -->
+      <canvas :class="className" :id="canvasId" :width="canvasWidth" :height="canvasHeight" :style="{
+         width: canvasWidth + 'px',
+         height: canvasHeight + 'px'
+       }" />
+      <!-- #endif -->
+      <!-- #ifndef MP-ALIPAY -->
+      <canvas :class="className" :canvas-id="canvasId" :width="canvasWidth" :height="canvasHeight" :style="{
+         width: canvasWidth + 'px',
+         height: canvasHeight + 'px'
+       }" />
+      <!-- #endif -->
+      <image class="canvas-img" :src="lotteryImg" :style="{
+         width: canvasWidth + canvasMarginTotal + 'px',
+         height: canvasHeight + canvasMarginTotal + 'px',
+         transform: `rotate(${canvasAngle + targetAngle}deg)`,
+         transitionDuration: `${transitionDuration}s`
+       }"
+        v-if="lotteryImg"></image>
+      <view class="almost-lottery__action" :style="{
+         width: actionSize + 'px',
+         height: actionSize + 'px'
+       }" @click="handleActionStart"></view>
+      <!-- 为了兼容 app 端 ctx.measureText 所需的标签 -->
+      <text class="almost-lottery__measureText">{{ measureText }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+	import { pathToBase64 } from '@/almost-utils/image-tools.js'
+	import { getStore, setStore, clearStore, downloadFile } from '@/almost-utils/almost-utils.js'
+  export default {
+    name: 'AlmostLottery',
+    props: {
+      // canvas 宽度
+      canvasWidth: {
+        type: Number,
+        default: 240
+      },
+      // canvas 高度
+      canvasHeight: {
+        type: Number,
+        default: 240
+      },
+      // 奖品列表
+      prizeList: {
+        type: Array,
+        required: true,
+        validator: (value) => {
+          return value.length > 1
+        }
+      },
+      // 中奖奖品在列表中的下标
+      prizeIndex: {
+        type: Number,
+        required: true
+      },
+      // 奖品区块对应背景颜色
+      colors: {
+        type: Array,
+        default: () => [
+          '#FFFFFF',
+          '#FFE9AA'
+        ]
+      },
+      // 旋转动画时间 单位s
+      duration: {
+        type: Number,
+        default: 8
+      },
+      // 旋转的圈数
+      ringCount: {
+        type: Number,
+        default: 8
+      },
+      // 指针位置
+      pointerPosition: {
+        type: String,
+        default: 'edge',
+        validator: (value) => {
+          return value === 'edge' || value === 'middle'
+        }
+      },
+      // 字体颜色
+      fontColor: {
+        type: String,
+        default: '#C30B29'
+      },
+      // 文字的大小
+      fontSize: {
+        type: Number,
+        default: 12
+      },
+      // 奖品文字多行情况下的行高
+      lineHeight: {
+        type: Number,
+        default: 16
+      },
+      // 奖品名称所对应的 key 值
+      strKey: {
+        type: String,
+        default: 'name'
+      },
+      // 奖品文字总长度限制
+      strMaxLen: {
+        type: Number,
+        default: 12
+      },
+      // 奖品文字多行情况下第一行文字长度
+      strLineLen: {
+        type: Number,
+        default: 6
+      },
+      // 奖品图片的宽
+      imageWidth: {
+        type: Number,
+        default: 30
+      },
+      // 奖品图片的高
+      imageHeight: {
+        type: Number,
+        default: 30
+      },
+			// 转盘绘制成功的提示
+			successMsg: {
+				type: String,
+				default: '奖品准备就绪,快来参与抽奖吧'
+			},
+			// 转盘绘制失败的提示
+			failMsg: {
+				type: String,
+				default: '奖品仍在准备中,请稍后再来...'
+			},
+			// 是否开启画板的缓存
+			canvasCached: {
+				type: Boolean,
+				default: true
+			},
+			// 内圈与外圈的间距
+			canvasMargin: {
+        type: Number,
+        default: 5
+      }
+    },
+    data() {
+      return {
+        // 画板className
+        className: 'almost-lottery__canvas',
+        // 画板标识
+        canvasId: 'almostLotteryCanvas',
+        // 画板导出的图片
+        lotteryImg: '',
+        // 旋转到奖品目标需要的角度
+        targetAngle: 0,
+        // 旋转动画时间 单位 s
+        transitionDuration: 0,
+        // 是否正在旋转
+        isRotate: false,
+        // 当前停留在那个奖品的序号
+        stayIndex: 0,
+        // 当前中奖奖品的序号
+        targetIndex: 0,
+				// 是否存在可用的缓存转盘图
+				isCacheImg: false,
+				oldLotteryImg: '',
+        // 解决 app 不支持 measureText 的问题
+				// app 已在 2.9.3 的版本中提供了对 measureText 的支持,将在后续版本逐渐稳定后移除相关兼容代码
+        measureText: ''
+      }
+    },
+    computed: {
+      // 根据奖品列表计算 canvas 旋转角度
+      canvasAngle() {
+        let prizeCount = this.prizeList.length
+        let prizeClip = 360 / prizeCount
+        let result = 0
+
+        let diffNum = 90 / prizeClip
+        if (this.pointerPosition === 'edge') {
+          result = -(prizeClip * diffNum)
+        } else {
+          result = -(prizeClip * diffNum + prizeClip / 2)
+        }
+        return result
+      },
+      // 外圆的半径
+      outsideRadius() {
+        return this.canvasWidth / 2
+      },
+      // 内圆的半径
+      insideRadius() {
+        return 20
+      },
+      // 字体的半径
+      textRadius() {
+        return this.fontSize / 2
+      },
+      // 根据画板的宽度计算奖品文字与中心点的距离
+      textDistance() {
+        const textZeroY = Math.round(this.outsideRadius - (this.insideRadius / 2))
+        return textZeroY - this.textRadius
+      },
+      // 设备像素密度
+      pixelRatio() {
+        return uni.getSystemInfoSync().pixelRatio
+      },
+			// 内圈与外圈的距离
+			canvasMarginTotal () {
+				let diffNum = 5
+				let margin = this.canvasMargin * 2
+				if (this.canvasWidth > 240) {
+					return -(this.canvasWidth / 240 * 2) - margin
+				} else if (this.canvasWidth < 240) {
+					return diffNum + (this.canvasWidth / 240 * 2) - margin
+				} else {
+					return diffNum - margin
+				}
+			},
+			// 抽奖按钮的宽高
+			actionSize () {
+				return this.canvasWidth / 2.4
+			}
+    },
+    watch: {
+      // 监听获奖序号的变动
+      prizeIndex(newVal, oldVal) {
+        if (newVal > -1) {
+          this.targetIndex = newVal
+          this.onRotateStart()
+        } else {
+          console.info('旋转结束,prizeIndex 已重置')
+        }
+      }
+    },
+    methods: {
+      // 开始旋转
+      onRotateStart() {
+        // 奖品总数
+        let prizeCount = this.prizeList.length
+        let baseAngle = 360 / prizeCount
+        let angles = 0
+        if (this.targetAngle === 0) {
+          // 第一次旋转
+          // 因为第一个奖品是从0°开始的,即水平向右方向
+          // 第一次旋转角度 = 270度 - (停留的序号-目标序号) * 每个奖品区间角度 - 每个奖品区间角度的一半 - canvas自身旋转的度数
+          angles = (270 - (this.targetIndex - this.stayIndex) * baseAngle - baseAngle / 2) - this.canvasAngle
+        } else {
+          // 后续旋转
+          // 后续继续旋转 就只需要计算停留的位置与目标位置的角度
+          angles = -(this.targetIndex - this.stayIndex) * baseAngle
+        }
+        // 更新目前序号
+        this.stayIndex = this.targetIndex
+        // 转 8 圈,圈数越多,转的越快
+        this.targetAngle += angles + 360 * this.ringCount
+
+        // 计算转盘结束的时间,预加一些延迟确保转盘停止后触发结束事件
+        let endTime = this.transitionDuration * 1000 + 100
+        let endTimer = setTimeout(() => {
+          clearTimeout(endTimer)
+          endTimer = null
+
+          this.isRotate = false
+          this.$emit('draw-end')
+        }, endTime)
+
+        let resetPrizeTimer = setTimeout(() => {
+          clearTimeout(resetPrizeTimer)
+          resetPrizeTimer = null
+
+          // 每次抽奖结束后都要重置父级附件的 prizeIndex
+          this.$emit('reset-index')
+        }, endTime + 50)
+      },
+      // 点击 开始抽奖 按钮
+      handleActionStart() {
+        if (this.isRotate) return
+        this.isRotate = true
+        this.$emit('draw-start')
+      },
+      // 渲染转盘
+      async onCreateCanvas() {
+        // 获取 canvas 画布
+        const canvasId = this.canvasId
+        const ctx = uni.createCanvasContext(canvasId, this)
+
+        // canvas 的宽高
+        let canvasW = this.canvasWidth
+        let canvasH = this.canvasHeight
+
+        // 根据奖品个数计算 角度
+        let prizeCount = this.prizeList.length
+        let baseAngle = Math.PI * 2 / prizeCount
+
+        // 设置描边颜色
+        ctx.setStrokeStyle('#FFBE04')
+
+        // 设置字体和字号
+        // #ifndef MP
+        let fontFamily =
+          '-apple-system, BlinkMacSystemFont, \'PingFang SC\', \'Helvetica Neue\', STHeiti, \'Microsoft Yahei\', Tahoma, Simsun, sans-serif'
+        ctx.font = `${this.fontSize}px ${fontFamily}`
+        // #endif
+        // #ifdef MP
+        ctx.setFontSize(this.fontSize)
+        // #endif
+
+        // 注意,开始画的位置是从0°角的位置开始画的。也就是水平向右的方向。
+        // 画具体内容
+        for (let i = 0; i < prizeCount; i++) {
+					let prizeItem = this.prizeList[i]
+          // 当前角度
+          let angle = i * baseAngle
+
+          // 保存当前画布的状态
+          ctx.save()
+
+          // 开始画内容
+          ctx.beginPath()
+
+          // 开始画圆弧
+          // x => 圆弧对应的圆心横坐标 x
+          // y => 圆弧对应的圆心横坐标 y
+          // radius => 圆弧的半径大小
+          // startAngle => 圆弧开始的角度,单位是弧度
+          // endAngle => 圆弧结束的角度,单位是弧度
+          // anticlockwise(可选) => 绘制方向,true 为逆时针,false 为顺时针
+          ctx.arc(canvasW * 0.5, canvasH * 0.5, this.outsideRadius, angle, angle + baseAngle, false)
+          ctx.arc(canvasW * 0.5, canvasH * 0.5, this.insideRadius, angle + baseAngle, angle, true)
+
+          // 开始链接线条
+          ctx.stroke()
+          // 每个奖品区块背景填充颜色
+          if (this.colors.length === 2) {
+            ctx.setFillStyle(this.colors[i % 2])
+          } else {
+            ctx.setFillStyle(this.colors[i])
+          }
+          // 填充颜色
+          ctx.fill()
+
+          // 开始绘制奖品内容
+          // 重新映射画布上的 (0,0) 位置
+          let translateX = canvasW * 0.5 + Math.cos(angle + baseAngle / 2) * this.textDistance
+          let translateY = canvasH * 0.5 + Math.sin(angle + baseAngle / 2) * this.textDistance
+          ctx.translate(translateX, translateY)
+
+          // 绘制奖品名称
+          ctx.setFillStyle(this.fontColor)
+          let rewardName = this.strLimit(prizeItem[this.strKey])
+
+          // rotate方法旋转当前的绘图,因为文字是和当前扇形中心线垂直的
+          ctx.rotate(angle + (baseAngle / 2) + (Math.PI / 2))
+
+          // 设置文本位置并处理换行
+          // 是否需要换行
+          let isLineBreak = rewardName.length > this.strLineLen
+          let textOffsetX = this.fontSize === 12 ? 0 : this.textRadius
+          if (isLineBreak) {
+            // 获得多行文本数组
+            rewardName = rewardName.substring(0, this.strLineLen) + ',' + rewardName.substring(this.strLineLen)
+            let rewardNames = rewardName.split(',')
+
+            // 循环文本数组,计算每一行的文本宽度
+            for (let j = 0; j < rewardNames.length; j++) {
+              if (ctx.measureText && ctx.measureText(rewardNames[j]).width) {
+                // 文本的宽度信息
+                let tempStrSize = ctx.measureText(rewardNames[j])
+                ctx.fillText(rewardNames[j], -(tempStrSize.width / 2 + textOffsetX), j * this.lineHeight)
+              } else {
+                this.measureText = rewardNames[j]
+
+                // 等待页面重新渲染
+                await this.$nextTick()
+
+                let textWidth = await this.getTextWidth()
+
+                ctx.fillText(rewardNames[j], -(textWidth / 2 + textOffsetX), j * this.lineHeight)
+                // console.log(rewardNames[j], textWidth, i)
+              }
+            }
+          } else {
+            if (ctx.measureText && ctx.measureText(rewardName).width) {
+              // 文本的宽度信息
+              let tempStrSize = ctx.measureText(rewardName)
+              ctx.fillText(rewardName, -(tempStrSize.width / 2 + textOffsetX), 0)
+            } else {
+              this.measureText = rewardName
+
+              // 等待页面重新渲染
+              await this.$nextTick()
+
+              let textWidth = await this.getTextWidth()
+              ctx.fillText(rewardName, -(textWidth / 2 + textOffsetX), 0)
+            }
+          }
+
+          // 绘制奖品图片
+          if (prizeItem.prizeImage) {
+						// App-Android平台 系统 webview 更新到 Chrome84+ 后 canvas 组件绘制本地图像 uni.canvasToTempFilePath 会报错
+						// 统一将图片处理成 base64
+						// https://ask.dcloud.net.cn/question/103303
+						let reg = /^(https|http)/g
+						// 处理远程图片
+						if (reg.test(prizeItem.prizeImage)) {
+							console.warn('###当前数据列表中的奖品图片为网络图片,开始下载图片...###')
+							let res = await downloadFile(prizeItem.prizeImage)
+							console.log('处理远程图片', res)
+							if (res.ok) {
+								let tempFilePath = res.tempFilePath
+								// #ifndef MP
+								prizeItem.prizeImage = await pathToBase64(tempFilePath)
+								// #endif
+								// #ifdef MP
+								prizeItem.prizeImage = tempFilePath
+								// #endif
+							}
+						} else {
+							// #ifndef MP
+							prizeItem.prizeImage = await pathToBase64(prizeItem.prizeImage)
+							// #endif
+						}
+						
+            ctx.drawImage(prizeItem.prizeImage, -(this.imageWidth / 2), canvasW / 10, this.imageWidth, this.imageHeight)
+          }
+
+          ctx.restore()
+        }
+
+        // 保存绘图并导出图片
+        ctx.draw(true, () => {
+          let drawTimer = setTimeout(() => {
+            clearTimeout(drawTimer)
+            drawTimer = null
+
+            // #ifdef MP-ALIPAY
+            ctx.toTempFilePath({
+              destWidth: this.canvasWidth * this.pixelRatio,
+              destHeight: this.canvasHeight * this.pixelRatio,
+              success: (res) => {
+                // console.log(res.apFilePath)
+                this.handlePrizeImg({
+									ok: true,
+									data: res.apFilePath,
+									msg: '画布导出生成图片成功'
+								})
+              },
+							fail: (err) => {
+                this.handlePrizeImg({
+									ok: false,
+									data: err,
+									msg: '画布导出生成图片失败'
+								})
+							}
+            })
+            // #endif
+            // #ifndef MP-ALIPAY
+            uni.canvasToTempFilePath({
+              destWidth: this.canvasWidth * this.pixelRatio,
+              destHeight: this.canvasHeight * this.pixelRatio,
+              canvasId: this.canvasId,
+              success: (res) => {
+                // 在 H5 平台下,tempFilePath 为 base64
+                // console.log(res.tempFilePath)
+                this.handlePrizeImg({
+									ok: true,
+									data: res.tempFilePath,
+									msg: '画布导出生成图片成功'
+								})
+              },
+							fail: (err) => {
+                this.handlePrizeImg({
+									ok: false,
+									data: err,
+									msg: '画布导出生成图片失败'
+								})
+							}
+            }, this)
+            // #endif
+          }, 500)
+        })
+      },
+      // 处理导出的图片
+      handlePrizeImg(res) {
+				if (res.ok) {
+					let data = res.data
+					
+					if (!this.canvasCached) {
+						this.lotteryImg = data
+						this.handlePrizeImgSuc(res)
+						return
+					}
+					
+					// #ifndef H5
+					if (this.isCacheImg) {
+						uni.getSavedFileList({
+							success: (sucRes) => {
+								let fileList = sucRes.fileList
+								// console.log('getSavedFileList Cached', fileList)
+								
+								let cached = false
+								for (let i = 0; i < fileList.length; i++) {
+									let item = fileList[i]
+									if (item.filePath === data) {
+										cached = true
+										this.lotteryImg = data
+										
+										console.info('经查,本地缓存中存在转盘图可用,本次将不再绘制转盘')
+										this.handlePrizeImgSuc(res)
+										break
+									}
+								}
+								
+								if (!cached) {
+									console.info('经查,本地缓存中存在转盘图不可用,需要重新初始化转盘绘制')
+									this.initCanvasDraw()
+								}
+							},
+							fail: (err) => {
+								this.initCanvasDraw()
+							}
+						})
+					} else {
+						uni.saveFile({
+							tempFilePath: data,
+							success: (sucRes) => {
+								let filePath = sucRes.savedFilePath
+								// console.log('saveFile', filePath)
+								setStore('lotteryImg', filePath)
+								this.lotteryImg = filePath
+								this.handlePrizeImgSuc({
+									ok: true,
+									data: filePath,
+									msg: '画布导出生成图片成功'
+								})
+							},
+							fail: (err) => {
+								this.handlePrizeImg({
+									ok: false,
+									data: err,
+									msg: '画布导出生成图片失败'
+								})
+							}
+						})
+					}
+					// #endif
+					// #ifdef H5
+					console.info('当前为 H5 端,直接使用导出的/缓存中的 base64 图')
+					setStore('lotteryImg', data)
+					this.lotteryImg = data
+					this.handlePrizeImgSuc(res)
+					// #endif
+				} else {
+					console.error('处理导出的图片失败', res)
+					uni.hideLoading()
+					// #ifdef H5
+					console.error('###当前为 H5 端,下载网络图片需要后端配置允许跨域###')
+					// #endif
+					// #ifdef MP
+					console.error('###当前为小程序端,下载网络图片需要配置域名白名单###')
+					// #endif
+				}
+      },
+			// 处理图片完成
+			handlePrizeImgSuc (res) {
+				uni.hideLoading()
+				this.$emit('finish', {
+					ok: res.ok,
+					data: res.data,
+					msg: res.ok ? this.successMsg : this.failMsg
+				})
+			},
+      // 兼容 app 端不支持 ctx.measureText
+      // 已知问题:初始绘制时,低端安卓机 平均耗时 2s
+      getTextWidth() {
+        return new Promise((resolve, reject) => {
+          uni.createSelectorQuery().in(this).select('.almost-lottery__measureText').fields({
+            size: true,
+          }, (res) => {
+            resolve(res.width)
+          }).exec()
+        })
+      },
+      // 处理文字溢出
+      strLimit(value) {
+        let maxLength = this.strMaxLen
+        if (!value || !maxLength) return value
+        return value.length > maxLength ? value.slice(0, maxLength - 1) + '...' : value
+      },
+			// 检查本地缓存中是否存在转盘图
+			checkCacheImg () {
+				console.log('检查本地缓存中是否存在转盘图')
+				// 检查是否已有缓存的转盘图
+				// 检查是否与本次奖品数据相同
+				this.oldLotteryImg = getStore('lotteryImg')
+				let oldPrizeList = getStore('prizeList')
+				let newPrizeList = JSON.stringify(this.prizeList)
+				if (this.oldLotteryImg) {
+					if (oldPrizeList === newPrizeList) {
+						console.log(`经查,本地缓存中存在转盘图 => ${this.oldLotteryImg}`)
+						this.isCacheImg = true
+						
+						console.log('需要继续判断这张缓存图是否可用')
+						this.handlePrizeImg({
+							ok: true,
+							data: this.oldLotteryImg,
+							msg: '画布导出生成图片成功'
+						})
+						return
+					}
+				}
+				
+				console.log('经查,本地缓存中不存在转盘图')
+				this.initCanvasDraw()
+			},
+      // 初始化绘制
+      initCanvasDraw () {
+				console.log('开始初始化转盘绘制')
+				this.isCacheImg = false
+				this.lotteryImg = ''
+				clearStore('lotteryImg')
+        setStore('prizeList', this.prizeList)
+        this.onCreateCanvas()
+      }
+    },
+    mounted() {
+      this.$nextTick(() => {
+        let stoTimer = setTimeout(() => {
+          clearTimeout(stoTimer)
+          stoTimer = null
+					
+					if (this.canvasCached) {
+						this.checkCacheImg()
+					} else {
+						this.onCreateCanvas()
+					}
+          this.transitionDuration = this.duration
+        }, 50)
+      })
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  $lotteryBgUrl: '~static/almost-lottery/almost-lottery__bg';
+  $actionBgUrl: '~static/almost-lottery/almost-lottery__action';
+
+  .almost-lottery {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    margin: 0 auto;
+    background-repeat: no-repeat;
+    background-position: center center;
+    background-size: contain;
+    background-image: url($lotteryBgUrl + ".png");
+
+    @media (-webkit-min-device-pixel-ratio: 2), (min-device-pixel-ratio: 2) {
+      background-image: url($lotteryBgUrl + "2x.png");
+    }
+
+    @media (-webkit-min-device-pixel-ratio: 3), (min-device-pixel-ratio: 3) {
+      background-image: url($lotteryBgUrl + "3x.png");
+    }
+  }
+	
+	.almost-lottery__wrap {
+    position: relative;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+	}
+
+  .almost-lottery__canvas {
+    position: absolute;
+    left: -9999px;
+    opacity: 0;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+
+  .almost-lottery__action {
+    position: absolute;
+    background-repeat: no-repeat;
+    background-position: center center;
+    background-size: contain;
+    background-image: url($actionBgUrl + ".png");
+
+    @media (-webkit-min-device-pixel-ratio: 2), (min-device-pixel-ratio: 2) {
+      background-image: url($actionBgUrl + "2x.png");
+    }
+
+    @media (-webkit-min-device-pixel-ratio: 3), (min-device-pixel-ratio: 3) {
+      background-image: url($actionBgUrl + "3x.png");
+    }
+  }
+
+  .almost-lottery__measureText {
+    position: absolute;
+    left: 0;
+    top: 0;
+    white-space: nowrap;
+    font-size: 12px;
+    opacity: 0;
+  }
+
+  .canvas-img {
+		display: block;
+    transition: transform cubic-bezier(.34, .12, .05, .95);
+  }
+</style>

+ 9 - 0
pages.json

@@ -207,6 +207,15 @@
             }
             
         }
+        ,{
+            "path" : "pages/luckDraw/luckDraw",
+            "style" :                                                                                    
+            {
+                "navigationBarTitleText": "幸运大转盘",
+                "enablePullDownRefresh": false
+            }
+            
+        }
     ],
 	"globalStyle": {
 		"navigationBarTextStyle": "black",

+ 15 - 3
pages/index/index.vue

@@ -64,7 +64,7 @@
 		<u-popup v-model="showLottery" mode="center" closeable :border-radius="30" width="450rpx" height="450rpx">
 			<view class="lottery-content">
 				<view class="lottery-confrim-btn">
-					<u-button>去抽奖</u-button>
+					<u-button @click="toLuckDraw" size="mini">去抽奖</u-button>
 				</view>
 			</view>
 		</u-popup>
@@ -349,6 +349,12 @@
 				uni.navigateTo({
 					url: '/pages/store/store?id=' + item.id + '&stationNo=' + item.stationNo
 				})
+			},
+			// 去抽奖
+			toLuckDraw(){
+				uni.navigateTo({
+					url: '/pages/luckDraw/luckDraw'
+				})
 			}
 		}
 	}
@@ -370,10 +376,16 @@
 		}
 		.lottery-content{
 			display: flex;
-			align-items: center;
+			justify-content: center;
+			height: 100%;
 			position: relative;
 			.lottery-confrim-btn{
-				
+				width: 100%;
+				display: flex;
+				justify-content: center;
+				position: absolute;
+				bottom: 40rpx;
+				left: 0;
 			}
 		}
 		.storeList {

+ 353 - 0
pages/luckDraw/luckDraw.vue

@@ -0,0 +1,353 @@
+<template>
+	<view class="content">
+		<view class="luckwheel">
+			<almost-lottery
+				:prize-list="prizeList"
+				:prize-index="prizeIndex"
+				@reset-index="prizeIndex = -1"
+				@draw-start="handleDrawStart"
+				@draw-end="handleDrawEnd"
+				@finish="handleDrawFinish"
+				v-if="prizeList.length"
+			/>
+			<view class="synums">
+				抽奖剩余次数:
+				<text>2</text>
+			</view>
+		</view>
+		<!-- 获奖人员滚动列表 -->
+		<view class="hjList">
+			<view>
+				恭喜
+				<text>185****5252</text>
+				获得
+				<text>7</text>
+				乐豆
+			</view>
+			<view>
+				恭喜
+				<text>185****5252</text>
+				获得
+				<text>7</text>
+				乐豆
+			</view>
+			<view>
+				恭喜
+				<text>185****5252</text>
+				获得
+				<text>7</text>
+				乐豆
+			</view>
+			<view>
+				恭喜
+				<text>185****5252</text>
+				获得
+				<text>7</text>
+				乐豆
+			</view>
+			<view>
+				恭喜
+				<text>185****5252</text>
+				获得
+				<text>7</text>
+				乐豆
+			</view>
+			<view>
+				恭喜
+				<text>185****5252</text>
+				获得
+				<text>7</text>
+				乐豆
+			</view>
+			<view>
+				恭喜
+				<text>185****5252</text>
+				获得
+				<text>7</text>
+				乐豆
+			</view>
+			<view>
+				恭喜
+				<text>185****5252</text>
+				获得
+				<text>7</text>
+				乐豆
+			</view>
+			<view>
+				恭喜
+				<text>185****5252</text>
+				获得
+				<text>7</text>
+				乐豆
+			</view>
+		</view>
+		<!-- 抽奖规则 -->
+		<view class="guize">
+			<view>抽奖规则</view>
+			<view>
+				<text>1.活动时间:</text>
+				<text>2020.12.10 至 2020.12.31</text>
+			</view>
+			<view>
+				<text>2.奖品:</text>
+				<view>
+					<view>一等奖 华为P40</view>
+					<view>二等奖 小米电动牙刷</view>
+					<view>三等奖 瑞士军刀</view>
+					<view>四等奖 20乐豆</view>
+					<view>五等奖 7乐豆</view>
+				</view>
+			</view>
+			<view><text>3.抽奖条件:</text></view>
+			<view>①首次注册乐色管家小程序即可获得一次抽奖机会;</view>
+			<view>②分享给好友或发送到朋友圈可获得1次抽奖机会;</view>
+			<view><text>4.抽奖说明:</text></view>
+			<view>本活动最终解释权归XXXXXX所有。</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import AlmostLottery from '@/components/almost-lottery/almost-lottery.vue';
+import { clearCacheFile } from '@/almost-utils/almost-utils.js';
+export default {
+	name: 'Home',
+	components: {
+		AlmostLottery
+	},
+	data() {
+		return {
+			// canvas 宽高
+			canvasData: {
+				width: 200,
+				height: 200
+			},
+			// 奖品数据
+			prizeList: [],
+			// 中奖下标
+			prizeIndex: -1,
+			// 中奖类目名称
+			targetName: '',
+			// 奖品是否设有库存
+			onStock: true,
+			// 是否由前端控制概率,默认不开启
+			onFrontend: false,
+			// 权重随机数的最大值
+			weightTotal: 0,
+			// 权重数组
+			weightArr: []
+		};
+	},
+	methods: {
+		// 重新生成
+		handleInitCanvas() {
+			clearCacheFile();
+			this.targetName = '';
+			this.prizeList = [];
+			this.getPrizeList();
+		},
+		// 获取奖品列表
+		async getPrizeList() {
+			uni.showLoading({
+				title: '奖品准备中...'
+			});
+
+			let res = await this.requestPrizeList();
+			console.log('获取奖品列表', res);
+
+			if (res.ok) {
+				let data = res.data;
+				if (data.length) {
+					// stock 奖品库存
+					// weight 中奖概率,数值越大中奖概率越高
+					this.prizeList = data;
+
+					// 如果开启了前端控制概率
+					// 计算出权重的总和并生成权重数组
+					if (this.onFrontend) {
+						this.prizeList.forEach(item => (this.weightTotal += item.weight));
+						this.weightArr = this.prizeList.map(item => item.weight);
+					}
+				}
+			} else {
+				uni.hideLoading();
+				uni.showToast({
+					title: '获取奖品失败'
+				});
+			}
+		},
+		// 模拟请求奖品列表接口
+		requestPrizeList() {
+			return new Promise((resolve, reject) => {
+				let requestTimer = setTimeout(() => {
+					clearTimeout(requestTimer);
+					requestTimer = null;
+
+					resolve({
+						ok: true,
+						data: [
+							{
+								prizeId: 1,
+								name: '0.1元现金',
+								stock: 10,
+								weight: 0,
+								prizeImage: 'https://vkceyugu.cdn.bspapp.com/VKCEYUGU-dc-site/56f085e0-bcfe-11ea-b244-a9f5e5565f30.png'
+							},
+							{
+								prizeId: 2,
+								name: '10元现金',
+								stock: 0,
+								weight: 0,
+								prizeImage: 'https://vkceyugu.cdn.bspapp.com/VKCEYUGU-dc-site/56f085e0-bcfe-11ea-b244-a9f5e5565f30.png'
+							},
+							{ prizeId: 3, name: '5元话费', stock: 1, weight: 0 },
+							{ prizeId: 4, name: '50元现金', stock: 0, weight: 0 },
+							{ prizeId: 5, name: '1卷抽纸', stock: 3, weight: 0 },
+							{ prizeId: 6, name: '0.2元现金', stock: 8, weight: 0 },
+							{ prizeId: 7, name: '谢谢参与', stock: 100, weight: 0 },
+							{ prizeId: 8, name: '100金币', stock: 100, weight: 0 }
+						]
+					});
+				}, 2000);
+			});
+		},
+		// 本次抽奖开始
+		handleDrawStart() {
+			this.targetName = '';
+
+			let list = [...this.prizeList];
+
+			// 模拟请求
+			let stoTimer = setTimeout(() => {
+				clearTimeout(stoTimer);
+				stoTimer = null;
+
+				// 判断是否由前端控制概率
+				// 前端控制概率的情况下,需要拿到最接近随机权重且大于随机权重的值
+				// 后端控制概率的情况下,通常会直接返回 prizeId
+				if (this.onFrontend) {
+					if (!this.weightTotal) {
+						console.warn('###当前已开启前端控制中奖概率,但是奖品数据列表中的 weight 参数似乎配置不正确###');
+						return;
+					}
+					console.warn('###当前处于前端控制中奖概率,安全起见,强烈建议由后端控制###');
+					console.log('当前权重总和为 =>', this.weightTotal);
+
+					// 注意这里使用了 Math.ceil,如果某个权重的值为 0,则始终无法中奖
+					let weight = Math.ceil(Math.random() * this.weightTotal);
+					console.log('本次权重随机数 =>', weight);
+
+					// 生成大于等于随机权重的数组
+					let tempMaxArrs = [];
+					list.forEach(item => {
+						if (item.weight >= weight) {
+							tempMaxArrs.push(item.weight);
+						}
+					});
+
+					// 如果大于随机权重的数组有值,先对这个数组排序然后取值
+					// 反之新建一个临时的包含所有权重的已排序数组,然后取值
+					if (tempMaxArrs.length) {
+						tempMaxArrs.sort((a, b) => a - b);
+						this.prizeIndex = this.weightArr.indexOf(tempMaxArrs[0]);
+					} else {
+						let tempWeightArr = [...this.weightArr];
+						tempWeightArr.sort((a, b) => a - b);
+						this.prizeIndex = this.weightArr.indexOf(tempWeightArr[tempWeightArr.length - 1]);
+					}
+				} else {
+					console.warn('###当前处于模拟的随机中奖概率,实际场景中,中奖概率应由后端控制###');
+					// 这里随机产生的 prizeId 是模拟后端返回的 prizeId
+					let prizeId = Math.floor(Math.random() * list.length + 1);
+					list.forEach((item, index) => {
+						if (item.prizeId === prizeId) {
+							// 中奖下标
+							this.prizeIndex = index;
+						}
+					});
+				}
+				console.log('本次抽中奖品 =>', this.prizeList[this.prizeIndex].name);
+				if (this.onStock) {
+					console.log('本次奖品库存 =>', this.prizeList[this.prizeIndex].stock);
+				}
+			}, 200);
+		},
+		// 本次抽奖结束
+		handleDrawEnd() {
+			// 旋转结束后,可以执行拿到结果后的逻辑
+			let prizeName = this.prizeList[this.prizeIndex].name;
+			if (this.onStock) {
+				let prizeStock = this.prizeList[this.prizeIndex].stock;
+				this.targetName = prizeName === '谢谢参与' ? prizeName : prizeStock ? `恭喜您,获得 ${prizeName}` : '很抱歉,您来晚了,当前奖品已无库存';
+			}else{
+				this.targetName = prizeName === '谢谢参与' ? prizeName : `恭喜您,获得 ${prizeName}`;
+			}
+			console.log(this.targetName)
+		},
+		// 抽奖转盘绘制完成
+		handleDrawFinish(res) {
+			console.log('抽奖转盘绘制完成', res);
+			uni.showToast({
+				title: res.msg,
+				duration: 2000,
+				mask: true,
+				icon: 'none'
+			});
+		}
+	},
+	onLoad() {
+		// 模拟请求奖品数据
+		this.getPrizeList();
+	},
+	onUnload() {
+		uni.hideLoading();
+	}
+};
+</script>
+
+<style lang="less">
+.content {
+	width: 100%;
+	background: linear-gradient(#00287a, #002268);
+	.luckwheel {
+		padding: 30rpx 0;
+		.synums {
+			font-size: 28rpx;
+			color: #fff;
+			padding-top: 20rpx;
+			text-align: center;
+			text {
+				color: #ffff00;
+				margin-left: 6rpx;
+			}
+		}
+	}
+	.hjList {
+		height: 30vh;
+		overflow: auto;
+		> view {
+			width: 70%;
+			margin: 10rpx auto;
+			background: #acdbff;
+			padding: 6rpx;
+			border-radius: 50rpx;
+			text-align: center;
+			font-size: 24rpx;
+		}
+	}
+	.guize {
+		color: #ffffff;
+		padding: 30rpx 0;
+		width: 70%;
+		margin: 0 auto;
+		align-items: left;
+		font-size: 24rpx;
+		> view {
+			display: flex;
+			align-items: center;
+			padding: 10rpx 0;
+			width: 100%;
+		}
+	}
+}
+</style>

BIN
static/almost-lottery/almost-lottery__action.png


BIN
static/almost-lottery/almost-lottery__action2x.png


BIN
static/almost-lottery/almost-lottery__action3x.png


BIN
static/almost-lottery/almost-lottery__bg.png


BIN
static/almost-lottery/almost-lottery__bg2x.png


BIN
static/almost-lottery/almost-lottery__bg3x.png