2093 lines
60 KiB
HTML
2093 lines
60 KiB
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 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-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 2px 8px;
|
||
margin: 2px;
|
||
border-radius: 4px;
|
||
background: rgba(255,255,255,0.06);
|
||
color: var(--text-secondary);
|
||
position: relative;
|
||
}
|
||
|
||
.name-list .name-tag .delete-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 16px;
|
||
height: 16px;
|
||
font-size: 11px;
|
||
line-height: 1;
|
||
color: #888;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
padding: 0;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.name-list .name-tag .delete-btn:hover {
|
||
color: #ff4444;
|
||
background: rgba(255, 68, 68, 0.15);
|
||
}
|
||
|
||
.name-list .name-tag .delete-btn:active {
|
||
transform: scale(0.85);
|
||
}
|
||
|
||
.name-list.disabled .name-tag .delete-btn {
|
||
display: none;
|
||
}
|
||
|
||
/* ===== Mode Switch ===== */
|
||
.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);
|
||
}
|
||
|
||
/* ===== Probability Settings ===== */
|
||
.probability-hint {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.probability-list {
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.probability-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 8px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.probability-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.probability-row .person-name {
|
||
flex: 0 0 80px;
|
||
font-size: 14px;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.probability-row .weight-input {
|
||
width: 56px;
|
||
padding: 4px 6px;
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
border-radius: 4px;
|
||
background: var(--bg-secondary);
|
||
color: var(--text-primary);
|
||
text-align: center;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
outline: none;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.probability-row .weight-input:focus {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.weight-quick-btns {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.weight-quick-btns button {
|
||
padding: 2px 8px;
|
||
font-size: 12px;
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
border-radius: 4px;
|
||
background: var(--bg-card);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
font-family: inherit;
|
||
}
|
||
|
||
.weight-quick-btns button:hover {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.weight-quick-btns button.active {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.probability-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.probability-actions button {
|
||
flex: 1;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border: none;
|
||
padding: 8px 16px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
font-family: inherit;
|
||
}
|
||
|
||
.probability-actions button:hover {
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
.probability-actions button.secondary {
|
||
background: var(--bg-card);
|
||
color: var(--text-primary);
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
}
|
||
|
||
.probability-actions button.secondary:hover {
|
||
background: rgba(255,255,255,0.08);
|
||
}
|
||
|
||
/* ===== Winner Popup ===== */
|
||
.winner-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.75);
|
||
z-index: 10001;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
backdrop-filter: blur(6px);
|
||
-webkit-backdrop-filter: blur(6px);
|
||
}
|
||
|
||
.winner-overlay.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.winner-modal {
|
||
background: var(--bg-secondary);
|
||
border-radius: 20px;
|
||
padding: 40px 50px;
|
||
text-align: center;
|
||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||
animation: winnerPopIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||
position: relative;
|
||
max-width: 90vw;
|
||
}
|
||
|
||
@keyframes winnerPopIn {
|
||
0% {
|
||
opacity: 0;
|
||
transform: scale(0.5) translateY(30px);
|
||
}
|
||
100% {
|
||
opacity: 1;
|
||
transform: scale(1) translateY(0);
|
||
}
|
||
}
|
||
|
||
.winner-modal .winner-close {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 16px;
|
||
font-size: 24px;
|
||
color: var(--text-secondary);
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
padding: 4px 8px;
|
||
border-radius: 50%;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.winner-modal .winner-close:hover {
|
||
color: var(--accent);
|
||
background: rgba(255,255,255,0.08);
|
||
}
|
||
|
||
.winner-modal .winner-congrats {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: #FFD700;
|
||
margin-bottom: 20px;
|
||
text-shadow: 0 0 20px rgba(255, 215, 0, 0.4);
|
||
}
|
||
|
||
.winner-modal .winner-avatar-large {
|
||
width: 120px;
|
||
height: 120px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0 auto 20px;
|
||
font-size: 40px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
text-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||
border: 4px solid rgba(255,255,255,0.2);
|
||
box-shadow: 0 0 30px rgba(255, 215, 0, 0.3);
|
||
}
|
||
|
||
.winner-modal .winner-name-large {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.winner-modal .winner-label {
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* ===== Horizontal Scroll (Slot Machine) ===== */
|
||
.scroll-container {
|
||
width: 100%;
|
||
overflow: hidden;
|
||
position: relative;
|
||
height: 40px;
|
||
margin: 8px 0;
|
||
}
|
||
|
||
.scroll-container::before,
|
||
.scroll-container::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 40px;
|
||
z-index: 2;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.scroll-container::before {
|
||
left: 0;
|
||
background: linear-gradient(to right, var(--bg-secondary), transparent);
|
||
}
|
||
|
||
.scroll-container::after {
|
||
right: 0;
|
||
background: linear-gradient(to left, var(--bg-secondary), transparent);
|
||
}
|
||
|
||
.scroll-track {
|
||
display: flex;
|
||
align-items: center;
|
||
height: 100%;
|
||
will-change: transform;
|
||
}
|
||
|
||
.scroll-item {
|
||
flex-shrink: 0;
|
||
padding: 0 16px;
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
white-space: nowrap;
|
||
opacity: 0.5;
|
||
transition: opacity 0.1s;
|
||
}
|
||
|
||
.scroll-item.active {
|
||
opacity: 1;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* ===== Scrollbar ===== */
|
||
::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
::-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="scrollContainer" class="scroll-container hidden">
|
||
<div id="scrollTrack" class="scroll-track"></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>
|
||
<p class="probability-hint">权重值 0-100,数值越大被抽中的概率越高。默认权重为 1,设为 0 则不参与抽奖。</p>
|
||
<div id="probabilityList" class="probability-list"></div>
|
||
<div class="probability-actions">
|
||
<button id="btnResetAllWeights" class="secondary">重置全部为 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>
|
||
|
||
<!-- 中奖弹窗(默认隐藏) -->
|
||
<div id="winnerOverlay" class="winner-overlay hidden">
|
||
<div class="winner-modal">
|
||
<button id="winnerClose" class="winner-close">✕</button>
|
||
<div class="winner-congrats">🎉 恭喜中奖!🎉</div>
|
||
<div id="winnerAvatarLarge" class="winner-avatar-large">?</div>
|
||
<div id="winnerNameLarge" class="winner-name-large"></div>
|
||
<div class="winner-label">本次幸运儿</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
'use strict';
|
||
|
||
// ===== 共享状态 =====
|
||
const state = {
|
||
pool: [], // 当前抽奖池:[{ id, name, color, avatar, weight }]
|
||
history: [], // 历史记录(内存,页面关闭清空)
|
||
totalImported: 0, // 累计导入总人数
|
||
|
||
// 抽奖引擎
|
||
mode: 'single', // 'single' | 'repeat'
|
||
isDrawing: false, // 是否正在抽奖动画中
|
||
winner: null, // 当前中奖人
|
||
|
||
// 后门
|
||
backdoor: {
|
||
enabled: false,
|
||
mustWinList: [], // 必中名单(名字字符串数组)
|
||
excludeList: [], // 排除名单(名字字符串数组)
|
||
probabilities: {}, // { name: weight },权重值 0-100
|
||
},
|
||
|
||
// 动画
|
||
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),
|
||
weight: 1 // v1.1.0: 默认权重 1
|
||
};
|
||
},
|
||
|
||
/** 获取当前抽奖池人数 */
|
||
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 = '';
|
||
// 抽奖中禁用删除
|
||
if (state.isDrawing) {
|
||
container.classList.add('disabled');
|
||
} else {
|
||
container.classList.remove('disabled');
|
||
}
|
||
var fragment = document.createDocumentFragment();
|
||
for (var i = 0; i < state.pool.length; i++) {
|
||
var person = state.pool[i];
|
||
var span = document.createElement('span');
|
||
span.className = 'name-tag';
|
||
|
||
var nameText = document.createTextNode(person.name);
|
||
span.appendChild(nameText);
|
||
|
||
// 删除按钮
|
||
var delBtn = document.createElement('button');
|
||
delBtn.className = 'delete-btn';
|
||
delBtn.textContent = '×';
|
||
delBtn.title = '删除 ' + person.name;
|
||
delBtn.setAttribute('data-name', person.name);
|
||
if (state.isDrawing) {
|
||
delBtn.disabled = true;
|
||
}
|
||
span.appendChild(delBtn);
|
||
|
||
fragment.appendChild(span);
|
||
}
|
||
container.appendChild(fragment);
|
||
},
|
||
|
||
/** 按名字删除人员(v1.1.0 新增) */
|
||
removeByName: function(name) {
|
||
if (state.isDrawing) return; // 抽奖中禁止删除
|
||
|
||
// 从池中移除
|
||
var idx = -1;
|
||
for (var i = 0; i < state.pool.length; i++) {
|
||
if (state.pool[i].name === name) {
|
||
idx = i;
|
||
break;
|
||
}
|
||
}
|
||
if (idx === -1) return;
|
||
state.pool.splice(idx, 1);
|
||
|
||
// 同步清理必中名单
|
||
var mwIdx = state.backdoor.mustWinList.indexOf(name);
|
||
if (mwIdx !== -1) state.backdoor.mustWinList.splice(mwIdx, 1);
|
||
|
||
// 同步清理排除名单
|
||
var exIdx = state.backdoor.excludeList.indexOf(name);
|
||
if (exIdx !== -1) state.backdoor.excludeList.splice(exIdx, 1);
|
||
|
||
// 同步清理概率配置
|
||
delete state.backdoor.probabilities[name];
|
||
|
||
this.updatePoolCount();
|
||
this.renderNameList();
|
||
}
|
||
};
|
||
|
||
// ===== 抽奖引擎模块 =====
|
||
const LotteryEngine = {
|
||
/** 根据模式和后门设置,从池中选出中奖人(v1.1.0 加权随机) */
|
||
pickOne: function() {
|
||
// 1. 候选池 = state.pool 副本
|
||
var candidates = state.pool.slice();
|
||
|
||
// 2. 过滤排除名单 + 权重为 0 的人
|
||
var self = this;
|
||
candidates = this.applyExclude(candidates);
|
||
candidates = candidates.filter(function(person) {
|
||
var w = self.getWeight(person.name);
|
||
return w > 0;
|
||
});
|
||
|
||
// 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) {
|
||
return intersection[Math.floor(Math.random() * intersection.length)];
|
||
}
|
||
}
|
||
|
||
// 4. 加权随机(无必中名单或必中名单不匹配时)
|
||
if (candidates.length === 0) return null;
|
||
|
||
var totalWeight = 0;
|
||
for (var k = 0; k < candidates.length; k++) {
|
||
totalWeight += self.getWeight(candidates[k].name);
|
||
}
|
||
|
||
if (totalWeight === 0) {
|
||
// 兜底:所有人权重都为 0 时等概率
|
||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||
}
|
||
|
||
// 加权随机选择
|
||
var r = Math.random() * totalWeight;
|
||
var cumulative = 0;
|
||
for (var m = 0; m < candidates.length; m++) {
|
||
cumulative += self.getWeight(candidates[m].name);
|
||
if (r < cumulative) {
|
||
return candidates[m];
|
||
}
|
||
}
|
||
|
||
// 兜底
|
||
return candidates[candidates.length - 1];
|
||
},
|
||
|
||
/** 获取某人权重(v1.1.0) */
|
||
getWeight: function(name) {
|
||
// 优先从 probabilities 读取,其次 person.weight,默认 1
|
||
if (state.backdoor.probabilities.hasOwnProperty(name)) {
|
||
return state.backdoor.probabilities[name];
|
||
}
|
||
for (var i = 0; i < state.pool.length; i++) {
|
||
if (state.pool[i].name === name) {
|
||
return state.pool[i].weight || 1;
|
||
}
|
||
}
|
||
return 1;
|
||
},
|
||
|
||
/** 过滤掉排除名单中的人 */
|
||
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,
|
||
|
||
/** 名字滚动动画:水平老虎机效果 → 减速 → 停止在 winner */
|
||
startRollAnimation: function(winnerPerson, callback) {
|
||
var self = this;
|
||
var startTime = performance.now();
|
||
var duration = 3500; // 总时长 3.5 秒
|
||
var rollNameEl = document.getElementById('rollName');
|
||
var avatarTextEl = document.getElementById('avatarText');
|
||
var avatarDisplayEl = document.getElementById('avatarDisplay');
|
||
var scrollContainerEl = document.getElementById('scrollContainer');
|
||
var scrollTrackEl = document.getElementById('scrollTrack');
|
||
|
||
// 移除 highlight
|
||
rollNameEl.classList.remove('highlight');
|
||
|
||
// 构建滚动轨道:重复多份名单以产生连续滚动效果
|
||
var allNames = [];
|
||
for (var i = 0; i < 8; i++) {
|
||
for (var j = 0; j < state.pool.length; j++) {
|
||
allNames.push(state.pool[j]);
|
||
}
|
||
}
|
||
|
||
// 清空并填充轨道
|
||
scrollTrackEl.innerHTML = '';
|
||
scrollTrackEl.style.transform = 'translateX(0px)';
|
||
for (var k = 0; k < allNames.length; k++) {
|
||
var item = document.createElement('span');
|
||
item.className = 'scroll-item';
|
||
item.textContent = allNames[k].name;
|
||
scrollTrackEl.appendChild(item);
|
||
}
|
||
|
||
// 显示滚动容器,隐藏静态名字
|
||
scrollContainerEl.classList.remove('hidden');
|
||
rollNameEl.textContent = '';
|
||
|
||
// 测量轨道总宽度
|
||
var totalWidth = scrollTrackEl.scrollWidth;
|
||
var containerWidth = scrollContainerEl.clientWidth;
|
||
|
||
// 缓动函数
|
||
function easeOutCubic(t) {
|
||
return 1 - Math.pow(1 - t, 3);
|
||
}
|
||
|
||
var lastItemIndex = -1;
|
||
|
||
function tick(now) {
|
||
var elapsed = now - startTime;
|
||
var progress = Math.min(elapsed / duration, 1);
|
||
var easedProgress = easeOutCubic(progress);
|
||
|
||
// 总位移 = 轨道宽度 - 容器宽度(滚动到末尾附近)
|
||
var maxScroll = totalWidth - containerWidth;
|
||
// 最终停在 winner 附近
|
||
var targetScroll = maxScroll * 0.85;
|
||
var currentScroll = targetScroll * easedProgress;
|
||
|
||
scrollTrackEl.style.transform = 'translateX(' + (-currentScroll) + 'px)';
|
||
|
||
// 高亮当前可见的中间项
|
||
var items = scrollTrackEl.querySelectorAll('.scroll-item');
|
||
var centerOffset = containerWidth / 2 + currentScroll;
|
||
var itemWidth = 100; // 估算每个名字宽度
|
||
var currentIndex = Math.floor(centerOffset / itemWidth) % items.length;
|
||
|
||
if (currentIndex !== lastItemIndex && currentIndex >= 0 && currentIndex < items.length) {
|
||
// 清除旧高亮
|
||
for (var m = 0; m < items.length; m++) {
|
||
items[m].classList.remove('active');
|
||
}
|
||
items[currentIndex].classList.add('active');
|
||
|
||
// 更新头像和名字
|
||
var personIdx = currentIndex % state.pool.length;
|
||
var currentPerson = state.pool[personIdx];
|
||
if (currentPerson) {
|
||
rollNameEl.textContent = currentPerson.name;
|
||
avatarTextEl.textContent = currentPerson.avatar;
|
||
avatarDisplayEl.style.background = currentPerson.color;
|
||
}
|
||
lastItemIndex = currentIndex;
|
||
}
|
||
|
||
if (progress < 1) {
|
||
self.rAFId = requestAnimationFrame(tick);
|
||
} else {
|
||
// 最终停在 winner
|
||
scrollContainerEl.classList.add('hidden');
|
||
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)];
|
||
},
|
||
|
||
/** 显示中奖弹窗(v1.1.0 新增) */
|
||
showWinnerPopup: function(winnerPerson) {
|
||
var overlay = document.getElementById('winnerOverlay');
|
||
var avatarLarge = document.getElementById('winnerAvatarLarge');
|
||
var nameLarge = document.getElementById('winnerNameLarge');
|
||
|
||
avatarLarge.textContent = winnerPerson.avatar;
|
||
avatarLarge.style.background = winnerPerson.color;
|
||
nameLarge.textContent = winnerPerson.name;
|
||
|
||
overlay.classList.remove('hidden');
|
||
},
|
||
|
||
/** 关闭中奖弹窗 */
|
||
hideWinnerPopup: function() {
|
||
var overlay = document.getElementById('winnerOverlay');
|
||
overlay.classList.add('hidden');
|
||
},
|
||
|
||
/** Canvas 烟花粒子效果(v1.1.0 改进:弹窗周围爆炸 + 5秒 + 更密集) */
|
||
fireFireworks: function() {
|
||
var canvas = document.getElementById('fireworksCanvas');
|
||
var ctx = canvas.getContext('2d');
|
||
var running = true;
|
||
var fireworks = [];
|
||
var spawnTimer = 0;
|
||
var endTime = performance.now() + 5000; // 持续 5 秒
|
||
|
||
// 获取弹窗位置作为爆炸中心
|
||
var popupEl = document.getElementById('winnerOverlay');
|
||
var popupRect = popupEl.getBoundingClientRect();
|
||
var centerX = popupRect.left + popupRect.width / 2;
|
||
var centerY = popupRect.top + popupRect.height / 2;
|
||
var spreadRadius = Math.max(popupRect.width, popupRect.height) * 0.8;
|
||
|
||
// 调整 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() * 8;
|
||
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() * 4;
|
||
this.life = 80 + Math.random() * 50;
|
||
this.maxLife = this.life;
|
||
this.gravity = 0.04;
|
||
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();
|
||
};
|
||
|
||
// 烟花类(v1.1.0:无上升阶段,直接爆炸)
|
||
function Firework(targetX, targetY, colors) {
|
||
this.targetX = targetX;
|
||
this.targetY = targetY;
|
||
this.colors = colors;
|
||
this.particles = [];
|
||
this.state = 'exploding';
|
||
// 初始爆炸生成更多粒子
|
||
for (var i = 0; i < 100 + Math.floor(Math.random() * 40); i++) {
|
||
var color = colors[Math.floor(Math.random() * colors.length)];
|
||
this.particles.push(new Particle(targetX, targetY, color));
|
||
}
|
||
}
|
||
|
||
Firework.prototype.update = function() {
|
||
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 === '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);
|
||
|
||
// 每 150-400ms 生成一朵烟花
|
||
spawnTimer++;
|
||
if (spawnTimer > 3 + Math.random() * 5) {
|
||
// 在弹窗周围随机位置生成爆炸点
|
||
var angle = Math.random() * Math.PI * 2;
|
||
var radius = spreadRadius * (0.5 + Math.random() * 1.2);
|
||
var x = centerX + Math.cos(angle) * radius;
|
||
var y = centerY + Math.sin(angle) * radius;
|
||
// 限制在屏幕范围内
|
||
x = Math.max(50, Math.min(canvas.width - 50, x));
|
||
y = Math.max(50, Math.min(canvas.height - 50, y));
|
||
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>';
|
||
}
|
||
|
||
// v1.1.0: 渲染概率列表
|
||
this.renderProbabilityList();
|
||
},
|
||
|
||
/** 渲染概率设置列表(v1.1.0 新增) */
|
||
renderProbabilityList: function() {
|
||
var list = document.getElementById('probabilityList');
|
||
if (!list) return;
|
||
list.innerHTML = '';
|
||
|
||
if (state.pool.length === 0) {
|
||
list.innerHTML = '<span style="color:var(--text-secondary);font-size:12px;">抽奖池为空</span>';
|
||
return;
|
||
}
|
||
|
||
for (var i = 0; i < state.pool.length; i++) {
|
||
var person = state.pool[i];
|
||
var row = document.createElement('div');
|
||
row.className = 'probability-row';
|
||
row.setAttribute('data-name', person.name);
|
||
|
||
// 获取当前权重
|
||
var currentWeight = 1;
|
||
if (state.backdoor.probabilities.hasOwnProperty(person.name)) {
|
||
currentWeight = state.backdoor.probabilities[person.name];
|
||
} else if (person.weight) {
|
||
currentWeight = person.weight;
|
||
}
|
||
|
||
// 人名
|
||
var nameSpan = document.createElement('span');
|
||
nameSpan.className = 'person-name';
|
||
nameSpan.textContent = person.name;
|
||
|
||
// 权重输入框
|
||
var weightInput = document.createElement('input');
|
||
weightInput.type = 'number';
|
||
weightInput.className = 'weight-input';
|
||
weightInput.min = 0;
|
||
weightInput.max = 100;
|
||
weightInput.value = currentWeight;
|
||
weightInput.setAttribute('data-name', person.name);
|
||
|
||
// 快捷按钮
|
||
var quickBtns = document.createElement('div');
|
||
quickBtns.className = 'weight-quick-btns';
|
||
var quickValues = [0, 1, 5, 10, 50, 100];
|
||
for (var q = 0; q < quickValues.length; q++) {
|
||
var w = quickValues[q];
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.textContent = w;
|
||
btn.setAttribute('data-weight', w);
|
||
if (w === currentWeight) {
|
||
btn.className = 'active';
|
||
}
|
||
(function(input, b, wVal) {
|
||
btn.addEventListener('click', function() {
|
||
input.value = wVal;
|
||
var siblings = quickBtns.querySelectorAll('button');
|
||
for (var s = 0; s < siblings.length; s++) {
|
||
siblings[s].className = '';
|
||
}
|
||
b.className = 'active';
|
||
});
|
||
})(weightInput, btn, w);
|
||
quickBtns.appendChild(btn);
|
||
}
|
||
|
||
// 输入框变化时更新快捷按钮高亮
|
||
weightInput.addEventListener('input', function() {
|
||
var v = parseInt(this.value) || 0;
|
||
var btns = quickBtns.querySelectorAll('button');
|
||
for (var b = 0; b < btns.length; b++) {
|
||
btns[b].className = (parseInt(btns[b].getAttribute('data-weight')) === v) ? 'active' : '';
|
||
}
|
||
});
|
||
|
||
row.appendChild(nameSpan);
|
||
row.appendChild(weightInput);
|
||
row.appendChild(quickBtns);
|
||
list.appendChild(row);
|
||
}
|
||
},
|
||
|
||
/** 保存概率设置(v1.1.0 新增) */
|
||
saveProbabilities: function() {
|
||
var inputs = document.querySelectorAll('.weight-input');
|
||
var newProbs = {};
|
||
for (var i = 0; i < inputs.length; i++) {
|
||
var input = inputs[i];
|
||
var name = input.getAttribute('data-name');
|
||
var weight = Math.max(0, Math.min(100, parseInt(input.value) || 0));
|
||
if (weight !== 1) {
|
||
newProbs[name] = weight;
|
||
}
|
||
}
|
||
state.backdoor.probabilities = newProbs;
|
||
|
||
// 同步更新 pool 中每个人的 weight
|
||
for (var j = 0; j < state.pool.length; j++) {
|
||
var person = state.pool[j];
|
||
if (newProbs.hasOwnProperty(person.name)) {
|
||
person.weight = newProbs[person.name];
|
||
} else {
|
||
person.weight = 1;
|
||
}
|
||
}
|
||
},
|
||
|
||
/** 重置所有权重为 1(v1.1.0 新增) */
|
||
resetAllWeights: function() {
|
||
state.backdoor.probabilities = {};
|
||
for (var i = 0; i < state.pool.length; i++) {
|
||
state.pool[i].weight = 1;
|
||
}
|
||
this.renderProbabilityList();
|
||
},
|
||
|
||
/** 确认必中名单 */
|
||
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'),
|
||
// v1.1.0 新增
|
||
btnConfirmProbabilities: document.getElementById('btnConfirmProbabilities'),
|
||
btnResetAllWeights: document.getElementById('btnResetAllWeights'),
|
||
winnerOverlay: document.getElementById('winnerOverlay'),
|
||
winnerClose: document.getElementById('winnerClose')
|
||
};
|
||
|
||
// ===== 事件绑定 =====
|
||
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();
|
||
}
|
||
});
|
||
|
||
// v1.1.0: 名单删除按钮(事件委托)
|
||
DOM.nameList.addEventListener('click', function(e) {
|
||
var btn = e.target.closest('.delete-btn');
|
||
if (btn && !btn.disabled) {
|
||
var name = btn.getAttribute('data-name');
|
||
if (name) {
|
||
NameManager.removeByName(name);
|
||
}
|
||
}
|
||
});
|
||
|
||
// v1.1.0: 概率设置确认
|
||
DOM.btnConfirmProbabilities.addEventListener('click', function() {
|
||
Backdoor.saveProbabilities();
|
||
});
|
||
|
||
// v1.1.0: 重置所有权重
|
||
DOM.btnResetAllWeights.addEventListener('click', function() {
|
||
Backdoor.resetAllWeights();
|
||
});
|
||
|
||
// v1.1.0: 关闭中奖弹窗
|
||
DOM.winnerClose.addEventListener('click', function() {
|
||
AnimationEngine.hideWinnerPopup();
|
||
});
|
||
|
||
// v1.1.0: 点击遮罩关闭中奖弹窗
|
||
DOM.winnerOverlay.addEventListener('click', function(e) {
|
||
if (e.target === DOM.winnerOverlay) {
|
||
AnimationEngine.hideWinnerPopup();
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 设置抽奖按钮的点击/长按事件 */
|
||
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');
|
||
NameManager.renderNameList(); // 禁用删除按钮
|
||
|
||
// 执行抽奖逻辑
|
||
var winner = LotteryEngine.pickOne();
|
||
if (!winner) {
|
||
state.isDrawing = false;
|
||
DOM.btnStart.disabled = false;
|
||
NameManager.renderNameList();
|
||
alert('无法选出中奖人,请检查名单或后门设置。');
|
||
return;
|
||
}
|
||
|
||
// 开始滚动动画
|
||
AnimationEngine.startRollAnimation(winner, function(finalWinner) {
|
||
// 动画结束
|
||
state.winner = finalWinner;
|
||
|
||
// 根据模式处理
|
||
if (state.mode === 'single') {
|
||
LotteryEngine.removeWinner(finalWinner.id);
|
||
} else {
|
||
LotteryEngine.keepWinner(finalWinner.id);
|
||
}
|
||
|
||
// 记录历史
|
||
HistoryModule.addRecord(finalWinner);
|
||
|
||
// 显示中奖弹窗
|
||
AnimationEngine.showWinnerPopup(finalWinner);
|
||
|
||
// 播放烟花(围绕弹窗)
|
||
AnimationEngine.fireFireworks();
|
||
|
||
// 恢复按钮
|
||
state.isDrawing = false;
|
||
DOM.btnStart.disabled = false;
|
||
NameManager.renderNameList(); // 恢复删除按钮
|
||
});
|
||
}
|
||
|
||
// ===== 初始化 =====
|
||
function init() {
|
||
initEvents();
|
||
HistoryModule.render();
|
||
NameManager.updatePoolCount();
|
||
}
|
||
|
||
// DOMContentLoaded 后启动
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |