Files
online-attendance-lottery/Code/docs/frontend-architecture.md

1269 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 在线点名抽奖 — 前端架构设计文档
> 项目 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 核心状态
```js
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 人员对象
```js
{
id: 'uuid-v4-style', // 唯一标识
name: '张三',
color: '#E74C3C', // 哈希分配的头像背景色
avatar: '三', // 名字后两个字(单字名取最后一个字)
}
```
### 2.3 历史记录
```js
{
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)
```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
```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
<div class="avatar" style="background: hsl(120, 65%, 55%);">
<span>张三</span>
</div>
```
```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<string, number>` | `{}` | 键为人名值为权重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
<!-- 后门面板(更新后的结构) -->
<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 概率列表项结构
每个人员的概率设置行:
```html
<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 新增样式
```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*