Files
online-attendance-lottery/index.html

1447 lines
40 KiB
HTML
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.
<!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 Variables & Reset ===== */
: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;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
/* ===== Layout ===== */
#app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* ===== Header ===== */
.header {
text-align: center;
padding: 20px 0;
border-bottom: 1px solid rgba(255,255,255,0.08);
margin-bottom: 24px;
}
.header h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: 1px;
}
.pool-count {
font-size: 14px;
color: var(--text-secondary);
}
.pool-count b {
color: var(--accent);
font-size: 16px;
}
/* ===== Main Grid ===== */
.main {
display: flex;
flex-direction: column;
gap: 20px;
}
@media (min-width: 768px) {
.main {
flex-direction: row;
}
}
@media (min-width: 1200px) {
.main {
gap: 32px;
}
}
/* ===== Panel ===== */
.panel {
background: var(--bg-secondary);
border-radius: var(--border-radius);
padding: 20px;
box-shadow: var(--shadow);
}
.panel h2 {
font-size: 18px;
margin-bottom: 16px;
color: var(--text-primary);
}
#namePanel {
flex: 1;
min-width: 0;
}
#lotteryPanel {
flex: 1.2;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
}
/* ===== Textarea ===== */
textarea {
width: 100%;
min-height: 100px;
padding: 12px;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
background: var(--bg-card);
color: var(--text-primary);
font-size: 14px;
resize: vertical;
outline: none;
transition: var(--transition);
font-family: inherit;
}
textarea:focus {
border-color: var(--accent);
}
/* ===== Button Row ===== */
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0;
}
button, .file-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
font-family: inherit;
text-decoration: none;
}
button {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(255,255,255,0.12);
}
button:hover {
background: rgba(255,255,255,0.08);
}
.file-btn {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(255,255,255,0.12);
}
.file-btn:hover {
background: rgba(255,255,255,0.08);
}
/* ===== Name List ===== */
.name-list {
max-height: 200px;
overflow-y: auto;
margin-top: 12px;
padding: 8px;
background: var(--bg-card);
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
}
.name-list:empty {
display: none;
}
.name-list .name-tag {
display: inline-block;
padding: 2px 8px;
margin: 2px;
border-radius: 4px;
background: rgba(255,255,255,0.06);
color: var(--text-secondary);
}
/* ===== Mode Switch ===== */
.mode-switch {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 20px;
font-size: 14px;
}
.mode-switch label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
color: var(--text-secondary);
}
.mode-switch input[type="radio"] {
accent-color: var(--accent);
width: 16px;
height: 16px;
cursor: pointer;
}
/* ===== Roll Area ===== */
.roll-area {
text-align: center;
padding: 30px 20px;
margin: 10px 0 20px;
width: 100%;
}
.avatar-display {
width: 100px;
height: 100px;
border-radius: 50%;
background: var(--bg-card);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
transition: var(--transition);
border: 3px solid rgba(255,255,255,0.1);
}
.avatar-text {
font-size: 32px;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.roll-name {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
min-height: 36px;
transition: opacity 0.08s, transform 0.08s;
}
.roll-name.highlight {
color: var(--accent);
transform: scale(1.15);
text-shadow: 0 0 20px rgba(233, 69, 96, 0.5);
}
/* ===== Start Button ===== */
.btn-start {
padding: 14px 48px;
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), #c0392b);
color: #fff;
border: none;
border-radius: 50px;
cursor: pointer;
transition: var(--transition);
box-shadow: 0 4px 15px rgba(233, 69, 96, 0.4);
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.btn-start:hover {
background: linear-gradient(135deg, var(--accent-hover), var(--accent));
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(233, 69, 96, 0.5);
}
.btn-start:active {
transform: translateY(0);
}
.btn-start:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-start.pressing {
transform: scale(0.95);
box-shadow: 0 2px 10px rgba(233, 69, 96, 0.3);
}
/* ===== Result Area ===== */
.result-area {
text-align: center;
margin-top: 20px;
animation: fadeInUp 0.5s ease;
}
.result-area.hidden {
display: none;
}
.result-text {
font-size: 20px;
font-weight: 600;
color: var(--success);
}
.result-text span {
color: var(--accent);
font-size: 24px;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== Fireworks Canvas ===== */
#fireworksCanvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
}
/* ===== History Panel ===== */
#historyPanel {
margin-top: 20px;
}
#historyTable {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
#historyTable thead th {
padding: 10px 12px;
text-align: left;
color: var(--text-secondary);
font-weight: 600;
border-bottom: 2px solid rgba(255,255,255,0.1);
}
#historyTable tbody td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
color: var(--text-primary);
}
#historyTable tbody tr:hover {
background: rgba(255,255,255,0.03);
}
#historyBody:empty::after {
content: '暂无记录';
display: block;
text-align: center;
color: var(--text-secondary);
padding: 20px;
font-size: 14px;
}
/* ===== Backdoor Overlay ===== */
.backdoor-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.backdoor-overlay.hidden {
display: none;
}
.backdoor-modal {
background: var(--bg-secondary);
border-radius: var(--border-radius);
padding: 28px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow);
}
.backdoor-modal h2 {
font-size: 20px;
margin-bottom: 20px;
text-align: center;
}
.backdoor-section {
margin-bottom: 20px;
}
.backdoor-section h3 {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.backdoor-section textarea {
min-height: 60px;
margin-bottom: 8px;
}
.checklist {
max-height: 150px;
overflow-y: auto;
margin-bottom: 8px;
padding: 8px;
background: var(--bg-card);
border-radius: 8px;
}
.checklist label {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
}
.checklist input[type="checkbox"] {
accent-color: var(--accent);
width: 16px;
height: 16px;
cursor: pointer;
}
.backdoor-section button {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.backdoor-section button:hover {
background: var(--accent-hover);
}
.btn-close {
display: block;
width: 100%;
margin-top: 16px;
padding: 10px;
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.btn-close:hover {
background: rgba(255,255,255,0.08);
}
/* ===== Scrollbar ===== */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.15);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.25);
}
/* ===== Utility ===== */
.hidden {
display: none !important;
}
</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,.tsv" 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>
(function() {
'use strict';
// ===== 共享状态 =====
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, // 当前轮次
};
// ===== 工具函数 =====
const Utils = {
/** 生成类 UUID 的唯一标识 */
generateId: function() {
return 'xxxx-xxxx-xxxx'.replace(/x/g, function() {
return ((Math.random() * 16) | 0).toString(16);
});
},
/** 名字哈希颜色HSL 保证舒适度) */
hashColor: function(name) {
let hash = 0;
for (var i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
var hue = Math.abs(hash) % 360;
return 'hsl(' + hue + ', 65%, 55%)';
},
/** 取名字后两个字(单字名取最后一个字) */
getAvatarText: function(name) {
var chars = name.trim();
if (chars.length <= 2) return chars;
return chars.slice(-2);
},
/** 格式化时间 */
formatTime: function(date) {
var d = date || new Date();
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' +
pad(d.getMonth() + 1) + '-' +
pad(d.getDate()) + ' ' +
pad(d.getHours()) + ':' +
pad(d.getMinutes()) + ':' +
pad(d.getSeconds());
},
/** 解析名字文本:按分隔符拆分,去空、去重 */
parseNames: function(text) {
// 支持逗号、分号、顿号、换行、制表符等分隔
var names = text.split(/[,\s;,,、\n\r\t]+/);
// 去空、去前后空格
names = names.map(function(n) { return n.trim(); }).filter(function(n) { return n.length > 0; });
// 去重
var seen = {};
var unique = [];
for (var i = 0; i < names.length; i++) {
var key = names[i];
if (!seen[key]) {
seen[key] = true;
unique.push(names[i]);
}
}
return unique;
}
};
// ===== 名单管理模块 =====
const NameManager = {
/** 解析文本输入 */
parseInput: function(text) {
return Utils.parseNames(text);
},
/** 导入 TXT 文件 */
importTXT: function(file) {
var reader = new FileReader();
var self = this;
reader.onload = function(e) {
var text = e.target.result;
var names = self.parseInput(text);
self.addToPool(names);
};
reader.readAsText(file, 'UTF-8');
},
/** 导入 CSV 文件 */
importCSV: function(file) {
var reader = new FileReader();
var self = this;
reader.onload = function(e) {
var text = e.target.result;
// CSV 按行或逗号分隔parseNames 统一处理
var names = self.parseInput(text);
self.addToPool(names);
};
reader.readAsText(file, 'UTF-8');
},
/** 将名字列表转为人员对象,加入抽奖池,去重 */
addToPool: function(names) {
var existingNames = {};
for (var i = 0; i < state.pool.length; i++) {
existingNames[state.pool[i].name] = true;
}
var added = 0;
for (var j = 0; j < names.length; j++) {
var name = names[j];
if (!existingNames[name]) {
existingNames[name] = true;
state.pool.push(this.generatePerson(name));
added++;
}
}
state.totalImported += added;
this.updatePoolCount();
this.renderNameList();
},
/** 为单个名字生成人员对象 */
generatePerson: function(name) {
return {
id: Utils.generateId(),
name: name,
color: Utils.hashColor(name),
avatar: Utils.getAvatarText(name)
};
},
/** 获取当前抽奖池人数 */
getPoolCount: function() {
return state.pool.length;
},
/** 更新池人数显示 */
updatePoolCount: function() {
var el = document.getElementById('poolCount');
if (el) el.textContent = state.pool.length;
},
/** 渲染已导入名单列表 */
renderNameList: function() {
var container = document.getElementById('nameList');
if (!container) return;
container.innerHTML = '';
var fragment = document.createDocumentFragment();
for (var i = 0; i < state.pool.length; i++) {
var span = document.createElement('span');
span.className = 'name-tag';
span.textContent = state.pool[i].name;
fragment.appendChild(span);
}
container.appendChild(fragment);
}
};
// ===== 抽奖引擎模块 =====
const LotteryEngine = {
/** 根据模式和后门设置,从池中选出中奖人 */
pickOne: function() {
// 1. 候选池 = state.pool 副本
var candidates = state.pool.slice();
// 2. 过滤排除名单
candidates = this.applyExclude(candidates);
// 3. 若必中名单非空,取交集
if (state.backdoor.mustWinList.length > 0) {
var intersection = [];
for (var i = 0; i < candidates.length; i++) {
for (var j = 0; j < state.backdoor.mustWinList.length; j++) {
if (candidates[i].name === state.backdoor.mustWinList[j]) {
intersection.push(candidates[i]);
break;
}
}
}
if (intersection.length > 0) {
candidates = intersection;
}
}
// 4. 从候选池随机选一人
if (candidates.length === 0) return null;
return candidates[Math.floor(Math.random() * candidates.length)];
},
/** 过滤掉排除名单中的人 */
applyExclude: function(pool) {
if (state.backdoor.excludeList.length === 0) return pool;
return pool.filter(function(person) {
for (var i = 0; i < state.backdoor.excludeList.length; i++) {
if (person.name === state.backdoor.excludeList[i]) return false;
}
return true;
});
},
/** 单次模式:从池中移除中奖人 */
removeWinner: function(id) {
for (var i = 0; i < state.pool.length; i++) {
if (state.pool[i].id === id) {
state.pool.splice(i, 1);
break;
}
}
NameManager.updatePoolCount();
NameManager.renderNameList();
},
/** 重复模式:保留在池中(无需操作) */
keepWinner: function(id) {
// 不做任何事
}
};
// ===== 动画模块 =====
const AnimationEngine = {
rAFId: null,
lastTickTime: 0,
/** 名字滚动动画:快速切换 → 3秒后减速 → 停止在 winner */
startRollAnimation: function(winnerPerson, callback) {
var self = this;
var startTime = performance.now();
var duration = 3500; // 总时长 3.5 秒
var lastTickTime = 0;
var rollNameEl = document.getElementById('rollName');
var avatarTextEl = document.getElementById('avatarText');
var avatarDisplayEl = document.getElementById('avatarDisplay');
// 移除 highlight 类
rollNameEl.classList.remove('highlight');
function tick(now) {
var elapsed = now - startTime;
var progress = Math.min(elapsed / duration, 1);
// 缓动:前 85% 快速切换,后 15% 减速
var easeProgress = self.easeOutCubic(progress);
var interval = 50 + easeProgress * 400; // 50ms → 450ms
if (elapsed - lastTickTime >= interval) {
// 获取当前滚动候选
var candidate = self.getRollCandidate(winnerPerson, progress);
if (candidate) {
rollNameEl.textContent = candidate.name;
avatarTextEl.textContent = candidate.avatar;
avatarDisplayEl.style.background = candidate.color;
}
lastTickTime = elapsed;
}
if (progress < 1) {
self.rAFId = requestAnimationFrame(tick);
} else {
// 最终停在 winner添加 highlight
rollNameEl.textContent = winnerPerson.name;
avatarTextEl.textContent = winnerPerson.avatar;
avatarDisplayEl.style.background = winnerPerson.color;
rollNameEl.classList.add('highlight');
callback(winnerPerson);
}
}
this.rAFId = requestAnimationFrame(tick);
},
/** 停止滚动动画 */
stopRollAnimation: function() {
if (this.rAFId) {
cancelAnimationFrame(this.rAFId);
this.rAFId = null;
}
},
/** 缓动函数easeOutCubic */
easeOutCubic: function(t) {
return 1 - Math.pow(1 - t, 3);
},
/** 获取滚动候选:最后 10% 逐渐偏向 winner */
getRollCandidate: function(winner, progress) {
if (state.pool.length === 0) return null;
if (progress > 0.9 && Math.random() < 0.3) {
return winner;
}
return state.pool[Math.floor(Math.random() * state.pool.length)];
},
/** Canvas 烟花粒子效果 */
fireFireworks: function() {
var canvas = document.getElementById('fireworksCanvas');
var ctx = canvas.getContext('2d');
var running = true;
var fireworks = [];
var spawnTimer = 0;
var endTime = performance.now() + 4000; // 持续 4 秒
// 调整 canvas 尺寸
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
// 粒子类
function Particle(x, y, color) {
var angle = Math.random() * Math.PI * 2;
var 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;
}
Particle.prototype.update = function() {
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--;
};
Particle.prototype.draw = function(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();
};
// 烟花类
function Firework(targetX, targetY, colors) {
this.x = targetX;
this.y = canvas.height;
this.targetX = targetX;
this.targetY = targetY;
this.colors = colors;
this.particles = [];
this.state = 'rising';
this.speed = 4 + Math.random() * 3;
}
Firework.prototype.update = function() {
if (this.state === 'rising') {
this.y -= this.speed;
if (this.y <= this.targetY) {
this.state = 'exploding';
// 生成爆炸粒子
for (var i = 0; i < 60 + Math.floor(Math.random() * 20); i++) {
var color = this.colors[Math.floor(Math.random() * this.colors.length)];
this.particles.push(new Particle(this.targetX, this.targetY, color));
}
}
} else if (this.state === 'exploding') {
for (var j = 0; j < this.particles.length; j++) {
this.particles[j].update();
}
// 过滤死亡粒子
this.particles = this.particles.filter(function(p) { return p.life > 0; });
if (this.particles.length === 0) {
this.state = 'dead';
}
}
};
Firework.prototype.draw = function(ctx) {
if (this.state === 'rising') {
ctx.save();
ctx.fillStyle = this.colors[0];
ctx.beginPath();
ctx.arc(this.x, this.y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else if (this.state === 'exploding') {
for (var i = 0; i < this.particles.length; i++) {
this.particles[i].draw(ctx);
}
}
};
// 随机颜色调色板
function randomColorPalette() {
var palettes = [
['#FF6B6B', '#FFA07A', '#FFD700'],
['#4ECDC4', '#45B7D1', '#96CEB4'],
['#DDA0DD', '#FF69B4', '#FFB6C1'],
['#F97F51', '#E056FD', '#686DE0'],
['#F9FBE7', '#A7C957', '#6CB4B0'],
['#FF6B6B', '#4ECDC4', '#FFD700'],
['#FF69B4', '#45B7D1', '#FFA07A']
];
return palettes[Math.floor(Math.random() * palettes.length)];
}
// 主循环
function loop() {
if (!running) return;
var now = performance.now();
if (now > endTime) {
running = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 每 200-500ms 生成一朵烟花
spawnTimer++;
if (spawnTimer > 4 + Math.random() * 6) {
var x = canvas.width * (0.2 + Math.random() * 0.6);
var y = canvas.height * (0.1 + Math.random() * 0.4);
var colors = randomColorPalette();
fireworks.push(new Firework(x, y, colors));
spawnTimer = 0;
}
// 更新和绘制
fireworks = fireworks.filter(function(fw) { return fw.state !== 'dead'; });
for (var i = 0; i < fireworks.length; i++) {
fireworks[i].update();
fireworks[i].draw(ctx);
}
requestAnimationFrame(loop);
}
loop();
}
};
// ===== 历史记录模块 =====
const HistoryModule = {
/** 追加一条记录到 state.history */
addRecord: function(winner) {
state.round++;
state.history.push({
round: state.round,
winner: winner.name,
time: Utils.formatTime()
});
this.render();
},
/** 重新渲染底部历史记录列表 */
render: function() {
var tbody = document.getElementById('historyBody');
if (!tbody) return;
tbody.innerHTML = '';
var fragment = document.createDocumentFragment();
// 从最新到最旧显示
for (var i = state.history.length - 1; i >= 0; i--) {
var record = state.history[i];
var tr = document.createElement('tr');
var tdRound = document.createElement('td');
tdRound.textContent = record.round;
tr.appendChild(tdRound);
var tdWinner = document.createElement('td');
tdWinner.textContent = record.winner;
tr.appendChild(tdWinner);
var tdTime = document.createElement('td');
tdTime.textContent = record.time;
tr.appendChild(tdTime);
fragment.appendChild(tr);
}
tbody.appendChild(fragment);
},
/** 清空历史记录 */
clear: function() {
state.history = [];
state.round = 0;
this.render();
}
};
// ===== 后门模块 =====
const Backdoor = {
/** 打开后门面板 */
open: function() {
var panel = document.getElementById('backdoorPanel');
panel.classList.remove('hidden');
this.renderChecklists();
},
/** 关闭后门面板 */
close: function() {
var panel = document.getElementById('backdoorPanel');
panel.classList.add('hidden');
},
/** 渲染复选框列表(从当前池中勾选) */
renderChecklists: function() {
var mustWinChecklist = document.getElementById('mustWinChecklist');
var excludeChecklist = document.getElementById('excludeChecklist');
// 必中名单复选框
mustWinChecklist.innerHTML = '';
if (state.pool.length > 0) {
for (var i = 0; i < state.pool.length; i++) {
var person = state.pool[i];
var label = document.createElement('label');
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = person.name;
// 如果已在必中名单中,勾选
if (state.backdoor.mustWinList.indexOf(person.name) !== -1) {
checkbox.checked = true;
}
label.appendChild(checkbox);
label.appendChild(document.createTextNode(person.name));
mustWinChecklist.appendChild(label);
}
} else {
mustWinChecklist.innerHTML = '<span style="color:var(--text-secondary);font-size:12px;">抽奖池为空</span>';
}
// 排除名单复选框
excludeChecklist.innerHTML = '';
if (state.pool.length > 0) {
for (var j = 0; j < state.pool.length; j++) {
var p = state.pool[j];
var lbl = document.createElement('label');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = p.name;
if (state.backdoor.excludeList.indexOf(p.name) !== -1) {
cb.checked = true;
}
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(p.name));
excludeChecklist.appendChild(lbl);
}
} else {
excludeChecklist.innerHTML = '<span style="color:var(--text-secondary);font-size:12px;">抽奖池为空</span>';
}
},
/** 确认必中名单 */
confirmMustWin: function() {
var names = [];
// 从文本输入解析
var input = document.getElementById('mustWinInput').value;
var parsed = Utils.parseNames(input);
for (var i = 0; i < parsed.length; i++) {
names.push(parsed[i]);
}
// 从复选框收集
var checkboxes = document.getElementById('mustWinChecklist').querySelectorAll('input[type="checkbox"]:checked');
for (var j = 0; j < checkboxes.length; j++) {
var val = checkboxes[j].value;
if (names.indexOf(val) === -1) {
names.push(val);
}
}
state.backdoor.mustWinList = names;
state.backdoor.enabled = names.length > 0 || state.backdoor.excludeList.length > 0;
// 清空输入
document.getElementById('mustWinInput').value = '';
},
/** 确认排除名单 */
confirmExclude: function() {
var names = [];
// 从文本输入解析
var input = document.getElementById('excludeInput').value;
var parsed = Utils.parseNames(input);
for (var i = 0; i < parsed.length; i++) {
names.push(parsed[i]);
}
// 从复选框收集
var checkboxes = document.getElementById('excludeChecklist').querySelectorAll('input[type="checkbox"]:checked');
for (var j = 0; j < checkboxes.length; j++) {
var val = checkboxes[j].value;
if (names.indexOf(val) === -1) {
names.push(val);
}
}
state.backdoor.excludeList = names;
state.backdoor.enabled = state.backdoor.mustWinList.length > 0 || names.length > 0;
// 清空输入
document.getElementById('excludeInput').value = '';
}
};
// ===== DOM 引用 =====
var DOM = {
nameInput: document.getElementById('nameInput'),
btnAdd: document.getElementById('btnAdd'),
fileTXT: document.getElementById('fileTXT'),
fileCSV: document.getElementById('fileCSV'),
nameList: document.getElementById('nameList'),
poolCount: document.getElementById('poolCount'),
btnStart: document.getElementById('btnStart'),
rollName: document.getElementById('rollName'),
avatarText: document.getElementById('avatarText'),
avatarDisplay: document.getElementById('avatarDisplay'),
resultArea: document.getElementById('resultArea'),
winnerName: document.getElementById('winnerName'),
backdoorPanel: document.getElementById('backdoorPanel'),
btnCloseBackdoor: document.getElementById('btnCloseBackdoor'),
btnConfirmMustWin: document.getElementById('btnConfirmMustWin'),
btnConfirmExclude: document.getElementById('btnConfirmExclude'),
fireworksCanvas: document.getElementById('fireworksCanvas')
};
// ===== 事件绑定 =====
function initEvents() {
// 添加名单按钮
DOM.btnAdd.addEventListener('click', function() {
var text = DOM.nameInput.value;
if (!text.trim()) return;
var names = NameManager.parseInput(text);
if (names.length > 0) {
NameManager.addToPool(names);
DOM.nameInput.value = '';
}
});
// TXT 文件导入
DOM.fileTXT.addEventListener('change', function(e) {
var file = e.target.files[0];
if (!file) return;
NameManager.importTXT(file);
e.target.value = ''; // 重置 file input
});
// CSV 文件导入
DOM.fileCSV.addEventListener('change', function(e) {
var file = e.target.files[0];
if (!file) return;
NameManager.importCSV(file);
e.target.value = ''; // 重置 file input
});
// 模式切换
var modeRadios = document.querySelectorAll('input[name="mode"]');
for (var i = 0; i < modeRadios.length; i++) {
modeRadios[i].addEventListener('change', function() {
state.mode = this.value;
});
}
// 抽奖按钮 - 普通点击 / 长按后门
setupStartButton();
// 关闭后门
DOM.btnCloseBackdoor.addEventListener('click', function() {
Backdoor.close();
});
// 确认必中名单
DOM.btnConfirmMustWin.addEventListener('click', function() {
Backdoor.confirmMustWin();
});
// 确认排除名单
DOM.btnConfirmExclude.addEventListener('click', function() {
Backdoor.confirmExclude();
});
// 点击遮罩关闭后门
DOM.backdoorPanel.addEventListener('click', function(e) {
if (e.target === DOM.backdoorPanel) {
Backdoor.close();
}
});
}
/** 设置抽奖按钮的点击/长按事件 */
function setupStartButton() {
var pressTimer = null;
var isLongPress = false;
function startPress(e) {
if (state.isDrawing) return;
// 阻止默认行为(防止移动端文本选择等)
if (e.cancelable) e.preventDefault();
isLongPress = false;
DOM.btnStart.classList.add('pressing');
pressTimer = setTimeout(function() {
isLongPress = true;
DOM.btnStart.classList.remove('pressing');
Backdoor.open();
pressTimer = null;
}, 3000);
}
function endPress(e) {
if (state.isDrawing) return;
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
DOM.btnStart.classList.remove('pressing');
if (!isLongPress) {
startLottery();
}
isLongPress = false;
}
function leavePress() {
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
DOM.btnStart.classList.remove('pressing');
}
// 鼠标事件
DOM.btnStart.addEventListener('mousedown', startPress);
DOM.btnStart.addEventListener('mouseup', endPress);
DOM.btnStart.addEventListener('mouseleave', leavePress);
// 触摸事件(移动端兼容)
DOM.btnStart.addEventListener('touchstart', startPress, { passive: false });
DOM.btnStart.addEventListener('touchend', function(e) {
e.preventDefault();
endPress(e);
});
DOM.btnStart.addEventListener('touchcancel', leavePress);
}
/** 开始抽奖 */
function startLottery() {
if (state.isDrawing) return;
// 检查抽奖池
var candidates = LotteryEngine.applyExclude(state.pool.slice());
if (candidates.length === 0) {
alert('抽奖池为空,请先添加名单!');
return;
}
state.isDrawing = true;
DOM.btnStart.disabled = true;
DOM.resultArea.classList.add('hidden');
// 执行抽奖逻辑
var winner = LotteryEngine.pickOne();
if (!winner) {
state.isDrawing = false;
DOM.btnStart.disabled = false;
alert('无法选出中奖人,请检查名单或后门设置。');
return;
}
// 开始滚动动画
AnimationEngine.startRollAnimation(winner, function(finalWinner) {
// 动画结束,显示结果
state.winner = finalWinner;
// 显示中奖结果
DOM.winnerName.textContent = finalWinner.name;
DOM.resultArea.classList.remove('hidden');
// 根据模式处理
if (state.mode === 'single') {
LotteryEngine.removeWinner(finalWinner.id);
} else {
LotteryEngine.keepWinner(finalWinner.id);
}
// 记录历史
HistoryModule.addRecord(finalWinner);
// 播放烟花
AnimationEngine.fireFireworks();
// 恢复按钮
state.isDrawing = false;
DOM.btnStart.disabled = false;
});
}
// ===== 初始化 =====
function init() {
initEvents();
HistoryModule.render();
NameManager.updatePoolCount();
}
// DOMContentLoaded 后启动
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>