纯静态HTML年会抽奖软件:零依赖、断网可用的现场抽奖方案
简介这是一份面向企业年会、团建活动等现场场景的静态 HTML 抽奖工具适合具备一定 HTML 基础、希望自行调整奖项与规则的使用者。软件采用纯前端实现无需服务器环境浏览器打开即可运行交互逻辑与随机抽取过程均由页面脚本完成轻量且便于部署。压缩包共 22 个文件约 178KB包含 2 个 html 页面、7 个 js 脚本、1 个 css 样式表以及 8 个 png、4 个 jpg 图片素材分别承担页面结构、抽奖逻辑、视觉样式与界面装饰等职责目录组织相对紧凑。目前已有 1356 人学习下载说明其在年会抽奖场景中具有一定参考价值。读者可从中获得一套可直接运行的抽奖页面原型并借助源码理解奖项设置、参与者名单与中奖概率的实现方式按需修改后用于实际活动也可作为前端交互练习的素材。1. 年会抽奖这件小事静态 HTML 就能扛下来每年年底行政群里总会冒出一句“今年抽奖用什么软件”。有人提议买商业抽奖系统有人翻出某小程序结果现场网络一抖、账号一登不上几百号人盯着大屏干等。我见过最稳的方案反而最朴素一个纯静态的 HTML 文件双击用浏览器打开投到大屏上就能抽。这份「公司年会抽奖软件静态 html」走的就是这条路——没有后端、没有数据库、没有构建工具html 抽奖 的全部逻辑压在前端抽奖软件该有的名单导入、滚动动画、中奖记录、防重复都能在一个页面里跑完。它适合谁适合公司年会、部门团建、班级活动这类“一次性、要稳、要快”的场景也适合想拿一个完整小项目练手前端 DOM 操作和状态管理的开发者。下面我按“能干什么 → 怎么跑 → 怎么改”拆开讲。2. 项目定位与技术选型为什么一个 HTML 文件就够了2.1 静态方案的适用边界先把话说清楚静态 HTML 抽奖不是万能方案。它的核心假设是“单机、单屏、单人操作”——一台笔记本接投影主持人点按钮观众看大屏。只要不涉及多终端同时参与、不要求远程投票、不需要把结果实时同步到别人手机静态方案就是性价比最高的选择。反过来如果活动要求“员工扫码在手机上抽”那就必须上后端和实时通信这份资源不适合硬改。从工程角度看静态方案有三个实打实的好处。第一是部署成本为零拷到 U 盘里带到会场断网也能跑这在酒店会议室网络不靠谱的时候就是救命稻草。第二是没有环境依赖不需要装 Node、不需要配数据库浏览器就是运行时。第三是可审计所有逻辑都在一个文件里出问题能当场打开源码看不用猜后端返回了什么。代价也很明显数据存在浏览器本地换台电脑名单就没了所以名单导入导出必须做扎实。技术栈上这类项目通常是 HTML5 原生 JavaScriptES6 CSS3不引框架。原因很直接抽奖页面的交互复杂度撑不起 React/Vue 的收益反而多一层构建就多一个现场翻车的可能。常见做法是把名单、奖项配置、中奖记录都放在localStorage里刷新页面不丢动画用requestAnimationFrame或 CSStransition驱动滚动效果大屏适配用vw/vh加transform: scale()做等比缩放。这些选择背后是一条原则现场稳定性优先于代码优雅。2.2 目录结构与文件职责静态项目的目录通常极简但职责要分清否则二次开发时改一处崩一片。一个合格的抽奖软件目录大致长这样lucky-draw/ ├── index.html # 主页面抽奖大屏 ├── css/ │ ├── reset.css # 基础样式重置 │ └── main.css # 大屏布局、滚动动画、中奖弹层 ├── js/ │ ├── config.js # 奖项配置一等奖几名、二等奖几名 │ ├── storage.js # localStorage 读写封装 │ ├── draw.js # 抽奖核心随机、去重、滚动 │ └── main.js # 事件绑定、页面初始化 ├── data/ │ └── members.json # 默认名单可选也可现场导入 └── assets/ └── bg.jpg # 背景图这个划分的关键在于把“数据”和“逻辑”分开。config.js只放奖项数量和名称运营改奖项不用碰核心代码storage.js把 localStorage 的 key 和序列化逻辑收口避免到处写JSON.parse(localStorage.getItem(...))draw.js是纯逻辑输入名单和奖项输出中奖者方便单独测试。很多网上流传的单文件版本把所有东西塞进一个script改起来确实快但一旦要加“三等奖抽完自动切二等奖”这种需求就会变成一坨。我一般会建议至少拆成上面这几个文件成本不高收益很大。2.3 核心数据模型抽奖软件的本质是三个集合的运算候选池、奖项配置、中奖记录。候选池是还没中奖的人奖项配置决定每个奖抽几个中奖记录用于展示和防重复。数据结构设计得好后面所有功能都是顺水推舟。// config.js —— 奖项配置按抽取顺序从大到小或从小到大排列 const PRIZE_CONFIG [ { id: p3, name: 三等奖, count: 10, pool: all }, { id: p2, name: 二等奖, count: 5, pool: all }, { id: p1, name: 一等奖, count: 2, pool: all }, { id: sp, name: 特等奖, count: 1, pool: all } ]; // storage.js —— 统一管理本地存储的 key避免拼写错误 const STORAGE_KEYS { members: ld_members, // 全部参与人员 winners: ld_winners, // 已中奖记录 [{name, prizeId, time}] current: ld_current_prize // 当前进行到哪个奖项 };PRIZE_CONFIG里count是每个奖项的名额pool目前都是all意思是所有人共享一个候选池如果以后要做“仅管理层参与特等奖”把pool改成manager再在候选池过滤时按标签筛就行。STORAGE_KEYS单独抽出来是为了防止某处写成ld_winner、另一处写成ld_winners这种低级错误在赶工期的项目里特别常见血泪经验。中奖记录里存time是为了导出时能对账年会结束后行政往往要一份带时间的名单。3. 跑起来从双击打开到现场投屏的完整流程3.1 本地启动与浏览器选择这份资源是静态 HTML启动方式简单到不需要命令解压后双击index.html浏览器直接打开。但“能打开”和“现场能用”是两回事有几个细节必须提前确认。第一不要用 file:// 协议直接跑带 fetch 的版本。如果项目里用fetch(data/members.json)加载默认名单Chrome 在file://下会因跨域策略拒绝读取本地文件控制台报CORS policy错误页面名单是空的。解决办法有两个要么把名单直接内联成 JS 变量推荐最稳要么起一个本地静态服务器。起服务器的方式# 在项目根目录执行Python 3 自带无需安装 python3 -m http.server 8080 # 然后浏览器访问 http://localhost:8080http.server是 Python 标准库自带的静态文件服务器8080是端口被占用就换8081。这条命令只影响本机会场没网也能用。如果现场电脑连 Python 都没有那就老老实实用内联名单别在会场装环境。第二浏览器选 Chrome 或 Edge。抽奖动画大量依赖 CSStransform和requestAnimationFrame这两个在 Chromium 内核下表现最稳。Safari 在投影外接屏上偶尔会有动画掉帧Firefox 的字体渲染在大屏上偏细。提前在会场同款电脑上试一遍比现场救火强。第三投屏前按 F11 全屏并把浏览器缩放调到 100%。有些笔记本默认 125% 缩放投到大屏上布局会错位vw单位算出来的字号可能溢出容器。3.2 名单导入的两种方式名单是抽奖软件的命门。这份项目的设计是“默认名单 现场导入”双通道。默认名单放在data/members.json或内联在config.js里格式就是一个字符串数组// 内联名单最稳的方式不依赖任何文件读取 const DEFAULT_MEMBERS [ 张一, 张二, 张三, 张四, 张五, 李一, 李二, 李三, 李四, 李五 // ... 实际使用时替换成真实名单 ];现场导入则通过一个input typefile读取 CSV 或 txt用FileReader解析。CSV 的好处是行政可以直接从 Excel 另存不用手动敲。解析逻辑要处理两个坑BOM 头和空行。// 读取用户选择的 CSV 文件按行拆成名字数组 function importMembers(file) { const reader new FileReader(); reader.onload (e) { let text e.target.result; // 去掉 Excel 导出 CSV 时可能带的 BOM 头否则第一个名字会带不可见字符 text text.replace(/^\uFEFF/, ); const names text .split(/\r?\n/) // 兼容 Windows(\r\n) 和 Unix(\n) 换行 .map(s s.trim()) // 去掉首尾空格 .filter(Boolean); // 过滤空行 saveMembers(names); // 写入 localStorage renderMemberCount(names.length); }; reader.readAsText(file, UTF-8); }FileReader是浏览器原生 APIreadAsText的第二个参数指定编码中文名单必须用UTF-8否则会乱码。replace(/^\uFEFF/, )这一行是踩坑换来的Excel 另存 CSV 默认带 BOM不去掉的话第一个中奖者名字前面会多一个看不见的字符大屏上显示成方块。filter(Boolean)顺手把空行清掉避免抽到空名字。导入后立刻写localStorage这样即使中途刷新页面名单也不会丢。3.3 抽奖主流程与滚动动画抽奖的交互流程是选奖项 → 点开始 → 名字快速滚动 → 点停止 → 定格中奖者 → 写入记录。核心难点不在随机而在“滚动时看起来公平、停止时结果确定”。常见做法是滚动阶段用setInterval快速切换显示的名字停止时用Math.random从候选池里选一个真正中奖的再让动画停在这个名字上。// draw.js —— 抽奖核心逻辑 let rollingTimer null; // 开始滚动每 50ms 换一个名字纯视觉效果 function startRolling(candidates, displayEl) { rollingTimer setInterval(() { const randomIndex Math.floor(Math.random() * candidates.length); displayEl.textContent candidates[randomIndex]; }, 50); } // 停止滚动真正决定中奖者并清理定时器 function stopRolling(candidates, displayEl) { clearInterval(rollingTimer); rollingTimer null; const winnerIndex Math.floor(Math.random() * candidates.length); const winner candidates[winnerIndex]; displayEl.textContent winner; return winner; }startRolling里的50是滚动间隔单位毫秒数值越小滚得越快30 以下人眼就看不清了80 以上又显得拖沓50 是实测比较舒服的值。stopRolling才是真正抽奖的地方Math.floor(Math.random() * candidates.length)生成[0, length)的整数索引。这里有个容易被忽略的点滚动阶段显示的名字和最终中奖者没有因果关系滚动只是氛围结果由停止那一刻的随机数决定。有人担心“停得快会不会影响公平”不会因为随机数在停止时才生成。候选池candidates必须是“全部人员减去已中奖者”这个过滤在调用前完成否则会出现重复中奖。3.4 中奖记录与防重复防重复是抽奖软件的基本盘。实现方式是在每次抽奖后把中奖者从候选池剔除同时写入winners记录。候选池的计算放在每次抽奖前而不是维护一个可变数组这样逻辑更清晰、不容易出错。// 计算当前候选池全部人员中排除已中奖的 function getCandidates(allMembers, winners) { const winnerNames new Set(winners.map(w w.name)); return allMembers.filter(name !winnerNames.has(name)); } // 抽完一个奖项后批量写入中奖记录 function saveWinners(prizeId, names) { const winners loadWinners(); const now new Date().toLocaleString(zh-CN); names.forEach(name winners.push({ name, prizeId, time: now })); localStorage.setItem(STORAGE_KEYS.winners, JSON.stringify(winners)); }Set的查找是 O(1)比用Array.includes在大名单下快得多几百上千人的名单差别不明显但习惯用Set做去重是好事。toLocaleString(zh-CN)生成“2025/1/20 14:30:00”这种格式导出时行政看得懂。注意saveWinners是追加写入不是覆盖所以抽多个奖项时记录会累积。如果要做“撤销上一次抽奖”就从winners里pop()最后一个并重新渲染这个功能在彩排时特别有用——主持人手滑多点了一次没有后悔药就尴尬了。4. 核心模块拆解状态管理、大屏适配与导出4.1 用 localStorage 做轻量状态管理静态项目没有后端所有状态都活在浏览器里。这份项目用localStorage存三类数据名单、中奖记录、当前奖项进度。封装一层读写函数比到处裸写 API 靠谱得多。// storage.js —— 带默认值的读写封装 function loadJSON(key, defaultValue) { try { const raw localStorage.getItem(key); return raw ? JSON.parse(raw) : defaultValue; } catch (e) { console.warn(读取 ${key} 失败使用默认值, e); return defaultValue; } } function saveJSON(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { // 超出配额约 5MB时会抛异常提示用户清理 alert(本地存储空间不足请清理浏览器数据后重试); } }try/catch不是摆设。JSON.parse遇到损坏的数据会抛异常比如用户手动改过 localStorage或者上次写入被中断。没有这层保护页面直接白屏现场就慌了。saveJSON里的配额异常也真实存在虽然名单数据远到不了 5MB但中奖记录如果带截图之类的字段就可能撑爆提前兜住比事后排查强。这套封装的思路是“读失败给默认值写失败给提示”保证页面永远能渲染出来。4.2 大屏等比缩放适配年会现场的分辨率五花八门有 1920×1080 的投影也有 3840×2160 的电视。用固定像素写布局换块屏就错位。常见做法是设计稿按 1920×1080 做运行时按实际窗口等比缩放整个容器。/* main.css —— 以 1920x1080 为设计基准的等比缩放 */ #stage { width: 1920px; height: 1080px; transform-origin: left top; /* 缩放锚点固定在左上角 */ position: absolute; left: 50%; top: 50%; }// main.js —— 根据窗口尺寸计算缩放比例并居中 function fitStage() { const stage document.getElementById(stage); const scaleX window.innerWidth / 1920; const scaleY window.innerHeight / 1080; const scale Math.min(scaleX, scaleY); // 取小值保证完整显示不裁切 stage.style.transform translate(-50%, -50%) scale(${scale}); } window.addEventListener(resize, fitStage); fitStage(); // 初始化时执行一次Math.min是关键如果取scaleX在超宽屏上高度会溢出取小值保证整个 1920×1080 的画布完整落在屏幕内多出来的部分留黑边。transform-origin: left top配合translate(-50%, -50%)实现居中这个组合比用margin负值更稳因为transform不触发重排。resize监听保证窗口大小变化时重新计算比如从笔记本屏切到投影的瞬间。注意transform缩放不会改变元素的实际占位所以#stage内部所有布局仍按 1920×1080 写心智负担小。4.3 中奖名单导出 CSV活动结束后行政一定要一份中奖名单。导出功能用Blob 临时a标签触发下载纯前端实现不经过服务器。// 把中奖记录导出为 CSVExcel 可直接打开 function exportWinners() { const winners loadJSON(STORAGE_KEYS.winners, []); if (winners.length 0) { alert(还没有中奖记录); return; } // 表头 每行数据字段用逗号分隔 const rows [[姓名, 奖项, 中奖时间]]; winners.forEach(w rows.push([w.name, w.prizeId, w.time])); const csv rows.map(r r.join(,)).join(\n); // 加 BOM 头否则 Excel 打开中文会乱码 const blob new Blob([\uFEFF csv], { type: text/csv;charsetutf-8 }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download 中奖名单_${new Date().toISOString().slice(0, 10)}.csv; a.click(); URL.revokeObjectURL(url); // 释放内存避免泄漏 }这里又出现了 BOM只不过这次是主动加。导入时要去 BOM导出时要加 BOM方向相反但目的相同让 Excel 正确识别 UTF-8 中文。URL.createObjectURL生成一个临时地址a.click()触发下载revokeObjectURL及时释放否则多次导出会累积内存。文件名用toISOString().slice(0, 10)取日期部分生成“中奖名单_2025-01-20.csv”行政一眼能对上活动日期。如果奖项要显示中文名而不是p1这种 id导出前用PRIZE_CONFIG做一次映射即可。5. 避坑与现场排查那些彩排时才发现的问题5.1 刷新后名单丢失现象彩排时导入名单正常正式活动前手滑刷新页面名单变回默认的几个人。原因导入逻辑只更新了内存变量没有写localStorage或者写入的 key 和读取的 key 不一致。解决导入成功后立刻saveJSON(STORAGE_KEYS.members, names)并在页面初始化时优先从localStorage读读不到再用默认名单。key 统一从STORAGE_KEYS取禁止手写字符串。5.2 抽到重复中奖者现象同一个人先中了三等奖后面又出现在一等奖候选里。原因候选池用的是初始化时的全量名单没有在每次抽奖后重新过滤或者winners记录写入失败导致过滤时看不到已中奖者。解决每次抽奖前调用getCandidates(allMembers, loadWinners())实时计算不要缓存候选池。抽完立即saveWinners并在控制台打印当前候选池长度彩排时肉眼核对。5.3 大屏上文字溢出或错位现象笔记本上预览正常投到投影后名字超出框、按钮跑到屏幕外。原因浏览器缩放不是 100%或者用了固定px布局没做等比缩放。解决投屏前按Ctrl0重置缩放确认#stage的fitStage()已执行。检查是否所有尺寸都基于 1920×1080 设计稿避免混用vw和px。5.4 CSV 导入中文乱码现象导入后名字显示成问号或方块。原因CSV 文件不是 UTF-8 编码常见于 Windows 下 Excel 默认另存为 GBK。解决让行政在 Excel 里“另存为 → CSV UTF-8逗号分隔”代码侧readAsText(file, UTF-8)明确指定编码并在解析前去掉 BOM。如果拿到的是 GBK 文件用TextDecoder(gbk)解码ArrayBuffer兜底。5.5 动画卡顿或定时器叠加现象快速连点“开始”按钮名字滚动越来越快或者停止后还在滚。原因每次点击都新建setInterval旧的没清理多个定时器同时跑。解决startRolling开头先clearInterval(rollingTimer)按钮点击时判断rollingTimer是否为空非空则忽略。停止时务必clearInterval并置null形成“开-关”配对。6. 二次开发把单机抽奖改成可配置的活动工具拿到这份静态项目后最值得做的改造不是加花哨动画而是把“硬编码”变成“可配置”让它能复用到不同活动。我的做法是抽出一层activity配置对象把奖项、名单来源、是否允许重复中奖都收进去页面只读配置不写死逻辑。// activity.js —— 活动级配置换活动只改这里 const ACTIVITY { title: 2025 公司年会, allowRepeatWin: false, // 是否允许一人多奖 prizes: [ { id: p3, name: 三等奖, count: 10 }, { id: p2, name: 二等奖, count: 5 }, { id: p1, name: 一等奖, count: 2 } ], memberSource: inline // inline | csv };改造时按这个顺序验证先改prizes的数量确认抽奖名额和界面显示一致再把allowRepeatWin设为true确认候选池不再剔除已中奖者最后换memberSource为csv走一遍导入导出闭环。每一步都在控制台打印候选池长度和中奖记录条数数字对得上才算过。另一个实用改造是加“彩排模式”在 URL 后加?dryRun1抽奖结果不写入localStorage只在大屏显示。这样正式活动前可以反复演练流程不用担心把假数据带进正式记录。实现上就是在saveWinners前判断new URLSearchParams(location.search).has(dryRun)命中则直接返回。还有一个容易被忽略的点多奖项连续抽取的节奏控制。三等奖抽 10 个人是一个一个抽还是一次性抽 10 个前者气氛好但耗时长后者快但缺少悬念。我的建议是配置里加batchSize三等奖按 5 个一批抽一等奖一个一个抽主持人根据现场节奏切换。这个改动只涉及draw.js的循环逻辑不动数据结构属于低成本高回报。从那以后我每次做这类现场工具都会在活动前一天用真实名单和真实设备完整走一遍“导入 → 抽奖 → 导出 → 清空重来”把彩排当正式跑。静态 HTML 抽奖软件看着简单但现场只有一次机会提前把坑踩完台上才能稳。希望帮到你。本文还有配套的精品资源点击获取