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

34 KiB
Raw Blame History

在线点名抽奖 — 前端架构设计文档

项目 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 关键样式

  • 滚动展示区:居中、大字号、带发光效果
  • 头像:圆形、居中文字、纯色背景
  • 抽奖按钮:大尺寸、渐变色、长按态(视觉反馈)
  • 后门面板:全屏遮罩 + 居中模态框
  • 烟花 Canvasposition: 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 视觉

  • 名字切换时带 opacitytransform: 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 朵烟花
  • Canvaswill-change: transform 提示浏览器优化
  • DOM 更新:历史记录使用 DocumentFragment 批量更新

16. v1.1.0 架构变更M002概率后门

变更类型:状态结构扩展 + 核心算法改造 + UI 增强
影响模块:stateLotteryEngineBackdoor
关联修改项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