34 KiB
34 KiB
在线点名抽奖 — 前端架构设计文档
项目 ID: PROJ-20260523012
类型: 纯静态 Web(单 HTML 文件)
约束: 无后端、无外部依赖、无持久化、CSS/JS 全部内嵌
1. 技术选型
| 维度 | 选择 | 理由 |
|---|---|---|
| 文件格式 | 单个 .html |
零部署,双击即可运行 |
| CSS | 内联 <style> + CSS Variables |
主题统一、响应式友好 |
| JS | 内联 <script> + IIFE 模块 |
无构建、无依赖、作用域隔离 |
| 动画 | CSS transitions + requestAnimationFrame |
名字滚动用 rAF,粒子用 Canvas |
| Canvas | 原生 <canvas> 2D Context |
烟花粒子效果 |
| 文件解析 | 原生 FileReader + TextDecoder |
无需第三方库 |
| 颜色哈希 | String.prototype.charCodeAt 累加取模 |
轻量、确定性的颜色分配 |
2. 数据结构设计
2.1 核心状态
const state = {
// 名单管理
pool: [], // 当前抽奖池:[{ id, name, color, avatar }]
history: [], // 历史记录(内存,页面关闭清空)
totalImported: 0, // 累计导入总人数
// 抽奖引擎
mode: 'single', // 'single' | 'repeat'
isDrawing: false, // 是否正在抽奖动画中
winner: null, // 当前中奖人
// 后门
backdoor: {
enabled: false,
mustWinList: [], // 必中名单(名字字符串数组)
excludeList: [], // 排除名单(名字字符串数组)
},
// 动画
round: 0, // 当前轮次
};
2.2 人员对象
{
id: 'uuid-v4-style', // 唯一标识
name: '张三',
color: '#E74C3C', // 哈希分配的头像背景色
avatar: '三', // 名字后两个字(单字名取最后一个字)
}
2.3 历史记录
{
round: 1,
winner: '张三',
time: '2026-05-23 21:40:00',
}
3. 模块设计
┌─────────────────────────────────────────────────┐
│ App (IIFE) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────┐│
│ │名单管理 │ │抽奖引擎 │ │动画模块 │ │历史 ││
│ │Module │ │Module │ │Module │ │模块 ││
│ └──────────┘ └──────────┘ └──────────┘ └─────┘│
│ ↕ ↕ ↕ ↕ │
│ ┌───────────────────────────────────────────┐ │
│ │ state (共享状态) │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
3.1 名单管理模块 (NameManager)
| 方法 | 说明 |
|---|---|
parseInput(text) |
解析逗号、分号、顿号、换行分隔的文本,去重后返回名字数组 |
importTXT(file) |
读取 TXT 文件 → parseInput |
importCSV(file) |
读取 CSV → 按行或逗号分隔 → parseInput |
addToPool(names) |
将名字列表转为人员对象,加入 state.pool,去重 |
getPoolCount() |
返回当前抽奖池人数 |
generatePerson(name) |
为单个名字生成 { id, name, color, avatar } |
3.2 抽奖引擎模块 (LotteryEngine)
| 方法 | 说明 |
|---|---|
pickOne() |
根据 state.mode、必中/排除名单,从池中选出中奖人 |
applyMustWin() |
若必中名单非空且池中有匹配,从中随机选 |
applyExclude(pool) |
过滤掉排除名单中的人 |
removeWinner(id) |
单次模式:从池中移除中奖人 |
keepWinner(id) |
重复模式:保留在池中 |
抽奖逻辑:
1. 候选池 = state.pool 副本
2. 候选池 = 排除 filter(候选池)
3. 若 mustWinList 非空:
交集 = 候选池 ∩ mustWinList
若交集非空 → 候选池 = 交集
4. 从候选池随机选一人
5. 若 mode === 'single' → 从 state.pool 中移除
3.3 动画模块 (AnimationEngine)
| 方法 | 说明 |
|---|---|
startRollAnimation(callback) |
名字快速滚动 → 3秒后减速 → 停止在 winner |
stopRollAnimation() |
停止滚动 |
fireFireworks() |
Canvas 烟花粒子效果 |
3.4 历史记录模块 (HistoryModule)
| 方法 | 说明 |
|---|---|
addRecord(winner) |
追加一条记录到 state.history |
render() |
重新渲染底部历史记录列表 |
clear() |
清空历史记录 |
4. 页面结构 (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 内嵌 */</style>
</head>
<body>
<div id="app">
<!-- 顶部标题 -->
<header class="header">
<h1>🎯 在线点名抽奖</h1>
<span class="pool-count">当前名单: <b id="poolCount">0</b> 人</span>
</header>
<!-- 主内容区 -->
<main class="main">
<!-- 左侧:名单管理 -->
<section id="namePanel" class="panel">
<h2>📋 名单管理</h2>
<textarea id="nameInput" placeholder="输入名字,用逗号、换行等分隔..."></textarea>
<div class="btn-row">
<button id="btnAdd">添加名单</button>
<label class="file-btn">
📄 导入 TXT
<input type="file" id="fileTXT" accept=".txt" hidden>
</label>
<label class="file-btn">
📊 导入 CSV
<input type="file" id="fileCSV" accept=".csv" hidden>
</label>
</div>
<div id="nameList" class="name-list"></div>
</section>
<!-- 右侧:抽奖区 -->
<section id="lotteryPanel" class="panel">
<h2>🎰 抽奖</h2>
<!-- 模式切换 -->
<div class="mode-switch">
<label><input type="radio" name="mode" value="single" checked> 单次模式(抽中移除)</label>
<label><input type="radio" name="mode" value="repeat"> 重复模式(抽中保留)</label>
</div>
<!-- 名字滚动展示区 -->
<div id="rollArea" class="roll-area">
<div id="avatarDisplay" class="avatar-display">
<div id="avatarText" class="avatar-text">?</div>
</div>
<div id="rollName" class="roll-name">等待开始</div>
</div>
<!-- 抽奖按钮 -->
<button id="btnStart" class="btn-start">🎲 开始抽奖</button>
<!-- 中奖结果 -->
<div id="resultArea" class="result-area hidden">
<div class="result-text">🎉 本次幸运儿是:<span id="winnerName"></span></div>
</div>
<!-- Canvas 烟花 -->
<canvas id="fireworksCanvas"></canvas>
</section>
</main>
<!-- 底部:历史记录 -->
<section id="historyPanel" class="panel">
<h2>📜 历史记录</h2>
<table id="historyTable">
<thead><tr><th>轮次</th><th>中奖人</th><th>时间</th></tr></thead>
<tbody id="historyBody"></tbody>
</table>
</section>
<!-- 后门面板(默认隐藏) -->
<div id="backdoorPanel" class="backdoor-overlay hidden">
<div class="backdoor-modal">
<h2>⚙️ 高级设置</h2>
<div class="backdoor-section">
<h3>必中名单</h3>
<textarea id="mustWinInput" placeholder="输入必中名字..."></textarea>
<div id="mustWinChecklist" class="checklist"></div>
<button id="btnConfirmMustWin">确认</button>
</div>
<div class="backdoor-section">
<h3>排除名单</h3>
<textarea id="excludeInput" placeholder="输入排除名字..."></textarea>
<div id="excludeChecklist" class="checklist"></div>
<button id="btnConfirmExclude">确认</button>
</div>
<button id="btnCloseBackdoor" class="btn-close">关闭</button>
</div>
</div>
</div>
<script>/* 所有 JS 内嵌 */</script>
</body>
</html>
5. CSS 方案
5.1 设计原则
- 响应式:Flexbox + CSS Grid,移动端优先
- 配色:深色主题 + 高对比度强调色
- 无外部字体:使用系统字体栈
5.2 CSS Variables
: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 长按检测
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 算法
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 实现要点
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 文字提取
function getAvatarText(name) {
// 取名字后两个字
const chars = name.trim();
if (chars.length <= 2) return chars;
return chars.slice(-2);
}
9.2 颜色哈希
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 头像渲染
<div class="avatar" style="background: hsl(120, 65%, 55%);">
<span>张三</span>
</div>
.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 代码组织
(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 导入
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 导入
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 字段,用于存储每个人的概率权重:
const state = {
// ... 原有字段保持不变
backdoor: {
enabled: false,
mustWinList: [], // 必中名单(名字字符串数组)
excludeList: [], // 排除名单(名字字符串数组)
// ▼ v1.1.0 新增
probabilities: {}, // { name: weight },权重值 0-100
// 例:{ "张三": 50, "李四": 5, "王五": 1 }
},
};
字段说明:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
probabilities |
Object<string, number> |
{} |
键为人名,值为权重(0-100 整数)。未在此对象中列出的人使用默认权重 1。 |
16.1.2 人员对象扩展
person 对象新增 weight 字段:
{
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 新增概率设置区域
在现有后门面板中,必中名单和排除名单之间新增概率设置区域:
<!-- 后门面板(更新后的结构) -->
<div id="backdoorPanel" class="backdoor-overlay hidden">
<div class="backdoor-modal">
<h2>⚙️ 高级设置</h2>
<!-- 必中名单(原有) -->
<div class="backdoor-section">
<h3>🎯 必中名单</h3>
<textarea id="mustWinInput" placeholder="输入必中名字..."></textarea>
<div id="mustWinChecklist" class="checklist"></div>
<button id="btnConfirmMustWin">确认</button>
</div>
<!-- ▼ v1.1.0 新增:概率设置 -->
<div class="backdoor-section">
<h3>🎲 概率设置</h3>
<p class="probability-hint">权重值 0-100,数值越大被抽中的概率越高。默认权重为 1,设为 0 则不参与抽奖。</p>
<div id="probabilityList" class="probability-list">
<!-- 动态生成,每人一行 -->
<!-- 结构:[人名] [权重输入框] [快捷按钮: 0/1/5/10/50/100] -->
</div>
<div class="probability-actions">
<button id="btnResetAllWeights">重置全部为 1</button>
<button id="btnConfirmProbabilities">确认</button>
</div>
</div>
<!-- 排除名单(原有) -->
<div class="backdoor-section">
<h3>🚫 排除名单</h3>
<textarea id="excludeInput" placeholder="输入排除名字..."></textarea>
<div id="excludeChecklist" class="checklist"></div>
<button id="btnConfirmExclude">确认</button>
</div>
<button id="btnCloseBackdoor" class="btn-close">关闭</button>
</div>
</div>
16.3.2 概率列表项结构
每个人员的概率设置行:
<div class="probability-row" data-name="张三">
<span class="person-name">张三</span>
<input type="number" class="weight-input"
min="0" max="100" value="1"
data-name="张三">
<div class="weight-quick-btns">
<button data-weight="0">0</button>
<button data-weight="1">1</button>
<button data-weight="5">5</button>
<button data-weight="10">10</button>
<button data-weight="50">50</button>
<button data-weight="100">100</button>
</div>
</div>
16.3.3 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 交互逻辑
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() 改造点
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(名单编辑)的集成
当删除人员时,同步清理概率配置:
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 新增人员时的权重处理
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