uniapp + h5 -- 简易抽奖转盘组件(文字版)

sector.vue(扇形子组件)

html 复制代码
<!--
  简易扇形组件
-->
<template>
  <view class="sector-component" :style="{
    '--point-x': threePoint[0],
    '--point-y': threePoint[1],
    '--index-rotate': index * angle + 'deg',
    '--bg-color': bgColor,
    '--color': color,
    '--size': size,
    '--text-angle': angle / 2 - 45 + 'deg',
    '--text-origin': radius + 'rpx',
    '--text-translate-x-y': translateXY,
    '--radius': -radius + 'rpx'
  }">
    <view class="text-wrap">
      <view class="text">
        <text class="title" v-if="text">
          {{ text }}
        </text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'SimpleRotarySector',
  props: {
    // 文本内容
    text: {
      type: String,
      default: '测试'
    },
    // 扇形角度
    angle: {
      type: Number,
      default: 45
    },
    // 在整圈中的索引
    index: {
      type: Number,
      default: 0
    },
    // 背景色
    bgColor: {
      type: String,
      default: '#ff0000'
    },
    // 文字颜色
    color: {
      type: String,
      default: '#ffffff'
    },
    // 字号
    size: {
      type: String,
      default: '24rpx'
    },
    // 半径
    radius: {
      type: Number,
      default: 300
    }
  },
  computed: {
    threePoint() {
      if (this.angle < 0 || this.angle > 180) {
        console.error('弧度值取值范围为0~180之间,当前值:' + this.angle);
        return [0, 0];
      }
      const a = (this.angle <= 90 ? this.angle : 180 - this.angle) * (Math.PI / 180); // 角度转弧度
      const r = this.radius;
      let x = '0%';
      let y = '0%';
      if (this.angle > 90) {
        y = '0%';
        x = (((r + r * Math.cos(a)) / 2 / r) * 100).toFixed(2) + '%';
      } else if (this.angle === 90) {
        x = '50%';
        y = '0%';
      } else {
        y = (((r - r * Math.sin(a)) / 2 / r) * 100).toFixed(2) + '%';
        x = (((r - r * Math.cos(a)) / 2 / r) * 100).toFixed(2) + '%';
      }
      return [x, y];
    },
    translateXY() {
      return (Math.sqrt(this.radius * this.radius * 2) - this.radius - 30).toFixed(0) + 'rpx';
    }
  }
};
</script>

<style lang="scss" scoped>
.sector-component {
  width: 100%;
  height: 100%;
  background-color: var(--bg-color);
  border-radius: 50%;
  position: absolute;
  left: 0;
  top: 0;
  clip-path: polygon(0 0, 0 50%, 50% 50%, var(--point-x) var(--point-y));
  transform: rotate(calc(var(--index-rotate) + 90deg));

  .text-wrap {
    transform-origin: var(--text-origin) var(--text-origin);
    // transform: rotate(var(--text-angle)) translateX(var(--text-translate-x-y)) translateY(var(--text-translate-x-y));
    transform: rotate(var(--text-angle)) translateX(var(--text-translate-x-y)) translateY(var(--text-translate-x-y));
    // border: 1px solid red;
  }

  .text {
    font-size: var(--size);
    color: var(--color);
    padding: 0 10rpx;
    height: 60rpx;
    writing-mode: vertical-rl;
    text-orientation: mixed;
    display: flex;
    align-items: center;
    // justify-content: center;
    transform: rotate(-45deg);

    .title {
      white-space: nowrap;
      text-align: center;
      letter-spacing: 2rpx;
    }
  }
}
</style>

simple-rotary-table.vue(主组件)

html 复制代码
<!--
  简易抽奖转盘组件(文字版)
-->
<template>
  <view class="simple-rotary-table" :style="{
    width: radius * 2 + 'rpx',
    height: radius * 2 + 'rpx'
  }">
    <view class="content-table" :style="'-webkit-transform:rotate(' +
      deg +
      'deg) translateZ(0);transform:rotate(' +
      deg +
      'deg) translateZ(0)'
      ">
      <sector v-for="(item, index) in displayList" :key="index" :radius="radius" :text="item.title"
              :bgColor="item.bgColor" :color="item.color" :size="item.size" :angle="singleAngle" :index="index" />
    </view>
    <view class="turntable_pointer" @click="handleClick">
      <!-- 静态资源:/static/imgs/begin-btn.png -->
      <image src="/static/imgs/begin-btn.png" class="icon" />
    </view>
  </view>
</template>

<script>
import sector from './sector.vue';

export default {
  name: 'SimpleRotaryTable',
  components: { sector },
  props: {
    // 原始奖项列表
    list: {
      type: Array,
      default: () => []
    },
    // 标题字段名,可配置,默认 title
    labelKey: {
      type: String,
      default: 'title'
    },
    // 圆盘半径,单位 rpx
    radius: {
      type: Number,
      default: 300
    },
    // 旋转速度
    speed: {
      type: Number,
      default: 20
    },
    // 文字字号
    fontSize: {
      type: String,
      default: '24rpx'
    }
  },
  data() {
    return {
      deg: 0,
      isStart: false,
      timer: null,
      // 中奖项,从 1 开始
      awardNumer: 0,
      // 中奖项数据
      awardData: {},
      // 每个扇区的随机背景色
      segmentColors: []
    };
  },
  computed: {
    // 每个扇区角度
    singleAngle() {
      const len = this.displayList.length || 1;
      return Number((360 / len).toFixed(2));
    },
    // 中奖结束角度
    endAddAngle() {
      return 360 - ((this.awardNumer - 1) * this.singleAngle + this.singleAngle / 2);
    },
    // 用于渲染的列表:只显示文字,自动填充随机背景色和白色字体
    displayList() {
      return this.list.map((item, index) => {
        const titleKey = this.labelKey || 'title';
        const text = item[titleKey] != null ? String(item[titleKey]) : '';
        const bgColor = item.bgColor || this.segmentColors[index] || this.getRandomColor();
        const color = item.color || '#ffffff';
        const size = item.size || this.fontSize;

        return {
          title: text,
          bgColor,
          color,
          size
        };
      });
    }
  },
  watch: {
    list: {
      handler() {
        this.initSegmentColors();
      },
      deep: true,
      immediate: true
    }
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  },
  methods: {
    // 初始化随机颜色,保证每个块颜色尽量不一样
    initSegmentColors() {
      const len = this.list.length || 0;
      if (!len) {
        this.segmentColors = [];
        return;
      }

      // 一组预置的高对比色,随机打乱后依次取用,不够再随机补充
      const baseColors = [
        '#F56C6C',
        '#E6A23C',
        '#67C23A',
        '#409EFF',
        '#909399',
        '#F0A1A8',
        '#9B59B6',
        '#1ABC9C',
        '#2ECC71',
        '#3498DB',
        '#E74C3C',
        '#F39C12'
      ];

      const shuffled = baseColors.slice().sort(() => Math.random() - 0.5);

      const colors = [];
      for (let i = 0; i < len; i++) {
        if (i < shuffled.length) {
          colors.push(shuffled[i]);
        } else {
          colors.push(this.getRandomColor());
        }
      }

      this.segmentColors = colors;
    },
    // 生成一个随机背景色
    getRandomColor() {
      const r = Math.floor(Math.random() * 200);
      const g = Math.floor(Math.random() * 200);
      const b = Math.floor(Math.random() * 200);
      return `rgb(${r}, ${g}, ${b})`;
    },
    // 点击中心按钮:开始旋转并抛出事件,外部去调用接口拿中奖 id
    handleClick() {
      if (this.isStart || !this.displayList.length) return;
      this.$emit('start');
      // this.begin();
    },
    // 圆盘正式转起来
    begin() {
      if (this.isStart || !this.displayList.length) return;
      this.isStart = true;
      this.turnRound();
    },
    turnRound() {
      let beginDeg = this.deg;
      const beginSpeed = this.speed;

      const rangeAngle = (Math.floor(Math.random() * 4) + 4) * 360; // 随机旋转几圈再停止

      let cAngle;
      beginDeg = 0;
      let waitDeg = 0; // 等待接口返回前转的角度

      this.timer = setInterval(() => {
        if (!this.awardNumer) {
          beginDeg += beginSpeed;
          waitDeg = beginDeg;
        } else {
          if (waitDeg) {
            beginDeg = 0;
            waitDeg = 0;
          }
          if (beginDeg < rangeAngle) {
            beginDeg += beginSpeed;
          } else {
            const a = (this.endAddAngle + rangeAngle - beginDeg) / beginSpeed;
            cAngle = a > beginSpeed ? beginSpeed : a < 1 ? 1 : a;
            beginDeg += cAngle;

            if (beginDeg >= this.endAddAngle + rangeAngle) {
              beginDeg = this.endAddAngle + rangeAngle;
              this.isStart = false;
              clearInterval(this.timer);
              this.timer = null;
              this.$emit('end', this.awardData);
            }
          }
        }

        this.deg = beginDeg;
      }, 1000 / 60);
    },
    /**
     * 停止转盘,number 为中奖序号(从 1 开始,对应传入 list 的第几个)
     */
    stop(id) {
      // console.log('列表数据:', this.list, id);
      const index = this.list.findIndex(item => item.id == id)
      // console.log('中奖序号:', index);
      this.awardNumer = index + 1;
      this.awardData = this.list[index]
    },
    /**
     * 立即停止转盘,指针回到默认开始位置,不选择任何块
     * 用于接口报错时的处理
     */
    stopReset() {
      this.awardNumer = 0;
      this.awardData = {};
      this.isStart = false;
      if (this.timer) {
        clearInterval(this.timer);
        this.timer = null;
      }
      this.deg = 0;
    }
  }
};
</script>

<style scoped lang="scss">
.simple-rotary-table {
  border-radius: 50%;
  border: 1px solid #999;
  position: relative;
  background-color: #f5f5f5;
  overflow: hidden;
}

.content-table {
  width: 100%;
  height: 100%;
}

.turntable_pointer {
  position: absolute;
  width: 180rpx;
  height: 180rpx;
  top: 50%;
  left: 50%;
  transform: translateX(-50%) translateY(-50%);

  .icon {
    width: 100%;
    height: 100%;
  }
}
</style>

简易抽奖转盘组件(文字版)

组件目录:components/simple-rotary-table/

组件文件:simple-rotary-table.vue(主组件)、sector.vue(扇形子组件)

该组件为纯文字抽奖转盘 ,实现参考了现有转盘逻辑,但为全新实现,不依赖 mosowe-rotary-table-compatible 目录下的任何组件。

每个扇区使用随机背景色、白色文字,文字在扇区内居中展示。

功能说明

  • 动态扇区数量:根据传入奖项列表长度,自动计算每个扇区角度。
  • 随机背景色 :为每个扇区分配高对比随机背景色,也可由外部指定 bgColor
  • 白色文字居中:文字在扇区内居中显示,仅展示文字,不显示图片。
  • 字段名可配置 :通过 labelKey 指定要显示的字段,默认使用 title
  • 中间按钮开始抽奖 :中间使用 static/imgs/begin-btn.png 作为按钮图片,点击后触发 start 事件。
  • 等待接口返回中奖 id 再停 :组件内部先持续旋转,外部获取中奖数据后调用 stop(data),转盘缓动到对应扇区。
  • 接口异常处理 :提供 stopReset() 方法,用于接口报错时重置转盘状态。

Attributes

主组件:simple-rotary-table.vue

属性 说明 类型 默认
list 奖项列表 any[] []
labelKey 用于显示文字的字段名 string 'title'
radius 圆盘半径,单位 rpx number 300
speed 转盘旋转速度 number 20
fontSize 扇区中文字字号,单位 rpx string '24rpx'

listItem 建议结构

组件只依赖文字字段,其他字段可按业务需要自定义,例如:

js 复制代码
{
  name: '一等奖',      // 或自定义字段,通过 labelKey 指定
  id: 1,             // 业务字段,用于 stop 时匹配
  bgColor: '#F56C6C', // 可选,如不传则内部生成随机色
  color: '#ffffff',    // 可选,文字颜色,默认白色
  size: '24rpx'       // 可选,文字大小
}

Events

Events 说明 回调参数
start 点击中间抽奖按钮时触发(需外部调用 begin) -
end 转盘完全停止时触发 awardData(中奖项完整数据)

方法

通过 ref 调用主组件:

方法名 说明
begin 手动开始转动(点击按钮后需手动调用此方法开始旋转)
stop(data) 停止转动并停在指定奖项,参数为中奖项的 id 或数据对象
stopReset 立即停止转盘,指针回到默认开始位置(用于接口报错)

stop(data: object | number)

  • data:中奖项数据对象或 id

    • 传入对象:组件会根据 id 字段在 list 中查找对应奖项
    • 传入 id:直接根据 id 在 list 中查找对应奖项
  • 示例:

    js 复制代码
    // 方式1:传入完整数据对象
    this.$refs.simpleRotaryRef.stop({ id: 1, name: '一等奖' })
    
    // 方式2:只传入 id
    this.$refs.simpleRotaryRef.stop(1)

stopReset()

  • 无参数,用于接口调用失败时重置转盘状态

  • 转盘会停止并回到初始位置(角度为 0)

  • 示例:

    js 复制代码
    this.$refs.simpleRotaryRef.stopReset()

使用示例

html 复制代码
<template>
  <view class="page-lottery">
    <simple-rotary-table
      ref="simpleRotaryRef"
      :list="prizeList"
      labelKey="name"
      :radius="260"
      :speed="20"
      @start="start"
      @end="end"
    />
  </view>
</template>

<script>
  import SimpleRotaryTable from "@/components/simple-rotary-table/simple-rotary-table.vue";

  export default {
    components: { SimpleRotaryTable },
    data() {
      return {
        prizeList: [
          { name: "一等奖", id: 1 },
          { name: "二等奖", id: 2 },
          { name: "三等奖", id: 3 },
          { name: "谢谢参与", id: 4 },
        ],
      };
    },
    methods: {
      // 点击中间"开始抽奖"按钮后触发
      async start() {
        // 检查活动状态和次数等
        if (!this.activityDetail.status) {
          uni.showToast({ title: '活动已结束' })
          return
        }
        if (!this.activityDetail.remainRewordCount) {
          uni.showToast({ title: '抽奖次数已用完' })
          return
        }

        // 开始转盘
        this.$refs.simpleRotaryRef.begin();

        // 调用接口获取中奖信息
        try {
          const res = await lottery({ id: this.activityId });
          if (res.code == '1') {
            // 接口返回中奖数据,停止转盘
            this.$refs.simpleRotaryRef.stop(res.data);
          }
        } catch (err) {
          // 接口报错,重置转盘
          console.log('报错停止转盘', err);
          this.$refs.simpleRotaryRef.stopReset();
        }
      },

      // 转盘完全停止
      end(e) {
        // e 为中奖项的完整数据对象
        uni.showToast({
          title: e.name,  // 或 e.name,取决于 labelKey 配置
          icon: 'none'
        });
      },
    },
  };
</script>

工作流程

  1. 用户点击中间抽奖按钮
  2. 触发 @start 事件
  3. 外部检查业务逻辑(活动状态、抽奖次数等)
  4. 调用 begin() 方法开始转盘旋转
  5. 同时请求抽奖接口获取中奖结果
  6. 接口返回后,调用 stop(data) 传入中奖数据
  7. 转盘缓动停止在对应扇区
  8. 触发 @end 事件,返回中奖项完整数据

异常处理

场景 处理方式
接口调用失败 调用 stopReset() 重置转盘
活动已结束 @start 事件中拦截,不调用 begin
抽奖次数已用完 @start 事件中拦截,不调用 begin
中奖数据 id 不匹配 转盘会在最后一个扇区停止

子组件:sector.vue(说明)

sector.vue 为内部扇形绘制组件:

  • 通过 angleindexradius 计算 clip-path,绘制不同数量的扇形块。
  • 使用 bgColor 控制每个扇区的背景色。
  • 文本通过 textcolorsize 控制,居中显示在对应扇区内。

外部一般无需直接使用 sector.vue,只需要使用主组件 simple-rotary-table.vue 即可。

注意事项

  1. 点击按钮 ≠ 开始旋转@start 事件只是触发,需要外部调用 begin() 才会真正旋转
  2. stop 方法参数 :可以传入完整对象或 id,组件会自动匹配 list 中的数据
  3. stopReset 使用场景 :仅在接口异常时调用,正常流程使用 stop(data) 停止
  4. 扇区数量:建议不少于 2 个,否则转盘显示异常
相关推荐
特立独行的猫a1 小时前
跨平台开发实战:uni-app x 鸿蒙HarmonyOS网络模块封装与轮播图实现
android·网络·uni-app·harmonyos·轮播图·uni-app-x
Swift社区1 小时前
Flutter 中如何优雅地处理复杂表单
前端·flutter·前端框架
这是个栗子1 小时前
前端开发中的常用工具函数(三)
前端·javascript·charat
慧一居士1 小时前
Vite 常用插件详解与使用指南
前端
切糕师学AI1 小时前
JavaScript 中 == 和 === 的区别
javascript·js语法
之歆1 小时前
Vue3 + Vite2.0 全栈开发实践:从零到一构建通用后台管理系统-下
javascript·vue.js·vue3
zhougl9961 小时前
前端UI框架
前端·ui
love530love10 小时前
Scoop 完整迁移指南:从 C 盘到 D 盘的无缝切换
java·服务器·前端·人工智能·windows·scoop
王码码203512 小时前
Flutter for OpenHarmony:Flutter 三方库 bluez 玩转 Linux 风格的蓝牙操作(蓝牙底层互操作)
linux·运维·服务器·前端·flutter·云原生·harmonyos