# 在线点名抽奖 — 前端架构设计文档 > 项目 ID: PROJ-20260523012 > 类型: 纯静态 Web(单 HTML 文件) > 约束: 无后端、无外部依赖、无持久化、CSS/JS 全部内嵌 --- ## 1. 技术选型 | 维度 | 选择 | 理由 | |------|------|------| | 文件格式 | 单个 `.html` | 零部署,双击即可运行 | | CSS | 内联 `

🎯 在线点名抽奖

当前名单: 0

📋 名单管理

🎰 抽奖

?
等待开始

📜 历史记录

轮次中奖人时间
``` --- ## 5. CSS 方案 ### 5.1 设计原则 - **响应式**:Flexbox + CSS Grid,移动端优先 - **配色**:深色主题 + 高对比度强调色 - **无外部字体**:使用系统字体栈 ### 5.2 CSS Variables ```css :root { --bg-primary: #1a1a2e; --bg-secondary: #16213e; --bg-card: #0f3460; --text-primary: #e0e0e0; --text-secondary: #a0a0b0; --accent: #e94560; --accent-hover: #ff6b81; --success: #2ecc71; --warning: #f39c12; --border-radius: 12px; --shadow: 0 4px 20px rgba(0, 0, 0, 0.3); --transition: all 0.3s ease; } ``` ### 5.3 布局 ``` ┌─────────────────────────────────────────────┐ │ Header (flex, center) │ ├─────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────────┐ │ │ │ 名单管理 │ │ 抽奖区 │ │ │ │ (panel) │ │ (panel) │ │ │ │ │ │ │ │ │ │ textarea │ │ 模式切换 │ │ │ │ button row │ │ 滚动展示区 │ │ │ │ name list │ │ 抽奖按钮 │ │ │ │ │ │ 结果 + Canvas │ │ │ └──────────────┘ └──────────────────┘ │ │ │ ├─────────────────────────────────────────────┤ │ 历史记录 (table) │ └─────────────────────────────────────────────┘ 移动端:面板纵向堆叠(flex-direction: column) 桌面端:面板横向排列(flex-direction: row) ``` ### 5.4 关键样式 - **滚动展示区**:居中、大字号、带发光效果 - **头像**:圆形、居中文字、纯色背景 - **抽奖按钮**:大尺寸、渐变色、长按态(视觉反馈) - **后门面板**:全屏遮罩 + 居中模态框 - **烟花 Canvas**:`position: fixed; top: 0; left: 0; pointer-events: none; z-index: 9999` --- ## 6. 交互流程 ### 6.1 主流程 ``` [用户打开页面] │ ▼ [输入/导入名单] ──→ [名单加入抽奖池] ──→ [显示总人数] │ ▼ [选择模式] ──→ 单次/重复 │ ▼ [点击"开始抽奖"] │ ▼ [名字滚动动画 3s → 减速停止] │ ▼ [显示中奖人 + 烟花动画] │ ▼ [记录历史] ──→ [单次模式: 移除中奖人] │ ▼ [可继续下一轮] ``` ### 6.2 后门流程 ``` [长按"开始"按钮 ≥ 3秒] │ ▼ [触发后门面板(遮罩 + 模态框)] │ ├─→ [设置必中名单] │ ├─ 手动输入 │ └─ 从当前池中勾选(复选框列表) │ ├─→ [设置排除名单] │ ├─ 手动输入 │ └─ 从当前池中勾选(复选框列表) │ └─→ [关闭面板] ``` ### 6.3 长按检测 ```js let pressTimer = null; btnStart.addEventListener('mousedown', (e) => { if (state.isDrawing) return; // 抽奖中不响应 pressTimer = setTimeout(() => { openBackdoor(); pressTimer = null; }, 3000); }); btnStart.addEventListener('mouseup', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; startLottery(); // 普通点击:开始抽奖 } }); btnStart.addEventListener('mouseleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } }); // 移动端兼容 btnStart.addEventListener('touchstart', (e) => { /* 同上 */ }); btnStart.addEventListener('touchend', (e) => { /* 同上 */ }); ``` --- ## 7. 滚动动画方案 ### 7.1 设计 使用 `requestAnimationFrame` 实现名字快速滚动,通过**缓动函数**控制减速。 ### 7.2 算法 ```js function rollAnimation(winner, duration = 3500) { const startTime = performance.now(); let lastIndex = -1; function tick(now) { const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); // 缓动:前 85% 快速切换,后 15% 减速 const easeProgress = easeOutCubic(progress); const interval = 50 + easeProgress * 400; // 50ms → 450ms if (elapsed - lastTickTime >= interval) { // 从池中随机选一个名字显示 const candidate = getRollCandidate(winner, progress); updateRollDisplay(candidate); lastTickTime = elapsed; } if (progress < 1) { rAFId = requestAnimationFrame(tick); } else { // 最终停在 winner updateRollDisplay(winner); callback(winner); } } rAFId = requestAnimationFrame(tick); } function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } function getRollCandidate(winner, progress) { // 最后 10% 逐渐偏向 winner if (progress > 0.9 && Math.random() < 0.3) { return winner; } return state.pool[Math.floor(Math.random() * state.pool.length)]; } ``` ### 7.3 视觉 - 名字切换时带 `opacity` 和 `transform: scale` 的 CSS transition - 滚动时名字颜色随机闪烁 - 停止时放大 + 发光效果 --- ## 8. Canvas 烟花粒子效果方案 ### 8.1 架构 ``` FireworksEngine ├── Particle 类 │ ├── x, y, vx, vy │ ├── color, alpha, size │ ├── life, maxLife │ └── gravity, friction │ ├── Firework 类 │ ├── x, y (发射点) │ ├── targetX, targetY (爆炸点) │ ├── particles[] (爆炸后粒子) │ └── state: 'rising' | 'exploding' | 'dead' │ └── 主循环 ├── update() — 更新所有烟花状态 ├── render() — 绘制到 canvas └── spawn() — 生成新烟花 ``` ### 8.2 实现要点 ```js class Particle { constructor(x, y, color) { const angle = Math.random() * Math.PI * 2; const speed = 2 + Math.random() * 6; this.x = x; this.y = y; this.vx = Math.cos(angle) * speed; this.vy = Math.sin(angle) * speed; this.color = color; this.alpha = 1; this.size = 2 + Math.random() * 3; this.life = 60 + Math.random() * 40; this.maxLife = this.life; this.gravity = 0.05; this.friction = 0.98; } update() { this.vx *= this.friction; this.vy *= this.friction; this.vy += this.gravity; this.x += this.vx; this.y += this.vy; this.alpha = this.life / this.maxLife; this.life--; } draw(ctx) { ctx.save(); ctx.globalAlpha = this.alpha; ctx.fillStyle = this.color; ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } class FireworksEngine { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.fireworks = []; this.running = false; this.spawnTimer = 0; } start(duration = 4000) { this.resize(); this.running = true; this.endTime = performance.now() + duration; this.loop(); } loop() { if (!this.running) return; const now = performance.now(); if (now > this.endTime) { this.stop(); return; } this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // 每 200-500ms 生成一朵烟花 this.spawnTimer++; if (this.spawnTimer > 4 + Math.random() * 6) { this.spawnFirework(); this.spawnTimer = 0; } // 更新和绘制 this.fireworks = this.fireworks.filter(fw => fw.state !== 'dead'); this.fireworks.forEach(fw => { fw.update(); fw.draw(this.ctx); }); requestAnimationFrame(() => this.loop()); } spawnFirework() { const x = this.canvas.width * (0.2 + Math.random() * 0.6); const y = this.canvas.height * (0.1 + Math.random() * 0.4); const colors = this.randomColorPalette(); this.fireworks.push(new Firework(x, y, colors)); } randomColorPalette() { const palettes = [ ['#FF6B6B', '#FFA07A', '#FFD700'], ['#4ECDC4', '#45B7D1', '#96CEB4'], ['#DDA0DD', '#FF69B4', '#FFB6C1'], ['#F97F51', '#E056FD', '#686DE0'], ['#F9FBE7', '#A7C957', '#6CB4B0'], ]; return palettes[Math.floor(Math.random() * palettes.length)]; } stop() { this.running = false; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } } ``` ### 8.3 触发时机 - 中奖结果显示的同时启动烟花 - 持续约 4 秒后自动停止 - Canvas 覆盖全屏,`pointer-events: none` 不阻挡交互 --- ## 9. 头像生成方案 ### 9.1 文字提取 ```js function getAvatarText(name) { // 取名字后两个字 const chars = name.trim(); if (chars.length <= 2) return chars; return chars.slice(-2); } ``` ### 9.2 颜色哈希 ```js function hashColor(name) { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } // 使用 HSL 保证亮度和饱和度在舒适范围 const hue = Math.abs(hash) % 360; return `hsl(${hue}, 65%, 55%)`; } ``` ### 9.3 头像渲染 ```html
张三
``` ```css .avatar { width: 80px; height: 80px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: bold; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.3); } ``` --- ## 10. JS 代码组织 ```js (function() { 'use strict'; // ===== 共享状态 ===== const state = { /* ... */ }; // ===== 工具函数 ===== const Utils = { generateId() { /* ... */ }, hashColor(name) { /* ... */ }, getAvatarText(name) { /* ... */ }, formatTime(date) { /* ... */ }, parseNames(text) { /* ... */ }, }; // ===== 模块 ===== const NameManager = { /* ... */ }; const LotteryEngine = { /* ... */ }; const AnimationEngine = { /* ... */ }; const HistoryModule = { /* ... */ }; const Backdoor = { /* ... */ }; // ===== DOM 引用 ===== const DOM = { /* ... */ }; // ===== 事件绑定 ===== function initEvents() { /* ... */ } // ===== 初始化 ===== function init() { initEvents(); HistoryModule.render(); } // DOMContentLoaded 后启动 document.addEventListener('DOMContentLoaded', init); })(); ``` --- ## 11. 响应式断点 | 断点 | 布局 | |------|------| | `< 768px` | 单列,面板纵向堆叠 | | `≥ 768px` | 双列,名单管理 | 抽奖区 | | `≥ 1200px` | 双列,间距加大,最大宽度 1200px 居中 | --- ## 12. 文件导入处理 ### TXT 导入 ```js function importTXT(file) { const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; const names = Utils.parseNames(text); NameManager.addToPool(names); }; reader.readAsText(file, 'UTF-8'); } ``` ### CSV 导入 ```js function importCSV(file) { const reader = new FileReader(); reader.onload = (e) => { const text = e.target.result; // 先按行分割,再按逗号分割,合并所有名字 const names = Utils.parseNames(text); NameManager.addToPool(names); }; reader.readAsText(file, 'UTF-8'); } ``` `parseNames` 统一处理:按 `[,\s;;,,、\n\r\t]+` 分割,去空、去重。 --- ## 13. 文件清单 | 文件 | 说明 | |------|------| | `index.html` | 唯一文件,包含所有 HTML + CSS + JS | --- ## 14. 兼容性 | 特性 | 最低版本 | |------|----------| | CSS Variables | Chrome 49+, Firefox 31+, Safari 9.1+ | | Canvas 2D | IE 9+, 所有现代浏览器 | | FileReader | IE 10+, 所有现代浏览器 | | requestAnimationFrame | IE 10+, 所有现代浏览器 | | Flexbox | IE 11+, 所有现代浏览器 | 目标:所有现代浏览器(Chrome / Firefox / Safari / Edge 最新两个大版本)。 --- ## 15. 性能考量 - 名单池上限:无硬限制,但 UI 渲染建议 ≤ 10000 人 - 名字滚动:使用 `requestAnimationFrame`,非 `setInterval` - 烟花粒子:单朵烟花 ≤ 80 个粒子,同时 ≤ 5 朵烟花 - Canvas:`will-change: transform` 提示浏览器优化 - DOM 更新:历史记录使用 `DocumentFragment` 批量更新 --- ## 16. v1.1.0 架构变更(M002:概率后门) > 变更类型:状态结构扩展 + 核心算法改造 + UI 增强 > 影响模块:`state`、`LotteryEngine`、`Backdoor` > 关联修改项:M002(概率后门) ### 16.1 状态结构变更 #### 16.1.1 `state.backdoor` 扩展 新增 `probabilities` 字段,用于存储每个人的概率权重: ```js const state = { // ... 原有字段保持不变 backdoor: { enabled: false, mustWinList: [], // 必中名单(名字字符串数组) excludeList: [], // 排除名单(名字字符串数组) // ▼ v1.1.0 新增 probabilities: {}, // { name: weight },权重值 0-100 // 例:{ "张三": 50, "李四": 5, "王五": 1 } }, }; ``` **字段说明:** | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `probabilities` | `Object` | `{}` | 键为人名,值为权重(0-100 整数)。未在此对象中列出的人使用默认权重 1。 | #### 16.1.2 人员对象扩展 `person` 对象新增 `weight` 字段: ```js { id: 'uuid-v4-style', name: '张三', color: '#E74C3C', avatar: '三', weight: 1, // ▼ v1.1.0 新增:默认 1,表示等概率 } ``` **说明:** - 默认值为 `1`,表示与其他默认权重人员等概率 - 权重值范围:`0-100` - 权重为 `0` 的人不参与抽奖(等同于排除) - 权重值越高,被抽中的概率越大 ### 16.2 加权随机算法设计 #### 16.2.1 算法概述 `LotteryEngine.pickOne()` 从原来的等概率随机改为**加权随机算法**(Weighted Random Selection)。 **核心思路:** 将每个人的权重映射到数轴上的区间,生成一个随机数落在哪个区间,就选中对应的人。 #### 16.2.2 优先级规则 ``` 必中名单 > 概率权重 > 排除名单 ``` | 优先级 | 规则 | 说明 | |--------|------|------| | 1(最高) | 必中名单 | 若必中名单非空且池中有匹配,**无视概率权重**,在交集内等概率随机 | | 2 | 概率权重 | 无必中名单时,按权重加权随机 | | 3(最低) | 排除名单 | 排除名单中的人 + 权重为 0 的人,不参与任何抽奖 | #### 16.2.3 伪代码 ``` LotteryEngine.pickOne(): // Step 1: 获取候选池副本 candidates = state.pool 的浅拷贝 // Step 2: 过滤排除名单 candidates = candidates.filter(p => p.name 不在 state.backdoor.excludeList 中 AND p.weight > 0 ) // Step 3: 必中名单优先(最高优先级) if state.backdoor.mustWinList 非空: 交集 = candidates.filter(p => p.name 在 state.backdoor.mustWinList 中 ) if 交集非空: return 交集中的等概率随机一人 // 必中名单内等概率,不按权重 // 交集为空:必中名单中的人都不在候选池,降级到加权随机 // Step 4: 加权随机(无必中名单或必中名单不匹配时) if candidates.length == 0: return null // 无人可抽 // 计算总权重 totalWeight = candidates.reduce((sum, p) => sum + p.weight, 0) if totalWeight == 0: return 等概率随机一人 // 所有人权重都为 0 的兜底 // 加权随机选择 r = Math.random() * totalWeight cumulative = 0 for each person in candidates: cumulative += person.weight if r < cumulative: return person // 兜底(理论上不会执行到) return candidates[candidates.length - 1] ``` #### 16.2.4 算法示例 假设候选池为: | 名字 | 权重 | |------|------| | 张三 | 50 | | 李四 | 5 | | 王五 | 1 | | 赵六 | 1 | - 总权重 = 50 + 5 + 1 + 1 = 57 - 张三被抽中的概率 = 50/57 ≈ 87.7% - 李四被抽中的概率 = 5/57 ≈ 8.8% - 王五被抽中的概率 = 1/57 ≈ 1.8% - 赵六被抽中的概率 = 1/57 ≈ 1.8% **边界情况处理:** | 场景 | 处理方式 | |------|----------| | 所有人权重都为 0 | 降级为等概率随机(兜底) | | 无人列出在 `probabilities` 中 | 所有人使用默认权重 1,等同于等概率 | | 部分人权重为 0 | 权重为 0 的人不参与抽奖 | | 必中名单与排除名单冲突 | 排除名单先过滤,必中名单在过滤后的池中匹配 | ### 16.3 后门面板 UI 变更 #### 16.3.1 新增概率设置区域 在现有后门面板中,必中名单和排除名单之间新增**概率设置区域**: ```html ``` #### 16.3.2 概率列表项结构 每个人员的概率设置行: ```html
张三
``` #### 16.3.3 CSS 新增样式 ```css /* 概率设置区域 */ .probability-hint { font-size: 12px; color: var(--text-secondary); margin-bottom: 12px; } .probability-list { max-height: 300px; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 8px; } .probability-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } .probability-row:last-child { border-bottom: none; } .person-name { flex: 0 0 80px; font-size: 14px; } .weight-input { width: 60px; padding: 4px 8px; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; background: var(--bg-secondary); color: var(--text-primary); text-align: center; } .weight-quick-btns { display: flex; gap: 4px; } .weight-quick-btns button { padding: 2px 8px; font-size: 12px; border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 4px; background: var(--bg-card); color: var(--text-secondary); cursor: pointer; transition: var(--transition); } .weight-quick-btns button:hover { background: var(--accent); color: #fff; } .weight-quick-btns button.active { background: var(--accent); color: #fff; } .probability-actions { display: flex; gap: 8px; margin-top: 12px; } ``` #### 16.3.4 交互逻辑 ```js Backdoor.renderProbabilityList() { // 1. 获取当前池中的所有人员 const list = document.getElementById('probabilityList'); list.innerHTML = ''; state.pool.forEach(person => { const row = document.createElement('div'); row.className = 'probability-row'; row.dataset.name = person.name; // 获取当前权重(优先从 probabilities 中读取,否则用 person.weight) const currentWeight = state.backdoor.probabilities[person.name] ?? person.weight ?? 1; // 人名 const nameSpan = document.createElement('span'); nameSpan.className = 'person-name'; nameSpan.textContent = person.name; // 权重输入框 const weightInput = document.createElement('input'); weightInput.type = 'number'; weightInput.className = 'weight-input'; weightInput.min = 0; weightInput.max = 100; weightInput.value = currentWeight; weightInput.dataset.name = person.name; // 快捷按钮 const quickBtns = document.createElement('div'); quickBtns.className = 'weight-quick-btns'; [0, 1, 5, 10, 50, 100].forEach(w => { const btn = document.createElement('button'); btn.textContent = w; btn.dataset.weight = w; if (w === currentWeight) btn.classList.add('active'); btn.addEventListener('click', () => { weightInput.value = w; quickBtns.querySelectorAll('button').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); quickBtns.appendChild(btn); }); // 输入框变化时更新快捷按钮高亮 weightInput.addEventListener('input', () => { const v = parseInt(weightInput.value) || 0; quickBtns.querySelectorAll('button').forEach(b => { b.classList.toggle('active', parseInt(b.dataset.weight) === v); }); }); row.append(nameSpan, weightInput, quickBtns); list.appendChild(row); }); } Backdoor.saveProbabilities() { const inputs = document.querySelectorAll('.weight-input'); const newProbs = {}; inputs.forEach(input => { const name = input.dataset.name; const weight = Math.max(0, Math.min(100, parseInt(input.value) || 0)); if (weight !== 1) { // 只存储非默认值,节省空间 newProbs[name] = weight; } }); state.backdoor.probabilities = newProbs; // 同步更新 pool 中每个人的 weight state.pool.forEach(person => { person.weight = state.backdoor.probabilities[person.name] ?? 1; }); } ``` ### 16.4 与现有模块的集成 #### 16.4.1 `LotteryEngine.pickOne()` 改造点 ```js LotteryEngine.pickOne = function() { // 1. 获取候选池 let candidates = [...state.pool]; // 2. 过滤排除名单 + 权重为 0 的人 candidates = candidates.filter(p => { const isExcluded = state.backdoor.excludeList.includes(p.name); const weight = state.backdoor.probabilities[p.name] ?? p.weight ?? 1; return !isExcluded && weight > 0; }); // 3. 必中名单优先 if (state.backdoor.mustWinList.length > 0) { const mustWinCandidates = candidates.filter(p => state.backdoor.mustWinList.includes(p.name) ); if (mustWinCandidates.length > 0) { // 必中名单内等概率随机 return mustWinCandidates[Math.floor(Math.random() * mustWinCandidates.length)]; } } // 4. 加权随机 if (candidates.length === 0) return null; const totalWeight = candidates.reduce((sum, p) => { return sum + (state.backdoor.probabilities[p.name] ?? p.weight ?? 1); }, 0); if (totalWeight === 0) { // 兜底:所有人权重都为 0 时等概率 return candidates[Math.floor(Math.random() * candidates.length)]; } let r = Math.random() * totalWeight; for (const person of candidates) { const w = state.backdoor.probabilities[person.name] ?? person.weight ?? 1; r -= w; if (r <= 0) return person; } return candidates[candidates.length - 1]; }; ``` #### 16.4.2 与 M001(名单编辑)的集成 当删除人员时,同步清理概率配置: ```js NameManager.removeByName(name) { state.pool = state.pool.filter(p => p.name !== name); // 同步清理必中名单 state.backdoor.mustWinList = state.backdoor.mustWinList.filter(n => n !== name); // 同步清理排除名单 state.backdoor.excludeList = state.backdoor.excludeList.filter(n => n !== name); // ▼ v1.1.0 新增:同步清理概率配置 delete state.backdoor.probabilities[name]; // 更新 UI NameManager.renderNameList(); NameManager.updatePoolCount(); } ``` #### 16.4.3 新增人员时的权重处理 ```js NameManager.addToPool(names) { names.forEach(name => { if (state.pool.some(p => p.name === name)) return; // 去重 const person = { id: Utils.generateId(), name: name, color: Utils.hashColor(name), avatar: Utils.getAvatarText(name), weight: 1, // v1.1.0: 默认权重 1 }; state.pool.push(person); }); NameManager.updatePoolCount(); NameManager.renderNameList(); } ``` ### 16.5 数据流图 ``` 用户操作后门面板 │ ▼ [Backdoor.renderProbabilityList()] │ 读取 state.backdoor.probabilities + state.pool │ ▼ [用户调整权重输入] │ ▼ [Backdoor.saveProbabilities()] │ 写入 state.backdoor.probabilities │ 同步更新 state.pool[].weight │ ▼ [用户点击"开始抽奖"] │ ▼ [LotteryEngine.pickOne()] │ 读取 state.backdoor.{mustWinList, excludeList, probabilities} │ 读取 state.pool[].weight │ 优先级:必中 > 权重 > 排除 │ ▼ 返回中奖人对象 ``` --- *文档版本: v1.0 → v1.1* *创建时间: 2026-05-23* *更新时间: 2026-05-24* *作者: 前端架构 Agent*