Vue3+TS+Vite2+Element Plus 两天写个抽奖小系统(前端篇)

3,417 阅读4分钟

功能介绍

功能清单

功能不多,毕竟是随意练手的,目前给的是个本地版的,不需后台就能使用的。

主要功能:

  • 抽奖逻辑
  • 奖品清单
  • 中奖名单
  • 奖品数量控制
  • 用户操作提示
  • 背景音乐控制

界面预览

抽奖页面

火狐截图_2021-03-29T05-46-33.808Z.png

奖品清单

火狐截图_2021-03-29T05-51-12.850Z.png

中奖名单

火狐截图_2021-03-29T05-51-36.195Z.png

撸代码前准备

看标题大家应该就清楚用的技术栈是什么了,没啥好说的,前端攻城狮就是得用新技术哈~然后我用的编辑器是VSCode。

Vite创建项目

如果不知道Vite是啥?那你先去官网cn.vitejs.dev/ 了解一下。

说明:包管理工具使用的yarn,如果用npm的朋友不要照搬哦~~

yarn create @vitejs/app

命令行输完,会有一个交互操作,这里你输入自己的项目名称,模板使用vue3+ts。

ESLint和Prettier配置

安装依赖

相关的包有:

# 这里为了让大家看的清楚一些,所以分开安装

yarn add -D prettier
yarn add -D eslint
yarn add -D eslint-plugin-vue
yarn add -D eslint-plugin-prettier
yarn add -D eslint-config-prettier
yarn add -D @typescript-eslint/eslint-plugin
yarn add -D @typescript-eslint/parser

ESLint配置

在项目根目录创建.eslintrc.js文件,配置如下(个人喜好不同,规则rules可以自己修改):

module.exports = {
  parser: 'vue-eslint-parser',
  parserOptions: {
    parser: '@typescript-eslint/parser',
    ecmaVersion: 2020,
    sourceType: 'module'
  },
  extends: [
    'plugin:vue/vue3-recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended',
    'prettier'
  ],
  rules: {
    'no-unused-vars': 'off',
    '@typescript-eslint/no-unused-vars': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off'
  }
}

Prettier配置

在项目根目录创建prettier.config.js文件,配置如下(根据自己编码习惯自行调整):

// 看单词应该就懂什么意思了,还是不太懂的朋友可以百度
module.exports = {
  printWidth: 80,
  tabWidth: 2,
  useTabs: false,
  vueIndentScriptAndStyle: false,
  singleQuote: true,
  quoteProps: 'as-needed',
  trailingComma: 'none',
  endOfLine: 'auto',
  semi: false
}

编辑器配置(可选)

因为我自己会经常在MacOS和Windows切换办公,所以配置一下编辑器保持统一,配置前需要在VSCode插件商店安装EditorConfig for VS Code插件。

安装完后在项目根目录创建.editorconfig文件,配置如下:

# 编辑器配置

root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
# insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

Element Plus配置

组件按需引用我用的是vite-plugin-imp插件,官方文档用的是vite-plugin-style-import,这边你自己选择。

方式一:参考官方文档:Element+快速开始

方式二:

yarn add element-plus vite-plugin-imp

安装后打开根目录vite.config.ts文件配置:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vitePluginImp from 'vite-plugin-imp'
import path from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  resolve: {
    alias: {
      '@/': `${path.resolve(__dirname, 'src')}/`
    }
  },
  plugins: [
    vue(),
    vitePluginImp({
      libList: [
        {
          libName: 'element-plus',
          style: (name) => {
            return `element-plus/lib/theme-chalk/${name}.css`
          }
        }
      ]
    })
  ]
})

如果引入path有报错,需要安装一下@types/node:

yarn add -D @types/node

Vue Router和Vuex

这边项目功能比较简单,就不作配置了,想要了解的同学直接去官网文档一看就知道,或者用vue-cli创建一个vue3的项目看一下代码就行了。

准备就绪,开撸代码

template部分

<template>
  <div>
    <!-- 音频相关 -->
    <audio ref="music" preload="auto" loop :src="musicFile" />
    <audio ref="bgm" preload="auto" loop :src="bgmFile" />

    <!-- 背景装饰图片 -->
    <div class="heng-fu">
      <el-image :src="hengFuImg" />
    </div>
    <div class="deng-long-left">
      <el-image :src="dengLongImg" />
    </div>
    <div class="deng-long-right">
      <el-image :src="dengLongImg" />
    </div>
    <div class="niu-left">
      <el-image :src="niuLeftImg" />
    </div>
    <div class="niu-right">
      <el-image :src="niuRightImg" />
    </div>

    <!-- 按钮相关 -->
    <div class="music-btn">
      <input
        type="image"
        :src="musicImg"
        title="音乐开关"
        class="btn-music"
        @click="musicOpen = !musicOpen"
      />
    </div>
    <el-tooltip content="抽奖" placement="top" effect="light">
      <input
        type="image"
        :src="jinLiImg"
        class="btn-jin-li"
        @click="lotteryBtnClick"
      />
    </el-tooltip>
    <el-tooltip content="奖品" placement="top" effect="light">
      <input
        type="image"
        :src="hongBaoImg"
        class="btn-hong-bao"
        @click="prizeDrawer = true"
      />
    </el-tooltip>
    <el-tooltip content="中奖名单" placement="top" effect="light">
      <input
        type="image"
        :src="huaDuoImg"
        class="btn-hua-duo"
        @click="recordDrawer = true"
      />
    </el-tooltip>

    <!-- 中间头像相关 -->
    <div class="avatar">
      <el-avatar
        :size="200"
        :src="avatarUrl"
        shape="square"
        class="avatar-border"
        @click="retryLottery"
      />
      <div class="name-label">{{ currentName }}</div>
    </div>

    <!-- 奖品清单侧边栏 -->
    <el-drawer v-model="prizeDrawer" title="奖品清单" size="400">
      <div style="padding: 0 20px 20px 20px">
        <el-select v-model="currentPrizeTitle" placeholder="请选择抽取的奖品">
          <el-option
            v-for="prize in prizes"
            :key="prize.type"
            :label="prize.title"
            :value="prize.title"
            :disabled="isPrizeUnavailable(prize.title)"
          >
            <span style="float: left">{{ prize.title }}</span>
            <span style="float: right; color: #8492a6; font-size: 13px">{{
              prize.text
            }}</span>
          </el-option>
        </el-select>
      </div>
      <el-space direction="vertical">
        <el-card v-for="prize in prizes" :key="prize.type" class="prize-card">
          <el-image
            style="width: 100px; height: 100px"
            :src="prize.img"
            fit="contain"
          />
          <div style="margin-left: 20px">
            <h4>{{ prize.text }} / {{ prize.count }}个</h4>
            <h5>{{ prize.title }}</h5>
          </div>
        </el-card>
      </el-space>
    </el-drawer>

    <!-- 中奖名单侧边栏 -->
    <el-drawer v-model="recordDrawer" title="中奖名单" size="400">
      <el-empty v-if="lotteryRecords.length <= 0" description="暂无记录" />
      <el-space v-else direction="vertical" :size="0">
        <div
          v-for="(record, index) in lotteryRecords"
          :key="index"
          class="lottery-record"
        >
          <div class="lottery-record-content">
            <el-avatar :size="50" :src="record.avatar" />
            <div style="margin-left: 20px">
              <span style="font-weight: bold">{{ record.name }}</span> 抽中
              <span style="font-weight: bold">{{ record.prize }}</span>
            </div>
          </div>
          <el-divider style="margin: 10px 0" />
        </div>
      </el-space>
    </el-drawer>
  </div>
</template>

style部分

<style>
body {
  margin: 0;
}

#app {
  width: 100vw;
  height: 100vh;
  background-color: #f39f86;
  background-image: linear-gradient(315deg, #f39f86 0%, #f9d976 74%);
}

.heng-fu {
  position: absolute;
  top: 24px;
  left: 50%;
  transform: translateX(-50%);
}

.deng-long-left {
  position: absolute;
  left: 30px;
  top: 0;
}

.deng-long-right {
  position: absolute;
  right: 30px;
  top: 0;
}

.niu-left {
  position: absolute;
  bottom: 0;
  left: 30px;
}

.niu-right {
  position: absolute;
  bottom: 0;
  right: 30px;
}

.btn-jin-li {
  position: absolute;
  bottom: 55px;
  left: calc(50% - 250px);
  width: 130px;
  height: 60px;
}

.btn-hong-bao {
  position: absolute;
  bottom: 30px;
  left: 50%;
  width: 130px;
  height: 133px;
  transform: translateX(-50%);
}

.btn-hua-duo {
  position: absolute;
  bottom: 0;
  left: calc(50% + 120px);
  width: 85px;
  height: 130px;
}

.music-btn {
  position: absolute;
  right: 15px;
  top: 15px;
}

.btn-music {
  width: 40px;
  height: 40px;
}

.avatar {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translateX(-50%) translateY(-50%);
}

.avatar-border {
  padding: 5px;
  background: white;
}

.name-label {
  margin-top: 10px;
  font-size: 30px;
  color: #fff;
  font-weight: bold;
  text-align: center;
}

input:focus {
  outline: none;
}

.prize-card {
  margin: 0 20px;
  width: 360px;
}

.el-card__body {
  padding: 0;
  height: 100px;
  display: flex;
  flex-direction: row;
}

.el-drawer {
  width: 400px;
  overflow: auto;
  display: flex;
  flex-direction: column;
}

.lottery-record {
  margin: 0 20px;
  width: 360px;
}

.lottery-record-content {
  display: flex;
  flex-direction: row;
  align-items: center;
}
</style>

script部分

import { computed, defineComponent, ref, watch } from 'vue'
import { ElMessage, ElNotification, ElMessageBox } from 'element-plus'
import personJsonData from './assets/json/person.json'
import { prizes } from './models/prize'
import { res } from './composables/res'
import {
  musicOpen,
  switchLotteryEffect,
  music,
  bgm
} from './composables/music-control'

export default defineComponent({
  name: 'App',
  setup() {
    /** 是否打开奖品侧边栏 */
    const prizeDrawer = ref(false)
    /** 中奖名单侧边栏是否打开 */
    const recordDrawer = ref(false)
    /** 抽奖是否结束 */
    const lotteryFinish = ref(false)
    // 所有人员名单
    const personList = ref<Person[]>(personJsonData)
    // 当前人员头像
    const avatarUrl = ref(personList.value[0].avatar)
    // 当前人员姓名
    const currentName = ref(personList.value[0].name)

    const availablePersons = computed(() => {
      // 设置当前可以获奖的人员名单,移除已经获奖的
      const personToRemove = lotteryRecords.value.map((item) => item.name)
      const filterArr = personList.value.filter(
        (item) => personToRemove.indexOf(item.name) < 0
      )
      return filterArr
    })

    // 中奖记录
    const lotteryRecords = ref<LotteryRecord[]>([])

    // 监听中奖记录,处理奖品的切换和抽奖是否结束状态
    watch(
      lotteryRecords,
      () => {
        if (lotteryRecords.value.length > 0) {
          // 判断奖品是否抽完以及自动切换奖品
          const isCurrentPrizeUnavailable = isPrizeUnavailable(
            currentPrizeTitle.value
          )
          if (isCurrentPrizeUnavailable) {
            const prePrize = currentPrizeTitle.value
            if (currentPrizeIndex.value > 0) {
              currentPrizeIndex.value--
              ElNotification({
                title: '消息提醒',
                message: `奖品 【${prePrize}】 已经抽完,开始抽取奖品 【${
                  prizes[currentPrizeIndex.value].title
                }】`,
                position: 'top-left',
                type: 'info'
              })
            } else {
              lotteryFinish.value = true
              ElNotification({
                title: '消息提醒',
                message: '奖品全部抽完,抽奖已经结束',
                position: 'top-left',
                type: 'warning'
              })
            }
          }
        }
      },
      { deep: true }
    )

    // 设置可抽奖人数的随机最大index
    const maxIndex = computed(() => availablePersons.value.length - 1)

    // 是否正在抽奖
    const lotteryRunning = ref(false)
    watch(lotteryRunning, () => {
      switchLotteryEffect(lotteryRunning.value)
    })

    // 抽奖定时器
    let timer: NodeJS.Timeout

    // 抽奖
    const lottery = () => {
      lotteryRunning.value = true
      const index = Math.round(Math.random() * maxIndex.value)
      avatarUrl.value = availablePersons.value[index].avatar
      currentName.value = availablePersons.value[index].name
      timer = setTimeout(lottery, 50)
    }

    // 停止抽奖
    const stopLottery = () => {
      lotteryRunning.value = false
      clearTimeout(timer)

      const record: LotteryRecord = {
        avatar: avatarUrl.value,
        name: currentName.value,
        prize: currentPrizeTitle.value,
        prizeIndex: currentPrizeIndex.value
      }
      lotteryRecords.value.unshift(record)
    }

    // 监听是否正在抽奖
    watch(lotteryRunning, () => {
      if (lotteryRunning.value) {
        ElMessage.success({
          message: `正在抽取 ${currentPrize.value.text} / ${currentPrize.value.title}`,
          type: 'success'
        })

        lottery()
      } else {
        stopLottery()
      }
    })

    /**
     * 抽奖按钮点击
     */
    const lotteryBtnClick = () => {
      if (lotteryFinish.value) {
        ElNotification({
          title: '消息提醒',
          message: '奖品全部抽完,抽奖已经结束',
          position: 'top-left',
          type: 'warning'
        })
      } else {
        lotteryRunning.value = !lotteryRunning.value
      }
    }

    /**
     * 头像点击重新抽奖
     */
    const retryLottery = () => {
      console.log('retryLottery')
      if (lotteryRecords.value.length <= 0) return

      ElMessageBox.confirm('此操作将重新抽取刚才的奖品,是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      })
        .then(() => {
          const recordToRemove = lotteryRecords.value[0]
          if (recordToRemove.prizeIndex !== currentPrizeIndex.value) {
            currentPrizeIndex.value = recordToRemove.prizeIndex
          }

          lotteryRecords.value.splice(0, 1)
          ElMessage.success({
            message: '操作成功,请继续进行抽奖',
            type: 'success'
          })
        })
        .catch(() => {
          console.log('cancel')
        })
    }

    /** 当前奖品的对应数组的index */
    const currentPrizeIndex = ref(prizes.length - 1)

    /** 监听当前奖品的index */
    watch(
      currentPrizeIndex,
      () => (currentPrizeTitle.value = prizes[currentPrizeIndex.value].title)
    )

    /** 当前奖品的名称 */
    const currentPrizeTitle = ref(prizes[currentPrizeIndex.value].title)

    /** 当前正在抽的奖品 */
    const currentPrize = computed(() => {
      const filterArr = prizes.filter(
        (item) => item.title === currentPrizeTitle.value
      )
      return filterArr.length > 0 ? filterArr[0] : <Prize>{}
    })

    /** 判断奖品是否抽满 */
    const isPrizeUnavailable = (prizeTitle: string) => {
      const filterPrizes = prizes.filter((item) => item.title === prizeTitle)
      if (filterPrizes.length <= 0) return false

      const prize = filterPrizes[0]
      const filterRecords = lotteryRecords.value.filter(
        (item) => item.prize === prizeTitle
      )
      return prize.count <= filterRecords.length
    }

    return {
      ...res,
      avatarUrl,
      currentName,
      lotteryBtnClick,
      retryLottery,
      prizeDrawer,
      prizes,
      currentPrizeTitle,
      isPrizeUnavailable,
      lotteryRecords,
      recordDrawer,
      musicOpen,
      music,
      bgm
    }
  }
})

前端源码

GitHub地址 Gitee

后端管理系统+API接口

毕竟随手写写的项目,花了两小时用Django+Django Rest Framework写了一个配套的后端部分。

API接口界面

火狐截图_2021-03-29T09-27-43.168Z.png

管理系统界面

火狐截图_2021-03-29T09-28-06.877Z.png

这里就不多讲了,有兴趣的朋友可以直接看代码~

后端源码

Gitee

但行好事,莫问前程