简介:这是一款专为年会现场设计的滚动照片抽奖程序,面向活动策划、行政人员及具备基础前端能力的开发者,解决线下抽奖互动性不足、部署复杂的问题。程序采用静态数据存储,无需数据库,包含照片滚动展示与抽奖两个核心页面,通过随机滚动抽取参与者照片活跃现场气氛。抽奖规则内置首个中奖者免发红包、后续可抽100至1000元五档红包,并以每人2000元总额上限控制预算,兼顾趣味与公平。压缩包共74个文件、约2.3MB,以png/jpg图片素材、JavaScript脚本、CSS样式及JSP/HTML页面为主,另含少量Java类与依赖文件,目录结构便于快速替换素材和调整逻辑。参与者信息与照片以静态资源方式组织,替换后可在活动现场运行,也便于二次开发调整红包档位与中奖逻辑。已有2282人学习,适合需要低成本搭建年会抽奖页面、复用滚动展示与随机抽取逻辑的读者参考。
1. 年会前夜的三百张照片
年会前一天晚上十点,行政把三百张员工照片一次性发过来,要求第二天现场大屏上滚起来,主持人喊停就定格。这个滚动照片抽奖小程序就是为这种场面准备的:把照片铺成网格持续滚动,按下空格后逐格定格,中奖头像放大,结果自动留档。它不用后端、不用登录,拷进现场笔记本双击就能跑,断网也不影响。适合两类人:接手年会抽奖环节的活动负责人,以及想拿它当底子改成自家抽奖台的开发者。
2. 项目定位与技术选型:为什么坚持零后端单页
2.1 现场真正会被按到的只有四个键
先把功能范围收紧。年会抽奖这类场景,台上只有一个主持人加一块投影,交互路径越短越安全。项目实际跑起来只有四个动作:空格开始或停止滚动、方向键切换奖项轮次、回车确认本轮结果、退格回滚上一轮。剩下的全是展示层——网格里的照片滚动、定格后放大、中奖名单在大屏侧边滚动展示。
功能清单大致是这样几块:
- 照片池加载:默认读取同目录的
photos.json清单,也支持现场临时从本地拖一批图片进来兜底。 - 滚动展示:固定格数的网格,格子里的照片按固定节奏随机切换,制造"跑马灯"效果。
- 抽奖与去重:按轮次抽取,默认不放回,同一个人不会中两次。
- 定格与放大:中奖照片从网格位置平滑放大到屏幕中央,带名字。
- 结果留档:每轮结果写入浏览器本地存储,支持一键导出 CSV 交给行政对账。
注意这套功能里没有"用户注册""在线投票""远程控制"。每加一个远程能力,就多一个断网、多一个端口、多一次现场调试,血泪经验是年会现场最不该赌的就是网络。
2.2 技术选型的三个约束条件
这个项目的技术选型不是从"哪个框架新"出发,而是从现场约束倒推的,三条硬条件:
第一,现场机器不可控。可能是行政的旧笔记本,可能是酒店的会议一体机,Chrome 版本未知,装不了 Node,也未必有管理员权限。所以最终产物必须是一堆静态文件,index.html双击打开就能用,构建产物里不能有需要本地服务才能跑的模块化导入。
第二,时间窗口极短。年会前一天拿到照片,第二天开场前只有一两个小时布置。这意味着改配置必须落在一个人能看懂的地方,而不是散落在十几个组件里。
第三,视觉要求高于工程要求。观众看到的是照片糊不糊、滚动顺不顺、定格有没有节奏感。所以性能预算主要花在图片预解码和 DOM 复用上,而不是框架层面的抽象。
基于这三点,技术栈定为:原生 HTML + CSS + ES Module 编写的 JavaScript,不引入任何运行时框架;动画用setInterval做换图、用 CSStransition做定格放大;数据用fetch读本地 JSON 清单,失败时降级到<input type="file" multiple>选图;持久化用localStorage。整个项目零依赖,不需要npm install,也不需要打包。
这里有个常见的误判:不少人第一反应是上 Canvas 或者 WebGL 做照片墙,觉得 DOM 撑不住几百张图。实际测算下来,屏幕上同时存在的格子也就 12 到 24 个,DOM 节点数量是固定的,换的只是background-image,浏览器合成层压力很小。用 Canvas 反而要自己处理图片解码、缩放裁切、文字排版和分辨率适配,改动成本高一截。合格的开发在这个场景下一般会选 DOM,把复杂度留给图片本身。
2.3 照片池从哪来:清单优先,本地选图兜底
现场最常见的情况是照片文件名乱七八糟:IMG_2043.JPG、微信图片_2024.jpg、张三 - 副本.jpg。让程序去猜名字不现实,所以设计成双通道。
主通道是清单文件。把照片放进photos/目录,同时在根目录维护一份photos.json,每条记录声明文件名和展示名。程序只认清单,清单怎么来由你决定——手工写、用一段脚本扫目录生成都行。
// pool.js —— 照片池加载,清单优先,选图兜底 async function loadPhotoPool() { try { // cache: 'no-store' 很关键,现场改完清单刷新就能生效,不会被缓存住 const res = await fetch('./photos.json', { cache: 'no-store' }); if (!res.ok) throw new Error(`清单读取失败 ${res.status}`); const list = await res.json(); return list.map((it, i) => ({ id: it.id ?? `p${i}`, // 去重和记录都用 id,不用文件名 name: it.name ?? `同事${i + 1}`, // 大屏上显示的名字 src: `./photos/${encodeURIComponent(it.file)}` // 文件名可能有空格和中文,必须编码 })); } catch (err) { console.warn('[pool] 清单不可用,等待现场选图:', err.message); return []; // 返回空数组,UI 侧显示"请选择照片"入口 } }这段逻辑里有两个参数值得说清楚。cache: 'no-store'是为了对抗浏览器缓存,年会现场改完photos.json按 F5 却还是旧名单,是非常典型的翻车点。encodeURIComponent则是为了文件名里出现空格、括号、中文时不至于 404——在 macOS 上看着正常,拷到 Windows 现场机就可能打不开。
兜底通道是本地选图。当照片池为空时,页面显示一个虚线框,支持把整个文件夹的图片拖进去,用URL.createObjectURL生成临时地址。这条路不依赖清单、不依赖文件名规范,缺点是刷新页面就没了,所以只做紧急补救用。
提示:
photos.json里建议显式写name。真实姓名当成文件名的一部分,在某些解压工具和传输环节被打乱的概率比想象中高,现场发现大屏上显示IMG_2043是很尴尬的。
3. 目录结构与数据流:一次抽奖到底发生了什么
3.1 目录结构:六个文件撑起整场
项目刻意保持扁平,因为接手的人可能是临时被抓来的实习生,多一层目录就多一次找错文件的机会。
| 路径 | 作用 | 现场是否要改 |
|---|---|---|
index.html | 唯一入口,双击打开 | 否 |
css/stage.css | 网格布局、定格动画、大屏尺寸体系 | 偶尔(改配色) |
js/config.js | 所有可调参数集中在这里 | 是,主要改这个 |
js/pool.js | 照片池加载、预解码 | 否 |
js/draw.js | 抽样、去重、轮次状态机 | 否 |
js/ui.js | 键盘事件、DOM 更新、结果导出 | 偶尔(改快捷键) |
photos/+photos.json | 照片与清单 | 是,每场都换 |
config.js是唯一需要现场动手的文件,形如:
// config.js —— 全场只需要动这里的数字 export const CONFIG = { tickMs: 70, // 格子换图间隔,40 太快像抽帧,120 太慢像卡住,70 最像"抽奖" lockGapMs: 130, // 逐格定格的时间间隔,制造"一张张停下来"的节奏 tileCount: 16, // 网格格子数,4x4 在 16:9 大屏上最稳 winnersPerRound: 1, // 每轮抽几人,三等奖可以设 5 allowRepeat: false, // 是否允许重复中奖,默认不允许 prizes: ['三等奖', '二等奖', '一等奖', '特等奖'] // 轮次名称,长度=轮数 };把参数集中成一份对象,好处是二次开发时不用在文件之间跳。tickMs和lockGapMs这两个值直接决定现场观感,我一般会在彩排时各试三档,让主持人在旁边看着选一个他觉得"像那么回事"的。
3.2 状态机:idle → rolling → locking → settled
抽奖环节最容易出的 bug 不是算法错,而是状态乱。比如定格动画还在跑,主持人又按了一次空格,结果两轮结果叠在一起。所以把整个流程拆成四个明确状态:
// draw.js —— 极简状态机,任何操作先看当前状态 export const STATE = { IDLE: 'idle', // 静止,等待开始 ROLLING: 'rolling', // 滚动中,只接受"停止" LOCKING: 'locking', // 定格动画播放中,拒绝一切输入 SETTLED: 'settled' // 已出结果,可确认/回滚/进入下一轮 }; let state = STATE.IDLE; const listeners = new Set(); export const getState = () => state; export function setState(next) { if (state === next) return; const prev = state; state = next; listeners.forEach(fn => fn(next, prev)); // ui.js 订阅状态,只做渲染不做判断 }这段的分工是:draw.js只负责判断状态能不能走下一步,ui.js只负责根据状态改 DOM。LOCKING期间拒绝输入这一点很重要,它把"手快的主持人连按三下"这类问题在源头掐掉了。订阅模式而不是直接调 DOM,是为了后面加"多屏同步"或者"控制台页面"时不用改核心逻辑。
3.3 大屏尺寸体系:别用 px
现场投影分辨率从 1024×768 到 4K 都有,写死 px 意味着换个场地就崩。项目的做法是全用相对单位,根字号跟着视口走:
/* stage.css —— 一套单位适配从 1024 到 4K */ :root { --gap: clamp(8px, 1.2vmin, 28px); /* 格子间距,随短边缩放 */ --radius: clamp(6px, 0.9vmin, 18px); --name-size: clamp(14px, 2.2vmin, 44px); /* 名字字号跟着屏幕一起放大 */ } .stage { display: grid; grid-template-columns: repeat(4, 1fr); /* 与 CONFIG.tileCount=16 对应 */ gap: var(--gap); padding: var(--gap); height: 100vh; box-sizing: border-box; } .tile { aspect-ratio: 3 / 4; /* 竖版人像更常见,横版改 4/3 */ background-size: cover; background-position: center 20%; /* 人像裁切偏上,避免把脸裁掉 */ border-radius: var(--radius); transition: transform 320ms ease, box-shadow 320ms ease; } .tile.locked { transform: scale(1.06); box-shadow: 0 0 0 4px #ffcc33; }vmin取的是视口短边,横屏和竖屏切换时不会突然变巨。background-position: center 20%这一行是踩坑换来的:员工照片大多是证件照或自拍,center居中裁切经常把额头以上切掉,偏上一点命中率高得多。
4. 核心模块拆解:滚动照片墙与定格算法
4.1 预解码:先让图片进内存,再谈滚动
照片墙卡顿九成不是 DOM 的问题,是滚动过程中才去加载原图。三百张 4MB 的手机照片,滚起来必然掉帧。解法是开场前把要用的图全部预加载并decode(),让浏览器提前完成解码和光栅化。
// pool.js —— 预加载 + 解码,返回真正可用的照片池 export async function preload(list, onProgress) { let done = 0; const ok = []; await Promise.all(list.map(p => new Promise(resolve => { const img = new Image(); img.onload = async () => { try { await img.decode(); } catch (e) { /* 老浏览器没有 decode,忽略即可 */ } ok.push(p); // 只有成功的才进池子 onProgress?.(++done, list.length); // 进度回调,给"正在准备 137/300"用 resolve(); }; img.onerror = () => { console.warn('[preload] 跳过损坏图片:', p.src); onProgress?.(++done, list.length); resolve(); // 单张失败不阻塞整体 }; img.src = p.src; }))); return ok; }三个要点。img.decode()是让浏览器在主线程外把图片解成位图,后续设置background-image时不再触发解码开销。单张onerror必须resolve而不是reject,否则一张坏图会让整个Promise.all挂掉,现场直接白屏。ok只收成功项,保证池子里的每个元素都是能画出来的。
注意:三百张原图全量预加载在旧机器上会吃满内存。超过 200 张时,常见做法是先离线批量压到长边 800px 再放进
photos/,这一步比任何代码优化都有效。
4.2 网格滚动:定时换图,而不是 CSS 动画
第一版我用 CSSanimation平移一长条照片,视觉上确实"滚",但有两个问题:一是照片条要复制一份做无缝循环,内存翻倍;二是定格时要算偏移量对齐到具体某张图,逻辑很绕。
第二版改成固定网格 + 定时换图,简单粗暴:格子数量不变,每隔tickMs把格子里的图随机换一张。观众看到的效果是照片墙在"翻",节奏感反而更接近抽奖机。
// ui.js —— 滚动主循环 let timer = null; export function startRoll(tiles, pool) { if (timer) return; // 幂等,防连点 timer = setInterval(() => { for (const tile of tiles) { if (tile.dataset.locked === '1') continue; // 已定格的格子不再参与 const p = pool[(Math.random() * pool.length) | 0]; tile.style.backgroundImage = `url("${p.src}")`; tile.dataset.id = p.id; } }, CONFIG.tickMs); } export function stopRoll(tiles, winners) { clearInterval(timer); timer = null; // 逐格定格:第 i 个中奖者落在第 i 个格子,间隔 lockGapMs 依次停下 winners.forEach((p, i) => { setTimeout(() => { const tile = tiles[i % tiles.length]; tile.style.backgroundImage = `url("${p.src}")`; tile.dataset.id = p.id; tile.dataset.locked = '1'; tile.classList.add('locked'); }, i * CONFIG.lockGapMs); }); }参数怎么调:tickMs控制"糊"的程度,70ms 时人眼几乎看不清具体是谁,只觉得照片在闪,这正是抽奖想要的效果;调到 200ms 就能看清脸了,气氛反而没了。lockGapMs控制在多人中奖时逐个停下的节奏,130ms 大约是"嗒、嗒、嗒"三声的间隔,再多就显得拖沓。
>// draw.js —— 不放回抽样 import { CONFIG } from './config.js'; let pool = []; // 全量,用于 allowRepeat=true 的场景 let remaining = []; // 未中奖,用于默认场景 export function initPool(list) { pool = list.slice(); remaining = list.slice(); } export function draw(count = CONFIG.winnersPerRound) { const source = CONFIG.allowRepeat ? pool : remaining; if (source.length < count) { throw new Error(`照片池只剩 ${source.length} 张,不够抽 ${count} 个`); } const picked = []; for (let i = 0; i < count; i++) { const idx = Math.floor(Math.random() * source.length); picked.push(source.splice(idx, 1)[0]); // slice 出来顺手删掉 = 不放回 } if (!CONFIG.allowRepeat) remaining = source; // 同步回去 return picked; } export const restCount = () => (CONFIG.allowRepeat ? pool.length : remaining.length);
这里的参数说明:count默认取配置里的winnersPerRound,抽三等奖时可以临时传 5。restCount()是给 UI 显示"剩余 287 人"用的,主持人念出来会更有现场感。
有人会问要不要用洗牌算法预先生成序列。两种都行,但洗牌会在开始前就固定了顺序,万一需要临时加人或者删人,得重洗一次;splice方案是每次现抽,中途改池子更灵活。这个项目选了后者。
4.4 结果落库与导出:给行政留一份能对账的东西
抽完就完事是不行的,散场后行政要名单发奖。项目用localStorage记每一轮,格式带上轮次、时间戳和照片 id,避免同名同姓混淆。
// ui.js —— 结果持久化与导出 const KEY = 'lottery_result_v1'; export function saveRound(roundName, winners) { const all = JSON.parse(localStorage.getItem(KEY) || '[]'); all.push({ round: roundName, time: new Date().toISOString(), winners: winners.map(w => ({ id: w.id, name: w.name })) }); localStorage.setItem(KEY, JSON.stringify(all)); } export function exportCsv() { const all = JSON.parse(localStorage.getItem(KEY) || '[]'); const rows = [['轮次', '时间', '编号', '姓名']]; all.forEach(r => r.winners.forEach(w => rows.push([r.round, r.time, w.id, w.name]) )); // BOM 头不能省,否则 Excel 打开中文全是乱码 const csv = '\uFEFF' + rows.map(r => r.map(v => `"${v}"`).join(',')).join('\r\n'); const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); const a = document.createElement('a'); a.href = url; a.download = `抽奖结果_${Date.now()}.csv`; a.click(); URL.revokeObjectURL(url); }\uFEFF这个 BOM 头是踩过坑的:不加的话 Excel 默认按 GBK 解析,中文姓名全变问号,行政当场就要你重导一遍。localStorage而不是sessionStorage,是因为现场有可能误刷新页面,sessionStorage一刷就没了。
5. 避坑与排查:大屏现场最容易出问题的五个地方
5.1 照片一片空白,控制台报 404
现象:本地打开图片都正常,投到大屏上网格里全是灰底,F12 一看一堆 404。
原因:文件名里带空格、中文或#,浏览器请求时没编码,#之后的部分被当成锚点截断了。另一种情况是清单写的是photos/张三.jpg,实际目录里是photos/张三.JPG,Linux 系机器上大小写敏感,直接 404。
解决:所有src拼装统一走encodeURIComponent;清单生成时把扩展名统一小写;开场前在目标机器上打开一次控制台,看有没有 404,别等到台上才发现。
5.2 滚动时明显掉帧,画面一顿一顿
现象:开发机上很顺,现场机器滚起来像幻灯片,换图时有明显白闪。
原因:一是没预解码,每次换background-image都要现场解码 4MB 的原图;二是图片尺寸远超显示尺寸,一张 4000px 宽的图塞进 200px 的格子。
解决:开场前跑一遍preload()并显示进度;照片离线批量压到长边 800px,体积能降一个数量级。如果时间实在不够,至少把tileCount从 16 降到 9,同时换图的节点少了,压力立刻下来。
5.3 主持人手快连按,两轮结果叠在一起
现象:定格动画还在逐个停,第二次空格已经触发,网格里出现两拨不同的人。
原因:没有状态守卫,停止逻辑只管clearInterval,管不住"定格进行中"这个中间态。
解决:把LOCKING状态做进去,定格动画播放期间所有输入直接丢弃。同时给startRoll加if (timer) return的幂等判断。这类问题的正确做法是在入口拒绝,而不是在出口去重。
5.4 抽到重复的人,现场要重抽
现象:二等奖抽出来,发现和刚才三等奖是同一张脸。
原因:多半是每轮都从全量pool里抽,remaining队列没同步;或者中途刷新过页面,内存里的remaining被重置成全量,但localStorage里的历史结果还在。
解决:draw()统一从remaining取,并且splice后立即回写;页面初始化时读一次localStorage的历史中奖 id,从remaining里剔除掉。这一步不做,刷新页面就等于给重复中奖开了口子。
5.5 投影仪偏色,深色背景下人像发灰
现象:笔记本上看着正常,投出来照片暗部糊成一片,金色高亮也不明显。
原因:会议室投影的对比度和色域普遍比显示器差,深灰背景加上深色衣服的照片,边界全糊在一起。
解决:背景用接近纯黑而不是深灰,格子加一圈浅色描边把边界勾出来;定格高亮别用金色渐变,用纯色高对比描边更稳。彩排时一定要用现场那台投影看一遍,别信自己屏幕。
6. 二次开发:把硬编码抽奖页改成可配置抽奖台
改造的第一刀应该切在配置外置上。现在参数在config.js里,每次换场都要改代码再刷新,如果这套东西要给多个部门复用,更省事的做法是抽一个settings.json出来,用fetch读,配合页面右上角一个隐藏的设置面板(连按三次Esc唤出),现场就能改轮次名、每轮人数、是否允许重复。判断改造成不成功有个简单标准:活动负责人不找你,自己就能把三等奖改成五个人。
第二刀是加"后悔药"。现在只有回滚上一轮,实际现场更需要两种操作:单个作废(某位中奖者已经离职不在场)和整轮重抽。实现上不要真的删localStorage,而是给每条记录加voided: true字段,展示和导出时过滤掉。这样万一误操作,把标记去掉就恢复了,比删了重建安全得多。
第三刀是验证。抽奖这种东西没法靠"看起来对"来验收,我一般写一个离线脚本跑一万次draw(),检查三件事:总抽取数不超过池子大小、没有重复 id、restCount()单调递减。这段脚本不需要框架,Node 里import一下draw.js就能跑:
// verify.mjs —— node verify.mjs 跑一次,三十秒能省一晚 import { initPool, draw } from './js/draw.js'; const fake = Array.from({ length: 50 }, (_, i) => ({ id: `p${i}`, name: `人${i}` })); initPool(fake); const seen = new Set(); for (let i = 0; i < 50; i++) { const [w] = draw(1); if (seen.has(w.id)) throw new Error(`重复中奖:${w.id}`); seen.add(w.id); } console.log('通过:50 人抽 50 次,无重复');从那以后我每次改完抽样逻辑,都强制走一遍这个脚本再上现场,因为抽奖的 bug 只在台上暴露一次,没有第二次机会。希望帮到你。
本文还有配套的精品资源,点击获取