
简介一款基于静态HTML开发的公司年会抽奖工具核心由随机数生成逻辑配合预设参与者名单实现抽奖流程。整体设计轻量无需服务器环境浏览器直接打开即可使用适合具备一定HTML、JavaScript基础的企业行政或活动策划人员自行修改奖项数量、参与名单和中奖概率非编程用户可能需要技术人员协助调整。压缩包共22个文件包含1个CSS、2个HTML、7个JavaScript、4个JPG及8个PNG图片资源大小约178KB。HTML负责抽奖界面结构CSS控制页面样式JavaScript承担点击抽奖、随机结果生成等交互逻辑JPG与PNG则用于背景、按钮及装饰图标等视觉元素。目前已有1353人学习或下载。通过解压并打开主页文件即可运行代码结构清晰、目录划分明确适合作为小型活动互动环节的快速部署方案也方便进阶学习者通过阅读源码理解前端页面与随机逻辑的基本协作方式。1. 年会抽奖软件为什么要用静态 HTML一个文件撑起整场年会每年 11 月到春节前总有行政、HR 或者被临时抓壮丁的开发来问年会抽奖用什么方案商用抽奖软件要授权费现成平台要联网后台一旦出问题全场冷场。这些年我经手过好几个年会项目最后都收敛到同一个答案公司年会抽奖软件用静态 HTML 做。一个文件拷到任何一台 Windows 笔记本上双击打开就是抽奖系统不装环境、不连服务器、不依赖网络名单塞进去就能跑。它解决的核心问题是现场不能出意外网络断了没事投影仪换了没事临时加人名、改奖项、补抽一轮改几行数组就行。适合预算有限、不想求 IT 部门配合、又想让现场有点仪式感的小团队。2. 名单怎么进页面从 Excel 到浏览器的最小路径静态 HTML 抽奖页面第一步卡住的往往不是抽奖逻辑而是名单怎么进去。常见的做法是让用户手输、粘贴或者上传文件三种方式我都试过最稳的反而是最朴素的 textarea 粘贴。下面拆开讲。2.1 用 textarea 粘贴名单最稳的兜底方案很多同事拿到抽奖页面第一反应是问名单在哪上传。我最初也做过拖拽上传后来发现现场最容易出问题的不是上传本身而是编码和格式。textarea 粘贴有个好处粘贴进来之后你能立刻看到名单长什么样有没有空行、有没有多余逗号、是不是有重复肉眼可见。常见的做法是在页面上放一个多行文本框div classconfig-panel textarea idnames rows10 placeholder每行一个名字或使用逗号分隔/textarea button idstartBtn开始抽奖/button /divtextarea在这里承担的是名单缓冲区的角色。页面加载时它会读取浏览器本地存储里的历史名单没有历史就留空等用户粘贴。这样做的逻辑很简单名单一进 textareaJavaScript 就能在内存里维护一份干净的数组后续所有抽奖操作都基于这个数组不再关心原始文件是谁、存在哪。参数上要注意两点rows只是显示高度不是容量上限几千人的名单也能完整保存placeholder写清楚分隔规则能减少一半的沟通成本我一般会注明每行一个名字或使用逗号分隔。如果你只是自己用这个方案足够如果还要兼顾行政姐姐从 Excel 复制出来直接粘贴的场景建议在粘贴之后加一步自动清理。2.2 用 FileReader 读本地名单绕开浏览器的本地文件限制静态页面没有后端能不能直接读本地文件能但必须通过input[typefile]FileReader。很多人第一次会尝试直接写死路径或者用fetch(C:/xxx/名单.txt)打开页面一片报错原因很简单浏览器出于安全策略不允许网页随意读取本地任意路径但用户主动选择一个文件是被允许的。这是静态 HTML 项目里最常见的一道坎。提供一个文件选择按钮同时保留 textarea 兜底input typefile idfileInput accept.txt,.csv /const fileInput document.getElementById(fileInput); const namesArea document.getElementById(names); fileInput.addEventListener(change, (e) { const file e.target.files[0]; if (!file) return; const reader new FileReader(); reader.onload (ev) { const text ev.target.result; const list text .split(/[\n,、;]/) .map(s s.trim()) .filter(Boolean); namesArea.value list.join(\n); console.log(解析到 list.length 个名字); }; reader.readAsText(file, utf-8); });逻辑说明文件读取成功后按换行、逗号、顿号、分号中英文都包括切成数组然后trim()去掉首尾空格filter(Boolean)清掉空串。这一串正则和空串过滤不是炫技而是 Excel 导出的名单里经常混着看不见的换行符和空格不清理的话后面抽奖会出现空白名字或者把同一个人拆成两条记录。参数上建议分场景设置accept.txt,.csv限制文件类型减少误选reader.readAsText(file, utf-8)只适合 UTF-8 文件GBK 文件会乱码下面一节专门说console.log保留解析条数方便现场确认名单有没有漏人。2.3 编码乱码UTF-8 的 HTML 碰到 GBK 的 CSV 会翻车静态 HTML 页面头部声明的是 UTF-8meta charsetutf-8而 Windows 上用 Excel 另存的 CSV默认是 GBK/ANSI 编码两者对不上读出来的名单就是这种乱码。我在早期项目中踩过这个坑后来总结出一套判断逻辑先用 UTF-8 读如果结果里乱码比例高再用 GBK 重新读一遍。实际代码可以这样处理function readFileAsText(file) { return new Promise((resolve) { const reader new FileReader(); reader.onload () resolve(reader.result); reader.readAsText(file, utf-8); }); } async function handleFile(file) { let text await readFileAsText(file); // 检查是否包含常见乱码替换符 if (text.includes(\uFFFD) || text.includes(Ã)) { const reader new FileReader(); reader.onload () { // 这里得到的是 GBK 解析结果 const list reader.result.split(/[\n,、;]/).map(s s.trim()).filter(Boolean); namesArea.value list.join(\n); }; reader.readAsText(file, gbk); } else { const list text.split(/[\n,、;]/).map(s s.trim()).filter(Boolean); namesArea.value list.join(\n); } }逻辑说明先按 UTF-8 读一遍并检查是否有\uFFFDUnicode 替换符或者是否出现Ã这类乱码特征字符。有的话换readAsText(file, gbk)重新解析。现代浏览器支持gbk编码参数不需要额外引入库。这里有一个容易被忽略的细节Excel 保存 CSV 时如果不指定编码Windows 版默认是 GBKMac 版却常常是 UTF-8。所以单靠从哪个软件导出来判断编码不可靠最稳妥的是看第一次解析结果是不是乱码。如果你希望彻底绕开这个问题可以在名单导入按钮旁边加一行提示请将 Excel 另存为 CSV UTF-8 格式但现场往往没人会再折腾一次所以自动降级到 GBK 才是我现在默认保留的分支。3. 抽奖核心逻辑去重、滚动动画与状态恢复名单进页面之后真正的技术重点才开始。这一章说的不是随机抽一个人这种五行的简单实现而是现场不掉链子需要考虑的完整逻辑不重复、动画可控制、中途换人、多轮奖项、刷新页面之后进度不丢。3.1 随机抽取的核心用剩余名单池而不是抽完再排除很多人第一次写抽奖会这样写从全名单里随机选一个选完把这个人记进中奖列表下次再随机时用if (winners.includes(name))判断是否跳过。逻辑上没问题但现场有一个隐患当剩余人数越来越少时随机命中已中奖者的概率越来越高可能连续好多次都抽中同一个人明明一个按钮的事屏幕上滚动了十几秒才出结果场面很尴尬。正确做法是维护一个剩余名单池const state { pool: [], // 剩余可抽名单 winners: [], // 已中奖记录 [{round: 一等奖, name: 张三}] currentRound: 一等奖, isRolling: false }; function nextWinner() { if (!state.pool.length) { alert(当前奖项的候选名单已抽完); return null; } const index Math.floor(Math.random() * state.pool.length); const name state.pool[index]; state.pool.splice(index, 1); // 抽中即从候选池移除 return name; }逻辑说明state.pool是本次抽奖所有可抽的人每次随机一个下标用splice把它从池里移除这样后面绝不会再抽到。winners是完整中奖记录每轮都 push 进去用来生成结果报表和回看。关于Math.random()够不够随机我直接说结论对年会抽奖这种场景完全够用不需要复杂的洗牌算法。它不是加密场景没人会去分析伪随机序列来预测下一个中奖者。如果你实在不放心可以用crypto.getRandomValues()获取更均匀的随机数但真没必要。3.2 滚动动画让抽奖有现场感又不把浏览器跑崩静态 HTML 抽奖的现场感主要靠滚动动画中奖名单在屏幕上快速跳动主持人喊停的瞬间定帧。实现方式有两种一种是用 requestAnimationFrame 逐步换名字另一种是用 setInterval 定时换名字。我都用过结论是 setInterval 就够了因为换名字本身不是动画插值只是一次 DOM 重绘。核心代码let rollTimer null; function startRoll() { if (state.isRolling) return; if (!state.pool.length) return; state.isRolling true; rollTimer setInterval(() { const index Math.floor(Math.random() * state.pool.length); document.getElementById(displayName).textContent state.pool[index]; }, 60); } function stopRoll() { if (!state.isRolling) return; clearInterval(rollTimer); rollTimer null; state.isRolling false; const winner nextWinner(); document.getElementById(displayName).textContent winner; state.winners.push({ round: state.currentRound, name: winner }); saveState(); updateWinnersList(); }逻辑说明startRoll用一个 60ms 的定时器让屏幕上的名字持续跳动stopRoll先清定时器再从候选池真正取出一个中奖者写入winners并保存状态。注意一个锁变量isRolling防止主持人或操作员手快连续点两下开始导致两个定时器重叠、停不下来。几个调参经验可以直接照抄60ms 是有点快但不晕的默认值想要更紧张的氛围可以调到 30ms想要柔和一点可以调到 80msdisplayName的 DOM 操作是最小的不要在这里同时更新中奖列表、剩余人数等节点否则可能引起重绘卡顿如果名单超过 1000 人state.pool的 splice 操作仍然是 O(n)但 n 才几千现场几乎感知不到。有个隐藏细节值得说一下如果要让停的那一刻更刺激可以在stopRoll里做一个减速效果例如每 80ms 停一次、再把间隔逐渐增加到 300ms最后定帧。这个效果需要额外维护一个减速定时器普通年会不做也可以做了会更像电视节目里的抽奖。后面我会在进阶部分提一个简单版本。3.3 多轮奖项与状态恢复用 localStorage 记住全场进度年会抽奖一般分三轮三等奖、二等奖、一等奖甚至还有特等奖、老板加抽。每一轮用的名单范围不太一样——有的公司是三等奖中过的人不再进二等奖有的则是每个奖项独立抽中奖的人可以重复中。这两种规则都要支持静态 HTML 方案里通常是区分候选池来处理。常见的数据结构设计const KEY annual-party-lottery-v1; const DEFAULT_STATE { pool: [], winners: [], currentRound: 三等奖, roundIndex: 0, rounds: [三等奖, 二等奖, 一等奖] }; function saveState() { localStorage.setItem(KEY, JSON.stringify(state)); } function loadState() { try { const saved JSON.parse(localStorage.getItem(KEY)); if (saved Array.isArray(saved.pool)) { return saved; } } catch (e) { // 忽略解析异常回退到默认状态 } return null; }逻辑说明rounds数组定义奖项顺序roundIndex指向当前奖项。切换奖项时从state.winners里把当前奖项之外已中奖的人从新池子中排除或保留具体规则由一个isRepeatAllowed开关控制。localStorage每抽中一个人就写一次防止现场误关浏览器、断电或者按错 F5 导致整场进度丢失。这里有一个很多人不知道的问题静态 HTML 页面在file://协议下打开localStorage 是可以用的但它绑定的源origin规则比较特殊。如果你复制这个 HTML 文件到另一个目录打开存储可能会失效。所以我在恢复逻辑里做了兜底loadState返回 null 时页面自动进入全新开始模式同时弹一个确认框让操作员决定是继续历史进度还是重置。参数选择上有三条建议KEY里带上版本号v1以后改了存储结构可以自动废弃旧数据不会在新代码里解析出奇怪的对象JSON.parse必须包在try-catch里因为 localStorage 里可能被塞进非 JSON 数据一旦出错整个页面加载就会断如果公司 IT 环境禁止 localStorage少数政企环境有这种策略可以在代码开头做一次能力探测不可用就退回内存态至少保证这次打开页面期间能正常抽奖。4. 大屏现场体验全屏、快捷键与字号适配抽奖逻辑跑通只是及格年会现场的体验才是真正拉开差距的地方。投影仪分辨率千奇百怪大屏离观众远操作员可能站在舞台侧面盲操作这章讲的全是现场会遇到的真实问题。4.1 让页面真正全屏全屏 API 的兼容性和触发限制年会抽奖页面通常是放在大屏上展示的如果页面上有浏览器地址栏和标签页气质就全没了。静态 HTML 可以调用浏览器原生全屏 API但有一个大前提必须由用户手势触发不能在页面加载时自动进入全屏。所以页面上要放一个明确的进入全屏按钮最好字体够大、颜色醒目。async function toggleFullscreen() { try { if (document.fullscreenElement) { await document.exitFullscreen(); } else { await document.documentElement.requestFullscreen(); } } catch (err) { // 某些浏览器或环境不支持全屏降级为提示用户按 F11 console.warn(Fullscreen API failed:, err); } } document.getElementById(fullscreenBtn).addEventListener(click, toggleFullscreen);逻辑说明requestFullscreen返回一个 Promiseawait可以捕获用户拒绝或不支持的异常。document.fullscreenElement用来判断当前是否已处于全屏状态。普通显示器上按 F11 也能全屏但投影仪外接模式下 F11 容易误触其他键所以还是推荐用 API。三个现场细节值得单独说全屏状态切换时会触发fullscreenchange事件可以用来在页面上显示已进入全屏的状态提示部分浏览器在文件协议下对requestFullscreen有限制如果点击按钮没反应指数检查是否为最新版 Edge 或 Chrome舞台上如果用的是老式投影仪或者视频矩阵分辨率可能是 1024×768页面布局要按这个最低分辨率做兼容后面一节具体说。4.2 字号与布局最后一排观众能看清才算合格静态 HTML 页面在 1080p 屏幕上做好之后换到 4K 大屏或者低分辨率投影仪上字号会变得过大或过小。最常见的问题是把字号写死成font-size: 36px换一台设备就得改代码。合理的做法是用视口单位vw配合最小字号兜底。#displayName { font-size: min(15vw, 180px); font-weight: 700; letter-spacing: 0.05em; text-align: center; line-height: 1.2; } .winner-count { font-size: max(16px, 1.2vw); color: #888; }布局参数说明15vw的意思是无论屏幕多宽名字宽度约占屏幕 15%这样在 1024px 和 3840px 屏幕上都能自适应min(15vw, 180px)防止在超宽屏上名字大得离谱加上上限max(16px, 1.2vw)用于辅助信息保证小屏下至少 16px 可读。如果你不想依赖 CSSmin()/max()的兼容性也可以用media query按分辨率区间设置几档字号但维护成本更高。静态 HTML 页面追求的是单文件自包含我现在的习惯是min/max为主实测 Chrome 和 Edge 都能正常解析。还需要关注的场景是舞台灯光打到投影幕上颜色对比度不够的话白底页面还好如果用了深色背景就要注意字体颜色。我的默认配色方案是深蓝色背景 金色字观众在灯光下也能看清。这个纯粹是视觉偏好但对比度优先于美观是我踩过坑之后的经验。4.3 快捷键操作台主持人不需要走近电脑年会现场的操作员通常不是开发人员可能是行政或者主持人的助理。屏幕上的按钮当然能点但如果操作员站在舞台侧边离电脑三五米远鼠标就够不到了。所以键盘快捷键是静态抽奖页面的刚需。常见的快捷键方案document.addEventListener(keydown, (e) { if (e.code Space) { e.preventDefault(); if (state.isRolling) { stopRoll(); } else { startRoll(); } } else if (e.code ArrowRight) { switchToNextRound(); } else if (e.code KeyR e.shiftKey) { if (confirm(确定要重置全部抽奖记录吗)) { resetAll(); } } });这个方案的设计逻辑是空格键承担开始/停止两个动作和电视抽奖节目里主持人喊开始和停完全对应操作员不需要看键盘。左右方向键切换奖项R 键重置都带一层确认防止误触清空全场记录。e.preventDefault()必须加在空格键监听里否则页面滚动到顶部或底部时按空格会触发浏览器默认的滚动行为。另外不建议绑定Enter因为操作员可能误触到其他按钮的聚焦状态触发意外的点击事件。快捷键功能说明空格开始/停止滚动套在最核心的交互上方便盲操作右方向键切换下一轮奖项三等奖→二等奖→一等奖Shift R重置全部记录带 confirm 确认防误触F切换全屏与点击按钮等效5. 避坑指南静态抽奖页面最容易现场翻车的几个点这一章写的全是真实线上翻车记录每一条都是先描述现象再解释原因最后给解决办法。按优先级排列前两条是不解决就会当场尴尬的类型。5.1 连续两轮抽到同一个人候选池和结果池混在一起现象三等奖抽完张三二等奖滚动屏幕又出现张三而且他真的又被抽中了。台下观众倒不觉得有什么但同一人中两次很容易被质疑有黑幕。原因实现时没有使用剩余候选池模型每轮都是从全名单重新随机。三等奖中奖的人只是被记录在结果列表里并没有从下一轮的候选池移除。解决把候选池和结果池彻底分开。每轮开始时用当前轮次规则从全名单里筛出新候选池已经中奖的人按规则直接filter掉。具体判断规则要提前和主办方确认是整场只能中一次还是每轮独立允许重复中两种规则对应不同的池子构建逻辑。最稳妥的做法是默认整场只能中一次并在界面上显示剩余人数避免最后剩一个没中的特别尴尬。5.2 名单从 Excel 导出后中文乱码编码没对上现象行政发来一个名单.csv双击打开页面后名字全是鐜嬩寒或者。原因Excel 在 Windows 上默认以 GBK/ANSI 保存 CSV而 HTML 声明的是 UTF-8FileReader 按 UTF-8 解码 GBK 字节流就乱了。解决在导入文件时先按 UTF-8 读一次检测到乱码特征字符后再用readAsText(file, gbk)重新解析。这个逻辑在上面第 2.3 节已经给过完整代码。如果不想依赖浏览器对 GBK 的支持也可以让行政把 Excel 另存为CSV UTF-8格式但这个要求在年会前三天很难落实所以还是代码里做兼容更现实。5.3 现场断网页面白屏或交互失效外部依赖没内联现象年会当天现场 Wi-Fi 不稳定打开页面发现样式全丢了或者点击抽奖按钮没反应控制台一堆红色报错。原因页面可能通过 CDN 引用了 jQuery、Bootstrap 或者字体库。网络一断这些外部资源加载失败整个页面的 JS 执行就停住了。你本地测试时网络畅通完全没问题现场却不一样。解决静态 HTML 抽奖页面坚持单文件、零外部依赖。引用的框架库全部内联到 HTML 里或者干脆不用框架原生 JS 足够完成这个项目。字体也只用系统字体不要用 Google Fonts 这类需要请求外网的资源。检查方法很简单打开页面后在 DevTools 的 Network 面板里看请求列表除了页面本身之外不允许有任何其他请求。5.4 操作员手滑双击页面出现两个滚动定时器缺少互斥锁现象主持人说开始操作员用力点了一下鼠标没反应又点了一下结果屏幕上的名字跳动速度忽快忽慢停止时停不下来。原因按钮点击事件没有被互斥锁保护。第一次点击启动了一个setInterval第二次点击又启动了一个两个定时器交替执行界面表现就是混乱的快慢交替。解决在startRoll开头加if (state.isRolling) return;在stopRoll里把isRolling设回false。这段代码在第 3.2 节已经出现。另外给按钮也做防重复处理开始按钮在滚动期间disabled停止按钮在非滚动期间disabled从交互层杜绝双击。5.5 大屏长时间没操作屏幕休眠或屏保启动现象年会彩排时页面开在大屏上显示待抽奖画面几分钟没人动电脑屏幕黑了或者跳出了系统屏保。原因操作系统层面的电源策略不是页面能完全控制的。浏览器全屏 API 对低亮度显示器的自动熄屏有一定抑制作用但 Win 系统的电源计划里有xx 分钟后关闭显示器的策略浏览器无法覆盖。解决在活动开始前提前设置好操作系统电源计划把关闭显示器改为从不。如果操作员没有权限修改系统设置可以在页面里放一个不断移动鼠标光标的小技巧例如每 30 秒用 JavaScript 触发一次document.dispatchEvent(new MouseEvent(mousemove))但这种方式只对部分是有效的还是建议在设备上关闭锁屏。另外requestFullscreen()本身会让部分 Windows 设备保持唤醒能延长一段时间但不要依赖它过夜。5.6 抽奖结果没保存中奖名单丢失无法补发奖品现象抽奖全部结束需要导出中奖名单时发现页面刷新过或者浏览器被关闭localStorage 里的数据是空的只能靠现场录的视频一个一个对。原因没有养成抽中即写存储的习惯或者把结果导出功能放在了最后中途浏览器崩溃、误关标签页都会丢数据。解决坚持每抽中一人立即saveState()的写法而不是抽完一轮再批量保存。同时提供导出当前结果按钮把winners数组转成 CSV 下载这样哪怕页面后来被关闭导出的文件还在。具体导出代码会在下一章给出。如果你现场有录屏也可以把它当成最后兜底的回放手段但不要依赖录屏回放来补名单清晰度经常不够。6. 进阶把页面收进单文件做成主持人敢操作的终版到了进阶这一步功能基本都全了剩下的是看起来专业和用起来放心。我一般会在交付前做三件事加导出中奖名单的 CSV 下载、加一个撤销上一次抽奖的后悔药以及把整个页面压缩成一个没有任何外部请求的单 HTML 文件。导出 CSV 的完整函数注意加 BOM 才能让 Excel 正确识别 UTF-8function exportWinners() { const lines state.winners.map(w ${w.round},${w.name}); const csv \ufeff lines.join(\n); const blob new Blob([csv], { type: text/csv;charsetutf-8 }); const a document.createElement(a); a.href URL.createObjectURL(blob); a.download 年会中奖名单.csv; a.click(); URL.revokeObjectURL(a.href); }撤销上一次抽奖也很简单其实就是从winners弹出最后一条把人塞回pool然后重绘界面。这个功能平时用不上但真到老板说这轮不算重抽的时候它就是全场救星。function undoLastWinner() { const last state.winners.pop(); if (!last) return; state.pool.push(last.name); saveState(); render(); }我在一次年会上遇到过主持人念错了名字、不得不重新抽的情况当时页面上没有撤销按钮全场等了三十多秒手动改代码。从那以后这个按钮就常驻在配置面板里字号不大但关键时刻不用慌。最后我会把整个页面收成一个单文件放到桌面双击就能用施舍员动鼠标即可不需要任何命令行。希望帮到你。本文还有配套的精品资源点击获取