简介:这套年会滚动照片抽奖小程序是一份可直接运行的Web项目源码,面向企业年会策划者、活动开发者和Java Web初学者,用于快速搭建带照片滚动效果的现场抽奖工具。抽奖规则设计灵活,第一位中奖者免发红包,后续红包分100元至1000元五档,并设置每位参与者红包总额不超过2000元的预算控制,兼顾氛围与公平。项目采用JSP、Servlet等技术,结合JavaScript实现照片动态滚动与随机抽奖逻辑,重点展示了数组循环、随机数生成、AJAX页面通信等典型写法的综合运用。
压缩包共74个文件、约2.3MB,涵盖JSP页面、Java类、jar依赖库、CSS样式、JavaScript脚本及大量png/jpg/gif图片素材,目录结构包含src、WebRoot、images、css等模块,便于按前后端分层查看。参与者数据以静态文件方式存储,无需数据库即可运行,修改数据文件即可更新照片和名单。已有2279人浏览学习,适合拿来直接导入开发工具后部署演练,也可借鉴其抽奖规则与交互设计完成二次开发。
1. 年会滚动照片抽奖小程序:一个让全场安静下来等定格的项目
去年帮某公司行政同学做年会抽奖,翻遍现成模板,要么是纯数字滚动的老虎机,要么是点名器式的随机抽取,照片一放上去就少了“滚动照片”的仪式感。这个项目就是从那次需求里长出来的:大屏上员工照片像照片墙一样横向滚动,主持人喊停后减速定格到中奖者,支持多轮抽奖和中奖名单去重。适合年会、团建、迎新会这类需要气氛和公平性兼顾的场合;对开发者来说,它还是一个把定时器、本地存储、页面生命周期串起来的小程序实战样本。整个包不依赖后端服务,导入微信开发者工具就能跑,改名单、改速度、加奖品档位都很直接。
2. 技术选型与工程结构:原生小程序加定时器加本地存储的轻量组合
2.1 为什么选原生微信小程序而不是 H5 页面
这个项目首先要解决的是“现场可用性”。年会现场没有时间装 App,主持人拿手机控制、大屏同步展示,微信里扫码即开是最顺的路径。小程序不需要提前部署服务器,也不用在会场电脑上折腾本地服务,打开开发者工具导入源码就能跑,真机上扫码预览也只是多一步传体验版。
技术栈上我选了原生小程序,而不是 uni-app 或 Taro。原因很实际:单页面抽奖场景用框架反而多一层构建依赖,还要维护 node_modules,对临时接手改名单的同事不友好。原生小程序的照片组件自带缓存和懒加载,所有头像可以全部打进包内,现场不依赖外部网络也能稳定展示。抽奖核心用 setTimeout 递归而不是 setInterval,因为每一帧的间隔需要动态变化,setInterval 做不到逐帧调整速度。
这里有一个我踩过的坑:如果数据源用网络图片链接,真机上会受 downloadFile 合法域名限制,平时开发工具里能显示,到了会场现场经常整屏黑块。所以这个工程里所有照片默认放本地资产目录,没有照片的人统一使用占位头像。
2.2 目录结构速览
拿到源码后,第一件事是看目录。这个项目的文件安排很紧凑,核心页面只有 lottery 一个。
annual-draw-lottery/ ├── app.js # 小程序入口,globalData 存全局配置 ├── app.json # 页面注册与窗口配置,自定义导航 ├── app.wxss # 全局样式 ├── pages/ │ └── lottery/ │ ├── lottery.wxml # 抽奖页结构:滚动墙 + 主照片 + 按钮 │ ├── lottery.wxss # 页面样式,滚动动画在这里定义 │ ├── lottery.js # 核心逻辑:滚动、抽奖、存储 │ └── lottery.json # 页面配置 ├── data/ │ └── participants.js # 参与人员数据,数组格式 ├── assets/ │ └── avatars/ # 本地头像目录,含 default.png └── utils/ └── lottery-core.js # 纯抽奖函数,独立于页面,便于测试每个文件职责都很单一。data/participants.js 是唯一需要手动维护的名单文件,结构是对象数组,每个对象包含 id、name、dept、photo 四个字段。utils/lottery-core.js 放的是不依赖页面实例的抽奖函数,这也是后期做概率验证的基础。assets/avatars 目录放员工照片,文件名和 participants.js 里的 photo 字段对应,没有照片的人统一走 default.png。
2.3 页面数据流与关键状态
整个抽奖过程就是围绕几个状态字段转的,先看它们各自的作用。
| 字段 | 类型 | 说明 |
|---|---|---|
| pool | Array | 本轮可抽人员列表,已经中奖的人不会再出现在 pool 中 |
| curIdx | Number | 当前大屏展示的照片索引 |
| curItem | Object | 当前展示的人员对象,包含姓名部门头像 |
| mode | String | idle / fast / stopping,决定按钮文案和帧间隔策略 |
| winners | Array | 已中奖名单,持久化到 storage |
| _delay | Number | 当前帧间隔毫秒数,仅页面实例内使用 |
| _fastFrames | Number | 已快速滚动的帧数,达到阈值后进入减速阶段 |
数据流是这样走的:onLoad 阶段读取 data/participants.js 初始化 pool,同时从 wx.getStorageSync 恢复 winners 名单。点击开始后 mode 从 idle 变 fast,帧函数按固定 60ms 间隔切换照片索引,同时累计 _fastFrames。达到预设帧数后自动切到 stopping 模式,每帧间隔逐步增大,照片滚动速度肉眼可见地慢下来。当间隔超过最大阈值,就调用 finish 完成定格,把中奖者写入 storage。
这个状态机的设计初衷是防止人为干预结果。主持人只负责按下开始,后续的快速滚动、减速、定格全部由代码自动完成,不需要在滚动过程中再点一次停止,也就避免了“什么时机按下停止”带来的争议。手动停止接口我留了,但默认流程走全自动。
3. 核心模块拆解:从滚动照片墙到中奖定格的完整链路
3.1 滚动照片墙:CSS 动画和抽奖照片切换的分工
大屏背景是一条横向滚动照片墙,这是仪式感的主要来源。它和抽奖主体的照片切换是两条独立链路:背景墙用纯 CSS 动画,循环播放,只负责氛围;主体照片用 JS 定时器切换,负责定格结果。两者互不干扰。
/* pages/lottery/lottery.wxss */ .scroll-wall { position: absolute; top: 0; left: 0; display: flex; width: max-content; animation: wallScroll 30s linear infinite; } .scroll-wall image { width: 320rpx; height: 200rpx; margin-right: 24rpx; border-radius: 12rpx; opacity: 0.5; } @keyframes wallScroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }背景墙实现的关键是“内容复制两遍”。wxml 里把前 8 张照片重复渲染两次,动画结束时 translateX 刚好移动了总宽度的一半,视觉上首尾无缝衔接,观众看不出循环点。opacity 设成 0.5 是为了弱化背景干扰,让主体照片更突出。
这里有个常见误用:有人会把滚动背景也交给 JS 定时器驱动,连续跑几个小时后 setData 频繁刷新,小程序会有明显卡顿。CSS 动画跑在合成线程上,不占用逻辑层,这是大屏场景下更稳的做法。
3.2 抽奖主流程:setTimeout 递归驱动的变速滚动
抽奖页的核心逻辑在 lottery.js 里,帧函数用 setTimeout 递归实现,每帧执行完再根据当前模式计算下一帧间隔。
// pages/lottery/lottery.js const BASE_DELAY = 60; // 起始帧间隔 60ms const STEP_DELAY = 24; // 减速阶段每帧增加 24ms const MAX_DELAY = 480; // 最大帧间隔,超过则定格 const FAST_FRAMES = 40; // 快速阶段至少滚 40 帧 Page({ data: { pool: [], curIdx: 0, curItem: {}, mode: 'idle', btnText: '开始抽奖', winners: [] }, onLoad() { const participants = require('../../data/participants'); const winners = wx.getStorageSync('winners_v1') || []; const pool = participants.filter(p => !winners.some(w => w.id === p.id)); this.setData({ pool, winners, curItem: pool[0] || {} }); this._delay = BASE_DELAY; this._fastFrames = 0; }, onTapStart() { // 自动流程:只负责启动,不负责停止 if (this.data.mode !== 'idle') return; if (!this.data.pool.length) { wx.showToast({ title: '没有可抽人员了', icon: 'none' }); return; } this._delay = BASE_DELAY; this._fastFrames = 0; this.setData({ mode: 'fast', btnText: '滚动中' }); this._timer = setTimeout(() => this.nextFrame(), BASE_DELAY); }, nextFrame() { const { pool, curIdx, mode } = this.data; if (!pool || !pool.length) { this.setData({ mode: 'idle', btnText: '重新开始' }); return; } // 随机跳 1 到 5 格,让过程更像滚屏而不是逐帧翻页 const step = Math.floor(Math.random() * 5) + 1; const nextIdx = (curIdx + step) % pool.length; this.setData({ curIdx: nextIdx, curItem: pool[nextIdx] }); let delay; if (mode === 'fast') { this._fastFrames += 1; delay = this._delay; if (this._fastFrames >= FAST_FRAMES) { this.setData({ mode: 'stopping' }); } } else if (mode === 'stopping') { this._delay += STEP_DELAY; delay = this._delay; } if (delay >= MAX_DELAY) { this.finish(); return; } this._timer = setTimeout(() => this.nextFrame(), delay); }, onUnload() { if (this._timer) clearTimeout(this._timer); } });几个参数直接决定手感。BASE_DELAY 越大,滚动起步越慢,适合照片数量少的场次;STEP_DELAY 控制减速的陡峭程度,数值越大,从滚动到定格越快,全场屏息的时间越短;FAST_FRAMES 是快速阶段的帧数底线,保证观众至少能感受到一段“疯狂滚动”的过程。支持手动停止的版本可以在 stopping 分支前加一个按钮事件,把 mode 直接置为 stopping,效果是马上开始减速,最终仍由代码定格。
随机跳步的 step 范围也是可以调的。如果希望抽奖感更强,step 可以放到 3 到 10;如果希望观众能看清每一张脸,step 保持 1 到 2 更合适,但那样滚动速度感会弱一些。我最终选了 1 到 5,平衡了识别度和速度感。
3.3 公平性设计:先随机定人,再表演滚动画
真正决定中奖者的逻辑不在帧函数里,而在 finish 阶段。这样做最大的好处是概率可控、可验证。观众看到的是照片滚了很多圈后慢慢停住,实际中奖人在定格前已经用 Math.random 选好了,后面的滚动只是视觉表演。
// finish 阶段 finish() { const { pool } = this.data; const winner = this.pickOneFromPool(); const winnerIdx = pool.findIndex(item => item.id === winner.id); this.setData({ curIdx: winnerIdx, curItem: winner, mode: 'idle', btnText: '再抽一轮' }); this.saveWinner(winner); wx.showModal({ title: '中奖者', content: `${winner.dept} ${winner.name}`, showCancel: false }); }, pickOneFromPool() { const { pool } = this.data; const idx = Math.floor(Math.random() * pool.length); return pool[idx]; }, saveWinner(winner) { const key = 'winners_v1'; const list = wx.getStorageSync(key) || []; if (!list.some(item => item.id === winner.id)) { list.push({ ...winner, time: Date.now() }); wx.setStorageSync(key, list); } const remainPool = this.data.pool.filter(item => item.id !== winner.id); this.setData({ winners: list, pool: remainPool }); }这里要注意,定格前的最后一帧是中奖者的概率并不是 100%。因为帧函数是随机跳步的,减速到最后一帧时可能停在任意一张照片上,如果直接把 curIdx 换成 winnerIdx,观众会看到明显的“瞬移”。处理方式有两种:一种是把定格前一帧强制设为中奖者的前一张,让视觉上顺滑地滑到中奖者;另一种是接受这个小跳跃,因为减速阶段每帧间隔已经接近 400ms,人眼对最后一两帧的细节记忆并不强。我最终选择了后者,现场效果没有翻过车。
pickOneFromPool 从当前 pool 中随机取人,而 pool 在 onLoad 和 saveWinner 里都过滤了已中奖者,所以不会出现同一人中两次的情况。storage 里的 winners 名单同步维护,页面每次加载时也会用它初始化 pool,形成闭环。
3.4 中奖名单去重与重置
中奖名单存储在本地 storage 里,key 是 winners_v1。saveWinner 里用 some 判断是否已存在,避免重复写入。重置功能单独放在一个按钮上,绑定的是 clear 方法,需要二次确认,防止现场误触把整场记录清掉。
resetAll() { wx.showModal({ title: '确认重置', content: '将清空全部中奖记录,确定继续吗?', success: (res) => { if (res.confirm) { wx.removeStorageSync('winners_v1'); const participants = require('../../data/participants'); this.setData({ pool: participants, winners: [], curItem: participants[0] }); } } }); }重置时机很关键。年会现场常见的翻车场景是:彩排时抽了几轮,正式开场忘了重置,导致正式抽奖少了好几个人。我的建议是每次彩排结束强制执行一次重置,并且在大屏上显示当前剩余人数,让操作者一眼就能确认状态。
4. 运行配置与常见问题排查:五类高发坑位一次排清
4.1 三分钟跑起来:导入、数据源与编译模式
拿到工程后,最顺的路径是打开微信开发者工具,选择导入项目,目录指向 annual-draw-lottery 根目录,AppID 填写 touristappid 测试号,开发者工具会自动识别 project.config.json。之后直接编译就能看到抽奖页面。
{ "appid": "touristappid", "projectname": "annual-draw-lottery", "compileType": "miniprogram", "setting": { "urlCheck": false, "es6": true, "minified": true } }projcet.config.json 里的 urlCheck 设为 false,开发环境不会校验图片域名,照片加载更自由。但真机上这个开关无效,所以工程里所有照片都放在本地 assets 目录,不依赖外链图片。
数据源是 data/participants.js,直接用文本编辑器打开改数组即可。字段结构是对象数组,每项包含 id、name、dept、photo。photo 填相对路径,如 /assets/avatars/p001.png,也可以留空,代码会自动使用默认头像。
4.2 真机预览与大屏投屏:分辨率与安全区
开发者工具里模拟器表现正常只是第一步,年会现场用的是真机加大屏,投屏方式一般是手机扫码预览后将画面镜像到大屏。这里有两个需要提前处理的问题。
第一是分辨率适配。大屏可能是 16:9 的投影,也可能是 4:3 的电视,抽奖页的顶层容器要用固定比例布局而非依赖屏幕尺寸。我的做法是外层 view 设置 width: 100vw; height: 100vh,内部主卡片按 rpx 固定宽高,aspectFill 模式裁剪照片,这块在真机上不会出现照片拉伸变形。
第二是安全区。带刘海的手机在横屏投屏时,顶部和底部会被裁掉一部分,所以操作按钮要放在屏幕中部偏下,避免贴在底部。页面开启自定义导航后,状态栏区域的遮挡问题也要在开发工具的真机调试里提前确认。
预览时建议用真机调试而不是普通预览,真机调试能直接看到大屏投屏的实际效果,还可以远程查看 console 日志。年会现场条件复杂,提前做一次全流程彩排比临时调样式要省心得多。
4.3 五条高频踩坑记录
坑位一:照片区域显示黑块。现象是真机上部分员工头像加载不出来,模拟器却一切正常。原因是图片路径是网络地址,触发了小程序合法域名校验。解决方法是把照片全部改为本地相对路径,或者将域名加入 downloadFile 合法域名白名单;年会场景我强烈建议全部本地化,现场网络不稳定时本地图片不会掉链子。
坑位二:连续抽奖几轮后页面越来越慢。现象是前几轮正常,第五轮开始按钮点击后要等一秒才有反应。原因是帧函数里的 setTimeout 链没有在每次启动前清掉,上一次的定时器还在跑,新点击又叠加了一条。解决方法是 onTapStart 开头先 clearTimeout(this._timer),onUnload 也要清理一次,确保页面销毁后没有遗留定时器。
坑位三:模拟器上每次定格都像瞬移。现象是减速阶段最后看到的还是上一个人,定格瞬间突然跳成另一个人,现场观众会质疑“黑幕”。原因是 finish 阶段直接把 curIdx 强制改成中奖者索引,没有预留视觉过渡。解决方法是接受最后一个小跳跃,或者把中奖者前一张照片设置成定格前一帧,让观众看到“滑”过去的动作。
坑位四:名单里有重复 id。现象是明明 pool 显示还有 20 人,实际抽两次就提示“没有可抽人员了”。原因是 participants.js 里 id 没有唯一性检查,saveWinner 去重时把重复 id 的人一起过滤掉了。解决方法是生成名单时给每行生成唯一 id,或者写入前做一次去重校验,重复 id 直接报错。
坑位五:真机上减速过程明显卡顿。现象是快速滚动,一到减速阶段画面就一帧一帧跳得厉害。原因是真机性能低于模拟器,每帧 setData 一次照片索引,页面频繁重绘。解决方法是快速阶段保持 60ms 间隔,减速阶段间隔递增时不要叠加 setData 的负担,curItem 的更新和 curIdx 合并到一次 setData 里,减少渲染次数。
5. 二次开发:把固定名单升级成可导入、可配置、可测试的抽奖工具
5.1 名单导入:Excel 转 JS 数据模块
年会名单通常是一张 Excel,手写 participants.js 容易出错。常见做法是写一个 Node 脚本,用 xlsx 把表格转成 JS 模块,字段映射在脚本里一目了然。
// scripts/gen-participants.js const XLSX = require('xlsx'); const rows = XLSX.utils.sheet_to_json( XLSX.readFile('participants.xlsx').Sheets.Sheet1 ); const participants = rows.map((row, i) => ({ id: `P${String(i + 1).padStart(3, '0')}`, name: String(row['姓名'] || '').trim(), dept: String(row['部门'] || '未知部门').trim(), photo: row['照片路径'] ? String(row['照片路径']).trim() : '' })); require('fs').writeFileSync( 'data/participants.js', `module.exports = ${JSON.stringify(participants, null, 2)};` );注意没有照片路径时不要留空字符串以外的东西,前端会在渲染时自动 fallback 到默认头像。脚本跑完以后重新编译,页面上的剩余人数会自动变化。
5.2 多轮奖品规则:档位与每轮人数
默认逻辑每轮抽一个人,年会现场往往需要按奖品档位一次抽多人。我的做法是把轮次配置抽成一个数组,抽奖函数按档位循环消耗剩余名单。
const rounds = [ { name: '三等奖', count: 10 }, { name: '二等奖', count: 5 }, { name: '一等奖', count: 1 } ]; function runRound(pool, excludedIds, round) { const winners = pickWinners(pool, excludedIds, round.count); return { winners, excludedIds: excludedIds.concat(winners.map(w => w.id)) }; }每轮抽完后把中奖者 id 累加到 excludedIds,下一轮就不会再抽到这些人。如果某个档位剩余人数不足,pickWinners 会返回剩余全部,页面侧需要加一个 toast 提示“剩余人数不足”,避免大屏上出现空名单。
5.3 可测试性改造:抽奖核心抽成纯函数
整个项目里最值得抽出来的就是抽奖函数,把它放到 utils/lottery-core.js,并且保持纯函数,不依赖 wx 和 Page,这样就能用 Node 直接跑概率测试。
function pickWinners(pool, excludedIds, count) { const remain = pool.filter(item => !excludedIds.includes(item.id)); const winners = []; for (let i = 0; i < count && remain.length; i++) { const idx = Math.floor(Math.random() * remain.length); winners.push(remain.splice(idx, 1)[0]); } return winners; }有了纯函数,验证概率分布就很简单了:构造一个 100 人的名单,跑五万次抽奖,统计每次第一个中奖者的 id 分布次数,把最大值和最小值打出来。正常情况下五万次里每个 id 出现的次数应该在几百到一千之间,差值不会太离谱。如果某一个 id 长期缺席,那就要怀疑随机源的问题了。
那次年会我改了奖品档位却忘了重置 storage,第二轮直接提示没有可抽人员,现场白屏了三秒。从那以后我每次改抽奖逻辑,都强制走一遍 Node 概率验证,再清空 storage 重跑一遍全流程,确认剩余人数正确才上大屏。希望帮到你,少走这种现场翻车的路。
本文还有配套的精品资源,点击获取