资讯动态

年会滚动照片抽奖小程序:Canvas大屏与服务端抽签设计

发布时间:2026/10/27 8:49:03 来源:尧图企业网站定制
简介面向企业年会、团建活动现场互动场景的滚动照片抽奖小程序适合活动组织者、策划人员及希望了解抽奖交互实现的前端初学者使用。程序无需数据库参与者信息以静态数据保存包含照片滚动展示与抽奖两个主要页面通过照片动态轮播提升现场参与感抽奖规则支持首轮免红包、后续按100元至1000元多档红包抽取并设置每人累计不超过2000元的预算上限。资源包为zip格式共74个文件约2.3MB主要包含网页页面、CSS样式、JavaScript脚本、PNG/JPG/GIF图片素材以及少量Java类、class与jar依赖整体呈现为可直接部署的传统Web项目结构图片与数据配置分离便于替换参与者照片和调整名单。已有2282人学习下载可作为年会抽奖工具或小型交互项目参考帮助读者快速理解滚动展示、随机抽取与红包额度控制之间的实现关系。1. 现场那台投影仪最怕的是抽奖卡住三秒年会最尴尬的三秒钟是主持人喊完“停”大屏上的照片还在往前冲或者干脆白屏几百号人盯着投影仪等你按刷新。这套年会滚动照片抽奖小程序就是用来避免这类问题的员工扫码进小程序传一张照片、填姓名和部门大屏照片墙立刻多一张抽奖时照片高速掠过、随机定格、中奖照片飞到中央放大多轮奖项自动去重散场前导出中奖名单。它并不面向专业活动公司的大型抽奖系统更适合临时接手写程序的前端、行政和学生会同学一个人、一个周末、一台笔记本加一台投影仪就能跑完一场年会。技术栈也不花哨小程序端负责采集浏览器大屏端用 Canvas 画照片墙服务端一个 Node 进程管理照片池和抽签中间用 WebSocket 传指令。2. 三端分工与工程骨架为什么抽签必须放在服务端2.1 员工端、大屏端、服务端各管什么先把职责切干净后面写代码才不会互相影响。员工端只做三件事进场景、拿照片、交信息。进场景靠扫码带参数把场次 ID 塞进页面路径拿照片走chooseMedia或相机拿到临时文件后立刻在本地压到 512×512 再上传别把 4MB 的原图往服务端扔大屏那边会非常卡交信息就是姓名、部门、工号加一个前端生成的clientId做兜底去重。大屏端是一个浏览器全屏页面跑在接投影仪的那台电脑上。它只做两件事把照片画出来、把动画演完。它不做决定也不该做决定。大屏端可以偷懒刷新、可以被主持人误关、可以在彩排和正式之间切来切去如果抽签逻辑写在前端一次刷新就可能出现重号或者漏号。服务端才是唯一的判断方。照片池、已中奖集合、轮次序号、抽签结果全部落在服务端。大屏点“开始”只是发一条指令服务端返回结果大屏再根据结果播动画。这样即便大屏崩了重启后拉一次全量状态画面和结果还是对的。提示抽签放在服务端不是为了防作弊是为了防“现场操作事故”。年会现场没有人会去黑你的程序但一定会有人手抖点两下。2.2 目录结构对照着建工程工程拆成四块server是核心screen是浏览器大屏miniapp是小程序端tools放彩排用的脚本。三端之间不共享代码只共享协议这样任何一端重写都不影响另外两端。lottery/ ├── server/ │ ├── src/ │ │ ├── index.js # HTTP WebSocket 入口 │ │ ├── lottery.js # 抽签核心纯函数能单测 │ │ ├── pool.js # 照片池、去重索引、中奖标记 │ │ ├── store.js # SQLite 读写封装 │ │ └── routes/ │ │ ├── upload.js # 照片上传与压缩落盘 │ │ └── admin.js # 导入名单、导出中奖结果 │ ├── data/ │ │ ├── photos/ # 缩略图512×512 │ │ └── lottery.db # SQLite 单文件 │ ├── config.js │ └── package.json ├── screen/ │ ├── index.html # 大屏全屏页 │ ├── stage.js # Canvas 渲染与动画 │ ├── ws.js # 指令通道 断线重连 │ └── theme.css # 配色和字号换皮只改这里 ├── miniapp/ │ ├── pages/join/ # 扫码进入、填信息 │ ├── pages/upload/ # 选图、裁剪、上传 │ └── app.json └── tools/ ├── mock.js # 造 N 条假数据压测 └── export.js # 手动导出兜底这个结构有一处是刻意安排的lottery.js里不 import 任何数据库和网络模块。它只接收一个数组返回一批中奖者。好处是抽签逻辑可以脱离现场环境单独跑单测年会前一天晚上改规则时你至少能确定算法本身没有引入随机偏差。2.3 指令协议与状态机大屏只负责演不负责判三端之间就靠几条消息通信。消息体尽量扁别嵌套三层现场排查的时候你是要肉眼读日志的。// screen/ws.js —— 指令协议定义 const MSG { POOL_SYNC: pool_sync, // 服务端 - 大屏全量或增量照片池 CMD: cmd, // 大屏 - 服务端start / stop / reset DRAW: draw, // 服务端 - 大屏本轮中奖者含 roundId STATE: state, // 服务端 - 大屏断线重连后的完整状态 }; // 大屏发出的指令统一长这样 function sendCmd(type, payload {}) { socket.send(JSON.stringify({ type: MSG.CMD, cmd: type, // start | stop | reset roundId: payload.roundId, // 轮次号服务端用它做幂等 ts: Date.now(), })); }roundId是关键参数由大屏在点开始的那一刻生成并缓存不是每次发消息重新生成。服务端拿它做幂等键同一个roundId的stop请求重复到达只返回第一次的结果。少了这个字段主持人连点两下就会抽出两批人。大屏本身跑一个四状态机IDLE照片墙缓慢漂移→ROLLING高速滚动→REVEAL中奖照片放大定格→ 回IDLE。状态切换只由服务端DRAW消息和本地动画完成事件驱动禁止用定时器硬切。我见过一个版本用setTimeout(3000)直接从 ROLLING 跳到 REVEAL结果服务端慢了 200ms 返回动画先定格在了别人照片上全场哗然。3. 核心模块拆解照片池、滚动动画与定格抽签的完整链路3.1 照片池入库、按内容去重与中奖标记照片池是整个程序里唯一需要长期维护的内存结构。它要扛住几百到几千条数据要能按 ID 查、按内容查重、还要记录谁中过奖。// server/src/pool.js const crypto require(crypto); class PhotoPool { constructor() { this.byId new Map(); // participantId - { id, name, dept, url, winner } this.byHash new Map(); // 图片内容 hash - participantId防止同一张图反复上传 this.winnerIds new Set(); // 已中奖跨轮次去重 } add({ name, dept, url, buffer }) { // 用图片内容的 md5 而不是姓名字符串做去重 // 因为现场同名的概率远高于同一张照片被传两次的概率 const hash crypto.createHash(md5).update(buffer).digest(hex); if (this.byHash.has(hash)) { return { ok: false, reason: duplicate, id: this.byHash.get(hash) }; } const id p_${this.byId.size 1}_${Date.now().toString(36)}; const record { id, name: name.trim(), dept: (dept || ).trim(), url, winner: false }; this.byId.set(id, record); this.byHash.set(hash, id); return { ok: true, id }; } markWinner(id) { const rec this.byId.get(id); if (!rec) return false; rec.winner true; this.winnerIds.add(id); return true; } // 抽签只吃这个快照避免抽签过程中池子被并发修改 snapshot() { return [...this.byId.values()].map(r ({ ...r })); } }byHash用内容 md5 做键是我踩过坑之后改的。最早用姓名做去重结果现场有两个同名同姓的同事第二个人的照片死活传不上去还以为是网络问题。改成内容哈希之后同一张照片重复上传会被挡掉不同人传同一张合影则能正常进入。姓名在展示层用ID 在数据层用这两件事必须分开。snapshot()那几行也别省。抽签过程中如果有人还在上传直接遍历byId会出现迭代期间修改集合的情况Node 里表现为抽出来的名单里混进半条脏数据。3.2 滚动动画Canvas 照片墙与逐帧渲染照片墙最忌讳直接往 DOM 里塞几百个img。每个img都有独立的布局和合成开销滚动起来掉帧是必然的。用一张 Canvas把缩略图当贴图来画帧率能稳很多。// screen/stage.js const CELL 160, GAP 12, COLS 8; const images new Map(); // url - HTMLImageElement let offsetY 0; let speed 40; // px/sIDLE 时慢速漂移ROLLING 时拉到 1800 function preload(pool) { // 先全部下载并 decodedecode 完成前不进入绘制循环 // 否则第一帧会大量同步解码画面直接卡住 return Promise.all(pool.map(p new Promise(res { const img new Image(); img.crossOrigin anonymous; img.onload () { images.set(p.url, img); res(); }; img.onerror () res(); // 单张失败不能拖垮整体 img.src p.url; }))); } function tick(ts) { const dt (ts - last) / 1000; // 秒用真实时间差而不是固定步长 last ts; offsetY (offsetY speed * dt) % (CELL GAP); ctx.clearRect(0, 0, W, H); for (let row -1; row Math.ceil(H / (CELL GAP)) 1; row) { for (let col 0; col COLS; col) { const idx (row * COLS col scrollBase) % list.length; const img images.get(list[idx].url); if (!img) continue; ctx.drawImage(img, col * (CELL GAP), row * (CELL GAP) offsetY, CELL, CELL); } } requestAnimationFrame(tick); }dt除以 1000 转成秒是为了让滚动速度跟屏幕刷新率解耦。144Hz 的显示器上如果按固定像素步进速度会比 60Hz 快一倍多主持人的节奏就乱了。scrollBase是滚动到一定偏移后整体跳一格配合取模实现无限循环比真的移动数组便宜得多。img.onerror里静默res()那一行不能删。现场总有一两张照片因为格式奇怪加载失败如果 reject 掉整个Promise.all大屏就永远停在加载页面。宁可在墙上留个空格也不能全屏不显示。3.3 定格抽签一次均匀随机的抽样怎么写抽签代码短但翻车点最多。最常见的错法是arr.sort(() Math.random() - 0.5)这个写法在很多引擎里分布并不均匀只是肉眼看不出来。正确做法是从候选池里做拒绝采样。// server/src/lottery.js const { randomInt } require(crypto); function drawWinners(candidates, count, { excludeWinners true } {}) { const pool excludeWinners ? candidates.filter(p !p.winner) : candidates; if (pool.length count) { return { ok: false, reason: NOT_ENOUGH, remain: pool.length }; } const picked []; const used new Set(); while (picked.length count) { const i randomInt(0, pool.length); // 半开区间均匀分布无取模偏差 if (used.has(i)) continue; // 重复索引直接跳过重新抽 used.add(i); picked.push(pool[i]); } return { ok: true, winners: picked }; }用crypto.randomInt而不是Math.random()主要看中它文档里写明的均匀分布且区间是半开的[0, max)不会越界。Math.random() * len取整在理论上存在极微小的偏差抽一个人看不出来抽一百轮就有人要发帖了。excludeWinners默认true意思是“每一轮都从没中奖的人里抽”。如果年会有“阳光普照奖”允许重复中奖把它置false就行不用改算法。NOT_ENOUGH这个返回码要给到大屏端让它显示“剩余人数不足”而不是抛异常屏幕上弹出个红字报错截图会流传一整年。3.4 幂等与重抽连点两下不能出两批人服务端接收stop指令的地方必须有一张幂等表。它不大按轮次存一下结果就行但能拦住现场最典型的事故。// server/src/index.js const roundCache new Map(); // roundId - winners function handleStop({ roundId, count }) { if (roundCache.has(roundId)) { // 同一轮重复请求原样返回第一次的结果不再消耗池子 return { ok: true, winners: roundCache.get(roundId), replay: true }; } const result drawWinners(pool.snapshot(), count, { excludeWinners: true }); if (!result.ok) return result; result.winners.forEach(w pool.markWinner(w.id)); roundCache.set(roundId, result.winners); return result; } // 重抽把上一轮的人放回池子再开一个新轮次 function handleRedraw({ prevRoundId, count }) { const prev roundCache.get(prevRoundId) || []; prev.forEach(w { pool.byId.get(w.id).winner false; pool.winnerIds.delete(w.id); }); roundCache.delete(prevRoundId); return handleStop({ roundId: r_${Date.now()}, count }); }roundCache只增不减会吃内存但一场年会最多几十轮每轮几条记录完全不用管。真到几千轮那种量级说明玩法设计有问题不是内存的问题。重抽的实现要注意必须真的把上一轮的人从中奖集合里摘掉而不是只清空大屏画面。有的实现只做了后者结果重抽抽出来的还是同一批人因为池子里他们的winner标记还在。这几行winner false和winnerIds.delete看着啰嗦是血泪经验。4. 本地跑通到现场彩排一套可复现的启动流程4.1 环境与依赖清单把依赖压到最少是为了现场断网时你还能npm i失败后从容地从 U 盘里拷一份node_modules。下面这张表是我一般会写在 README 第一屏的东西。依赖版本建议用途是否必需Node.js18 LTS 及以上服务端运行时自带crypto.randomInt必需ws8.xWebSocket 服务比 socket.io 轻必需better-sqlite39.x单文件数据库免安装必需sharp0.33.x服务端二次压缩兜底小程序压缩失败建议express4.x上传和导出接口必需qrcode1.x生成现场扫码图可选选 SQLite 不选 MySQL是因为年会场景只有一台机器、一场活动、几百条数据。装数据库这件事在现场就是纯粹的风险单文件库拷走就能备份散场后把data/目录打包发给行政就完事了。4.2 服务端启动与配置项配置全部收在一个文件里用环境变量覆盖避免现场改代码。// server/config.js module.exports { port: 3000, // HTTP上传、导出、静态照片 wsPort: 3001, // WebSocket大屏指令通道 photoDir: ./data/photos, // 缩略图落盘位置 maxUploadMB: 5, // 单张上限超过直接拒别让大屏背锅 thumbSize: 512, // 统一裁成正方形Canvas 里才好排格子 draw: { excludeWinners: true, // 默认跨轮次去重 defaultCount: 1, // 每次抽几人 }, adminToken: process.env.ADMIN_TOKEN || change-me-please, };thumbSize定成 512 是有讲究的。大屏格子显示 160px投影仪一般是 1080P512 的图缩下去绰绰有余同时单张体积能压到 60KB 上下几百张加起来也就几十兆全量预加载不会把内存撑爆。定成 1024 会白白多一倍带宽定成 256 在 4K 大屏上会糊。启动流程三步别省中间那步# 1. 装依赖 cd server npm i # 2. 初始化数据库和照片目录幂等可以反复跑 node src/store.js --init # 3. 起服务HTTP 3000 / WS 3001 ADMIN_TOKENabc123 npm run dev--init那步做的是建表、建目录、写默认场次。写成幂等的很重要彩排时我经常要清库重来rm -rf data node src/store.js --init两条命令就回到干净状态比手动进数据库删表快得多。4.3 用假数据把大屏先跑起来小程序端和大屏端联调是最耗时的环节因为要真机扫码。彩排阶段完全没必要等先造数据把大屏跑通。# 造 300 条假数据头像用本地生成的色块图不依赖外网 node tools/mock.js --count 300 --avatar-dir ./data/mock --scene年会2024// tools/mock.js 核心片段 const { randomInt } require(crypto); const NAMES [张一, 李二, 王三, 赵四]; // 占位实际是随机拼字 for (let i 0; i count; i) { const name NAMES[randomInt(0, NAMES.length)] i; const dept [研发, 市场, 运营, 行政][randomInt(0, 4)]; await savePhoto(makeColorBlock(i), ${avatarDir}/${i}.jpg); // 本地生成不走网络 pool.add({ name, dept, url: /photos/${i}.jpg, buffer: ... }); }--avatar-dir指定本地目录而不是随机图片接口是因为现场很可能没网压测脚本自己依赖网络就本末倒置了。300 条是个合适的数字足够看出滚动是否流畅、去重是否生效又不至于让你等太久。大屏页打开screen/index.html地址栏带上?wsws://127.0.0.1:3001。这里有个小坑如果大屏页是用file://直接双击打开的crossOrigin会失效Canvas 里画出来的图取不到像素。稳妥做法是让服务端把screen/当静态目录托管统一走http://127.0.0.1:3000/screen/。4.4 彩排把投影仪和网络提前踩一遍正式开场前至少完整走两遍流程而且要真的接上投影仪。笔记本上看流畅投到 1080P 大屏上未必。彩排清单我会过这几条先确认大屏电脑的 IP 是固定的别用localhost。主持人的机器和跑服务的机器如果分开大屏页里所有localhost都得换成局域网 IP不然照片一张都加载不出来。再把电源管理里的休眠和屏保全关掉Windows 默认 10 分钟黑屏正好卡在领导讲话结束那一刻。然后做一次断网演练。拔掉网线看大屏是不是还能继续滚动已加载的照片、是不是会弹一个“连接已断开正在重连”的角标。有角标比没角标强至少后台的人知道现在点按钮没用。重连逻辑写在ws.js里指数退避间隔别小于 1 秒否则服务端刚重启就被几十个重连打满。最后跑一次满员压测让 50 个人同时扫码上传。这一步主要看上传接口的并发和磁盘写入Node 单进程处理几百个小文件上传没什么压力但如果sharp压缩是同步调用的就会把事件循环堵住表现为所有人一起转圈。5. 避坑与排查年会现场最容易翻车的五件事5.1 照片加载不出来大屏一片白现象本地开发一切正常接到大屏电脑上打开格子全是空白控制台一堆Mixed Content或者CORS报错。原因大屏页如果部署在https的静态托管上而照片地址是http://内网IP:3000/photos/xxx.jpg浏览器会因为混合内容直接把图片拦掉。另一种情况是照片放在另一个域名下没给 Canvas 用的图片加crossOrigin或者服务端没返回Access-Control-Allow-Origin。解决现场就一台机器全部走同一个源最省事——服务端把screen/和/photos/一起托管在同端口下大屏页用相对路径/photos/xxx.jpg。确实要跨源就在返回头里显式加上Access-Control-Allow-Origin: *并且前端img.crossOrigin anonymous。这条在彩排前一定要在真机上验一遍不要只看开发机。5.2 同一个人中两次现象第一轮抽中了某位同事第三轮又抽到了同一个人而且大屏上看起来像是正常的随机结果。原因九成是去重标记没落到服务端。比如markWinner写在了抽签之后但抽签用的是pool.snapshot()的副本标记打在副本上原池子没变或者重抽逻辑只清了画面没清标记还有一种更隐蔽的服务端重启过内存里的winnerIds丢了数据库里没持久化。解决中奖标记必须同时写内存和数据库服务启动时从库里把winnerIds回填。抽签函数返回后立刻在原始池上打标不要在副本上打。查的时候直接在管理接口里GET /admin/winners看总数和抽出去的轮次对不上就说明标记丢了。5.3 滚动起来掉帧画面一顿一顿现象一两百人时还好到四百人以后滚动明显卡顿动画每帧都像在跳。原因多半是把原图直接塞进了绘制循环。手机拍的照片动辄 35MB、4000×3000Canvas 每帧drawImage都要解码和缩放几百张轮着来GPU 也扛不住。另一个常见原因是每帧都在调ctx.clearRect加重新创建Image对象。解决上传时就把图压到 512×512 并统一成 JPEG这一条能解决 80% 的掉帧。剩下的靠预加载和 decode所有图img.decode()完成后再启动绘制循环。绘制时只画可视区域内的格子屏幕外的直接跳过。如果还卡把照片墙从“实时滚动”改成“分段位移动画”用 CSS transform 交给合成层Canvas 只在定格时接管。5.4 上传静默失败人以为报上名了现象现场网络抖动同事点了上传进度条转了两秒就没了界面回到首页但服务端根本没有这条记录。原因上传超时或者 4xx/5xx 被前端catch里一个空的错误处理吞掉了用户看到的是“什么都没发生”。年会现场往往只有一两个 Wi-Fi 热点几十号人同时上传超时非常正常。解决上传失败必须有显式反馈和重试入口本地先把压缩后的图存到临时目录重试时直接读本地不用重新拍。服务端接口返回结构统一成{ ok, reason, id }前端根据reason给出人话提示。再准备一条保底路径一张二维码扫码进补录页只填姓名部门不传照片用默认头像顶上。5.5 名单和照片对不上现象抽出来的人名字是“李四”照片却是别人。或者导出的 Excel 里同一个人出现两行。原因展示层拿姓名当主键去了。重名、前后空格、全角半角、导入时 Excel 里那个不可见的换行符都会让匹配错位。名单从 Excel 导入的时候尤其容易出问题。解决所有关联一律用participantId姓名只做展示。导入接口里统一trim()并去掉零宽字符重名的直接标红让人工确认不静默合并。导出的时候带上id这一列事后核对有据可查。6. 二次开发换皮肤、加权重玩法与结果核验的三个改造点这套东西真正的价值在于改起来快抽奖本身反而不是重点。年会筹备期通常只有一两周需求还会在最后一刻变化所以我把可改的地方都收在了三个位置。改造点改动文件典型需求风险视觉换皮screen/theme.cssstage.js里的CELL/GAP/COLS换成公司主色、改成六列、加背景板改 COLS 后要同步调scrollBase的取模基数抽签规则server/src/lottery.js部门加权、指定部门、一次抽 N 人改完必须跑单测别在现场试结果核验server/src/routes/admin.js导出 CSV、查某一轮明细导出别直接用姓名做主键视觉这块最简单的做法是把配色和尺寸抽成 CSS 变量stage.js启动时从getComputedStyle里读格子尺寸而不是写死常量。这样换一次主题只改一个文件不用碰绘制逻辑。规则改造最常见的是部门加权——领导希望技术部多中点或者先抽一个部门再从这个部门里抽人。加权抽样的写法不用把算法整个重写只要在构造候选池时把权重展开成重复项就行简单粗暴但足够用// server/src/lottery.js —— 加权候选池 function weightedPool(candidates, weightOf) { const out []; for (const p of candidates) { // 权重取整数倍2 表示这个人被抽中的概率翻倍 const w Math.max(1, Math.round(weightOf(p))); for (let i 0; i w; i) out.push(p); } return out; } // 用法技术部权重 2其余 1仍然走原来的拒绝采样 const pool weightedPool( candidates.filter(p !p.winner), p (p.dept 研发 ? 2 : 1) ); const result drawWinners(pool, 3, { excludeWinners: false });注意第二段调用里excludeWinners要传false因为过滤已经在weightedPool之前做完了再过滤一次会让展开后的重复项被误判。这个参数我改过一次就再也没忘。权重值别设太大Math.round之后 5 倍的展开意味着候选数组膨胀 5 倍几百人的池子还好几千人就要留意内存和抽样循环的次数。验证方法很简单写个循环跑一万次统计每个人出现的频率看是否接近预期的权重比例。这一步跑通再上线比在现场盯着大屏祈祷靠谱得多。结果核验那块我一般在admin.js里留一个/admin/rounds接口把每一轮的roundId、时间戳、中奖 ID 列表按顺序吐出来散场后有人质疑直接把这个 JSON 发过去比解释“程序是随机的”有用一百倍。从那以后我每次做这类现场程序都会在彩排前强制走一遍“拔网线 满员并发上传 连点两次停止”这三件事。这三个动作覆盖了我遇到过的绝大多数翻车场景比读十遍代码管用。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑