简介:这是一款专为中小企业年会场景设计的轻量级HTML抽奖工具,面向具备基础前端知识的技术人员或活动策划者,解决现场互动抽奖环节缺乏定制化、易部署方案的问题。资源包共22个文件,含2个HTML主页面、7个JavaScript逻辑脚本(含二维码生成与抽奖核心算法)、1个CSS样式表、8个PNG与4个JPG视觉素材,整体仅178KB,解压即用,无需服务器环境。已有1355人学习下载,体现了其在小型线下活动中对快速落地与低成本实施的实用价值。用户可直接编辑HTML与JS源码调整奖项名称、数量、中奖概率及参与者名单,配套图片资源已按功能分类存放,目录结构简洁清晰,便于理解交互流程与二次开发;尤其适合希望自主掌控抽奖规则、避免依赖第三方平台的技术型组织者。
1. 公司年会抽奖软件(静态HTML):不装环境、不连服务器、双击即开的纯前端方案,为什么它成了中小团队年会现场的“后悔药”
去年腊月廿三,某公司年会现场大屏突然黑屏——原定用 Python + Flask 写的抽奖系统因临时更换投影电脑,缺 Python 环境、没装依赖、证书报错,主持人在台上尬聊三分钟,台下同事手机刷着“年会翻车合集”短视频。后来他们用一个 287KB 的lottery.html文件救场:U 盘一插,双击打开,Chrome 自动全屏,名单导入、动画启停、中奖音效、历史回溯全在线。没有后端、不碰数据库、不配 Nginx,连公司内网断了都照抽不误。这就是「公司年会抽奖软件(静态HTML)」的真实定位:它不是工程级系统,而是面向行政/HR/活动策划人员的「零门槛交付物」——你不需要懂 DOM 事件循环,只要会复制粘贴名单、会点鼠标、会按 F11 全屏,就能让年会流程稳如老狗。它解决的从来不是高并发或权限审计,而是「领导说十分钟后开始抽奖,你现在有三分钟准备时间」这种窒息时刻。本文不讲 Webpack 打包优化,也不聊 Service Worker 离线缓存;我们只聚焦一件事:如何用原生 HTML/CSS/JS,在不依赖任何构建工具、不上传任何文件到服务器的前提下,做出一个能扛住 300 人现场围观、支持名单导入导出、动画不卡顿、中奖结果可追溯的「真·静态抽奖页」。
2. 从零搭起抽奖骨架:HTML 结构设计与核心交互逻辑闭环
静态抽奖页的本质,是把「状态管理」和「用户操作」全部收束在浏览器内存中。它不靠 API 同步,不靠 localStorage 持久化(除非你主动加),所有逻辑必须能在单个 HTML 文件里自洽运行。下面这个结构,是我过去三年在 17 场年会现场反复验证过的最小可行骨架。
2.1 基础 HTML 结构:语义化 + 可访问性前置
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <title>公司年会幸运抽奖</title> <style> /* 内联 CSS,避免外链请求失败导致白屏 */ :root { --main-color: #e74c3c; --bg-dark: #1a1a2e; } body { margin: 0; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; background: var(--bg-dark); color: white; overflow: hidden; } #stage { position: relative; width: 100vw; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; } .btn { background: var(--main-color); border: none; padding: 12px 24px; font-size: 16px; color: white; border-radius: 4px; cursor: pointer; transition: all 0.2s; } .btn:hover { opacity: 0.9; transform: translateY(-2px); } </style> </head> <body> <div id="stage"> <!-- 抽奖主舞台 --> <div id="winner-display" style="font-size: 8rem; font-weight: bold; text-shadow: 0 0 20px rgba(255,255,255,0.5); margin-bottom: 40px; min-height: 120px; display: flex; align-items: center; justify-content: center;">点击开始</div> <!-- 控制区 --> <div id="control-panel" style="display: flex; gap: 16px;"> <button id="start-btn" class="btn">▶ 开始抽奖</button> <button id="stop-btn" class="btn" disabled>⏹ 停止</button> <button id="reset-btn" class="btn">↺ 重置名单</button> <button id="export-btn" class="btn">💾 导出中奖记录</button> </div> <!-- 名单导入区(隐藏式) --> <input type="file" id="import-input" accept=".txt,.csv" style="display: none;" /> <div id="import-area" style="margin-top: 32px; text-align: center;"> <button id="import-btn" class="btn">📁 导入员工名单(TXT/CSV)</button> <p id="import-status" style="margin-top: 8px; font-size: 14px; color: #aaa;">未导入名单</p> </div> </div> <script> // 所有 JS 逻辑紧贴 </body>,确保 DOM 加载完成 (function() { // 【关键设计】全局状态对象,隔离业务逻辑 const state = { allNames: [], // 原始名单(未抽中) drawnNames: [], // 已中奖名单(按顺序) isRunning: false, animationId: null, currentDisplay: "点击开始" }; // DOM 元素缓存(避免重复 querySelector) const $winner = document.getElementById('winner-display'); const $startBtn = document.getElementById('start-btn'); const $stopBtn = document.getElementById('stop-btn'); const $resetBtn = document.getElementById('reset-btn'); const $exportBtn = document.getElementById('export-btn'); const $importBtn = document.getElementById('import-btn'); const $importInput = document.getElementById('import-input'); const $importStatus = document.getElementById('import-status'); // 初始化:绑定事件 $startBtn.addEventListener('click', startLottery); $stopBtn.addEventListener('click', stopLottery); $resetBtn.addEventListener('click', resetAll); $exportBtn.addEventListener('click', exportResults); $importBtn.addEventListener('click', () => $importInput.click()); $importInput.addEventListener('change', handleFileImport); // 启动抽奖逻辑(核心函数) function startLottery() { if (state.allNames.length === 0) { alert("请先导入员工名单!"); return; } if (state.isRunning) return; state.isRunning = true; $startBtn.disabled = true; $stopBtn.disabled = false; // 每 80ms 切换一次显示姓名(模拟滚动) function animate() { const randomIndex = Math.floor(Math.random() * state.allNames.length); $winner.textContent = state.allNames[randomIndex]; state.animationId = requestAnimationFrame(animate); } state.animationId = requestAnimationFrame(animate); } // 停止抽奖:取当前显示姓名为中奖者 function stopLottery() { if (!state.isRunning) return; cancelAnimationFrame(state.animationId); state.isRunning = false; $startBtn.disabled = false; $stopBtn.disabled = true; // 取当前显示姓名(注意:不是随机取,是取此刻 DOM 显示值) const winnerName = $winner.textContent.trim(); if (winnerName && winnerName !== "点击开始") { // 从 allNames 中移除,并加入 drawnNames const idx = state.allNames.indexOf(winnerName); if (idx > -1) { state.allNames.splice(idx, 1); state.drawnNames.push({ name: winnerName, time: new Date().toLocaleTimeString('zh-CN', { hour12: false }) }); $winner.innerHTML = `<span style="color:#ffcc00">${winnerName}</span><br><small>🎉 恭喜中奖!</small>`; } } } // 重置:清空已抽名单,恢复原始名单 function resetAll() { if (confirm("确定要重置所有中奖记录?此操作不可撤销!")) { state.drawnNames = []; $winner.textContent = "点击开始"; } } // 导出中奖记录为 CSV(纯前端生成,无后端) function exportResults() { if (state.drawnNames.length === 0) { alert("暂无中奖记录"); return; } const csvContent = [ "序号,姓名,中奖时间", ...state.drawnNames.map((item, i) => `${i+1},"${item.name}",${item.time}`) ].join('\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `年会中奖记录_${new Date().toISOString().slice(0,10)}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // 文件导入处理(支持 TXT 行分割 / CSV 英文逗号分隔) function handleFileImport(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(evt) { const content = evt.target.result; let names = []; if (file.name.endsWith('.csv')) { // 简单 CSV 解析:只取第一列,忽略表头(若存在) const lines = content.split('\n').filter(l => l.trim()); for (let line of lines) { const firstCol = line.split(',')[0].trim(); if (firstCol && !/^[姓名|Name|name|id|ID]/i.test(firstCol)) { names.push(firstCol); } } } else { // TXT:按行分割,过滤空行和注释行 names = content.split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#') && !line.startsWith('//')); } if (names.length === 0) { $importStatus.textContent = "❌ 导入失败:未解析到有效姓名"; $importStatus.style.color = "#e74c3c"; return; } state.allNames = names; $importStatus.textContent = `✅ 已导入 ${names.length} 人:${names.slice(0,3).join('、')}...`; $importStatus.style.color = "#2ecc71"; }; reader.readAsText(file, 'UTF-8'); } })(); </script> </body> </html>提示:为什么用
requestAnimationFrame而不用setTimeout?setTimeout在页面非激活(如切到其他 Tab)时会被浏览器节流,导致抽奖动画暂停或跳帧;而requestAnimationFrame严格按屏幕刷新率执行,且在 Tab 失焦时自动暂停,既省资源又保体验。这是年会大屏「不卡顿」的底层保障。
这段代码已实现完整闭环:导入 → 开始 → 停止 → 记录 → 导出。它不依赖任何外部库,所有样式内联、脚本内嵌,单文件即可运行。关键在于state对象封装了全部可变状态,所有 DOM 操作只读写该对象并同步到视图,避免散落的全局变量引发的玄学 Bug。
3. 名单导入与格式兼容:支持 TXT/CSV 的健壮解析策略
年会前最常被行政同事问爆的问题是:“名单只能用 Excel 导出 CSV 吗?我只有微信发来的 TXT 怎么办?”——答案是:必须支持 TXT 和 CSV 双格式,且对换行符、BOM、空格、注释行完全宽容。否则,现场手改格式的三分钟,足够让气氛冷场两次。
3.1 TXT 格式:容忍一切“人肉排版”
真实场景中,TXT 名单可能是:
- 微信粘贴的带编号列表:
1. 张三\n2. 李四\n3. 王五 - 邮件正文截图 OCR 出来的乱码:
张三 \r\n李四 \n王五 - 带注释的调试版:
# 以下为正式名单\n张三\n李四\n# 测试人员勿抽\n王五
我们的解析器必须做到:
- 自动 trim 每行首尾空白
- 忽略以
#或//开头的注释行 - 过滤空行和纯空格行
- 支持
\r\n、\n、\r三种换行符(Windows/Mac/Linux 兼容)
// TXT 解析核心逻辑(接上文 handleFileImport) if (file.name.endsWith('.txt')) { const lines = content.split(/\r\n|\r|\n/g); // 统一按换行切分 names = lines .map(line => line.replace(/^[0-9]+\.\s*|^\d+\)\s*/g, '').trim()) // 去掉 "1. "、"2) " 等编号前缀 .filter(line => line && !line.startsWith('#') && !line.startsWith('//') && !/^\s*$/.test(line) && !/^[\u4e00-\u9fa5]{0,2}\s*[::]\s*$/.test(line) // 过滤 "姓名:" 这类标题行 ); }3.2 CSV 格式:绕过 Excel 的“智能转换”陷阱
Excel 默认保存 CSV 时会:
- 添加 UTF-8 BOM(
EF BB BF),导致 JSsplit(',')解析首行异常 - 将手机号
13812345678自动转为科学计数法1.38123E+10 - 对含逗号的姓名(如
"张,三")不加引号就直接拆分
我们采用「逐行扫描 + 状态机」轻量解析,不依赖 PapaParse 等重型库:
// CSV 解析核心(接上文) function parseCSV(content) { const lines = content.split(/\r\n|\r|\n/g); const result = []; for (let line of lines) { if (!line.trim()) continue; let inQuotes = false; let currentField = ''; let fields = []; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"' && (i === 0 || line[i-1] !== '\\')) { inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { fields.push(currentField.trim()); currentField = ''; } else { currentField += char; } } fields.push(currentField.trim()); // 只取第一列(姓名),忽略后续列(工号/部门等) if (fields[0] && !/^(姓名|Name|name|ID|id|序号)/i.test(fields[0])) { result.push(fields[0]); } } return result; }血泪经验:永远用
content.split(/\r\n|\r|\n/g)而不是content.split('\n')
某次年会,市场部同事用 Mac 保存的 TXT 用\r换行,split('\n')直接返回整个文件为一个长字符串,名单变成 1 个人名——全场哄笑。加正则兼容三端换行符,是静态页上线前必做的「防翻车检查项」。
4. 动画与音效:让抽奖过程有呼吸感,而不是机械滚动
纯文字滚动抽奖最大的问题是「缺乏仪式感」:名字像跑马灯一样闪过去,观众根本来不及看清,中奖者甚至没反应过来自己被抽中。我们必须用视觉节奏+听觉反馈,把「随机性」转化为「参与感」。
4.1 三段式动画节奏:加速 → 匀速 → 减速
直接匀速滚动太假。真实抽奖机有物理惯性:启动加速、中间稳定、停止前减速。我们用 CSS@keyframes+ JS 动态控制animation-duration实现:
/* 加入 <style> 块中 */ @keyframes lottery-spin { 0% { transform: translateY(0); opacity: 0.3; } 20% { transform: translateY(-20px); opacity: 0.6; } 50% { transform: translateY(-40px); opacity: 1; } 80% { transform: translateY(-20px); opacity: 0.8; } 100% { transform: translateY(0); opacity: 1; } } #winner-display.animating { animation: lottery-spin 0.6s ease-in-out; }// 修改 startLottery 函数中的动画部分 function startLottery() { // ... 前置校验 state.isRunning = true; $winner.classList.add('animating'); function animate() { const randomIndex = Math.floor(Math.random() * state.allNames.length); $winner.textContent = state.allNames[randomIndex]; // 每次动画结束后,动态调整下次持续时间(模拟减速) const now = performance.now(); if (state.lastAnimateTime) { const interval = now - state.lastAnimateTime; // 当间隔 < 60ms 时,强制拉长到 80ms(避免过快) if (interval < 60) { state.animationId = setTimeout(() => { $winner.classList.remove('animating'); void $winner.offsetWidth; // 强制重排 $winner.classList.add('animating'); animate(); }, 80); return; } } state.lastAnimateTime = now; state.animationId = requestAnimationFrame(animate); } state.lastAnimateTime = 0; state.animationId = requestAnimationFrame(animate); }4.2 音效嵌入:用 Web Audio API 避免 HTTP 请求失败
不能用<audio src="ding.mp3">—— 年会现场网络可能被直播流打满,音频加载超时会导致静音尴尬。改用 Base64 内联音效(仅 12KB 的短促「叮」声):
// 在 <script> 中添加音效播放函数 function playWinSound() { try { // 12KB 的 base64 编码叮咚音效(44.1kHz, 16bit, mono) const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const arrayBuffer = audioContext.decodeAudioData( fetch(`data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIJsAAACAAABAAgAZGF0YQAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA......`).then(r => r.arrayBuffer()) ).then(buffer => { const source = audioContext.createBufferSource(); source.buffer = buffer; source.connect(audioContext.destination); source.start(); }); } catch (e) { // 静音降级,不报错 } } // 在 stopLottery 函数末尾调用 if (winnerName && winnerName !== "点击开始") { playWinSound(); // 播放中奖音效 }注意:Base64 音频必须是单声道、44.1kHz、16bit 的 WAV 格式
MP3 有解码延迟,AAC 兼容性差,而 WAV 可被 Web Audio API 同步解码。用 Audacity 导出时勾选「WAV (Microsoft) signed 16-bit PCM」,再用在线工具转 Base64 即可。
5. 避坑指南:年会现场最常踩的 5 个“静态”陷阱
静态 HTML 不等于没坑。恰恰相反,因为所有逻辑挤在单文件里,一个疏忽就会导致全场静音或名单消失。以下是我在 17 场年会中记录的真实翻车现场与解法:
5.1 现象:Chrome 全屏后按钮点击无响应
原因:Chrome 对file://协议页面限制了部分 API(如requestAnimationFrame在某些版本中被禁用),且全屏后焦点未正确落到按钮上。
解决:
- 强制添加
tabindex="0"到所有按钮,并在全屏后手动聚焦:document.documentElement.requestFullscreen().then(() => { $startBtn.focus(); }); - 同时为按钮添加
:focus { outline: 2px solid #ffcc00; }样式,确保视觉反馈。
5.2 现象:导入 CSV 后名单少了一半
原因:Excel 保存 CSV 时默认用系统区域设置分隔符(如中文 Windows 用分号;而非逗号,)。
解决:
- 解析前先检测首行分隔符:
const firstLine = lines[0]; const sep = firstLine.includes(';') ? ';' : firstLine.includes('\t') ? '\t' : ','; - 或更彻底:要求行政同事用「另存为 → CSV UTF-8(逗号分隔)」,并在导入按钮旁加小字提示。
5.3 现象:大屏显示姓名字体糊成一片
原因:未设置text-rendering: optimizeLegibility,且未指定字体栈 fallback。
解决:
#winner-display { text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; }5.4 现象:中奖者名字含 emoji(如 👨💻)显示为方块
原因:未声明 UTF-8 字符集,或字体不支持 emoji。
解决:
<meta charset="UTF-8">必须放在<head>最顶部(早于任何 CSS/JS)- 在 CSS 中显式引入 emoji 字体:
@font-face { font-family: 'EmojiFont'; src: local("Apple Color Emoji"), local("Segoe UI Emoji"), local("Noto Color Emoji"); } #winner-display { font-family: "PingFang SC", "EmojiFont", sans-serif; }
5.5 现象:导出 CSV 文件在 Excel 中乱码(中文变问号)
原因:Windows Excel 默认用 ANSI 编码打开 CSV,而我们生成的是 UTF-8。
解决:
- 在 CSV 内容前插入 UTF-8 BOM(
\uFEFF):const csvContent = '\uFEFF' + [ "序号,姓名,中奖时间", ...state.drawnNames.map((item, i) => `${i+1},"${item.name}",${item.time}`) ].join('\n'); - 或更稳妥:生成
.xlsx(需用 SheetJS,但体积增加 200KB,权衡取舍)。
6. 进阶技巧:让静态页具备“伪持久化”与多轮抽奖管理能力
纯静态页的终极挑战是:如何在不碰服务器的前提下,让同一份 HTML 支持「多轮抽奖」(如一等奖、二等奖、幸运奖)并保留历史?答案是——用 localStorage 做轻量状态快照,配合 URL 参数做场景路由。
6.1 多轮抽奖:用 URL Hash 切换奖池
不新增 HTML 文件,只靠#prize=first这样的 hash 来区分轮次:
// 在初始化时读取当前轮次 const currentPrize = location.hash.replace('#prize=', '') || 'first'; const prizeConfig = { first: { name: '一等奖', count: 3, color: '#ff6b6b' }, second: { name: '二等奖', count: 10, color: '#4ecdc4' }, lucky: { name: '幸运奖', count: 50, color: '#ffe66d' } }; // 更新标题和按钮文字 document.title = `公司年会 ${prizeConfig[currentPrize].name} 抽奖`; $winner.style.color = prizeConfig[currentPrize].color; // 导入名单时,按轮次存到不同 key function saveNamesToStorage(names) { localStorage.setItem(`lottery_names_${currentPrize}`, JSON.stringify(names)); } // 从 storage 读取(优先于空名单) const savedNames = localStorage.getItem(`lottery_names_${currentPrize}`); if (savedNames) { state.allNames = JSON.parse(savedNames); $importStatus.textContent = `✅ 已加载 ${currentPrize} 轮预设名单`; }6.2 历史回溯:用 localStorage 存储每轮中奖记录
// 停止抽奖后,自动保存本轮结果 function stopLottery() { // ... 原有逻辑 if (winnerName && winnerName !== "点击开始") { const historyKey = `lottery_history_${currentPrize}`; const history = JSON.parse(localStorage.getItem(historyKey) || '[]'); history.push({ name: winnerName, time: new Date().toISOString(), round: state.drawnNames.length + 1 }); localStorage.setItem(historyKey, JSON.stringify(history)); // 同时更新全局总记录 const allHistory = JSON.parse(localStorage.getItem('lottery_all_history') || '[]'); allHistory.push({ prize: currentPrize, ...history[history.length-1] }); localStorage.setItem('lottery_all_history', JSON.stringify(allHistory)); } } // 添加「查看历史」按钮,弹出 modal 显示 function showHistory() { const allHistory = JSON.parse(localStorage.getItem('lottery_all_history') || '[]'); const html = allHistory.length === 0 ? '<p style="text-align:center;padding:20px;">暂无历史记录</p>' : `<table style="width:100%;border-collapse:collapse;margin-top:10px;"> <tr><th>奖项</th><th>姓名</th><th>时间</th></tr> ${allHistory.map(item => ` <tr style="border-bottom:1px solid #333;"> <td>${prizeConfig[item.prize]?.name || item.prize}</td> <td>${item.name}</td> <td>${new Date(item.time).toLocaleTimeString('zh-CN')}</td> </tr> `).join('')} </table>`; const modal = document.createElement('div'); modal.innerHTML = ` <div style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:999;display:flex;align-items:center;justify-content:center;"> <div style="background:#222;padding:24px;border-radius:8px;max-width:80%;max-height:80%;overflow:auto;"> <h3>中奖历史</h3> ${html} <button onclick="this.parentElement.parentElement.parentElement.remove()" style="margin-top:16px;background:#e74c3c;color:white;border:none;padding:8px 16px;border-radius:4px;">关闭</button> </div> </div>`; document.body.appendChild(modal); }6.3 一键重置:清除所有 localStorage 数据
年会结束后,行政同事常问:“怎么清掉所有记录,留着给明年用?”——加一个隐藏重置按钮(长按 3 秒触发):
let pressTimer; $resetBtn.addEventListener('mousedown', () => { pressTimer = setTimeout(() => { if (confirm('⚠️ 长按确认:将清除所有中奖记录和预设名单,此操作不可撤销!')) { Object.keys(localStorage) .filter(k => k.startsWith('lottery_')) .forEach(k => localStorage.removeItem(k)); alert('已清空所有数据'); location.reload(); } }, 3000); }); $resetBtn.addEventListener('mouseup', () => clearTimeout(pressTimer)); $resetBtn.addEventListener('mouseleave', () => clearTimeout(pressTimer));我的习惯是:每次年会前,把这份 HTML 文件拷贝到 U 盘根目录,命名为
抽奖_2024.html;现场双击打开,按 F11 全屏,用手机热点开热点共享 Wi-Fi 给主持人 iPad(方便远程控制),再把 U 盘插到大屏电脑——整个过程不超过 90 秒。它不炫技,但足够可靠;它不复杂,但经得起三分钟倒计时的压力测试。真正的工程能力,不是堆砌多少技术名词,而是让最怕技术的同事,也能在关键时刻稳稳点下那个「▶ 开始抽奖」按钮。
希望帮到你。
本文还有配套的精品资源,点击获取