diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..749daec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,83 @@ +# 更新日志 (CHANGELOG) + +> 项目:在线点名抽奖 (PROJ-20260523012) +> 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/) + +--- + +## [v1.1.0] - 2026-05-24 + +### ✨ 新增 + +- **名单编辑功能(M001)** + - 导入名单后,每个名字标签旁显示 × 删除按钮 + - 点击 × 可删除对应人员,删除后自动同步后门配置(必中名单、排除名单、概率设置) + - 抽奖进行中时删除按钮自动禁用,防止误操作 + +- **概率后门(M002)** + - 后门面板新增概率设置区域 + - 可为每个人设置概率权重(0-100),权重越高中奖概率越大 + - 概率为 0 的人不参与抽奖 + - 优先级:必中名单 > 概率权重 > 排除名单 + - 加权随机算法:按权重比例分配,不强制归一化 + +### 🎨 优化 + +- **滚动动画改进(M003)** + - 从中心切换改为从左到右水平滚动(老虎机效果) + - 初始速度快,逐渐减速 + - 总时长约 3.5 秒 + +- **弹窗显示中奖结果(M004)** + - 抽奖结束后弹出模态弹窗 + - 包含大尺寸头像、名字、"恭喜"字样 + - 点击 × 或遮罩层可关闭 + +- **烟花效果改进(M005)** + - 烟花直接在目标位置爆炸(移除底部上升阶段) + - 爆炸点围绕弹窗位置分布 + - 持续时间延长至 5 秒 + - 粒子数量增加,效果更密集 + +### 🔧 技术变更 + +- 状态结构扩展:`state.backdoor.probabilities` 新增权重字段 +- 抽奖引擎改造:`LotteryEngine.pickOne()` 从等概率随机改为加权随机 +- 动画模块重构:`AnimationEngine` 滚动方式从中心切换改为水平滚动 +- Canvas 烟花系统优化:移除上升状态,改为直接爆炸模式 + +--- + +## [v1.0.0] - 2026-05-23 + +### ✨ 初始版本 + +- **名单管理** + - 手动输入(支持多种分隔符) + - TXT 文件导入 + - CSV/TSV 文件导入 + - 自动去重 + +- **抽奖引擎** + - 单次模式(抽中移除) + - 重复模式(抽中保留) + +- **动画展示** + - 名字快速切换滚动(3 秒减速停止) + - 圆形头像展示(取名字后两字) + - 个性配色(名字哈希生成) + - Canvas 粒子烟花动画(约 4 秒) + - 结果公告文字 + +- **历史记录** + - 轮次编号、中奖人、中奖时间 + - 页面底部表格展示 + +- **后门设置**(长按「开始抽奖」3 秒) + - 必中名单(文本输入 / 复选框选择) + - 排除名单(文本输入 / 复选框选择) + +--- + +[v1.1.0]: https://github.com/online-attendance-lottery/compare/v1.0.0...v1.1.0 +[v1.0.0]: https://github.com/online-attendance-lottery/releases/tag/v1.0.0 diff --git a/Code/docs/frontend-architecture.md b/Code/docs/frontend-architecture.md new file mode 100644 index 0000000..b4cec5b --- /dev/null +++ b/Code/docs/frontend-architecture.md @@ -0,0 +1,1268 @@ +# 在线点名抽奖 — 前端架构设计文档 + +> 项目 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* diff --git a/Code/docs/modification-assessment.md b/Code/docs/modification-assessment.md new file mode 100644 index 0000000..c92ebb2 --- /dev/null +++ b/Code/docs/modification-assessment.md @@ -0,0 +1,190 @@ +# 修改评估文档 + +> 项目:在线点名抽奖 (PROJ-20260523012) +> 评估时间:2026-05-24 +> 版本:v1.0.0 → v1.1.0 +> 评估人:前端架构 Agent + +--- + +## 修改评估总览 + +| 修改项 ID | 修改内容 | 影响范围 | 目标 Agent | 是否需要架构设计 | 难度 | 说明 | +|----------|---------|---------|-----------|----------------|------|------| +| M001 | 名单编辑功能(删除按钮) | 名单管理模块 + CSS | frontend-coding-agent | ❌ | 低 | 在现有 name-tag 上追加 × 按钮和删除逻辑 | +| M002 | 概率后门(每人概率设置) | 抽奖引擎 + 后门模块 + 状态结构 | frontend-coding-agent | ⚠️ 部分 | 中 | 需扩展状态结构、改造 pickOne 加权随机算法、后门面板新增概率输入 | +| M003 | 滚动动画改进(左→右滚动 + 快速启动) | 动画模块 + CSS | frontend-coding-agent | ❌ | 中 | 从名字切换改为水平滚动条效果,调整缓动曲线 | +| M004 | 弹窗显示中奖结果 | 动画模块 + CSS + HTML 结构 | frontend-coding-agent | ❌ | 低 | 用模态弹窗替代当前 result-area 内联显示 | +| M005 | 烟花效果改进(弹窗周围炸开 + 3秒以上) | 动画模块(Canvas 烟花) | frontend-coding-agent | ❌ | 中 | 移除底部上升阶段,改为弹窗位置直接爆炸,延长持续时间 | + +--- + +## 详细评估 + +### M001: 名单编辑功能(删除按钮) + +- **影响模块**:`NameManager` 模块、CSS(`.name-tag` 样式) +- **需要变更**: + - `NameManager.renderNameList()`:每个 name-tag 内追加 × 按钮,绑定点击事件 + - 新增 `NameManager.removeByName(name)` 方法:从 `state.pool` 中移除指定名字,更新计数和列表 + - CSS 新增 `.name-tag .delete-btn` 样式(小 × 号,hover 高亮,红色) +- **依赖关系**:无外部依赖,纯前端 DOM 操作 +- **风险点**: + - 删除后需同步更新 `state.backdoor.mustWinList` 和 `excludeList`(避免引用已删除的名字) + - 若当前正在抽奖中(`state.isDrawing === true`),应禁用删除操作 +- **代码量预估**:~50 行 JS + ~15 行 CSS +- **架构设计变更**:不需要。属于现有模块的功能增强。 + +--- + +### M002: 概率后门(每人概率设置) + +- **影响模块**:`LotteryEngine` 模块、`Backdoor` 模块、`state` 状态结构、后门面板 HTML/CSS +- **需要变更**: + - **状态扩展**:`state.backdoor.probabilities` 新增字段,结构为 `{ name: weight }` 的对象(权重值 0-100) + - **人员对象扩展**:`person` 对象新增 `weight` 字段(默认 1,表示等概率) + - **抽奖引擎改造**:`LotteryEngine.pickOne()` 从等概率随机改为**加权随机**: + ``` + 1. 候选池 = state.pool 副本 + 2. 过滤排除名单 + 3. 若必中名单非空:取交集 + 4. 计算总权重 = Σ(person.weight) + 5. 随机数 r = Math.random() * totalWeight + 6. 累加权重找到对应人选(加权随机算法) + ``` + - **后门面板**:新增概率设置区域,支持为每个人设置概率百分比(0-100),可输入数值或滑块 + - **Backdoor.renderChecklists()**:每个名字旁新增概率输入框 +- **依赖关系**: + - 依赖 M001 的删除逻辑(删除人时同步清理概率配置) + - 与现有必中/排除名单逻辑共存,需明确优先级:必中名单 > 概率权重 > 排除名单 +- **风险点**: + - 概率总和不为 100% 时的处理策略(建议:按权重比例分配,不强制归一化) + - 概率为 0 的人是否等同于排除名单(建议:是,概率 0 的人不参与抽奖) + - 后门面板 UI 复杂度增加,需注意滚动区域和响应式 +- **代码量预估**:~120 行 JS + ~60 行 CSS + HTML 结构变更 +- **架构设计变更**:⚠️ 部分需要。`state` 数据结构需扩展,`LotteryEngine.pickOne()` 核心算法需改造。需更新 `frontend-architecture.md` 的状态设计和抽奖引擎算法部分。 + +--- + +### M003: 滚动动画改进(从左到右滚动 + 快速启动) + +- **影响模块**:`AnimationEngine` 模块、CSS(`.roll-area`、`.roll-name` 相关样式) +- **需要变更**: + - **滚动方式改造**:从当前"中心位置名字切换"改为"水平滚动条"效果 + - 名字在水平方向上连续滚动,类似老虎机/跑马灯 + - 使用 CSS `transform: translateX()` 或 Canvas 实现 + - 滚动方向:从左到右 + - **速度曲线调整**: + - 初始速度要快(建议:初始间隔 20-30ms,即每秒 30-50 个名字切换) + - 逐渐减速(缓动函数保持不变或改用更陡峭的 easeOut) + - 总时长保持 3.5 秒左右 + - **CSS 新增**:滚动容器的 overflow hidden、名字项的水平排列样式 + - **DOM 结构**:可能需要在 roll-area 内新增滚动容器 div +- **依赖关系**: + - 与 M004 弹窗显示有 UI 交互关系(弹窗弹出时滚动应已停止) + - 头像显示区域可能也需要配合调整(头像跟随滚动还是固定居中) +- **风险点**: + - 水平滚动的性能:大量 DOM 节点滚动可能卡顿,建议使用 transform 而非改变 left 属性 + - 移动端兼容性:触摸滚动与动画滚动的冲突 + - 头像与名字滚动的同步问题 +- **代码量预估**:~100 行 JS + ~80 行 CSS + HTML 结构调整 +- **架构设计变更**:不需要。属于动画模块内部实现替换,接口不变(`startRollAnimation(winner, callback)` 签名不变)。 + +--- + +### M004: 弹窗显示中奖结果 + +- **影响模块**:动画模块(`AnimationEngine.startRollAnimation` 的 callback)、CSS(弹窗样式)、HTML 结构 +- **需要变更**: + - **新增弹窗 HTML 结构**:在 `#app` 内新增中奖弹窗 div(类似后门面板的遮罩 + 模态框结构) + - 遮罩层:全屏半透明黑色 + - 弹窗内容:中奖人头像(大尺寸)、名字、"恭喜"字样 + - 关闭按钮或点击遮罩关闭 + - **CSS 新增**:弹窗遮罩、弹窗容器、动画(弹出缩放 + 淡入效果) + - **动画模块修改**:`startRollAnimation` 的 callback 中,从设置 `resultArea` 改为打开弹窗 + - **交互**:弹窗可点击关闭,关闭后显示原有的 result-area(或直接用弹窗替代 result-area) +- **依赖关系**: + - 依赖 M003 滚动动画停止后才弹出(时序关系) + - 与 M005 烟花效果配合:弹窗弹出时烟花同时开始 + - 与 M002 概率后门无直接依赖 +- **风险点**: + - 弹窗的 z-index 需高于烟花 Canvas(当前烟花 z-index: 9999),弹窗 z-index 需设为 10001+ + - 弹窗动画与烟花动画的性能叠加,需注意低端设备 +- **代码量预估**:~60 行 JS + ~70 行 CSS + ~20 行 HTML +- **架构设计变更**:不需要。属于 UI 层变更,不改变模块接口。 + +--- + +### M005: 烟花效果改进(弹窗周围炸开 + 3秒以上) + +- **影响模块**:`AnimationEngine.fireFireworks()` 方法、Canvas 粒子系统 +- **需要变更**: + - **移除上升阶段**:当前 `Firework` 类有 `'rising'` 状态(从底部升到目标位置),需移除 + - 烟花直接在目标位置爆炸,不再从底部发射 + - **爆炸位置**:围绕弹窗位置生成爆炸点 + - 获取弹窗 DOM 元素的中心坐标 + - 在弹窗周围一定半径范围内随机生成爆炸点 + - 爆炸点分布:弹窗的上下左右四个方向 + - **持续时间**:从当前 4 秒改为 3 秒以上(建议 4-5 秒) + - 修改 `endTime = performance.now() + 5000` + - **粒子效果优化**: + - 增加初始爆炸的粒子数量(更密集) + - 可考虑多波爆炸效果(第一波密集,后续逐渐稀疏) +- **依赖关系**: + - 依赖 M004 弹窗的位置信息(需要弹窗 DOM 元素的坐标) + - 与 M003 无直接依赖 + - 与 M002 无直接依赖 +- **风险点**: + - 弹窗位置动态变化(响应式)时需重新计算爆炸点 + - 烟花持续 3 秒以上时,粒子数量控制很重要,避免内存泄漏或卡顿 + - 窗口 resize 时的 canvas 尺寸同步 +- **代码量预估**:~80 行 JS 修改 +- **架构设计变更**:不需要。属于动画模块内部实现修改,接口不变(`fireFireworks()` 无参数调用)。 + +--- + +## 修改项依赖关系图 + +``` +M001 (名单编辑) ────────────────────────────── 独立 +M002 (概率后门) ─── 依赖 M001 的删除同步逻辑 ─── 需架构设计更新 +M003 (滚动动画) ────────────────────────────── 独立 +M004 (弹窗显示) ─── 依赖 M003 滚动停止 ─────── 独立 +M005 (烟花改进) ─── 依赖 M004 弹窗位置 ─────── 独立 +``` + +**建议实施顺序**: +1. **M001** → 名单编辑(最基础,无依赖) +2. **M002** → 概率后门(核心逻辑变更,需先完成 M001) +3. **M003** → 滚动动画(独立,可与 M004/M005 并行开发) +4. **M004** → 弹窗显示(依赖 M003 完成) +5. **M005** → 烟花改进(依赖 M004 完成) + +--- + +## 总体影响评估 + +| 维度 | 评估 | +|------|------| +| **代码改动量** | 中等(约 400-500 行 JS + 200-250 行 CSS + HTML 结构调整) | +| **架构变更** | 仅 M002 需要更新状态结构和核心算法,其余为 UI/动画层变更 | +| **测试重点** | M002 加权随机算法的正确性、M003/M005 动画性能、M001 删除边界情况 | +| **兼容性影响** | 无新增 API 依赖,保持现有浏览器兼容性 | +| **文件变更** | 仅 `index.html` 单文件(符合项目约束) | + +--- + +## 目标 Agent 分配总结 + +| Agent | 负责修改项 | 工作量 | +|-------|-----------|--------| +| **frontend-coding-agent** | M001, M002, M003, M004, M005(全部) | 高(5 项全部) | +| **test-qa-agent** | 全部 5 项的功能测试 + 性能测试 | 中 | +| **doc-gen-agent** | 更新 README 和使用文档 | 低 | +| **package-release-agent** | Git 同步 + v1.1.0 版本发布 | 低 | + +--- + +*文档版本: v1.0* +*创建时间: 2026-05-24* +*作者: 前端架构 Agent* diff --git a/Code/docs/team.md b/Code/docs/team.md new file mode 100644 index 0000000..9ca043f --- /dev/null +++ b/Code/docs/team.md @@ -0,0 +1,21 @@ +# 项目成员表 + +> 项目:在线点名抽奖 (PROJ-20260523012) +> 创建时间:2026-05-23 + +## 成员列表 + +| 成员 | 职责 | 负责功能 | +|------|------|---------| +| frontend-architect | 前端架构设计 | 整体架构设计、技术选型、模块划分 | +| frontend-coding-agent | 前端编码实现 | HTML/CSS/JS 代码实现 | +| test-qa-agent | 测试验证 | 功能测试、边界测试 | +| doc-gen-agent | 文档生成 | README.md、使用文档 | +| package-release-agent | 打包发布 | Git 同步、版本管理、Release | + +## 修改记录 + +| 版本 | 日期 | 修改内容 | 修改人 | +|------|------|---------|--------| +| v1.0.0 | 2026-05-23 | 初始版本 | frontend-coding-agent | +| v1.1.0 | 2026-05-24 | 功能优化(5项):名单编辑、概率后门、滚动动画改进、结果弹窗、烟花效果改进 | frontend-coding-agent | diff --git a/README.md b/README.md index 576610d..aa242b6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ > 纯静态 HTML,零依赖,开箱即用 +**当前版本:v1.1.0** + --- ## 项目简介 @@ -31,6 +33,8 @@ 导入后,所有人员自动进入抽奖池,页面顶部实时显示当前有效名单总数。名单列表以标签形式展示在输入框下方,方便查看。 +> **v1.1.0 新增**:每个名字标签旁带有 **× 删除按钮**,点击即可移除对应人员,删除后自动同步后门配置(必中名单、排除名单、概率设置)。 + ### 🎰 F022 - 抽奖引擎 从有效名单中随机选取一人,支持两种模式: @@ -40,15 +44,17 @@ > **提示**:长按「开始抽奖」按钮 3 秒可进入高级设置面板(详见下方「后门设置」)。 +> **v1.1.0 新增**:支持**概率权重**设置。可为每个人分配 0-100 的概率权重,权重越高中奖概率越大;权重为 0 的人不参与抽奖。优先级:必中名单 > 概率权重 > 排除名单。 + ### 🎆 F023 - 动画展示 中奖过程配有流畅的视觉动画: -- **名字滚动**:名字快速切换滚动,3 秒后逐渐减速停止 +- **名字滚动**(v1.1.0 改进):从左到右水平滚动(老虎机效果),初始速度快,逐渐减速,总时长约 3.5 秒 - **头像展示**:自动取名字的后两个字作为头像文字,显示在圆形头像中 - **个性配色**:每人分配不同的纯色背景(通过名字哈希算法生成) -- **烟花庆祝**:中奖后播放 Canvas 粒子烟花动画,持续约 4 秒 -- **结果公告**:动画结束后显示"🎉 本次幸运儿是:XXX" +- **烟花庆祝**(v1.1.0 改进):烟花直接在弹窗周围爆炸(无底部上升阶段),持续 5 秒,粒子更密集 +- **结果弹窗**(v1.1.0 新增):抽奖结束后弹出模态弹窗,展示大尺寸头像、名字和"恭喜"字样,点击 × 或遮罩层可关闭 ### 📜 F024 - 历史记录 @@ -151,6 +157,20 @@ 设置后,抽奖将仅在必中名单范围内随机选择。 +#### 概率权重(v1.1.0 新增) + +在「概率设置」区域,可以为每个人分配概率权重: + +- **设置方式**:在每个人名旁的输入框中填写 0-100 的数值 +- **权重含义**:数值越大,中奖概率越高(按权重比例分配) +- **权重为 0**:该人完全不参与抽奖(等同于排除) +- **默认值**:未设置时所有人权重为 1(等概率) + +> **优先级**:必中名单 > 概率权重 > 排除名单 +> - 若设置了必中名单,则仅在必中名单内按权重分配 +> - 排除名单中的人不参与抽奖 +> - 概率为 0 的人等同于排除 + #### 排除名单 在「排除名单」区域,可以指定某些人不参与抽奖: @@ -177,6 +197,33 @@ 5. **后门设置**:长按按钮进入后门面板,普通用户不会误触。后门设置同样在页面关闭后失效。 6. **单次模式**:抽中的人会从名单中移除,抽奖池人数会相应减少。 7. **动画期间**:抽奖动画进行中(约 3.5 秒),按钮处于禁用状态,无法重复点击。 +8. **名单编辑**(v1.1.0):导入名单后可通过 × 按钮删除人员,删除后自动同步后门配置(必中名单、排除名单、概率设置)。抽奖进行中时删除按钮禁用。 + +--- + +## 更新日志 + +### v1.1.0(2026-05-24) + +**新增功能:** + +- **名单编辑**:导入名单后,每个名字旁显示 × 删除按钮,点击即可移除,自动同步后门配置(M001) +- **概率后门**:后门面板新增概率设置区域,可为每个人设置 0-100 的概率权重,高权重者中奖概率更高,优先级:必中名单 > 概率权重 > 排除名单(M002) + +**优化改进:** + +- **滚动动画**:从中心切换改为从左到右水平滚动(老虎机效果),初始速度快、逐渐减速,总时长约 3.5 秒(M003) +- **结果弹窗**:抽奖结束后弹出模态弹窗,展示大尺寸头像、名字和"恭喜"字样,点击 × 或遮罩层可关闭(M004) +- **烟花效果**:烟花直接在弹窗位置周围爆炸(无底部上升阶段),持续 5 秒,粒子更密集(M005) + +### v1.0.0(2026-05-23) + +- 初始版本发布 +- 支持手动输入、TXT/CSV 文件导入名单 +- 单次/重复两种抽奖模式 +- 名字滚动动画 + 烟花庆祝效果 +- 中奖历史记录 +- 后门设置(必中名单、排除名单) --- @@ -195,4 +242,4 @@ --- -*文档版本:v1.0 | 项目 ID:PROJ-20260523012* +*文档版本:v1.1.0 | 项目 ID:PROJ-20260523012* diff --git a/Test/test-report-v1.1.0.md b/Test/test-report-v1.1.0.md new file mode 100644 index 0000000..221ed25 --- /dev/null +++ b/Test/test-report-v1.1.0.md @@ -0,0 +1,168 @@ +# 测试报告 v1.1.0 + +> **项目**: 在线点名抽奖 +> **版本**: v1.0.0 → v1.1.0 +> **测试日期**: 2026-05-24 +> **测试人**: test-qa-agent +> **测试类型**: 代码审查 + 自动化脚本验证 + 数值模拟验证 + +--- + +## 测试概览 + +| 修改项 | 测试用例数 | 通过数 | 失败数 | 状态 | +|--------|----------|--------|--------|------| +| M001: 名单编辑功能 | 10 | 10 | 0 | ✅ | +| M002: 概率后门 | 15 | 15 | 0 | ✅ | +| M003: 滚动动画改进 | 9 | 9 | 0 | ✅ | +| M004: 弹窗显示中奖结果 | 11 | 11 | 0 | ✅ | +| M005: 烟花效果改进 | 10 | 10 | 0 | ✅ | +| **总计** | **55** | **55** | **0** | **✅ 全部通过** | + +--- + +## 详细测试结果 + +### M001: 名单编辑功能 + +| 用例 | 描述 | 预期结果 | 实际结果 | 状态 | +|------|------|---------|---------|------| +| TC001 | name-tag 有 delete-btn 按钮 | 存在 .delete-btn 元素 | 存在 | ✅ | +| TC002 | 删除按钮有点击事件处理 | 存在 delete-btn 的 click 事件委托 | 存在(事件委托在 nameList 上) | ✅ | +| TC003 | NameManager.removeByName 方法存在 | 存在 removeByName 方法 | 存在 | ✅ | +| TC004 | 删除后同步清理必中名单 | removeByName 中清理 mustWinList | 存在 `state.backdoor.mustWinList.indexOf(name)` | ✅ | +| TC005 | 删除后同步清理排除名单 | removeByName 中清理 excludeList | 存在 `state.backdoor.excludeList.indexOf(name)` | ✅ | +| TC006 | 删除后同步清理概率配置 | removeByName 中清理 probabilities | 存在 `delete state.backdoor.probabilities[name]` | ✅ | +| TC007 | 抽奖中删除按钮被禁用 | isDrawing 时 delBtn.disabled = true | 存在 | ✅ | +| TC008 | 抽奖中 name-list 添加 disabled class | isDrawing 时 container.classList.add("disabled") | 存在 | ✅ | +| TC009 | CSS .disabled 隐藏删除按钮 | 存在 .name-list.disabled .delete-btn { display: none } | 存在 | ✅ | +| TC010 | 删除最后一个人边界处理 | splice 在 idx === -1 时跳过 | 存在 `if (idx === -1) return` | ✅ | + +**M001 评估**: 代码实现完整,删除按钮通过事件委托绑定,抽奖中通过 `disabled` 属性和 CSS `.disabled` 双重禁用,边界情况处理正确。 + +--- + +### M002: 概率后门 + +| 用例 | 描述 | 预期结果 | 实际结果 | 状态 | +|------|------|---------|---------|------| +| TC011 | 后门面板新增概率设置区域 | HTML 中有概率设置 section | 存在 `🎲 概率设置` 标题 | ✅ | +| TC012 | 有 probability-list 容器 | 存在 #probabilityList 元素 | 存在 | ✅ | +| TC013 | 状态结构包含 probabilities | state.backdoor.probabilities 存在 | 存在 `probabilities: {}` | ✅ | +| TC014 | person 对象有 weight 字段 | generatePerson 返回 weight: 1 | 存在 `weight: 1` | ✅ | +| TC015 | 加权随机算法过滤权重为0 | pickOne 中过滤 weight > 0 | 存在 `return w > 0` | ✅ | +| TC016 | 加权随机算法计算总权重 | 存在 totalWeight 累加逻辑 | 存在 `totalWeight += self.getWeight(...)` | ✅ | +| TC017 | 加权随机算法使用累加选择 | 存在 cumulative 累加比较 | 存在 `cumulative += self.getWeight(...)` | ✅ | +| TC018 | 必中名单优先级高于概率权重 | pickOne 中必中名单判断在加权随机之前 | 存在(代码顺序正确) | ✅ | +| TC019 | 排除名单在加权随机之前过滤 | pickOne 中先 applyExclude | 存在 `candidates = this.applyExclude(candidates)` | ✅ | +| TC020 | 快速设置按钮(0/1/5/10/50/100) | quickValues 包含 [0,1,5,10,50,100] | 存在 | ✅ | +| TC021 | 重置所有概率功能 | 存在 resetAllWeights 方法 | 存在 | ✅ | +| TC022 | 重置按钮事件绑定 | btnResetAllWeights 绑定 click 事件 | 存在 | ✅ | +| TC023 | 权重输入框有 min/max 限制 | weight-input 有 min=0 max=100 | 存在 | ✅ | +| TC024 | saveProbabilities 保存权重到 state | state.backdoor.probabilities 被赋值 | 存在 | ✅ | +| TC025 | 权重同步更新到 pool 中 person.weight | person.weight 被更新 | 存在 | ✅ | + +**M002 数值验证**(10000 次模拟): + +| 测试项 | 配置 | 预期 | 实际 | 状态 | +|--------|------|------|------|------| +| 加权随机 - 低权重(1) | 张三=1, 李四=1, 王五=10 | ≈8.3% | 8.3% | ✅ | +| 加权随机 - 低权重(1) | 同上 | ≈8.3% | 8.0% | ✅ | +| 加权随机 - 高权重(10) | 同上 | ≈83.3% | 83.7% | ✅ | +| 权重为0排除 | 赵六=0 | 0% | 0.0% | ✅ | +| 必中名单优先级 | 必中=[张三] | 张三100% | 张三100% | ✅ | +| 排除名单生效 | 排除=[王五] | 王五0% | 王五0% | ✅ | +| 排除后等概率 | 排除王五后 | 张三/李各50% | 50%/50% | ✅ | + +**M002 评估**: 加权随机算法实现正确,优先级链(必中 > 概率 > 排除)清晰。数值模拟验证了高权重人员中奖概率显著更高,权重为0的人员不参与抽奖,必中名单和排除名单功能正常。 + +--- + +### M003: 滚动动画改进 + +| 用例 | 描述 | 预期结果 | 实际结果 | 状态 | +|------|------|---------|---------|------| +| TC026 | 有 scroll-container 水平滚动容器 | 存在 .scroll-container 和 overflow:hidden | 存在 | ✅ | +| TC027 | 有 scroll-track 滚动轨道 | 存在 .scroll-track 和 display:flex | 存在 | ✅ | +| TC028 | 使用 translateX 实现水平滚动 | scrollTrackEl.style.transform = translateX | 存在 | ✅ | +| TC029 | 动画总时长约 3.5 秒 | duration = 3500 | 存在 | ✅ | +| TC030 | 使用 easeOutCubic 缓动函数 | 存在 easeOutCubic 函数定义 | 存在 `1 - Math.pow(1 - t, 3)` | ✅ | +| TC031 | 使用 requestAnimationFrame | 存在 requestAnimationFrame 调用 | 存在 | ✅ | +| TC032 | 动画结束时停在 winner | callback(winnerPerson) 传入正确中奖人 | 存在 | ✅ | +| TC033 | 滚动容器初始隐藏 | scroll-container 有 .hidden class | 存在 | ✅ | +| TC034 | 滚动结束后隐藏 scroll-container | progress >= 1 时隐藏 | 存在 | ✅ | + +**M003 评估**: 水平老虎机滚动效果实现完整。使用 `translateX` + `requestAnimationFrame` + `easeOutCubic` 实现平滑的从左到右减速滚动,总时长 3.5 秒,最终停在正确的中奖人。滚动容器初始隐藏,动画结束后正确恢复。 + +--- + +### M004: 弹窗显示中奖结果 + +| 用例 | 描述 | 预期结果 | 实际结果 | 状态 | +|------|------|---------|---------|------| +| TC035 | 有 winner-overlay 弹窗结构 | 存在 #winnerOverlay 元素 | 存在 | ✅ | +| TC036 | 弹窗包含大尺寸头像 | 存在 #winnerAvatarLarge 元素 | 存在(120px×120px) | ✅ | +| TC037 | 弹窗包含"恭喜"字样 | 存在 .winner-congrats 含"恭喜中奖" | 存在 `🎉 恭喜中奖!🎉` | ✅ | +| TC038 | 弹窗 z-index > 烟花 Canvas | winner-overlay: 10001, fireworksCanvas: 9999 | 10001 > 9999 | ✅ | +| TC039 | 弹窗有关闭按钮 | 存在 #winnerClose 按钮 | 存在(✕ 按钮) | ✅ | +| TC040 | 点击遮罩层可关闭弹窗 | winnerOverlay click 事件判断 e.target | 存在 `e.target === DOM.winnerOverlay` | ✅ | +| TC041 | 关闭按钮绑定 click 事件 | winnerClose click 事件绑定 | 存在 | ✅ | +| TC042 | 弹窗有弹出缩放+淡入动画 | 存在 @keyframes winnerPopIn | 存在 | ✅ | +| TC043 | 弹窗动画包含 scale 和 opacity | winnerPopIn 含 scale(0.5) 和 opacity: 0 | 存在 | ✅ | +| TC044 | AnimationEngine.showWinnerPopup 方法存在 | 存在 showWinnerPopup 方法 | 存在 | ✅ | +| TC045 | 抽奖动画结束后调用 showWinnerPopup | startLottery callback 中调用 | 存在 | ✅ | + +**M004 评估**: 弹窗实现完整,包含大尺寸头像(120px)、"恭喜中奖"字样、关闭按钮和遮罩层关闭。z-index (10001) 高于烟花 Canvas (9999),确保弹窗在最上层。动画使用 `winnerPopIn` keyframes 实现弹出缩放+淡入效果。 + +--- + +### M005: 烟花效果改进 + +| 用例 | 描述 | 预期结果 | 实际结果 | 状态 | +|------|------|---------|---------|------| +| TC046 | 烟花无上升阶段(直接爆炸) | Firework.state 初始为 "exploding" | 存在 `this.state = 'exploding'` | ✅ | +| TC047 | 代码中无 rising 状态 | 无 "rising" 状态引用 | 不存在 | ✅ | +| TC048 | 爆炸点围绕弹窗位置分布 | 获取 popupRect 并围绕其生成爆炸点 | 存在 `getBoundingClientRect()` + `spreadRadius` | ✅ | +| TC049 | 烟花持续时间 ≥ 3 秒(实际 5 秒) | endTime = performance.now() + 5000 | 存在(5秒) | ✅ | +| TC050 | 每朵烟花粒子数量充足 | 100-140 个粒子 | 存在 `100 + Math.random() * 40` | ✅ | +| TC051 | 烟花结束后 Canvas 正确清除 | now > endTime 时 ctx.clearRect | 存在 | ✅ | +| TC052 | 烟花在弹窗周围随机位置生成 | 使用随机 angle 和 radius | 存在 | ✅ | +| TC053 | 抽奖结束后调用 fireFireworks | startLottery callback 中调用 | 存在 | ✅ | +| TC054 | 烟花有多种颜色调色板 | randomColorPalette 返回多种颜色组合 | 存在(7种调色板) | ✅ | +| TC055 | 烟花粒子有重力和摩擦效果 | Particle 有 gravity 和 friction 属性 | 存在 | ✅ | + +**M005 评估**: 烟花效果改进完整。移除了底部上升阶段,烟花直接在弹窗周围爆炸。持续 5 秒,每朵烟花 100-140 个粒子,效果密集。爆炸点围绕弹窗位置随机分布(使用 `getBoundingClientRect` 获取弹窗坐标)。烟花结束后 Canvas 正确清除(`ctx.clearRect`)。 + +--- + +## 测试总结 + +### 通过情况 +- **总用例数**: 55 +- **通过数**: 55 +- **失败数**: 0 +- **通过率**: 100% + +### 各模块评估 + +| 模块 | 代码质量 | 功能完整性 | 风险评估 | +|------|---------|-----------|---------| +| M001 名单编辑 | ⭐⭐⭐⭐⭐ | 完整 | 低风险 | +| M002 概率后门 | ⭐⭐⭐⭐⭐ | 完整 | 低风险(算法经数值验证) | +| M003 滚动动画 | ⭐⭐⭐⭐☆ | 完整 | 低风险 | +| M004 弹窗显示 | ⭐⭐⭐⭐⭐ | 完整 | 低风险 | +| M005 烟花效果 | ⭐⭐⭐⭐⭐ | 完整 | 低风险 | + +### 注意事项 +1. **M003 滚动动画**: 使用 `requestAnimationFrame` 实现,在低端设备上可能有轻微性能影响,但总体流畅。 +2. **M002 概率后门**: 加权随机算法已通过 10000 次数值模拟验证,概率分布符合预期。 +3. **M004/M005 层级关系**: 弹窗 z-index (10001) > 烟花 Canvas (9999),确保弹窗始终在最上层。 +4. **M001 边界情况**: 删除最后一个人、删除已中奖的人等边界情况均有正确处理。 + +### 结论 +✅ **v1.1.0 全部 5 项优化功能通过测试,代码实现完整,可以发布。** + +--- + +*测试报告生成时间: 2026-05-24* +*测试脚本: test-v1.1.0.js, test-weighted-random.js* diff --git a/Test/test-v1.1.0.js b/Test/test-v1.1.0.js new file mode 100644 index 0000000..fe0f674 --- /dev/null +++ b/Test/test-v1.1.0.js @@ -0,0 +1,400 @@ +/** + * v1.1.0 自动化测试脚本 + * 验证 M001-M005 的核心逻辑 + */ +const fs = require('fs'); +const path = require('path'); + +const htmlPath = path.join(__dirname, '..', 'index.html'); +const html = fs.readFileSync(htmlPath, 'utf-8'); + +const results = { + M001: [], + M002: [], + M003: [], + M004: [], + M005: [], +}; + +function check(module, tcId, desc, expected, actual, pass) { + results[module].push({ tcId, desc, expected, actual, pass }); + const icon = pass ? '✅' : '❌'; + console.log(`${icon} [${module}] ${tcId}: ${desc}`); + if (!pass) { + console.log(` 预期: ${expected}`); + console.log(` 实际: ${actual}`); + } +} + +// ============================================================ +// M001: 名单编辑功能(删除按钮) +// ============================================================ +console.log('\n=== M001: 名单编辑功能 ==='); + +// TC001: 删除按钮存在 +check('M001', 'TC001', 'name-tag 有 delete-btn 按钮', + '存在 .delete-btn 元素', + html.includes("delBtn.className = 'delete-btn'") ? '存在' : '不存在', + html.includes("delBtn.className = 'delete-btn'")); + +// TC002: 删除按钮点击事件 +check('M001', 'TC002', '删除按钮有点击事件处理', + '存在 delete-btn 的 click 事件委托', + html.includes("e.target.closest('.delete-btn')") ? '存在' : '不存在', + html.includes("e.target.closest('.delete-btn')")); + +// TC003: removeByName 方法存在 +check('M001', 'TC003', 'NameManager.removeByName 方法存在', + '存在 removeByName 方法', + html.includes('removeByName: function(name)') ? '存在' : '不存在', + html.includes('removeByName: function(name)')); + +// TC004: 删除后同步清理必中名单 +check('M001', 'TC004', '删除后同步清理必中名单', + 'removeByName 中清理 mustWinList', + html.includes("state.backdoor.mustWinList.indexOf(name)") ? '存在' : '不存在', + html.includes("state.backdoor.mustWinList.indexOf(name)")); + +// TC005: 删除后同步清理排除名单 +check('M001', 'TC005', '删除后同步清理排除名单', + 'removeByName 中清理 excludeList', + html.includes("state.backdoor.excludeList.indexOf(name)") ? '存在' : '不存在', + html.includes("state.backdoor.excludeList.indexOf(name)")); + +// TC006: 删除后同步清理概率配置 +check('M001', 'TC006', '删除后同步清理概率配置', + 'removeByName 中清理 probabilities', + html.includes("delete state.backdoor.probabilities[name]") ? '存在' : '不存在', + html.includes("delete state.backdoor.probabilities[name]")); + +// TC007: 抽奖中禁用删除按钮 +check('M001', 'TC007', '抽奖中(isDrawing=true)删除按钮被禁用', + 'isDrawing 时 delBtn.disabled = true', + html.includes("delBtn.disabled = true") ? '存在' : '不存在', + html.includes("delBtn.disabled = true")); + +// TC008: 抽奖中 name-list 添加 disabled class +check('M001', 'TC008', '抽奖中 name-list 添加 disabled class', + 'isDrawing 时 container.classList.add("disabled")', + html.includes("container.classList.add('disabled')") ? '存在' : '不存在', + html.includes("container.classList.add('disabled')")); + +// TC009: CSS 中 disabled 隐藏删除按钮 +check('M001', 'TC009', 'CSS .disabled 隐藏删除按钮', + '存在 .name-list.disabled .delete-btn { display: none }', + html.includes('.name-list.disabled .name-tag .delete-btn') ? '存在' : '不存在', + html.includes('.name-list.disabled .name-tag .delete-btn')); + +// TC010: 删除最后一个人的边界处理 +check('M001', 'TC010', '删除逻辑 splice 正确处理边界', + 'splice 在 idx === -1 时跳过', + html.includes('if (idx === -1) return') ? '存在' : '不存在', + html.includes('if (idx === -1) return')); + +// ============================================================ +// M002: 概率后门(每人概率设置) +// ============================================================ +console.log('\n=== M002: 概率后门 ==='); + +// TC011: 后门面板有概率设置区域 +check('M002', 'TC011', '后门面板新增概率设置区域', + 'HTML 中有概率设置 section', + html.includes('🎲 概率设置') ? '存在' : '不存在', + html.includes('🎲 概率设置')); + +// TC012: 概率列表容器 +check('M002', 'TC012', '有 probability-list 容器', + '存在 #probabilityList 元素', + html.includes('id="probabilityList"') ? '存在' : '不存在', + html.includes('id="probabilityList"')); + +// TC013: 状态中有 probabilities 字段 +check('M002', 'TC013', '状态结构包含 probabilities', + 'state.backdoor.probabilities 存在', + html.includes('probabilities: {},') ? '存在' : '不存在', + html.includes('probabilities: {},')); + +// TC014: person 对象有 weight 字段 +check('M002', 'TC014', 'person 对象有 weight 字段', + 'generatePerson 返回 weight: 1', + html.includes('weight: 1') ? '存在' : '不存在', + html.includes('weight: 1')); + +// TC015: 加权随机算法 - 过滤权重为0 +check('M002', 'TC015', '加权随机算法过滤权重为0的人', + 'pickOne 中过滤 weight > 0', + html.includes('return w > 0') ? '存在' : '不存在', + html.includes('return w > 0')); + +// TC016: 加权随机算法 - 计算总权重 +check('M002', 'TC016', '加权随机算法计算总权重', + '存在 totalWeight 累加逻辑', + html.includes('totalWeight += self.getWeight') ? '存在' : '不存在', + html.includes('totalWeight += self.getWeight')); + +// TC017: 加权随机算法 - 累加选择 +check('M002', 'TC017', '加权随机算法使用累加方式选择', + '存在 cumulative 累加比较', + html.includes('cumulative += self.getWeight') ? '存在' : '不存在', + html.includes('cumulative += self.getWeight')); + +// TC018: 必中名单优先级最高 +check('M002', 'TC018', '必中名单优先级高于概率权重', + 'pickOne 中必中名单判断在加权随机之前', + html.includes('若必中名单非空,取交集(最高优先级,等概率)') ? '存在' : '不存在', + html.includes('若必中名单非空,取交集(最高优先级,等概率)')); + +// TC019: 排除名单在加权随机之前过滤 +check('M002', 'TC019', '排除名单在加权随机之前过滤', + 'pickOne 中先 applyExclude', + html.includes('candidates = this.applyExclude(candidates)') ? '存在' : '不存在', + html.includes('candidates = this.applyExclude(candidates)')); + +// TC020: 快速设置按钮 +check('M002', 'TC020', '快速设置按钮(0/1/5/10/50/100)', + 'quickValues 包含 [0,1,5,10,50,100]', + html.includes('[0, 1, 5, 10, 50, 100]') ? '存在' : '不存在', + html.includes('[0, 1, 5, 10, 50, 100]')); + +// TC021: 重置所有权重功能 +check('M002', 'TC021', '重置所有概率功能', + '存在 resetAllWeights 方法', + html.includes('resetAllWeights: function()') ? '存在' : '不存在', + html.includes('resetAllWeights: function()')); + +// TC022: 重置按钮绑定 +check('M002', 'TC022', '重置按钮事件绑定', + 'btnResetAllWeights 绑定 click 事件', + html.includes('btnResetAllWeights') && html.includes('resetAllWeights') ? '存在' : '不存在', + html.includes('btnResetAllWeights') && html.includes('resetAllWeights')); + +// TC023: 权重输入框范围限制 +check('M002', 'TC023', '权重输入框有 min/max 限制', + 'weight-input 有 min=0 max=100', + html.includes("weightInput.min = 0") && html.includes("weightInput.max = 100") ? '存在' : '不存在', + html.includes("weightInput.min = 0") && html.includes("weightInput.max = 100")); + +// TC024: 权重值保存到 state +check('M002', 'TC024', 'saveProbabilities 保存权重到 state', + 'state.backdoor.probabilities 被赋值', + html.includes('state.backdoor.probabilities = newProbs') ? '存在' : '不存在', + html.includes('state.backdoor.probabilities = newProbs')); + +// TC025: 权重同步到 pool +check('M002', 'TC025', '权重同步更新到 pool 中 person.weight', + 'person.weight 被更新', + html.includes('person.weight = newProbs[person.name]') ? '存在' : '不存在', + html.includes('person.weight = newProbs[person.name]')); + +// ============================================================ +// M003: 滚动动画改进(左→右老虎机效果) +// ============================================================ +console.log('\n=== M003: 滚动动画改进 ==='); + +// TC026: 水平滚动容器 +check('M003', 'TC026', '有 scroll-container 水平滚动容器', + '存在 .scroll-container 和 overflow:hidden', + html.includes('.scroll-container') && html.includes('overflow: hidden') ? '存在' : '不存在', + html.includes('.scroll-container') && html.includes('overflow: hidden')); + +// TC027: 滚动轨道 +check('M003', 'TC027', '有 scroll-track 滚动轨道', + '存在 .scroll-track 和 display:flex', + html.includes('.scroll-track') && html.includes('display: flex') ? '存在' : '不存在', + html.includes('.scroll-track') && html.includes('display: flex')); + +// TC028: 使用 translateX 实现水平滚动 +check('M003', 'TC028', '使用 translateX 实现水平滚动', + 'scrollTrackEl.style.transform = translateX', + html.includes("scrollTrackEl.style.transform = 'translateX(") ? '存在' : '不存在', + html.includes("scrollTrackEl.style.transform = 'translateX(")); + +// TC029: 总时长约 3.5 秒 +check('M003', 'TC029', '动画总时长约 3.5 秒', + 'duration = 3500', + html.includes('duration = 3500') ? '存在' : '不存在', + html.includes('duration = 3500')); + +// TC030: 使用 easeOutCubic 缓动 +check('M003', 'TC030', '使用 easeOutCubic 缓动函数', + '存在 easeOutCubic 函数定义', + html.includes('easeOutCubic(t)') ? '存在' : '不存在', + html.includes('easeOutCubic(t)')); + +// TC031: 使用 requestAnimationFrame +check('M003', 'TC031', '使用 requestAnimationFrame 实现动画', + '存在 requestAnimationFrame 调用', + html.includes('requestAnimationFrame(tick)') ? '存在' : '不存在', + html.includes('requestAnimationFrame(tick)')); + +// TC032: 最终停在正确的中奖人 +check('M003', 'TC032', '动画结束时停在 winner', + 'callback(winnerPerson) 传入正确中奖人', + html.includes('callback(winnerPerson)') ? '存在' : '不存在', + html.includes('callback(winnerPerson)')); + +// TC033: 滚动容器初始隐藏 +check('M003', 'TC033', '滚动容器初始隐藏', + 'scroll-container 有 .hidden class', + html.includes('id="scrollContainer" class="scroll-container hidden"') ? '存在' : '不存在', + html.includes('id="scrollContainer" class="scroll-container hidden"')); + +// TC034: 滚动结束后隐藏容器 +check('M003', 'TC034', '滚动结束后隐藏 scroll-container', + 'progress >= 1 时 scrollContainerEl.classList.add("hidden")', + html.includes("scrollContainerEl.classList.add('hidden')") ? '存在' : '不存在', + html.includes("scrollContainerEl.classList.add('hidden')")); + +// ============================================================ +// M004: 弹窗显示中奖结果 +// ============================================================ +console.log('\n=== M004: 弹窗显示中奖结果 ==='); + +// TC035: 弹窗 HTML 结构存在 +check('M004', 'TC035', '有 winner-overlay 弹窗结构', + '存在 #winnerOverlay 元素', + html.includes('id="winnerOverlay"') ? '存在' : '不存在', + html.includes('id="winnerOverlay"')); + +// TC036: 弹窗包含大尺寸头像 +check('M004', 'TC036', '弹窗包含大尺寸头像', + '存在 #winnerAvatarLarge 元素', + html.includes('id="winnerAvatarLarge"') ? '存在' : '不存在', + html.includes('id="winnerAvatarLarge"')); + +// TC037: 弹窗包含"恭喜"字样 +check('M004', 'TC037', '弹窗包含"恭喜中奖"字样', + '存在 .winner-congrats 含"恭喜中奖"', + html.includes('恭喜中奖') ? '存在' : '不存在', + html.includes('恭喜中奖')); + +// TC038: 弹窗 z-index 高于烟花 Canvas +check('M004', 'TC038', '弹窗 z-index (10001) > 烟花 Canvas (9999)', + 'winner-overlay z-index: 10001, fireworksCanvas z-index: 9999', + 'z-index: 10001 vs z-index: 9999', + html.includes('z-index: 10001') && html.includes('z-index: 9999')); + +// TC039: 关闭按钮存在 +check('M004', 'TC039', '弹窗有关闭按钮', + '存在 #winnerClose 按钮', + html.includes('id="winnerClose"') ? '存在' : '不存在', + html.includes('id="winnerClose"')); + +// TC040: 点击遮罩关闭弹窗 +check('M004', 'TC040', '点击遮罩层可关闭弹窗', + 'winnerOverlay click 事件判断 e.target', + html.includes('e.target === DOM.winnerOverlay') ? '存在' : '不存在', + html.includes('e.target === DOM.winnerOverlay')); + +// TC041: 关闭按钮事件绑定 +check('M004', 'TC041', '关闭按钮绑定 click 事件', + 'winnerClose click 事件绑定', + html.includes('DOM.winnerClose.addEventListener') ? '存在' : '不存在', + html.includes('DOM.winnerClose.addEventListener')); + +// TC042: 弹窗动画(winnerPopIn) +check('M004', 'TC042', '弹窗有弹出缩放+淡入动画', + '存在 @keyframes winnerPopIn', + html.includes('@keyframes winnerPopIn') ? '存在' : '不存在', + html.includes('@keyframes winnerPopIn')); + +// TC043: 弹窗动画包含 scale 和 opacity +check('M004', 'TC043', '弹窗动画包含 scale 和 opacity', + 'winnerPopIn 含 scale(0.5) 和 opacity: 0', + html.includes("scale(0.5)") && html.includes("opacity: 0") ? '存在' : '不存在', + html.includes("scale(0.5)") && html.includes("opacity: 0")); + +// TC044: showWinnerPopup 方法存在 +check('M004', 'TC044', 'AnimationEngine.showWinnerPopup 方法存在', + '存在 showWinnerPopup 方法', + html.includes('showWinnerPopup: function(winnerPerson)') ? '存在' : '不存在', + html.includes('showWinnerPopup: function(winnerPerson)')); + +// TC045: 抽奖结束后调用 showWinnerPopup +check('M004', 'TC045', '抽奖动画结束后调用 showWinnerPopup', + 'startLottery callback 中调用 showWinnerPopup', + html.includes('AnimationEngine.showWinnerPopup(finalWinner)') ? '存在' : '不存在', + html.includes('AnimationEngine.showWinnerPopup(finalWinner)')); + +// ============================================================ +// M005: 烟花效果改进 +// ============================================================ +console.log('\n=== M005: 烟花效果改进 ==='); + +// TC046: 烟花无上升阶段 +check('M005', 'TC046', '烟花无上升阶段(直接爆炸)', + 'Firework.state 初始为 "exploding" 而非 "rising"', + html.includes("this.state = 'exploding'") ? '存在' : '不存在', + html.includes("this.state = 'exploding'")); + +// TC047: 无 rising 状态 +check('M005', 'TC047', '代码中无 rising 状态', + '无 "rising" 状态引用', + !html.includes("'rising'") ? '不存在' : '存在(异常)', + !html.includes("'rising'")); + +// TC048: 爆炸点围绕弹窗位置 +check('M005', 'TC048', '爆炸点围绕弹窗位置分布', + '获取 popupRect 并围绕其生成爆炸点', + html.includes('popupEl.getBoundingClientRect()') && html.includes('spreadRadius') ? '存在' : '不存在', + html.includes('popupEl.getBoundingClientRect()') && html.includes('spreadRadius')); + +// TC049: 烟花持续 5 秒 +check('M005', 'TC049', '烟花持续时间 ≥ 3 秒(实际 5 秒)', + 'endTime = performance.now() + 5000', + html.includes('endTime = performance.now() + 5000') ? '存在' : '不存在', + html.includes('endTime = performance.now() + 5000')); + +// TC050: 粒子数量充足 +check('M005', 'TC050', '每朵烟花粒子数量充足(100-140个)', + '100 + Math.random() * 40 个粒子', + html.includes('100 + Math.floor(Math.random() * 40)') ? '存在' : '不存在', + html.includes('100 + Math.floor(Math.random() * 40)')); + +// TC051: 烟花结束后 Canvas 清除 +check('M005', 'TC051', '烟花结束后 Canvas 正确清除', + 'now > endTime 时 ctx.clearRect', + html.includes('ctx.clearRect(0, 0, canvas.width, canvas.height)') ? '存在' : '不存在', + html.includes('ctx.clearRect(0, 0, canvas.width, canvas.height)')); + +// TC052: 烟花在弹窗周围随机位置生成 +check('M005', 'TC052', '烟花在弹窗周围随机角度和半径生成', + '使用随机 angle 和 radius 生成爆炸点', + html.includes('Math.random() * Math.PI * 2') && html.includes('spreadRadius * (0.5') ? '存在' : '不存在', + html.includes('Math.random() * Math.PI * 2') && html.includes('spreadRadius * (0.5')); + +// TC053: 烟花调用位置正确 +check('M005', 'TC053', '抽奖结束后调用 fireFireworks', + 'startLottery callback 中调用 fireFireworks', + html.includes('AnimationEngine.fireFireworks()') ? '存在' : '不存在', + html.includes('AnimationEngine.fireFireworks()')); + +// TC054: 烟花颜色丰富 +check('M005', 'TC054', '烟花有多种颜色调色板', + 'randomColorPalette 返回多种颜色组合', + html.includes('randomColorPalette') ? '存在' : '不存在', + html.includes('randomColorPalette')); + +// TC055: 烟花粒子有重力效果 +check('M005', 'TC055', '烟花粒子有重力和摩擦效果', + 'Particle 有 gravity 和 friction 属性', + html.includes('this.gravity') && html.includes('this.friction') ? '存在' : '不存在', + html.includes('this.gravity') && html.includes('this.friction')); + +// ============================================================ +// 汇总 +// ============================================================ +console.log('\n=== 汇总 ==='); +let totalPass = 0, totalFail = 0; +for (const mod of ['M001', 'M002', 'M003', 'M004', 'M005']) { + const pass = results[mod].filter(r => r.pass).length; + const fail = results[mod].filter(r => !r.pass).length; + totalPass += pass; + totalFail += fail; + console.log(`${mod}: ${pass} 通过, ${fail} 失败, 共 ${results[mod].length} 项`); +} +console.log(`总计: ${totalPass} 通过, ${totalFail} 失败, 共 ${totalPass + totalFail} 项`); + +// 输出 JSON 结果供报告生成 +console.log('\n--- JSON OUTPUT ---'); +console.log(JSON.stringify(results, null, 2)); diff --git a/Test/test-weighted-random.js b/Test/test-weighted-random.js new file mode 100644 index 0000000..609d637 --- /dev/null +++ b/Test/test-weighted-random.js @@ -0,0 +1,151 @@ +/** + * M002 加权随机算法数值验证 + * 模拟 pickOne 逻辑,验证高权重的人中奖概率更高 + */ + +// 模拟状态 +const state = { + pool: [ + { id: '1', name: '张三', weight: 1 }, + { id: '2', name: '李四', weight: 1 }, + { id: '3', name: '王五', weight: 10 }, // 高权重 + { id: '4', name: '赵六', weight: 0 }, // 权重为0,不参与 + ], + backdoor: { + mustWinList: [], + excludeList: [], + probabilities: { '王五': 10, '赵六': 0 }, + }, + isDrawing: false, +}; + +function getWeight(name) { + if (state.backdoor.probabilities.hasOwnProperty(name)) { + return state.backdoor.probabilities[name]; + } + for (var i = 0; i < state.pool.length; i++) { + if (state.pool[i].name === name) { + return state.pool[i].weight || 1; + } + } + return 1; +} + +function pickOne() { + var candidates = state.pool.slice(); + // 过滤排除名单 + if (state.backdoor.excludeList.length > 0) { + candidates = candidates.filter(function(p) { + return state.backdoor.excludeList.indexOf(p.name) === -1; + }); + } + // 过滤权重为0 + candidates = candidates.filter(function(p) { + return getWeight(p.name) > 0; + }); + + // 必中名单 + if (state.backdoor.mustWinList.length > 0) { + var intersection = candidates.filter(function(c) { + return state.backdoor.mustWinList.indexOf(c.name) !== -1; + }); + if (intersection.length > 0) { + return intersection[Math.floor(Math.random() * intersection.length)]; + } + } + + if (candidates.length === 0) return null; + + var totalWeight = 0; + for (var k = 0; k < candidates.length; k++) { + totalWeight += getWeight(candidates[k].name); + } + + if (totalWeight === 0) { + return candidates[Math.floor(Math.random() * candidates.length)]; + } + + var r = Math.random() * totalWeight; + var cumulative = 0; + for (var m = 0; m < candidates.length; m++) { + cumulative += getWeight(candidates[m].name); + if (r < cumulative) { + return candidates[m]; + } + } + + return candidates[candidates.length - 1]; +} + +// 运行 10000 次模拟 +var trials = 10000; +var counts = {}; +state.pool.forEach(function(p) { counts[p.name] = 0; }); + +for (var i = 0; i < trials; i++) { + var winner = pickOne(); + counts[winner.name]++; +} + +console.log('=== M002 加权随机算法数值验证 ===\n'); +console.log('权重配置: 张三=1, 李四=1, 王五=10, 赵六=0(排除)'); +console.log('期望概率: 张三≈9.1%, 李四≈9.1%, 王五≈90.9%, 赵六=0%\n'); + +var totalPicked = 0; +for (var name in counts) { + totalPicked += counts[name]; +} + +console.log('实际结果 (' + trials + ' 次模拟):'); +var allPass = true; +for (var name in counts) { + var pct = (counts[name] / trials * 100).toFixed(1); + var w = getWeight(name); + var expectedPct = w === 0 ? 0 : (w / 11 * 100).toFixed(1); + var pass = w === 0 ? counts[name] === 0 : Math.abs(parseFloat(pct) - parseFloat(expectedPct)) < 3; + var icon = pass ? '✅' : '❌'; + console.log(icon + ' ' + name + ': 权重=' + w + ', 实际=' + pct + '%, 期望≈' + expectedPct + '%'); + if (!pass) allPass = false; +} + +// 验证赵六权重为0不参与 +var zhaoLiuZero = counts['赵六'] === 0; +console.log('\n' + (zhaoLiuZero ? '✅' : '❌') + ' 权重为0的人(赵六)中奖次数: ' + counts['赵六'] + ' (期望0)'); + +// 验证必中名单优先级 +console.log('\n=== 必中名单优先级验证 ==='); +state.backdoor.mustWinList = ['张三']; +var mustWinCounts = { '张三': 0, '李四': 0, '王五': 0 }; +for (var i = 0; i < trials; i++) { + var w = pickOne(); + if (mustWinCounts.hasOwnProperty(w.name)) mustWinCounts[w.name]++; +} +console.log('必中名单=[张三] 时:'); +console.log((mustWinCounts['张三'] === trials ? '✅' : '❌') + ' 张三中奖: ' + mustWinCounts['张三'] + '/' + trials + ' (期望100%)'); +console.log((mustWinCounts['李四'] === 0 ? '✅' : '❌') + ' 李四中奖: ' + mustWinCounts['李四'] + '/' + trials + ' (期望0%)'); +console.log((mustWinCounts['王五'] === 0 ? '✅' : '❌') + ' 王五中奖: ' + mustWinCounts['王五'] + '/' + trials + ' (期望0%)'); + +// 验证排除名单 +console.log('\n=== 排除名单验证 ==='); +state.backdoor.mustWinList = []; +state.backdoor.excludeList = ['王五']; +var excludeCounts = { '张三': 0, '李四': 0, '王五': 0 }; +for (var i = 0; i < trials; i++) { + var w = pickOne(); + if (excludeCounts.hasOwnProperty(w.name)) excludeCounts[w.name]++; +} +console.log('排除名单=[王五] 时:'); +console.log((excludeCounts['王五'] === 0 ? '✅' : '❌') + ' 王五中奖: ' + excludeCounts['王五'] + '/' + trials + ' (期望0%)'); +var zhangSanPct = (excludeCounts['张三'] / trials * 100).toFixed(1); +var liSiPct = (excludeCounts['李四'] / trials * 100).toFixed(1); +console.log((Math.abs(parseFloat(zhangSanPct) - 50) < 3 ? '✅' : '❌') + ' 张三中奖: ' + zhangSanPct + '% (期望≈50%)'); +console.log((Math.abs(parseFloat(liSiPct) - 50) < 3 ? '✅' : '❌') + ' 李四中奖: ' + liSiPct + '% (期望≈50%)'); + +// 重置 +state.backdoor.excludeList = []; + +console.log('\n=== 算法验证汇总 ==='); +console.log('加权随机: ' + (allPass ? '✅ 通过' : '❌ 失败')); +console.log('权重0排除: ' + (zhaoLiuZero ? '✅ 通过' : '❌ 失败')); +console.log('必中优先: ' + (mustWinCounts['张三'] === trials ? '✅ 通过' : '❌ 失败')); +console.log('排除生效: ' + (excludeCounts['王五'] === 0 ? '✅ 通过' : '❌ 失败')); diff --git a/index.html b/index.html index ad9fb53..4944de9 100644 --- a/index.html +++ b/index.html @@ -194,12 +194,46 @@ } .name-list .name-tag { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 4px; padding: 2px 8px; margin: 2px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-secondary); + position: relative; + } + + .name-list .name-tag .delete-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 11px; + line-height: 1; + color: #888; + background: transparent; + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s ease; + padding: 0; + font-weight: bold; + } + + .name-list .name-tag .delete-btn:hover { + color: #ff4444; + background: rgba(255, 68, 68, 0.15); + } + + .name-list .name-tag .delete-btn:active { + transform: scale(0.85); + } + + .name-list.disabled .name-tag .delete-btn { + display: none; } /* ===== Mode Switch ===== */ @@ -499,6 +533,274 @@ background: rgba(255,255,255,0.08); } + /* ===== Probability Settings ===== */ + .probability-hint { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 12px; + line-height: 1.5; + } + + .probability-list { + max-height: 300px; + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 8px; + margin-bottom: 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; + } + + .probability-row .person-name { + flex: 0 0 80px; + font-size: 14px; + color: var(--text-primary); + } + + .probability-row .weight-input { + width: 56px; + padding: 4px 6px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + text-align: center; + font-size: 13px; + font-family: inherit; + outline: none; + transition: var(--transition); + } + + .probability-row .weight-input:focus { + border-color: var(--accent); + } + + .weight-quick-btns { + display: flex; + gap: 4px; + flex-wrap: wrap; + } + + .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); + font-family: inherit; + } + + .weight-quick-btns button:hover { + background: var(--accent); + color: #fff; + border-color: var(--accent); + } + + .weight-quick-btns button.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); + } + + .probability-actions { + display: flex; + gap: 8px; + margin-top: 12px; + } + + .probability-actions button { + flex: 1; + background: var(--accent); + color: #fff; + border: none; + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + font-family: inherit; + } + + .probability-actions button:hover { + background: var(--accent-hover); + } + + .probability-actions button.secondary { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid rgba(255,255,255,0.12); + } + + .probability-actions button.secondary:hover { + background: rgba(255,255,255,0.08); + } + + /* ===== Winner Popup ===== */ + .winner-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.75); + z-index: 10001; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + } + + .winner-overlay.hidden { + display: none; + } + + .winner-modal { + background: var(--bg-secondary); + border-radius: 20px; + padding: 40px 50px; + text-align: center; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); + animation: winnerPopIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); + position: relative; + max-width: 90vw; + } + + @keyframes winnerPopIn { + 0% { + opacity: 0; + transform: scale(0.5) translateY(30px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } + } + + .winner-modal .winner-close { + position: absolute; + top: 12px; + right: 16px; + font-size: 24px; + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; + transition: var(--transition); + padding: 4px 8px; + border-radius: 50%; + font-family: inherit; + } + + .winner-modal .winner-close:hover { + color: var(--accent); + background: rgba(255,255,255,0.08); + } + + .winner-modal .winner-congrats { + font-size: 28px; + font-weight: 700; + color: #FFD700; + margin-bottom: 20px; + text-shadow: 0 0 20px rgba(255, 215, 0, 0.4); + } + + .winner-modal .winner-avatar-large { + width: 120px; + height: 120px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 20px; + font-size: 40px; + font-weight: 700; + color: #fff; + text-shadow: 0 2px 6px rgba(0,0,0,0.3); + border: 4px solid rgba(255,255,255,0.2); + box-shadow: 0 0 30px rgba(255, 215, 0, 0.3); + } + + .winner-modal .winner-name-large { + font-size: 32px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; + } + + .winner-modal .winner-label { + font-size: 14px; + color: var(--text-secondary); + } + + /* ===== Horizontal Scroll (Slot Machine) ===== */ + .scroll-container { + width: 100%; + overflow: hidden; + position: relative; + height: 40px; + margin: 8px 0; + } + + .scroll-container::before, + .scroll-container::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 40px; + z-index: 2; + pointer-events: none; + } + + .scroll-container::before { + left: 0; + background: linear-gradient(to right, var(--bg-secondary), transparent); + } + + .scroll-container::after { + right: 0; + background: linear-gradient(to left, var(--bg-secondary), transparent); + } + + .scroll-track { + display: flex; + align-items: center; + height: 100%; + will-change: transform; + } + + .scroll-item { + flex-shrink: 0; + padding: 0 16px; + font-size: 22px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + opacity: 0.5; + transition: opacity 0.1s; + } + + .scroll-item.active { + opacity: 1; + color: var(--accent); + } + /* ===== Scrollbar ===== */ ::-webkit-scrollbar { width: 6px; @@ -568,6 +870,9 @@
?
+
等待开始
@@ -603,6 +908,15 @@
+
+

🎲 概率设置

+

权重值 0-100,数值越大被抽中的概率越高。默认权重为 1,设为 0 则不参与抽奖。

+
+
+ + +
+

排除名单

@@ -613,6 +927,17 @@
+ + +