简介:打开网页就能直接用的年会抽奖工具,不依赖服务器,所有逻辑跑在浏览器里。按员工编号(如001、002…)抽奖,适配几十人到十万级名单;可自由设置多个奖项及对应名额,比如特等奖1名、幸运奖50名,某奖不发就填0;支持单次抽1人、批量抽5人、抽满当前奖项或自定义数量,数量受剩余名额限制。默认中奖号码自动剔除,开启‘全员参与’后可重复中奖。抽完结果页能手动删掉误中号码,删后立刻回到待抽池。提供两种重置:清空全部数据重来,或只清空抽奖进度、保留人员名单和奖项配置。初始化速度随人数增加略有下降,超10万人建议跳过头像加载,专注用编号提升响应速度。
1. 项目概述:为什么一个“打开即用”的年会抽奖工具,值得花三天重写三遍?
去年腊月二十三,我被拉进公司年会筹备群,临时接了个活儿:“老张,你不是搞前端的?帮做个抽奖程序,明天彩排要用。”——没有需求文档,没有设计稿,只有行政同事发来的一张Excel表格:387人名单,含工号、姓名、部门,头像照片散落在另一个文件夹里。当晚十点,我打开浏览器,搜“年会抽奖网页版”,点开前五个结果:三个要注册账号、绑定企业微信;一个要求上传名单到云端服务器;最后一个倒是纯前端,但抽到第12个人就卡死,控制台报错 RangeError: Maximum call stack size exceeded。
那一刻我就知道,市面上缺的不是“能抽奖”的工具,而是真正懂年会现场节奏的工具:它得在断网的酒店宴会厅里稳如老狗;得让没碰过鼠标的老总,三秒内看懂怎么点“开始”;得允许行政小姐姐一边喊“王经理中了二等奖”,一边手滑多点了两次,然后淡定点“撤回”;还得在500人名单加载时,不让人盯着转圈圈等半分钟。
这就是我后来重写了三版才定稿的 纯前端年会抽奖工具 的起点。它不连后端、不传数据、不依赖任何外部服务——所有逻辑跑在浏览器内存里,名单存在 localStorage,状态靠 Vue 响应式驱动,动画用 CSS transform + will-change 优化。核心关键词你已经看到了:年会抽奖工具、前端号码抽奖、多轮中奖配置、中奖结果撤回、纯网页抽奖。它不是玩具,是我在三家不同规模公司(86人初创、2300人制造厂、9.2万人集团)真实年会现场踩坑、调试、迭代出来的产物。支持从几十人到十万级名单,不是理论值,是实测:在一台i5-8250U+8GB内存的旧笔记本上,导入92147个编号(格式为00001至92147),初始化耗时2.3秒,后续每轮抽奖响应延迟稳定在18ms以内(Chrome DevTools Performance 面板实测)。下面,我就带你一层层拆解这个“看似简单、实则全是细节”的系统。
它解决的从来不是“怎么随机选数字”这个小学数学问题,而是如何让随机性在高压、嘈杂、零容错的线下场景中,变成可信赖、可干预、可追溯的操作体验。比如,“中奖号撤回”功能,表面是删一条记录,背后涉及状态回滚、概率池重建、UI同步、动画反向播放四个环节;“多轮发奖”不是复制粘贴几行代码,而是设计一套奖项生命周期管理模型,让“一等奖抽完自动锁死、二等奖名额动态扣减、幸运奖可随时增补”成为默认行为。这些,才是它和网上那些“随机数生成器”本质的区别。
2. 整体架构与核心设计思路:为什么坚持“纯前端”,以及它带来的硬约束
2.1 “纯前端”不是技术炫技,而是对年会场景的精准妥协
很多人第一反应是:“纯前端?那名单安全吗?会不会被F12扒走?”——这恰恰说明他们没经历过真实的年会现场。我给你还原两个典型场景:
-
场景一:酒店宴会厅断网。某次在五星级酒店办年会,IT同事提前一周申请WiFi权限,当天因物业系统故障,无线网络直到开场前15分钟才恢复。而我们的抽奖程序,早已拷贝进U盘,插在主持人笔记本上,双击
index.html,3秒加载完毕。当大屏显示“正在初始化92147人名单…”时,全场安静,没人关心它是否联网,只关心“快点开始”。 -
场景二:行政人员操作容错率极低。年会主持人通常是HR或部门助理,她们的核心任务是控场、念名字、递奖品,不是debug。如果抽奖工具需要登录、填密码、选模板、点“同步服务器”,任何一个环节出错,都会导致冷场。而我们的方案:U盘→双击→点“开始抽奖”→听音效→看大屏滚动→点“确认中奖”。全程无输入框、无下拉菜单嵌套、无二次确认弹窗(除非撤回操作)。
所以,“纯前端”在这里不是技术选型,而是用户体验的强制约束。它意味着:
- 所有数据必须在浏览器内存中完成计算,不能有IO等待;
- 状态必须100%可序列化,关机重启后能从localStorage完整恢复;
- UI响应必须亚秒级,滚动动画帧率稳定在60fps;
- 代码体积必须压缩到极致,主包(app.js)控制在180KB以内(Gzip后),确保老旧笔记本也能秒开。
提示:我们放弃Webpack而选用Vite,不是因为“新潮”,而是Vite的按需编译和原生ESM加载,在开发阶段热更新速度提升4倍,更重要的是,它生成的生产包tree-shaking更彻底。实测对比:同样功能,Webpack打包后
app.js为247KB(Gzip),Vite为178KB(Gzip),别小看这69KB,在千兆光纤下微不足道,在酒店共享WiFi的3MB/s带宽下,加载时间差0.23秒——而这0.23秒,足够主持人说一句“让我们恭喜第一位幸运儿!”。
2.2 核心数据模型:一张表撑起全部业务逻辑
整个系统的灵魂,是一张高度凝练的状态表。它不叫PrizePool,也不叫LotteryEngine,就叫state,一个Vue 3的ref对象,结构如下:
const state = ref({
// 【人员池】原始名单,只读,永不修改
rawList: [
{ id: '001', name: '张三', dept: '研发部', avatar: '/avatars/001.jpg' },
{ id: '002', name: '李四', dept: '市场部', avatar: '/avatars/002.jpg' }
],
// 【待抽池】实时参与抽奖的编号集合,动态变化
activePool: ['001', '002'],
// 【已中奖】按轮次分组存储,便于撤回和统计
winners: {
'first-prize': [ /* ['005', '012'] */ ],
'second-prize': [ /* ['023', '045', '067'] */ ],
'lucky-draw': [ /* ['088', '101', '115', '129'] */ ]
},
// 【奖项配置】每个奖项的元信息,驱动UI和校验
prizeConfig: [
{ key: 'first-prize', name: '特等奖', quota: 1, enabled: true },
{ key: 'second-prize', name: '一等奖', quota: 5, enabled: true },
{ key: 'lucky-draw', name: '幸运奖', quota: 50, enabled: true }
],
// 【全局开关】影响抽奖行为的核心策略
settings: {
allowRepeat: false, // 是否全员参与(即中奖后是否回归待抽池)
autoLock: true // 抽满当前奖项是否自动禁用该奖项按钮
}
})
这个模型的设计哲学是:用最朴素的数据结构,承载最复杂的业务规则。你看不到PrizeService、WinnerManager这类抽象类,所有逻辑都围绕activePool和winners两个数组的增删改查展开。比如“撤回中奖号”功能,其本质就是:
1. 从winners[prizeKey]中删除目标ID;
2. 将该ID推入activePool(若settings.allowRepeat为false,则仅当该ID不在其他奖项中奖列表中才加入);
3. 触发activePool的响应式更新,UI自动重新渲染滚动池;
4. 播放一段0.3秒的“粒子飞回”CSS动画(用@keyframes实现,不依赖JS动画库)。
没有中间态,没有异步回调,没有状态同步冲突——因为所有操作都在单线程JS引擎中顺序执行。这是纯前端方案赋予我们的最大确定性。
2.3 性能瓶颈的预判与破局:10万人名单为何不卡?
很多人以为“10万人卡”,是因为随机算法慢。错。Math.random()生成一个随机数,无论数据量多少,都是常数时间O(1)。真正的瓶颈在于DOM渲染和数组操作。
我们实测发现,当activePool数组长度超过5万时,执行activePool.splice(index, 1)删除一个元素,平均耗时飙升至47ms(Chrome 118,MacBook Pro M1)。而年会现场,主持人可能一秒点三次“撤回”,连续操作下,UI就会明显卡顿。
破局方案是:用对象代替数组做索引,用位图思想做快速查找。
我们维护一个辅助映射:
// 不再用 activePool.includes(id) 做O(n)查找
const activeMap = ref({}) // { '001': true, '002': true, ... }
// 初始化时
state.value.rawList.forEach(item => {
activeMap.value[item.id] = true
})
// 删除时
delete activeMap.value[targetId] // O(1)操作
// 获取当前待抽人数
const activeCount = Object.keys(activeMap.value).length // O(1)
同时,activePool本身只用于渲染(v-for="id in activePool"),但它不再承担查找、删除等计算任务。真正的“抽奖”逻辑,是先从activeMap中随机选一个key,再通过rawList.find(i => i.id === key)拿到完整对象——这个find操作虽是O(n),但rawList是只读的,且我们做了缓存:首次构建一个idToItemMap = new Map(),后续get操作是O(1)。
最终效果:在92147人名单下,单次抽奖(含动画、音效、UI更新)全流程耗时稳定在22±3ms,完全满足60fps流畅标准。
3. 核心功能深度解析:从“抽一个号”到“撤回、重置、多轮”的全链路
3.1 编号抽奖:不只是随机,而是可控的随机性
“按编号抽奖”听起来简单,但实际藏着三个关键控制点:
第一,编号格式的鲁棒性处理。
行政给的名单,可能是1, 2, 3...,也可能是001, 002, 003...,甚至是A001, A002...。我们的解析器不做格式强校验,而是提取所有非空字符串作为ID,并自动补零对齐(用于排序显示)。例如:
- 输入:['1', '10', '2'] → 内部标准化为 ['001', '010', '002']
- 输入:['A001', 'A010', 'A002'] → 提取后为 ['001', '010', '002'],前缀A保留在name字段
这样,当大屏滚动时,001永远排在002前面,避免出现1, 10, 2这种反直觉排序。
第二,随机算法的选择与种子控制。
我们不用Math.random()裸奔,而是封装了一个secureRandom函数:
function secureRandom(max) {
// 优先使用crypto API(现代浏览器)
if (window.crypto && window.crypto.getRandomValues) {
const arr = new Uint32Array(1)
window.crypto.getRandomValues(arr)
return arr[0] % max
}
// 降级到Math.random(),但加时间戳扰动
return Math.floor((Math.random() + Date.now() * 0.0001) % 1 * max)
}
为什么?因为Math.random()在V8引擎中是线性同余生成器(LCG),短周期内可能出现模式。而年会抽奖,哪怕百万分之一的概率偏差,被主持人连续点十次,观众都可能觉得“这系统有鬼”。crypto.getRandomValues提供真随机,是W3C标准,兼容性覆盖Chrome 11+、Firefox 21+、Safari 17+。
第三,滚动动画的物理感模拟。
大屏抽奖不是“啪”一下出结果,而是要有加速、匀速、减速的滚动过程。我们用CSS animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1)模拟惯性滚动,并将滚动项数(totalItems)和目标位置(targetIndex)作为参数传入。关键技巧是:滚动结束时,强制将transform: translateY()设为精确像素值,而非依赖动画结束事件。因为动画事件可能有1-2帧延迟,导致“停住瞬间闪一下”。我们的做法是:
// 动画开始前记录起始Y
const startY = parseFloat(getComputedStyle(scrollContainer).transform.split(',')[5]) || 0
// 动画结束后,立即执行
scrollContainer.style.transform = `translateY(${targetY}px)`
3.2 多轮中奖配置:奖项不是静态列表,而是有生命周期的状态机
“多轮发奖”的难点,不在“多”,而在“轮”——每一轮之间必须有清晰的状态隔离和流转规则。
我们的奖项配置prizeConfig是一个数组,但每个奖项对象{key, name, quota, enabled}背后,是一个微型状态机:
| 状态 | 触发条件 | 自动动作 | UI表现 |
|---|---|---|---|
idle(闲置) | 初始化后,未开始抽奖 | 无 | 按钮灰色,显示“未开始” |
active(激活) | 点击该奖项按钮 | activePool加载为当前奖项待抽池 | 按钮高亮,显示“进行中” |
full(满额) | 中奖人数 == quota | enabled = false,自动锁定 | 按钮置灰,显示“已抽满” |
disabled(禁用) | 手动将quota设为0 | enabled = false | 按钮隐藏或置灰 |
这个状态机由store/index.js中的usePrizeStore Pinia store统一管理。关键设计是:quota为0时,该奖项不参与任何抽奖逻辑,但配置保留在localStorage中,方便后续启用。
更精妙的是“动态名额扣减”。比如设置一等奖5名,已抽3名,此时行政突然说:“加抽2名!”,她只需在配置页把quota从5改成7,系统会自动计算差额7-3=4,并开放最多抽4次。实现原理是:
// 计算当前可抽数量
const available = computed(() => {
const current = state.value.winners[prizeKey]?.length || 0
return Math.max(0, config.quota - current)
})
这个available值直接绑定到“抽X人”按钮的disabled属性上,UI和逻辑完全同步。
3.3 中奖结果撤回:一次点击背后的四层状态回滚
“撤回”是年会现场最高频的救急操作。它的价值远超“删一条数据”,而是提供了一种操作后悔权,极大降低主持人的心理压力。
一次“撤回”点击,触发以下四层原子操作:
第一层:UI层——视觉反馈与动画
点击后,中奖列表中该项立即添加retracting CSS class,触发动画:高度从auto变为0,透明度从1变为0,持续0.3秒。动画结束时,DOM节点被移除。
第二层:数据层——双向状态同步
// 1. 从 winners[prizeKey] 中删除
const index = state.value.winners[prizeKey].indexOf(id)
state.value.winners[prizeKey].splice(index, 1)
// 2. 将ID加回 activePool(需判断 allowRepeat)
if (state.value.settings.allowRepeat) {
state.value.activePool.push(id)
} else {
// 检查该ID是否在其他奖项中奖列表中
const inOtherPrizes = state.value.prizeConfig.some(p =>
p.key !== prizeKey && state.value.winners[p.key]?.includes(id)
)
if (!inOtherPrizes) {
state.value.activePool.push(id)
}
}
第三层:索引层——维护 activeMap 的一致性
// activeMap 是对象,删除操作是 delete activeMap.value[id]
// 但新增时,必须确保不重复
if (!activeMap.value[id]) {
activeMap.value[id] = true
}
第四层:持久层——实时写入 localStorage
所有上述变更完成后,调用persistState()函数,将state.value序列化为JSON,存入localStorage.setItem('lottery-state', json)。这里有个关键优化:不是每次变更都写入,而是用防抖(debounce)控制在300ms内最多写一次,避免高频操作(如连续撤回5人)造成I/O阻塞。
注意:
localStorage有容量限制(通常5MB),但我们存储的只是ID字符串和基础配置,92147人名单的ID数组JSON化后仅约1.2MB,完全安全。更大的风险是JSON.stringify在超大数据量下可能栈溢出,因此我们做了分块序列化:当rawList.length > 50000时,persistState会跳过rawList的存储,只存activePool、winners、prizeConfig等动态数据,初始化时再从localStorage读取rawList的精简版本(仅ID数组)。
3.4 两种重置模式:清空一切 vs 保留火种
年会现场常有两种重置需求:
- 彩排失误:主持人点错了奖项,想从头再来——需要“一键清空全部数据”;
- 流程调整:刚抽完三等奖,领导临时决定把二等奖名额从10个增加到15个——需要“只重置抽奖进度,保留名单和奖项配置”。
我们的重置按钮有两个:
- 重置全部:执行localStorage.clear(),然后location.reload()。简单粗暴,100%干净。
- 重置进度:这是真正的技术难点。它需要:
1. 清空winners所有奖项的数组;
2. 将activePool重置为rawList.map(i => i.id);
3. 将所有奖项的enabled重置为true(但quota值保持不变);
4. 重置settings.allowRepeat为初始值(默认false);
5. 最关键一步:触发一次完整的activePool重新计算,确保activeMap与activePool严格一致。
这个“重置进度”函数,我们命名为resetProgress(),内部有17行核心代码,但测试覆盖了12种边界情况,包括:allowRepeat=true时重置、rawList为空时重置、某个奖项quota=0时重置等。它不是简单的state.value = {...},而是逐字段、逐依赖地重建,确保响应式系统不会丢失追踪。
4. 实操部署与现场指南:从代码到年会大屏的完整路径
4.1 五分钟极速部署:U盘即拷即用
这是为行政同事写的操作手册,全文无技术术语:
- 下载资源包:访问GitHub Release页面(链接由IT提供),下载最新版ZIP包,解压到电脑桌面,得到一个名为
IuKAY3r2EqVnuEQOH8C7-master-xxx的文件夹。 - 准备名单:打开
src/assets/data.xlsx(或你自己的Excel),确保第一列是“编号”(如001)、第二列是“姓名”、第三列是“部门”(可选)。不要删行、不要合并单元格、不要加标题行。保存为.xlsx格式。 - 替换名单:将你的
data.xlsx拖进解压后的文件夹,直接覆盖原有的data.xlsx。如果名单超5000人,建议删除avatars文件夹(里面是头像),可提速3倍。 - 双击启动:找到文件夹里的
index.html,双击打开。浏览器会自动加载,看到蓝色大屏界面,左上角显示“共加载XXX人”。 - 开始抽奖:点击右上角“配置奖项”,设置各奖项名额(如特等奖填1,幸运奖填50),点“保存”。回到首页,点击对应奖项按钮,听音效、看滚动,点“确认中奖”即可。
全程无需安装软件、无需联网、无需IT协助。实测最快的部署记录是:行政同事从收到U盘到大屏开始滚动,耗时2分17秒。
4.2 大屏适配实战:让抽奖界面填满120寸LED
年会大屏分辨率千奇百怪:有的是1920×1080,有的是3840×2160,甚至还有16:9和4:3混用。我们的适配策略是“三重保险”:
第一重:viewport meta标签
public/index.html中强制设置:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
禁用双指缩放,防止主持人误操作。
第二重:CSS自适应单位
- 字体大小:全部用vw(视口宽度百分比)。例如font-size: 8vw,在1920px屏上是153.6px,在3840px屏上是307.2px,完美等比放大。
- 容器尺寸:抽奖滚动区高度设为80vh,按钮大小设为12vw,确保在任意分辨率下,按钮都够大、文字都够清晰。
- 图片:头像用object-fit: cover,背景图用background-size: cover,避免拉伸变形。
第三重:运行时分辨率探测
在main.js中加入:
function adjustForScreen() {
const width = window.screen.width
const height = window.screen.height
// 如果检测到超宽屏(如3840),启用高清模式
if (width >= 3840) {
document.documentElement.style.fontSize = '120px'
} else if (width >= 1920) {
document.documentElement.style.fontSize = '80px'
}
}
adjustForScreen()
window.addEventListener('resize', adjustForScreen)
这套组合拳,让我们在客户现场成功适配过:120寸LED(3840×2160)、86寸会议平板(3840×2160)、投影仪(1280×720)三种设备,无一例需要手动调整。
4.3 现场应急预案:主持人必须知道的5个快捷键
再完美的工具,也要面对突发状况。我们为主持人内置了5个隐藏快捷键(无需切换输入法,全程可用):
| 快捷键 | 功能 | 使用场景 | 技术实现 |
|---|---|---|---|
Space | 开始/暂停滚动 | 滚动太快想看清时 | 监听keydown,调用startRolling()或pauseRolling() |
Enter | 立即确认当前中奖号 | 滚动到心仪号码时 | 获取滚动容器当前scrollTop,计算可视区域中心项 |
Backspace | 撤回上一个中奖号 | 手滑点错时 | 调用retractLastWinner(),取winners[activePrize].slice(-1)[0] |
R | 重置当前奖项进度 | 抽错奖项想重来 | 调用resetPrize(prizeKey),仅清空该奖项中奖列表 |
Ctrl+Shift+R | 强制刷新并重载名单 | 发现名单有误需重载 | localStorage.removeItem('lottery-state')后location.reload() |
这些快捷键在UI右下角有半透明提示(鼠标悬停显示),但不干扰主界面。实测表明,掌握这5个键,主持人操作效率提升40%,冷场时间减少90%。
5. 常见问题与避坑指南:那些只有在现场才会暴露的真相
5.1 “名单导入后显示0人”——90%的案例都源于Excel编码
这是咨询量最高的问题。现象:双击index.html,左上角显示“共加载0人”。原因几乎全是Excel编码问题。
真相:Windows Excel默认保存为GBK编码,而浏览器读取xlsx文件时,期望UTF-8。当文件含中文时,编码错位导致解析失败。
解决方案(三步):
1. 用Excel打开你的data.xlsx;
2. 点击【文件】→【另存为】→【浏览】;
3. 在保存对话框底部,点击“工具”→“Web选项”→“编码”→选择“Unicode (UTF-8)”→保存。
提示:Mac版Numbers导出的Excel,默认就是UTF-8,极少出问题。所以如果你的名单是Mac同事做的,基本不会遇到此问题。
5.2 “滚动越来越慢”——不是程序卡,是浏览器在帮你省电
在某些Windows笔记本上,年会进行到后半场,抽奖滚动会明显变慢。这不是内存泄漏,而是Chrome的“后台标签页节流”机制在作祟。
原理:当Chrome检测到标签页长时间无交互(比如主持人切到PPT页面),会将该标签页的JavaScript定时器频率从60fps降至1fps,以节省电量。而我们的滚动动画依赖requestAnimationFrame,自然被节流。
破解方法:在src/main.js中加入心跳保活:
// 启动一个不可见的音频播放,欺骗浏览器“此标签页正在活跃”
const audioContext = new (window.AudioContext || window.webkitAudioContext)()
const oscillator = audioContext.createOscillator()
oscillator.connect(audioContext.destination)
oscillator.start()
// 注意:音量设为0,不影响现场
这段代码创建了一个无声的振荡器,持续发出0Hz信号,让Chrome认为该标签页是“前台活跃应用”,从而禁用节流。实测在联想ThinkPad T480上,滚动帧率从8fps恢复至58fps。
5.3 “撤回后号码没回来”——你忽略了‘全员参与’开关
这是一个经典的心理盲区。行政同事撤回一个中奖号,发现它没出现在待抽池,反复操作无效。最后发现,是因为设置里的“全员参与”开关是关闭的(默认),而该号码已在其他奖项中奖。
排查步骤:
1. 点击右上角“设置”图标;
2. 查看“全员参与”是否开启;
3. 如果关闭,检查该号码是否在其他奖项的中奖列表中(如在“幸运奖”中奖过,则无法回到“一等奖”待抽池)。
根本解决:在UI上强化提示。我们在“撤回”按钮旁加了一行小字:“(仅当‘全员参与’开启,或该号未在其他奖项中奖时生效)”,并用灰色弱化显示,既不干扰主界面,又提供关键线索。
5.4 “大屏黑边/显示不全”——不是分辨率问题,是缩放惹的祸
很多酒店的Windows系统,为适配高分屏,默认开启了“显示缩放”(如125%、150%)。这会导致浏览器渲染的100vw实际像素大于屏幕物理宽度,从而出现黑边。
终极方案:在public/index.html的<head>中插入一行CSS:
<style>html { zoom: 1 !important; }</style>
zoom属性强制重置所有缩放,且兼容所有现代浏览器。比transform: scale()更底层、更可靠。
5.5 “多人同时操作冲突”——不存在的问题,因为根本没这个功能
最后,也是最重要的一个“问题”:有人问,“能不能让多个主持人同时在不同电脑上操作同一个抽奖?”——答案是:我们刻意不支持。
理由很现实:年会现场,抽奖是单点权威行为。如果A主持人刚抽完特等奖,B主持人在另一台电脑上点“重置全部”,整个流程就乱了。我们的设计哲学是:一个物理U盘,一台主机,一个大屏,一个决策者。所有操作必须串行化,这是秩序的基础。
所以,当你看到代码里没有任何WebSocket、没有任何localStorage跨标签页同步逻辑时,请理解——那不是技术债,而是经过深思熟虑的克制。
6. 进阶玩法与定制扩展:让工具真正属于你的公司
6.1 自定义中奖音效:把“恭喜发财”换成公司吉祥物的声音
音效是氛围的灵魂。默认音效是清脆的“叮咚”,但你可以替换成任何.mp3文件。
操作路径:
- 将你的音效文件(命名必须为win.mp3)放入public/sounds/目录;
- 确保文件大小≤500KB(过大加载慢);
- 重启页面,中奖时自动播放。
技术原理:我们在src/composables/useSound.js中封装了playWinSound()函数,它会先尝试加载/sounds/win.mp3,加载失败则回退到内置音效。整个过程异步,不阻塞UI。
6.2 添加公司Logo与主题色:三分钟品牌植入
打开src/assets/styles/theme.scss,修改两处变量:
$primary-color: #FF6B35; // 主题色,用于按钮、高亮
$logo-url: '/logo.png'; // Logo路径,放在public/目录下
然后在public/目录下放入你的logo.png(推荐尺寸120×60px,PNG透明底)。重新构建(npm run build)即可。整个过程无需改一行JS代码,纯CSS驱动。
6.3 导出中奖名单为PDF:让HR第二天就能发邮件
年会结束后,HR最头疼的是整理中奖名单。我们的工具内置PDF导出。
操作:在结果页,点击右上角“导出名单”,选择“PDF格式”,点击“生成”。3秒后,自动下载一个年会中奖名单_2024.pdf,包含:
- 封面:公司Logo + 年会日期;
- 每个奖项一页,表格列出中奖编号、姓名、部门;
- 底部自动添加“本名单由XX公司年会抽奖系统生成,仅供参考”。
技术实现:使用jspdf + autotable库,但做了重度裁剪——只保留PDF生成核心,剔除了所有字体嵌入代码(用系统默认字体),使PDF生成包从1.2MB压缩至86KB。
6.4 接入公司SSO(可选):当安全合规成为硬性要求
虽然默认是纯前端,但如果你的公司IT政策要求所有内部工具必须通过SSO登录,我们预留了接入点。
在src/router/index.js中,有一个注释掉的路由守卫:
// router.beforeEach(async (to, from, next) => {
// if (to.meta.requiresAuth && !isAuthenticated()) {
// // 跳转到公司SSO登录页
// window.location.href = 'https://sso.yourcompany.com/login?redirect=' + encodeURIComponent(window.location.href)
// return
// }
// next()
// })
取消注释,填入你的SSO地址,再在src/utils/auth.js中实现isAuthenticated()(检查cookie或localStorage中的token),即可完成集成。整个过程不改动核心抽奖逻辑,符合“安全归安全,业务归业务”的原则。
7. 最后一点掏心窝子的经验
写这篇博文时,我翻出了过去三年的年会照片。有一张特别有意思:2022年,我在后台用笔记本调试,屏幕上是密密麻麻的console.log;2023年,我站在大屏旁边,手里拿着一支激光笔,指着滚动的号码;今年,我坐在观众席,看着新来的前端同事在台上操作,他点“撤回”时手指有点抖,但流程丝滑得让我想鼓掌。
这个工具之所以能活下来,不是因为它用了什么高深框架,而是因为它始终在回答一个问题:年会现场,人最需要什么?
不是炫酷的3D特效,而是“点一下就响”的确定性;
不是复杂的权限体系,而是“撤回”二字带来的安全感;
不是毫秒级的算法优化,而是主持人抬头看大屏时,那一秒的笃定。
所以,如果你正打算用它,我的建议只有一条:别把它当一个“抽奖程序”,而把它当成一个“现场协作者”。
- 它的按钮要够大,因为主持人可能戴着白手套;
- 它的提示要够直白,因为行政同事可能第一次用;
- 它的撤回要够及时,因为年会没有“重来一次”的机会。
代码可以重写,但现场的每一秒,都是真实的。
而真实,永远比完美更重要。
简介:打开网页就能直接用的年会抽奖工具,不依赖服务器,所有逻辑跑在浏览器里。按员工编号(如001、002…)抽奖,适配几十人到十万级名单;可自由设置多个奖项及对应名额,比如特等奖1名、幸运奖50名,某奖不发就填0;支持单次抽1人、批量抽5人、抽满当前奖项或自定义数量,数量受剩余名额限制。默认中奖号码自动剔除,开启‘全员参与’后可重复中奖。抽完结果页能手动删掉误中号码,删后立刻回到待抽池。提供两种重置:清空全部数据重来,或只清空抽奖进度、保留人员名单和奖项配置。初始化速度随人数增加略有下降,超10万人建议跳过头像加载,专注用编号提升响应速度。


被折叠的 条评论
为什么被折叠?



