hz-zhhq-app/components/raffle-wheel/raffle-wheel.vue

391 lines
13 KiB
Vue
Raw Normal View History

2025-01-22 10:53:47 +08:00
<template>
<view class="raffle-wheel" :style="{ width: canvasWidth + 44 + 'px', height: canvasHeight + 44 + 'px'}">
<view class="raffle-wheel-wrap" :style="{width: canvasWidth + 'px', height: canvasHeight + 'px'}">
<canvas
:class="className"
:canvas-id="canvasId"
:width="canvasWidth"
:height="canvasHeight"
:style="{
width: canvasWidth + 'px',
height: canvasHeight + 'px'
}"
/>
<image
class="canvas-img"
:src="canvasImg"
:style="{
width: canvasWidth + 'px',
height: canvasHeight + 'px',
transform: `rotate(${canvasAngle + targetAngle}deg)`,
transitionDuration: `${transitionDuration}s`
}"
v-if="canvasImg"></image>
<view class="raffle-wheel__action" @click="handleAction"></view>
<!-- 为了兼容 app ctx.measureText 所需的标签 -->
<text class="raffle-wheel__measureText">{{ measureText }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'RaffleWheel',
props: {
// canvas 宽度
canvasWidth: {
type: Number,
default: 240
},
// canvas 高度
canvasHeight: {
type: Number,
default: 240
},
// 奖品列表
prizeList: {
type: Array,
// 必须是偶数
validator: function (value) {
return value.length % 2 === 0
},
required: true
},
// 奖品区块对应背景颜色
colors: {
type: Array,
default: () => [
'#FFF',
'#FFE9AA'
],
// 必须是偶数且仅为 2 个颜色相互交替
validator: function (value) {
return value.length === 2
}
},
// 旋转动画时间 单位s
duration: {
type: Number,
default: 8
},
// 旋转的圈数
ringCount: {
type: Number,
default: 8
},
// 字体颜色
fontColor: {
type: String,
default: '#C30B29'
},
// 文字的大小
fontSize: {
type: String,
default: '12px'
},
// 奖品文字多行情况下的行高
lineHeight: {
type: Number,
default: 20
},
// 奖品名称所对应的 key 值
strKey: {
type: String,
required: true
},
// 奖品文字总长度限制
strMaxLength: {
type: Number,
default: 12
},
// 奖品文字多行情况下第一行文字长度
strLineLength: {
type: Number,
default: 6
}
},
data () {
return {
// 画板className
className: 'raffle-wheel__canvas',
// 画板标识
canvasId: 'raffleWheelCanvas',
// 画板导出的图片
canvasImg: '',
// 旋转到奖品目标需要的角度
targetAngle: 0,
// 旋转动画时间 单位 s
transitionDuration: 0,
// 是否正在旋转
isRotate: false,
// 当前停留在那个奖品的序号
stayIndex: 0,
// 解决 app 不支持 measureText 的问题
measureText: ''
}
},
computed: {
// 设备像素密度
pixelRatio () {
return uni.getSystemInfoSync().pixelRatio
},
// 根据奖品列表计算 canvas 旋转角度
// 让 启动按钮指针 在奖品分区中间 position = 45
// 让 启动按钮指针 在奖品分区边界 position = 90
canvasAngle () {
let prizeCount = this.prizeList.length
let position = 90
if (prizeCount % 4 !== 0) {
return 0
} else {
let num = prizeCount / 4
return num % 2 === 0 ? position / num : position
}
},
// 根据画板的宽度计算奖品文字与中心点的距离
textRadius () {
return Math.round(this.canvasWidth / 2.4)
}
},
methods: {
// 开始旋转
handleStartRotate (targetIndex) {
// 奖品总数
let prizeCount = this.prizeList.length
let baseAngle = 360 / prizeCount
let angles = 0
if (this.targetAngle === 0) {
console.log('第一次旋转')
// 因为第一个奖品是从0°开始的即水平向右方向
// 第一次旋转角度 = 270度 - (停留的序号-目标序号) * 每个奖品区间角度 - 每个奖品区间角度的一半 - canvas自身旋转的度数
angles = (270 - (targetIndex - this.stayIndex) * baseAngle - baseAngle / 2) - this.canvasAngle
} else {
console.log('后续旋转')
// 后续继续旋转 就只需要计算停留的位置与目标位置的角度
angles = -(targetIndex - this.stayIndex) * baseAngle
}
// 更新目前序号
this.stayIndex = targetIndex
// 转 8 圈,圈数越多,转的越快
this.targetAngle += angles + 360 * this.ringCount
// 计算转盘结束对时间,预加一些延迟确保转盘停止后触发结束事件
let endTime = this.transitionDuration * 1000 + 100
setTimeout(() => {
this.isRotate = false
this.$emit('actionEnd', this.stayIndex)
}, endTime)
},
// 点击 开始抽奖 按钮
handleAction () {
if (this.isRotate) return
this.isRotate = true
this.$emit('actionStart')
},
// 渲染转盘
async drawWheelCanvas () {
// 获取 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.clearRect(0, 0, canvasW, canvasH)
// 设置描边颜色
ctx.strokeStyle = '#FFBE04'
// 设置字号字体Canvas 文本字号字体的默认值是 10px sans-serif这里必须对 字号 字体 同时覆盖
let family = "-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', STHeiti, 'Microsoft Yahei', Tahoma, Simsun, sans-serif"
ctx.font = `${this.fontSize} ${family}`
// 注意开始画的位置是从0°角的位置开始画的。也就是水平向右的方向。
// 画具体内容
for (let i = 0; i < prizeCount; i++) {
// 当前角度
let angle = i * baseAngle
// console.log('当前角度', angle)
// 开始画内容
ctx.beginPath()
// 开始画圆弧
// context.arc(x, y, ratius, startAngle, endAngle, anticlockwise)
// x => 圆弧对应的圆心横坐标 x
// y => 圆弧对应的圆心横坐标 y
// radius => 圆弧的半径大小
// startAngle => 圆弧开始的角度,单位是弧度
// endAngle => 圆弧结束的角度,单位是弧度
// anticlockwise(可选) => 绘制方向true 为逆时针false 为顺时针
let outsideRadius = canvasW / 2
let insideRadius = 20
ctx.arc(canvasW * 0.5, canvasH * 0.5, outsideRadius, angle, angle + baseAngle, false)
ctx.arc(canvasW * 0.5, canvasH * 0.5, insideRadius, angle + baseAngle, angle, true)
// 开始链接线条
ctx.stroke()
// 每个奖品区块背景填充颜色
ctx.fillStyle = this.colors[i % 2]
// 填充颜色
ctx.fill()
// 保存当前画布的状态
ctx.save()
// 开始绘制奖品内容
ctx.fillStyle = this.fontColor
let rewardName = this.strLimit(this.prizeList[i][this.strKey])
// translate方法重新映射画布上的 (0,0) 位置
let translateX = canvasW * 0.5 + Math.cos(angle + baseAngle / 2 ) * this.textRadius
let translateY = canvasH * 0.5 + Math.sin(angle + baseAngle / 2) * this.textRadius
ctx.translate(translateX, translateY)
// rotate方法旋转当前的绘图因为文字是和当前扇形中心线垂直的
ctx.rotate(angle + (baseAngle / 2 ) + (Math.PI / 2))
// 设置文本位置并处理换行
// console.log('原始文本', rewardName)
if (rewardName.length > this.strLineLength && this.strLineLength !== 0) {
rewardName = rewardName.substring(0, this.strLineLength) + ',' + rewardName.substring(this.strLineLength)
let rewardNames = rewardName.split(',')
// console.log('多行文本', rewardNames)
for (let j = 0; j < rewardNames.length; j++) {
if (ctx.measureText && ctx.measureText(rewardNames[j]).width) {
ctx.fillText(rewardNames[j], -ctx.measureText(rewardNames[j]).width / 2, j * this.lineHeight)
} else {
this.measureText = rewardNames[j]
await this.$nextTick()
let textWidth = await this.getTextWidth()
ctx.fillText(rewardNames[j], -textWidth / 2, j * this.lineHeight)
// console.log(rewardNames[j], textWidth, i)
}
}
} else {
if (ctx.measureText && ctx.measureText(rewardName).width) {
ctx.fillText(rewardName, -ctx.measureText(rewardName).width / 2, 0)
} else {
this.measureText = rewardName
await this.$nextTick()
let textWidth = await this.getTextWidth()
ctx.fillText(rewardName, -textWidth / 2, 0)
// console.log(rewardName, textWidth, i)
}
}
// 还原画板的状态到上一个save()状态之前
ctx.restore()
}
// 保存绘图并导出图片
ctx.draw(true, () => {
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.canvasImg = res.tempFilePath
// 通知父级组件,抽奖转品生成图片完成
this.$emit('done')
}
}, this)
})
},
// 兼容 app 端不支持 ctx.measureText
// 已知问题:初始绘制时,低端安卓机 平均耗时 2s
getTextWidth () {
return new Promise((resolve, reject) => {
uni.createSelectorQuery().in(this).select('.raffle-wheel__measureText').fields({
size: true,
}, (res) => {
resolve(res.width)
}).exec()
})
},
// 处理文字溢出
strLimit (value) {
let maxLength = this.strMaxLength
if (!value || !maxLength) return value
return value.length > maxLength ? value.slice(0, maxLength - 1) + '...' : value
}
},
mounted () {
this.$nextTick(() => {
setTimeout(() => {
this.drawWheelCanvas()
this.transitionDuration = this.duration
}, 50)
})
}
}
</script>
<style lang="scss" scoped>
$actionBgUrl: '~static/raffle-wheel/raffle-wheel__action';
$raffleBgUrl: '~static/raffle-wheel/raffle-wheel__bg';
.raffle-wheel {
position: relative;
left: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
}
.raffle-wheel {
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($raffleBgUrl + ".png");
@media (-webkit-min-device-pixel-ratio: 2),(min-device-pixel-ratio: 2) {
background-image: url($raffleBgUrl + "@2x.png");
}
@media (-webkit-min-device-pixel-ratio: 3),(min-device-pixel-ratio: 3) {
background-image: url($raffleBgUrl + "@3x.png");
}
}
.raffle-wheel__canvas {
position: absolute;
left: -9999px;
opacity: 0;
display: flex;
justify-content: center;
align-items: center;
}
.raffle-wheel__action {
position: absolute;
top: calc(50% - 58px);
left: calc(50% - 58px);
width: 114px;
height: 114px;
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");
}
}
.raffle-wheel__measureText {
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
font-size: 12px;
opacity: 0;
}
.canvas-img {
transition: transform cubic-bezier(.34,.12,.05,.95);
}
</style>