Commit 1d4e4590 by baihong

add;新增转盘抽奖组件

parent 308f259a
<template>
<view>
<u-popup v-model="show" mode="center" width="673rpx" :closeable='true'>
<view class="main">
<almost-lottery :prize-list="prizeList" :ring-count="2" :duration="3" :canvas-width="canvasData.width"
:canvas-height="canvasData.height" :prize-index="prizeIndex" @reset-index="prizeIndex = -1"
@draw-start="handleDrawStart" @draw-end="handleDrawEnd" @finish="handleDrawFinish"
v-if="prizeList.length" />
<image class="turn" src="../static/imgs/ic_turn_02@2x.png" mode=""></image>
<p>连续签到七天</p>
<text>抽奖最高获得700真我币礼包奖励!</text>
</view>
</u-popup>
</view>
</template>
<script>
import AlmostLottery from '@/uni_modules/almost-lottery/components/almost-lottery/almost-lottery.vue'
import {
clearCacheFile
} from '@/uni_modules/almost-lottery/utils/almost-utils.js'
export default {
components: {
AlmostLottery
},
props: {
show: {
type: Boolean,
default: false
},
},
watch: {
show: {
handler: function(val) {
if(val){
this.getPrizeList()
}
},
immediate: true
}
},
data() {
return {
// canvas 宽高
canvasData: {
width: 195,
height: 195
},
// 奖品数据
prizeList: [],
// 中奖下标
prizeIndex: -1,
// 是否正在抽奖中
prizeing: false,
// 中奖类目名称
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
},
{
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() {
if (this.prizeing) return
this.prizeing = true
this.targetName = ''
let list = [...this.prizeList]
// 判断是否由前端控制概率
// 前端控制概率的情况下,需要拿到最接近随机权重且大于随机权重的值
// 后端控制概率的情况下,通常会直接返回 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])
}
console.log('本次抽中奖品 =>', this.prizeList[this.prizeIndex].name)
// 如果奖品设有库存
if (this.onStock) {
console.log('本次奖品库存 =>', this.prizeList[this.prizeIndex].stock)
}
} else {
// 模拟请求获取中奖信息
let stoTimer = setTimeout(() => {
clearTimeout(stoTimer)
stoTimer = null
console.warn('###当前处于模拟的随机中奖概率,实际场景中,中奖概率应由后端控制###')
// 这里随机产生的 prizeId 是模拟后端返回的 prizeId
let prizeId = Math.floor(Math.random() * list.length + 1)
for (let i = 0; i < list.length; i++) {
let item = list[i]
if (item.prizeId === prizeId) {
// 中奖下标
this.prizeIndex = i
break
}
}
console.log('本次抽中奖品 =>', this.prizeList[this.prizeIndex].name)
// 如果奖品设有库存
if (this.onStock) {
console.log('本次奖品库存 =>', this.prizeList[this.prizeIndex].stock)
}
}, 500)
}
},
// 本次抽奖结束
handleDrawEnd() {
this.prizeing = false
// 旋转结束后,可以执行拿到结果后的逻辑
let prizeName = this.prizeList[this.prizeIndex].name
if (this.onStock) {
let prizeStock = this.prizeList[this.prizeIndex].stock
this.targetName = prizeName === '谢谢参与' ? prizeName : prizeStock ? `恭喜您,获得 ${prizeName}` :
'很抱歉,您来晚了,当前奖品已无库存'
}
this.targetName = prizeName === '谢谢参与' ? prizeName : `恭喜您,获得 ${prizeName}`
},
// 抽奖转盘绘制完成
handleDrawFinish(res) {
console.log('抽奖转盘绘制完成', res)
uni.showToast({
title: res.msg,
duration: 2000,
mask: true,
icon: 'none'
})
}
},
onLoad() {
},
onReady() {
},
onUnload() {
}
}
</script>
<style lang="scss" scoped>
.main {
display: flex;
align-items: center;
flex-direction: column;
padding-top: 99rpx;
padding-bottom: 71rpx;
.turn {
width: 163rpx;
height: 42rpx;
margin-top: -10rpx;
}
p{
font-size: 36rpx;
font-family: OpenSans;
font-weight: 600;
color: #000000;
line-height: 36rpx;
margin-top: 55rpx;
}
text{
font-size: 26rpx;
font-family: OpenSans;
color: #555555;
line-height: 36rpx;
margin-top: 20rpx;
}
}
</style>
......@@ -145,20 +145,24 @@
<view class="close" @click="showSuccess=false">知道了</view>
</view>
</u-popup>
<lottery :show="showLottery"/>
</view>
</template>
<script>
import pageHeader from '@/components/page-header.vue';
import lottery from '@/components/lottery.vue';
export default {
components: {
pageHeader
pageHeader,
lottery
},
data() {
return {
showLottery:true,
show: false,
showUpload:false,
showSuccess:true,
showSuccess:false,
src: 'https://t7.baidu.com/it/u=1951548898,3927145&fm=193&f=GIF',
number: 0,
};
......
## 1.5.6(2021-03-18)
本次更新:
- 适配 uni_modules 插件模式
<template>
<view class="almost-lottery" :style="{ width: canvasWidth + 17 + 'px', height: canvasHeight + 17 + '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 { getStore, setStore, clearStore, downloadFile, pathToBase64 } from '@/uni_modules/almost-lottery/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: () => [
'#E1662A',
'#FAA41B',
'#DD993C',
'#E1662A',
'#FAA41B',
'#DD993C',
'#E1662A',
'#FAA41B',
'#DD993C',
'#E1662A',
'#FAA41B',
'#DD993C'
]
},
// 旋转动画时间 单位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: '#FFFFFF'
},
// 文字的大小
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: 2
},
// 奖品图片的宽
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() {
if (this.isRotate) return
this.isRotate = true
// 奖品总数
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.$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: '@/uni_modules/almost-lottery/static/almost-lottery/ic_turn';
$actionBgUrl: '@/uni_modules/almost-lottery/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 + "2x.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>
{
"id": "almost-lottery",
"displayName": "Almost-Lottery抽奖转盘(Canvas 版)",
"version": "1.5.6",
"description": "Almost-Lottery抽奖转盘(Canvas 版),支持APP、小程序、H5",
"keywords": [
"转盘",
"抽奖",
"大转盘抽奖"
],
"repository": "https://github.com/ialmost/almost-components_uniapp",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"category": [
"前端组件",
"通用组件"
],
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"App": {
"app-vue": "y",
"app-nvue": "n"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}
\ No newline at end of file
# almost-lottery
*使用 Canvas 绘制的抽奖转盘*
> <br />
>
> 如果用着还行,请支持一下
> - 前往 [GitHub](https://github.com/ialmost/almost-components_uniapp) 给个 Star
> - 前往 [UniApp](https://ext.dcloud.net.cn/plugin?id=1030) 给个五星
>
> <br />
## 高能预警
- 本插件已更新支持 `uni_modules` 模式,强烈推荐使用该模式
- 在使用本插件之前,强烈建议使用 `HBuilderX` 导入示例项目验证可用性并参照修改
## 功能概要
- [x] 可配置奖品文字
- [x] 可配置每个奖品区块的背景颜色
- [x] 可配置每个奖品区块的奖品图片,**当图片是网络地址时,小程序端需要配置白名单,H5端需要允许跨域**
- [x] 奖品列表支持奇数,**奇数时需尽量能被 `360` 除尽**
- [x] 组件内 `SCSS` 样式中可替换转盘的外环背景图及点击抽奖按钮图,分别是 `$lotteryBgUrl``$actionBgUrl`**如需替换,需要适配高清设备**
- [x] 可配置中奖概率,**强烈推荐中奖概率应由后端控制**
- [x] 可配置画板是否缓存
- [x] 可配置内圈与外圈的间距
## 注意事项
- 编译到小程序端时,请务必勾选ES6转ES5
- `@reset-index="prizeIndex = -1"` 必须默认写入到 `template` 中,不可删除
- 每个奖品区块的奖品图片尺寸不宜过大,图片越大,绘制的过程越慢,尽量将图片尺寸控制在 `300*300` 以内
- 关于中奖概率的配置,请下载示例项目,参照 `pages/index/index.vue` 中的代码进行配置
- 组件本身不涉及任何业务逻辑,与业务相关的代码建议都放在 `pages/index/index.vue`
## 代码演示
#### 基础用法
```
// template
// @reset-index="prizeIndex = -1" 必须默认写入到 template 中,不可删除
<almost-lottery
:prize-list="prizeList"
:prize-index="prizeIndex"
@reset-index="prizeIndex = -1"
@draw-start="handleDrawStart"
@draw-end="handleDrawEnd"
@finish="handleDrawFinish"
v-if="prizeList.length"
/>
// script
import AlmostLottery from '@/uni_modules/almost-lottery/components/almost-lottery/almost-lottery.vue'
export default {
components: {
AlmostLottery
},
data () {
return {
// 获奖奖品序号,每次抽奖结束后需要重置为 -1
prizeIndex: -1,
// 奖品数据
prizeList: [
{ prizeId: 1, name: '0.1元现金', stock: 10, weight: 1, prizeImage: '/static/git.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: 3 },
{ prizeId: 6, name: '0.20元现金', stock: 8, weight: 2 },
{ prizeId: 7, name: '谢谢参与', stock: 100, weight: 10000 },
{ prizeId: 8, name: '100金币', stock: 100, weight: 1000 }
],
// 中奖下标
prizeIndex: -1,
// 中奖类目名称
targetName: '',
// 奖品是否设有库存
onStock: true,
// 是否由前端控制概率,默认不开启
onFrontend: false,
// 权重随机数的最大值
weightTotal: 0,
// 权重数组
weightArr: []
}
},
methods: {
// 本次抽奖开始
handleDrawStart () {
// 这里需要处理你的中奖逻辑,并得出 prizeIndex
// 请查看示例项目中的代码
},
// 本次抽奖结束
handleDrawEnd () {
// 完成抽奖后,这里处理你拿到结果后的逻辑
// 请查看示例项目中的代码
},
// 抽奖转盘绘制完成
handleDrawFinish (res) {
// 抽奖转盘准备就绪后,这里处理你的逻辑
// 请查看示例项目中的代码
// console.log('抽奖转盘绘制完成', res)
}
}
}
```
## API
#### Props
参数 | 说明 | 类型 | 默认值
:---|:---|:---|:---
canvas-width | Canvas 的宽度 | *`Number`* | `240`
canvas-height | Canvas 的高度 | *`Number`* | `240`
prize-index | 获奖奖品在奖品列表中的序号,**每次抽奖结束后会自动重置为 `-1`** | *`Number`* | `-1`
prize-list | 奖品列表,支持奇数(尽量能被 `360` 除尽),**为奇数时需要重设 `colors` 参数** | *`Array`* | -
colors | 奖品区块对应的背景颜色,默认 2 个颜色相互交替,**也可以对每个区块设置不同颜色** | *`Array`* | `['#FFFFFF', '#FFE9AA']`
duration | 转盘旋转的动画时长,单位:秒 | *`Number`* | `8`
ring-count | 旋转的圈数 | *`Number`* | `8`
pointer-position | 点击抽奖按钮指针的位置,可选值 `'edge'` => 指向边界 `'middle'` => 指向中间 | *`String`* | `'edge'`
font-color | 奖品名称的颜色 | *`String`* | `'#C30B29'`
font-size | 奖品名称的字号 | *`Number`* | `12`
line-height | 奖品名称多行情况下的行高 | *`Number`* | `16`
str-key | 奖品名称所对应的键名 `key` ,比如 `{ name: '88元现金' }``str-key` 就是 `'name'` | *`String`* | `'name'`
str-max-len | 奖品名称长度限制 | *`Number`* | `12`
str-line-len | 奖品名称在多行情况下第一行文字的长度 | *`Number`* | `6`
image-width | 奖品图片的宽度 | *`Number`* | `30`
image-height | 奖品图片的高度 | *`Number`* | `30`
successMsg | 转盘绘制成功的提示 | *`String`* | `'奖品准备就绪,快来参与抽奖吧'`
failMsg | 转盘绘制失败的提示 | *`String`* | `'奖品仍在准备中,请稍后再来...'`
canvasCached | 是否开启缓存,避免在数据不变的情况下重复绘制 | *`Boolean`* | `true`
canvasMargin | 内圈与外圈的间距 | *`Number`* | `5`
#### Events
事件名 | 说明 | 回调参数
:---|:---|:---
reset-index | 每次抽奖结束后重置获奖的序号为 `-1`**该事件必须默认写入到 `template` 中,不可删除** | -
draw-start | 转盘旋转开始时触发 | -
draw-end | 转盘旋转结束时触发 | -
finish | Canvas转盘绘制完成时触发 | ok: 绘制是否成功, data: 转盘的图片, msg: 绘制结果的提示
#### prizeList 数据结构
键名 | 说明 | 类型
:---|:---|:---
prizeId | 奖品对应 `ID` | *`Number`*
name | 奖品名称 | *`String`*
stock | 奖品库存 | *`Number`*
weight | 奖品权重 | *`Number`*
prizeImage | 奖品图片地址 | *`String`*
\ No newline at end of file
/**
* 存储 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
}
// 图像转换工具,可用于图像和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'))
})
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment