Last active
December 18, 2025 12:56
-
-
Save liuran001/6ac642b14aec326feaf4f5a73c6f6692 to your computer and use it in GitHub Desktop.
Lucky Draw | 幸运抽奖器
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| // 使用前请删除所有注释 | |
| // 使用参数 ?config=<Link> 可指定配置链接 | |
| // 界面主题:可选 "red" (红), "blue" (蓝), "green" (绿), "cyber" (赛博朋克) | |
| "theme": "red", | |
| // 页面顶部的大标题 | |
| "title": "2025年度公司年会盛典", | |
| // 抽奖模式:可选 "name" (名单模式) 或 "number" (数字模式) | |
| "mode": "name", | |
| // 数字模式下的最小值和最大值 (仅当 mode 为 number 时生效) | |
| "min": 1, | |
| "max": 500, | |
| // 全局背景图 (可选,填图片URL) | |
| "bgImg": "https://images.unsplash.com/photo-1606903251421-4d1066742512?auto=format&fit=crop&q=80&w=1920", | |
| // 全屏中奖展示时的背景图 (可选) | |
| "fullScreenBg": "", | |
| // 页面底部的装饰大图 (通常放奖品图,会被奖项预设覆盖) | |
| "bottomImg": "", | |
| // 连抽次数 (自动抽奖配置,可选) | |
| "autoCount": "", | |
| // 滚动时长 (自动抽奖配置,可选) | |
| "drawWait": "", | |
| // 间隔 (自动抽奖配置,可选) | |
| "autoInterval": "", | |
| // 结束后自动全屏展示名单 (自动抽奖配置,默认关) | |
| "autoFullscreen": false, | |
| // 不重复抽取 (默认开) | |
| "noRepeat": true, | |
| // 奖项预设列表 (在界面左侧下拉框选择) | |
| "prizes": [ | |
| { | |
| "label": "特等奖", // 下拉框显示的名称 | |
| "title": "特等奖:MacBook Pro", // 切换后,大屏幕显示的标题 | |
| "image": "https://img.icons8.com/color/480/macbook.png" // 底部显示的奖品图片 | |
| }, | |
| { | |
| "label": "一等奖", | |
| "title": "一等奖:iPhone 15", | |
| "image": "https://img.icons8.com/color/480/iphone.png" | |
| }, | |
| { | |
| "label": "二等奖", | |
| "title": "二等奖:Switch 游戏机", | |
| "image": "https://img.icons8.com/color/480/nintendo-switch.png" | |
| } | |
| ], | |
| // 参与人员名单 | |
| // 支持简单的字符串,也支持对象嵌套(用于在管理面板中按部门分组) | |
| "names": [ | |
| "总经理", // 这里的名字没有分组,直接在根目录 | |
| { | |
| "label": "技术部", // 这是一个分组 | |
| "children": [ | |
| "张三", | |
| "李四", | |
| "王五", | |
| "赵六" | |
| ] | |
| }, | |
| { | |
| "label": "市场部", // 这是另一个分组 | |
| "children": [ | |
| "Alice", | |
| "Bob", | |
| { | |
| "label": "销售一组", // 支持无限级嵌套分组 | |
| "children": ["Sale1", "Sale2"] | |
| } | |
| ] | |
| }, | |
| "行政-小红", | |
| "财务-小明" | |
| ] | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Lucky Draw | 幸运抽奖器</title> | |
| <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script> | |
| <style> | |
| :root { | |
| --bg-color: #fff; | |
| --bg-gradient: none; | |
| --panel-bg: #fff; | |
| --text-main: #000; | |
| --primary: #000; | |
| --accent: #f00; | |
| --border: #ccc; | |
| --shadow: none; | |
| --display-glow: none; | |
| --winner-color: #000; | |
| --winner-stroke: transparent; | |
| --winner-shadow: none; | |
| --btn-text: #fff; | |
| } | |
| [data-theme="red"] { --bg-color: #2b0000; --bg-gradient: radial-gradient(circle at center, #800000 0%, #1a0000 100%); --panel-bg: rgba(60, 0, 0, 0.6); --text-main: #fff; --primary: #ffd700; --accent: #ff3333; --border: rgba(255, 215, 0, 0.3); --shadow: 0 10px 40px rgba(0, 0, 0, 0.6); --display-glow: 0 0 30px rgba(255, 215, 0, 0.2); --winner-color: #fff; --winner-stroke: #ffd700; --winner-shadow: 0 0 20px rgba(255, 215, 0, 0.8), 0 0 50px rgba(255, 0, 0, 0.5); --btn-text: #3e0000; } | |
| [data-theme="blue"] { --bg-color: #f0f2f5; --bg-gradient: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); --panel-bg: rgba(255, 255, 255, 0.75); --text-main: #2c3e50; --primary: #2980b9; --accent: #e74c3c; --border: rgba(0, 0, 0, 0.1); --shadow: 0 4px 15px rgba(0,0,0,0.1); --display-glow: none; --winner-color: #d35400; --winner-stroke: #fff; --winner-shadow: 0 5px 15px rgba(0,0,0,0.1); --btn-text: #fff; } | |
| [data-theme="green"] { --bg-color: #e8f5e9; --bg-gradient: linear-gradient(to bottom, #e8f5e9, #c8e6c9); --panel-bg: rgba(255, 255, 255, 0.65); --text-main: #2e7d32; --primary: #43a047; --accent: #d84315; --border: rgba(67, 160, 71, 0.2); --shadow: 0 5px 15px rgba(46, 125, 50, 0.1); --display-glow: none; --winner-color: #1b5e20; --winner-stroke: #fff; --winner-shadow: 0 5px 15px rgba(0,0,0,0.2); --btn-text: #fff; } | |
| [data-theme="cyber"] { --bg-color: #050508; --bg-gradient: radial-gradient(circle at center, #1a1a2e 0%, #000 100%); --panel-bg: rgba(20, 20, 30, 0.7); --text-main: #fff; --primary: #00f3ff; --accent: #ff0055; --border: rgba(0, 243, 255, 0.3); --shadow: 0 0 20px rgba(0, 243, 255, 0.1); --display-glow: 0 0 30px var(--primary); --winner-color: #fff; --winner-stroke: #00f3ff; --winner-shadow: 0 0 30px #00f3ff, 0 0 60px #ff00ff; --btn-text: #000; } | |
| * { margin: 0; padding: 0; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; transition: background 0.3s, color 0.3s, border 0.3s; } | |
| body { | |
| background: var(--bg-color); | |
| background-image: var(--bg-gradient); | |
| background-size: cover; | |
| background-position: center; | |
| background-repeat: no-repeat; | |
| color: var(--text-main); | |
| height: 100vh; | |
| overflow: hidden; | |
| display: flex; flex-direction: column; | |
| } | |
| .main-layout { | |
| flex: 1; display: grid; grid-template-columns: 3fr 1fr; gap: 20px; padding: 20px; | |
| height: calc(100vh - 50px); overflow: hidden; | |
| } | |
| .stage-area { | |
| background: var(--panel-bg); backdrop-filter: blur(12px); border: 1px solid var(--border); | |
| border-radius: 20px; display: flex; flex-direction: column; align-items: center; | |
| justify-content: space-between; position: relative; box-shadow: var(--shadow); | |
| overflow: hidden; padding: 20px; | |
| } | |
| .stage-title { | |
| text-align: center; font-size: 6.0rem; color: var(--primary); opacity: 0.9; | |
| letter-spacing: 2px; font-weight: 900; text-transform: uppercase; | |
| text-shadow: 0 2px 10px rgba(0,0,0,0.1); margin-top: 10px; flex-shrink: 0; | |
| } | |
| .display-text { | |
| flex: 1; display: flex; align-items: center; justify-content: center; | |
| font-size: 11vw; font-weight: 800; color: var(--text-main); text-shadow: var(--display-glow); | |
| text-align: center; line-height: 1.1; word-break: break-word; z-index: 2; margin: 0; | |
| padding-bottom: 12vh; transition: padding 0.3s ease; | |
| } | |
| .stage-area.has-img .display-text { padding-bottom: 0; } | |
| .display-text.animate { transform: scale(1.05); opacity: 0.9; } | |
| .display-text.winner { | |
| color: var(--winner-color); -webkit-text-stroke: 6px var(--winner-stroke); paint-order: stroke fill; | |
| text-shadow: var(--winner-shadow); transform: scale(1.2); transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); | |
| } | |
| .title-img-display { | |
| height: 480px; max-width: 80%; object-fit: contain; | |
| filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3)); display: none; margin-bottom: 20px; flex-shrink: 0; | |
| } | |
| .sidebar { display: flex; flex-direction: column; gap: 15px; height: 100%; overflow: hidden; min-width: 300px; } | |
| .panel { | |
| background: var(--panel-bg); backdrop-filter: blur(12px); border: 1px solid var(--border); | |
| border-radius: 16px; padding: 15px; display: flex; flex-direction: column; box-shadow: var(--shadow); | |
| } | |
| .history-panel { flex: 1; min-height: 0; } | |
| .panel-header { | |
| display: flex; justify-content: space-between; align-items: center; | |
| border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 10px; flex-shrink: 0; | |
| } | |
| .panel-title { font-size: 0.95rem; color: var(--text-main); opacity: 0.8; font-weight: 700; } | |
| .btn-icon { | |
| background: transparent; border: 1px solid var(--border); color: var(--text-main); | |
| cursor: pointer; width: 28px; height: 28px; border-radius: 6px; | |
| display: flex; align-items: center; justify-content: center; opacity: 0.7; | |
| } | |
| .btn-icon:hover { opacity: 1; background: var(--border); } | |
| .btn-icon.danger:hover { color: #fff; background: #ff3333; border-color: #ff3333; } | |
| .history-list { | |
| flex: 1; overflow-y: auto; overflow-x: hidden; padding-right: 4px; display: flex; flex-direction: column; gap: 6px; | |
| scrollbar-width: thin; scrollbar-color: var(--border) transparent; | |
| } | |
| .history-list::-webkit-scrollbar { width: 4px; } | |
| .history-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } | |
| .history-item { | |
| background: rgba(255,255,255,0.05); border: 1px solid transparent; padding: 0; border-radius: 8px; | |
| border-left: 4px solid var(--border); display: flex; justify-content: space-between; align-items: center; | |
| cursor: pointer; position: relative; height: 44px; | |
| } | |
| .history-item:hover { background: var(--border); } | |
| .history-item:first-child { border-left-color: var(--primary); background: linear-gradient(90deg, rgba(255,255,255,0.1) 0%, transparent 100%); } | |
| .history-content { display: flex; justify-content: space-between; align-items: center; flex: 1; height: 100%; padding: 0 12px; } | |
| .history-index { font-size: 0.9rem; opacity: 0.6; font-family: monospace; min-width: 40px; } | |
| .history-val { font-weight: 700; font-size: 1.1rem; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; text-align: right; } | |
| .btn-delete-item { | |
| background: transparent; border: none; color: var(--accent); font-size: 1.2rem; | |
| width: 30px; height: 100%; cursor: pointer; display: flex; align-items: center; justify-content: center; | |
| opacity: 0; pointer-events: none; transition: opacity 0.2s; margin-right: 5px; | |
| } | |
| .history-item:hover .btn-delete-item { opacity: 1; pointer-events: auto; } | |
| .btn-delete-item:hover { transform: scale(1.2); font-weight: bold; } | |
| .controls-panel { flex-shrink: 0; position: relative; padding-top: 15px; } | |
| /* JSON Mode Visibility Rules */ | |
| .controls-panel.json-mode .manual-settings { display: none !important; } | |
| .controls-panel.json-mode .json-indicator { display: flex !important; } | |
| /* In JSON mode, hide the title/upload row and external config row, but keep advanced open */ | |
| .controls-panel.json-mode .title-controls-row, | |
| .controls-panel.json-mode .config-import-row { display: none !important; } | |
| .json-indicator { | |
| display: none; background: rgba(0,0,0,0.05); border: 1px solid var(--border); | |
| padding: 8px 12px; border-radius: 10px; margin-bottom: 12px; | |
| align-items: center; gap: 10px; flex-wrap: wrap; | |
| } | |
| .json-tag { font-size: 0.75rem; font-weight: 800; color: var(--primary); white-space: nowrap; opacity: 0.8; } | |
| .preset-select { | |
| flex: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.2); | |
| background: #fff; font-weight: bold; font-size: 0.9rem; color: #000; | |
| outline: none; min-width: 0; | |
| } | |
| .preset-select option { background: #fff; color: #000; } | |
| .btn-exit-json, .btn-manage-json { | |
| padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border); | |
| background: rgba(255,255,255,0.8); color: var(--text-main); font-size: 0.8rem; | |
| cursor: pointer; font-weight: bold; flex-shrink: 0; transition: all 0.2s; | |
| } | |
| .btn-exit-json:hover { background: var(--accent); color: #fff; border-color: var(--accent); } | |
| .btn-manage-json:hover { background: var(--primary); color: #fff; border-color: var(--primary); } | |
| .advanced-settings-toggle { | |
| width: 100%; padding: 8px; margin-bottom: 10px; border: 1px dashed var(--border); | |
| background: rgba(255,255,255,0.05); color: var(--text-main); font-size: 0.85rem; | |
| cursor: pointer; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 5px; opacity: 0.8; | |
| } | |
| .advanced-settings-toggle:hover { background: rgba(255,255,255,0.1); opacity: 1; } | |
| #advanced-wrapper { display: none; margin-bottom: 15px; padding: 10px; background: rgba(0,0,0,0.03); border-radius: 8px; } | |
| #advanced-wrapper.open { display: block; animation: slideDown 0.2s ease-out; } | |
| @keyframes slideDown { from {opacity:0; transform:translateY(-10px);} to {opacity:1; transform:translateY(0);} } | |
| .config-import-row { display: flex; gap: 5px; margin-bottom: 10px; } | |
| .config-input { | |
| flex: 1; padding: 6px; border-radius: 6px; border: 1px solid var(--border); | |
| background: rgba(255,255,255,0.1); color: var(--text-main); outline: none; font-size: 0.8rem; | |
| } | |
| .theme-select { | |
| width: 100%; margin-bottom: 8px; padding: 8px; border-radius: 8px; | |
| border: 1px solid var(--border); background: rgba(255,255,255,0.2); | |
| color: var(--text-main); outline: none; cursor: pointer; font-weight: bold; | |
| } | |
| .theme-select option { color: #000; background: #fff; } | |
| .title-controls-row { display: flex; gap: 5px; margin-bottom: 12px; } | |
| .title-input { | |
| flex: 1; padding: 8px; border-radius: 8px; border: 1px solid var(--border); | |
| background: rgba(255,255,255,0.1); color: var(--text-main); outline: none; font-weight: bold; font-size: 0.9rem; | |
| } | |
| .bg-controls { display: flex; gap: 5px; margin-bottom: 12px; } | |
| .btn-small { | |
| flex: 1; padding: 6px; font-size: 0.85rem; border: 1px solid var(--border); | |
| background: rgba(255,255,255,0.1); color: var(--text-main); border-radius: 6px; cursor: pointer; | |
| } | |
| .btn-small:hover { background: var(--border); } | |
| .tabs { display: flex; gap: 5px; margin-bottom: 10px; background: var(--border); padding: 4px; border-radius: 8px; } | |
| .tab { | |
| flex: 1; text-align: center; padding: 6px; cursor: pointer; border-radius: 6px; | |
| font-size: 0.85rem; color: var(--text-main); opacity: 0.6; font-weight: 600; | |
| } | |
| .tab.active { background: var(--bg-color); opacity: 1; color: var(--text-main); } | |
| .input-row { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; } | |
| input[type="number"], textarea { | |
| background: rgba(255,255,255,0.1); border: 1px solid var(--border); | |
| color: var(--text-main); padding: 8px; border-radius: 6px; width: 100%; outline: none; | |
| text-align: center; font-size: 1rem; font-weight: bold; | |
| } | |
| input:focus, textarea:focus { border-color: var(--primary); background: var(--bg-color); } | |
| .main-btn { | |
| width: 100%; padding: 16px; font-size: 1.3rem; font-weight: 800; border: none; | |
| border-radius: 12px; cursor: pointer; margin-top: 15px; letter-spacing: 1px; | |
| color: var(--btn-text); transition: transform 0.1s, box-shadow 0.2s; | |
| } | |
| .btn-start { background: var(--primary); box-shadow: 0 4px 15px var(--border); } | |
| .btn-start:hover { transform: translateY(-2px); filter: brightness(1.2); } | |
| .btn-stop { background: var(--accent); display: none; color: #fff; } | |
| footer { | |
| min-height: 40px; padding: 10px; display: flex; justify-content: center; align-items: center; | |
| flex-wrap: wrap; gap: 15px; font-size: 0.75rem; color: var(--text-main); opacity: 0.6; flex-shrink: 0; | |
| background: rgba(0,0,0,0.1); | |
| } | |
| footer a { color: inherit; text-decoration: none; border-bottom: 1px dashed currentColor; } | |
| footer a:hover { color: var(--primary); border-bottom-style: solid; } | |
| .footer-line { display: flex; gap: 10px; align-items: center; } | |
| .divider { opacity: 0.5; } | |
| .modal { | |
| display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; | |
| background: rgba(0,0,0,0.92); z-index: 999; | |
| flex-direction: column; justify-content: center; align-items: center; | |
| backdrop-filter: blur(15px); | |
| } | |
| .modal-content-single { | |
| font-size: 12vw; font-weight: 900; color: var(--winner-color); | |
| -webkit-text-stroke: 4px var(--winner-stroke); paint-order: stroke fill; | |
| text-shadow: var(--winner-shadow); text-align: center; padding: 20px; animation: zoomIn 0.3s; | |
| } | |
| .modal-full-layout { | |
| display: flex; flex-direction: column; align-items: center; justify-content: space-between; | |
| width: 100%; height: 100%; padding: 20px 40px; position: relative; box-sizing: border-box; | |
| background-size: cover; background-position: center; background-repeat: no-repeat; | |
| } | |
| .modal-full-title { | |
| font-size: 6.0rem; color: var(--primary); font-weight: 900; text-transform: uppercase; | |
| text-shadow: 0 2px 10px rgba(255,255,255,0.2); margin-bottom: 10px; flex-shrink: 0; text-align: center; | |
| } | |
| .modal-full-list { | |
| flex: 1; width: 100%; overflow-y: auto; display: flex; flex-wrap: wrap; | |
| justify-content: center; align-content: center; gap: 25px; padding: 20px; | |
| scrollbar-width: thin; | |
| } | |
| .modal-full-list span { | |
| padding: 10px 30px; background: var(--panel-bg); color: var(--winner-color); | |
| border: 2px solid var(--border); border-radius: 60px; font-weight: 900; font-size: 5.5rem; | |
| line-height: 1.1; box-shadow: 0 5px 20px rgba(0,0,0,0.3); text-shadow: 1px 1px 2px rgba(0,0,0,0.1); | |
| } | |
| .modal-full-img { | |
| height: 530px; max-width: 80%; object-fit: contain; margin-top: 10px; flex-shrink: 1; | |
| filter: drop-shadow(0 4px 6px rgba(0,0,0,0.5)); | |
| } | |
| .no-close { pointer-events: auto; } | |
| .delete-btn-modal { | |
| position: absolute; top: 30px; left: 30px; padding: 8px 16px; | |
| background: transparent; border: 1px solid #fff; color: #fff; border-radius: 6px; | |
| cursor: pointer; font-weight: bold; display: none; | |
| } | |
| .delete-btn-modal:hover { background: var(--accent); border-color: var(--accent); } | |
| .pool-manager-box, .prize-manager-box { | |
| background: var(--panel-bg); width: 90%; max-width: 600px; max-height: 80vh; | |
| border-radius: 12px; display: flex; flex-direction: column; border: 1px solid var(--border); | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.3); color: var(--text-main); | |
| } | |
| .pm-header { padding: 15px; border-bottom: 1px solid var(--border); display:flex; justify-content:space-between; align-items:center;} | |
| .pm-body { flex: 1; overflow-y: auto; padding: 10px; } | |
| .pm-item { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 8px; border-bottom: 1px solid rgba(0,0,0,0.05); margin-left: 20px; | |
| } | |
| .pm-name { font-weight: bold; font-size: 1.1rem; } | |
| .pm-switch { | |
| position: relative; display: inline-block; width: 40px; height: 22px; | |
| } | |
| .pm-switch input { opacity: 0; width: 0; height: 0; } | |
| .slider { | |
| position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; | |
| background-color: #ccc; transition: .4s; border-radius: 22px; | |
| } | |
| .slider:before { | |
| position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; | |
| background-color: white; transition: .4s; border-radius: 50%; | |
| } | |
| input:checked + .slider { background-color: var(--primary); } | |
| input:checked + .slider:before { transform: translateX(18px); } | |
| details.pm-group { margin-bottom: 5px; border: 1px solid rgba(0,0,0,0.05); border-radius: 8px; overflow: hidden; } | |
| details.pm-group > summary { | |
| padding: 10px; background: rgba(0,0,0,0.03); cursor: pointer; font-weight: bold; | |
| display: flex; justify-content: space-between; align-items: center; | |
| } | |
| details.pm-group > summary:hover { background: rgba(0,0,0,0.06); } | |
| details.pm-group[open] > summary { border-bottom: 1px solid rgba(0,0,0,0.05); } | |
| .pm-group-content { padding-left: 10px; } | |
| .prize-item { | |
| padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; | |
| background: rgba(255,255,255,0.05); display: flex; justify-content: space-between; align-items: center; | |
| } | |
| .prize-info h4 { margin: 0 0 4px 0; font-size: 1rem; } | |
| .prize-info p { margin: 0; font-size: 0.8rem; opacity: 0.7; } | |
| .prize-edit-form { | |
| padding: 15px; background: rgba(0,0,0,0.03); border-bottom: 1px solid var(--border); | |
| display: flex; flex-direction: column; gap: 8px; | |
| } | |
| .form-row { display: flex; gap: 8px; } | |
| .confirm-box { | |
| background: var(--panel-bg); padding: 30px; border-radius: 16px; text-align: center; | |
| border: 1px solid var(--border); color: var(--text-main); max-width: 400px; | |
| } | |
| .confirm-btns { display: flex; gap: 10px; margin-top: 20px; justify-content: center; } | |
| .btn-confirm { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-weight: bold; } | |
| .btn-confirm.yes { background: var(--accent); color: #fff; } | |
| .btn-confirm.soft { background: var(--primary); color: #fff; } | |
| .btn-confirm.no { background: transparent; border: 1px solid var(--border); color: var(--text-main); } | |
| .section-label { font-size:0.75rem; font-weight:bold; opacity:0.7; margin: 10px 0 5px 0; display:block; } | |
| @keyframes zoomIn { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } } | |
| @media (max-width: 1024px) { | |
| body { overflow-y: auto; height: auto; } | |
| .main-layout { display: flex; flex-direction: column; height: auto; overflow: visible; padding-bottom: 20px; } | |
| .stage-area { order: 1; min-height: 400px; margin-bottom: 20px; } | |
| .sidebar { order: 2; min-width: auto; height: auto; display: flex; flex-direction: column; overflow: visible; } | |
| .controls-panel { order: 1; margin-bottom: 20px; } | |
| .history-panel { order: 2; height: 400px; min-height: 400px; } | |
| .stage-title { font-size: 2.2rem; margin-top: 10px; } | |
| .title-img-display { height: 100px; margin-bottom: 10px; } | |
| .display-text { font-size: 15vw; padding-bottom: 4vh; } | |
| .modal-full-layout { padding: 10px; } | |
| .modal-full-title { font-size: 3rem; } | |
| .modal-full-list { align-content: flex-start; } | |
| .modal-full-list span { font-size: 2.5rem; padding: 5px 15px; } | |
| .modal-full-img { height: 150px; } | |
| footer { flex-direction: column; gap: 5px; text-align: center; } | |
| .desktop-divider { display: none; } | |
| } | |
| </style> | |
| </head> | |
| <body data-theme="red"> | |
| <div class="main-layout"> | |
| <section class="stage-area" id="stage-area"> | |
| <div class="stage-title" id="main-title">LUCKY DRAW</div> | |
| <div class="display-text" id="display">READY</div> | |
| <img id="title-img-display" class="title-img-display" src="" alt="Logo"> | |
| </section> | |
| <section class="sidebar"> | |
| <div class="panel history-panel"> | |
| <div class="panel-header"> | |
| <span class="panel-title">中奖名单</span> | |
| <div style="display:flex; gap:5px;"> | |
| <button class="btn-icon" onclick="copyList(this)" title="复制名单">📋</button> | |
| <button class="btn-icon danger" onclick="confirmClearHistory()" title="清空全部">🗑</button> | |
| <button class="btn-icon" onclick="showAllModal()" title="全屏">⛶</button> | |
| </div> | |
| </div> | |
| <div class="history-list" id="history-container"></div> | |
| </div> | |
| <div class="panel controls-panel" id="controls-panel"> | |
| <div class="json-indicator" id="json-indicator"> | |
| <div class="json-tag">⚡ 已加载外部配置</div> | |
| <select id="preset-selector" class="preset-select" style="display:none;" onchange="switchPreset(this.value)"> | |
| </select> | |
| <button class="btn-manage-json" onclick="openPrizeManager()" title="管理奖项预设">⚙️</button> | |
| <button class="btn-exit-json" onclick="exitJsonMode()" title="退出JSON配置模式">✕</button> | |
| </div> | |
| <div class="tabs manual-settings"> | |
| <div class="tab active" id="tab-num" onclick="switchTab('number')">数字模式</div> | |
| <div class="tab" id="tab-name" onclick="switchTab('name')">名单模式</div> | |
| </div> | |
| <div id="settings-num" class="manual-settings"> | |
| <div class="input-row"> | |
| <input type="number" id="min-val" placeholder="Min" value="1"> | |
| <span style="opacity:0.5">-</span> | |
| <input type="number" id="max-val" placeholder="Max" value="100"> | |
| </div> | |
| </div> | |
| <div id="settings-name" class="manual-settings" style="display: none;"> | |
| <textarea id="name-list" placeholder="粘贴名单... 张三 李四"></textarea> | |
| </div> | |
| <div class="status-bar"> | |
| <label style="display:flex;align-items:center;gap:5px;cursor:pointer;"> | |
| <input type="checkbox" id="no-repeat" checked> 不重复抽取 | |
| </label> | |
| <span id="pool-info" style="font-weight:bold;">池内: 0</span> | |
| </div> | |
| <div class="advanced-settings-toggle" onclick="toggleAdvancedSettings()"> | |
| ⚙️ 高级设置 <span id="adv-arrow">▼</span> | |
| </div> | |
| <div id="advanced-wrapper"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin: 10px 0 5px 0;"> | |
| <span class="section-label" style="margin: 0; flex: 1;">自动抽奖配置 (留空则为手动)</span> | |
| <button class="btn-small" onclick="resetAutoSettings()" style="padding: 2px 12px; width: auto; flex: 0 0 auto;">手动模式</button> | |
| </div> | |
| <div class="input-row"> | |
| <input type="number" id="auto-count" placeholder="连抽次数" title="留空默认1次"> | |
| <input type="number" id="draw-wait" placeholder="滚动时长" title="滚动多久后自动停, 留空不自动停"> | |
| <input type="number" id="auto-interval" placeholder="间隔" title="连抽时的间隔等待时间"> | |
| </div> | |
| <div style="margin-bottom: 12px;"> | |
| <label style="cursor:pointer; display:flex; align-items:center; gap:5px; font-size: 0.85rem; opacity:0.9;"> | |
| <input type="checkbox" id="auto-fullscreen"> 结束后自动全屏展示名单 | |
| </label> | |
| </div> | |
| <span class="section-label">主题与背景</span> | |
| <select class="theme-select" id="theme-selector" onchange="changeTheme(this.value)"> | |
| <option value="red">🧧 盛世红金 (Deep Red)</option> | |
| <option value="blue">🌊 清新蓝调 (Simple Blue)</option> | |
| <option value="green">🍃 护眼森系 (Zen Green)</option> | |
| <option value="cyber">🌃 赛博霓虹 (Cyberpunk)</option> | |
| </select> | |
| <div class="title-controls-row"> | |
| <input type="text" class="title-input" id="title-input" placeholder="设置奖项标题" oninput="updateTitle(this.value)"> | |
| <input type="file" id="title-img-upload" accept="image/*" style="display:none" onchange="handleTitleImg(event)"> | |
| <button class="btn-icon" onclick="document.getElementById('title-img-upload').click()" title="上传底部大图">📷</button> | |
| <button class="btn-icon danger" onclick="clearTitleImg()" title="清除图片">×</button> | |
| </div> | |
| <div class="bg-controls"> | |
| <input type="file" id="bg-file-input" accept="image/*" style="display:none" onchange="handleBgUpload(event)"> | |
| <button class="btn-small" onclick="document.getElementById('bg-file-input').click()">🖼 主背景</button> | |
| <input type="file" id="fs-bg-file-input" accept="image/*" style="display:none" onchange="handleFsBgUpload(event)"> | |
| <button class="btn-small" onclick="document.getElementById('fs-bg-file-input').click()">🖼 全屏背景</button> | |
| <button class="btn-small" onclick="clearCustomBg()" style="flex:0 0 30px;">✕</button> | |
| </div> | |
| <span class="section-label config-import-row">外部配置</span> | |
| <div class="config-import-row"> | |
| <input type="text" class="config-input" id="json-url-input" placeholder="输入JSON配置网址..."> | |
| <button class="btn-small" onclick="loadJsonFromUrl()" style="flex:0 0 40px;">加载</button> | |
| <input type="file" id="json-file-upload" accept=".json" style="display:none" onchange="handleJsonUpload(event)"> | |
| <button class="btn-small" onclick="document.getElementById('json-file-upload').click()" style="flex:0 0 40px;">📂</button> | |
| </div> | |
| </div> | |
| <div class="json-indicator" id="json-pool-btn-area" style="display:none; justify-content: center; background:transparent; border:none; padding:0;"> | |
| <button class="btn-small" onclick="openPoolManager()" style="width:100%; padding:10px; font-weight:bold;">👥 管理本次抽奖名单</button> | |
| </div> | |
| <button class="main-btn btn-start" id="btn-start" onclick="startDraw()">开始抽奖</button> | |
| <button class="main-btn btn-stop" id="btn-stop" onclick="stopDraw()">停 止</button> | |
| </div> | |
| </section> | |
| </div> | |
| <footer> | |
| <div class="footer-line"> | |
| <span>Lucky Draw V2.3 - 251218</span> | |
| </div> | |
| <span class="divider desktop-divider">|</span> | |
| <div class="footer-line"> | |
| <span>❤ From baka & Gemini</span> | |
| <span class="divider">|</span> | |
| <a href="https://gist.github.com/liuran001/6ac642b14aec326feaf4f5a73c6f6692" target="_blank">开源地址</a> | |
| </div> | |
| </footer> | |
| <div class="modal" id="modal" onclick="closeModal(event)"> | |
| <button class="delete-btn-modal" id="modal-del-btn">🗑 删除此条</button> | |
| <div id="modal-content"></div> | |
| </div> | |
| <div class="modal" id="confirm-modal"> | |
| <div class="confirm-box"> | |
| <h3>清空历史记录</h3> | |
| <p style="margin:15px 0; opacity:0.8;">您希望如何处理当前已中奖的人员?</p> | |
| <div class="confirm-btns"> | |
| <button class="btn-confirm no" onclick="closeConfirmModal()">取消</button> | |
| <button class="btn-confirm soft" onclick="performClear(false)">仅清空记录</button> | |
| <button class="btn-confirm yes" onclick="performClear(true)">清空并移除中奖者</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="modal" id="pool-modal"> | |
| <div class="pool-manager-box no-close" onclick="event.stopPropagation()"> | |
| <div class="pm-header"> | |
| <h3>名单管理</h3> | |
| <button class="btn-icon" onclick="document.getElementById('pool-modal').style.display='none'">×</button> | |
| </div> | |
| <div style="padding:10px; border-bottom:1px solid #eee; display:flex; gap:5px;"> | |
| <input type="text" id="temp-add-name" placeholder="临时添加人员..." class="config-input"> | |
| <button class="btn-small" onclick="addTempPerson()" style="flex:0 0 60px;">添加</button> | |
| </div> | |
| <div class="pm-body" id="pm-list"></div> | |
| </div> | |
| </div> | |
| <div class="modal" id="prize-modal"> | |
| <div class="prize-manager-box no-close" onclick="event.stopPropagation()"> | |
| <div class="pm-header"> | |
| <h3>奖项预设管理</h3> | |
| <button class="btn-icon" onclick="document.getElementById('prize-modal').style.display='none'">×</button> | |
| </div> | |
| <div class="prize-edit-form"> | |
| <div class="form-row"> | |
| <input type="text" id="prize-edit-label" placeholder="下拉显示名称 (如: 一等奖)" class="config-input"> | |
| <input type="text" id="prize-edit-title" placeholder="大屏显示标题" class="config-input"> | |
| </div> | |
| <div class="form-row"> | |
| <input type="text" id="prize-edit-img" placeholder="底部图片链接..." class="config-input"> | |
| <input type="file" id="prize-img-upload" accept="image/*" style="display:none" onchange="handlePrizeImgUpload(event)"> | |
| <button class="btn-small" onclick="document.getElementById('prize-img-upload').click()" title="上传本地图片" style="flex:0 0 40px;">📷</button> | |
| <button class="btn-small" onclick="savePrizeItem()" style="flex:0 0 60px; background:var(--primary); color:#fff; border:none;">保存</button> | |
| </div> | |
| <input type="hidden" id="prize-edit-index" value="-1"> </div> | |
| <div class="pm-body" id="prize-list"></div> | |
| </div> | |
| </div> | |
| <script> | |
| let state = { | |
| configMode: 'local', | |
| mode: 'number', | |
| pool: [], | |
| history: [], | |
| participants: [], // { name, active, isTemp, id, groupPath: [] } | |
| presets: [], // Array of { label, title, image } | |
| currentPresetIndex: -1, | |
| isRunning: false, | |
| timer: null, | |
| theme: 'red', | |
| titleText: 'LUCKY DRAW', | |
| fullScreenBg: '', | |
| // Auto draw state | |
| autoRemaining: 0, | |
| autoStopTimer: null, | |
| nextDrawTimer: null, | |
| isAutoSequence: false | |
| }; | |
| const els = { | |
| stageArea: document.getElementById('stage-area'), | |
| display: document.getElementById('display'), | |
| mainTitle: document.getElementById('main-title'), | |
| titleInput: document.getElementById('title-input'), | |
| historyList: document.getElementById('history-container'), | |
| poolInfo: document.getElementById('pool-info'), | |
| btnStart: document.getElementById('btn-start'), | |
| btnStop: document.getElementById('btn-stop'), | |
| tabNum: document.getElementById('tab-num'), | |
| tabName: document.getElementById('tab-name'), | |
| setNum: document.getElementById('settings-num'), | |
| setName: document.getElementById('settings-name'), | |
| modal: document.getElementById('modal'), | |
| modalContent: document.getElementById('modal-content'), | |
| modalDelBtn: document.getElementById('modal-del-btn'), | |
| minVal: document.getElementById('min-val'), | |
| maxVal: document.getElementById('max-val'), | |
| nameList: document.getElementById('name-list'), | |
| noRepeat: document.getElementById('no-repeat'), | |
| themeSelector: document.getElementById('theme-selector'), | |
| titleImgDisplay: document.getElementById('title-img-display'), | |
| controlsPanel: document.getElementById('controls-panel'), | |
| jsonIndicator: document.getElementById('json-indicator'), | |
| jsonPoolBtn: document.getElementById('json-pool-btn-area'), | |
| presetSelector: document.getElementById('preset-selector'), | |
| pmList: document.getElementById('pm-list'), | |
| confirmModal: document.getElementById('confirm-modal'), | |
| advWrapper: document.getElementById('advanced-wrapper'), | |
| advArrow: document.getElementById('adv-arrow'), | |
| prizeList: document.getElementById('prize-list'), | |
| // Auto inputs | |
| autoCount: document.getElementById('auto-count'), | |
| drawWait: document.getElementById('draw-wait'), | |
| autoInterval: document.getElementById('auto-interval'), | |
| autoFullscreen: document.getElementById('auto-fullscreen') // New Element | |
| }; | |
| window.onload = () => { | |
| const params = new URLSearchParams(window.location.search); | |
| const configUrl = params.get('config'); | |
| if (configUrl) { | |
| loadJsonFromUrl(configUrl); | |
| } else { | |
| loadLocalState(); | |
| } | |
| }; | |
| function toggleAdvancedSettings() { | |
| if (els.advWrapper.classList.contains('open')) { | |
| els.advWrapper.classList.remove('open'); | |
| els.advArrow.innerHTML = '▼'; | |
| } else { | |
| els.advWrapper.classList.add('open'); | |
| els.advArrow.innerHTML = '▲'; | |
| } | |
| } | |
| function loadLocalState() { | |
| const saved = localStorage.getItem('draw_app_state_v3'); | |
| if (saved) { | |
| try { | |
| const data = JSON.parse(saved); | |
| state.history = data.history || []; | |
| state.theme = data.theme || 'red'; | |
| state.mode = data.mode || 'number'; | |
| state.titleText = data.titleText || 'LUCKY DRAW'; | |
| state.fullScreenBg = data.fullScreenBg || ''; | |
| els.minVal.value = data.min || 1; | |
| els.maxVal.value = data.max || 100; | |
| els.nameList.value = data.names || ''; | |
| els.noRepeat.checked = data.noRepeat ?? true; | |
| // Load Auto Fullscreen setting | |
| els.autoFullscreen.checked = data.autoFullscreen ?? false; | |
| if (data.bgImg) document.body.style.backgroundImage = data.bgImg; | |
| // Auto settings | |
| if (data.autoCount) els.autoCount.value = data.autoCount; | |
| if (data.drawWait) els.drawWait.value = data.drawWait; | |
| if (data.autoInterval) els.autoInterval.value = data.autoInterval; | |
| state.configMode = data.configMode || 'local'; | |
| state.participants = data.participants || []; | |
| state.presets = data.presets || []; | |
| state.currentPresetIndex = data.currentPresetIndex !== undefined ? data.currentPresetIndex : -1; | |
| if (state.configMode === 'json') { | |
| renderPresetsUI(); | |
| const currentP = state.presets[state.currentPresetIndex]; | |
| if (currentP && currentP.image) { | |
| els.titleImgDisplay.src = currentP.image; | |
| els.titleImgDisplay.style.display = 'block'; | |
| els.stageArea.classList.add('has-img'); | |
| } else if (data.bottomImg) { | |
| els.titleImgDisplay.src = data.bottomImg; | |
| els.titleImgDisplay.style.display = 'block'; | |
| els.stageArea.classList.add('has-img'); | |
| } | |
| } | |
| } catch (e) { | |
| console.error("Local state parse error", e); | |
| } | |
| } | |
| applyConfigUI(); | |
| } | |
| function saveLocalState() { | |
| const data = { | |
| history: state.history, | |
| theme: state.theme, | |
| mode: state.mode, | |
| titleText: state.titleText, | |
| min: els.minVal.value, | |
| max: els.maxVal.value, | |
| names: els.nameList.value, | |
| noRepeat: els.noRepeat.checked, | |
| autoFullscreen: els.autoFullscreen.checked, // Save new setting | |
| bgImg: document.body.style.backgroundImage, | |
| fullScreenBg: state.fullScreenBg, | |
| configMode: state.configMode, | |
| participants: state.participants, | |
| presets: state.presets, | |
| currentPresetIndex: state.currentPresetIndex, | |
| bottomImg: els.titleImgDisplay.style.display !== 'none' ? els.titleImgDisplay.src : '', | |
| // Auto settings | |
| autoCount: els.autoCount.value, | |
| drawWait: els.drawWait.value, | |
| autoInterval: els.autoInterval.value | |
| }; | |
| localStorage.setItem('draw_app_state_v3', JSON.stringify(data)); | |
| if (!state.isRunning) updatePool(); | |
| } | |
| function applyConfigUI() { | |
| changeTheme(state.theme); | |
| updateTitle(state.titleText); | |
| els.themeSelector.value = state.theme; | |
| els.titleInput.value = state.titleText === 'LUCKY DRAW' ? '' : state.titleText; | |
| switchTab(state.mode); | |
| renderHistory(); | |
| if (state.configMode === 'json') { | |
| els.controlsPanel.classList.add('json-mode'); | |
| els.jsonPoolBtn.style.display = state.mode === 'name' ? 'flex' : 'none'; | |
| } else { | |
| els.controlsPanel.classList.remove('json-mode'); | |
| els.jsonPoolBtn.style.display = 'none'; | |
| els.presetSelector.style.display = 'none'; | |
| } | |
| updatePool(); | |
| } | |
| async function loadJsonFromUrl(url) { | |
| const targetUrl = url || document.getElementById('json-url-input').value; | |
| if (!targetUrl) return; | |
| try { | |
| const res = await fetch(targetUrl); | |
| const data = await res.json(); | |
| applyJsonConfig(data); | |
| } catch (e) { | |
| alert('加载配置失败,请检查链接或格式'); | |
| } | |
| } | |
| function handleJsonUpload(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const data = JSON.parse(e.target.result); | |
| applyJsonConfig(data); | |
| } catch (err) { alert('JSON格式错误'); } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| function parseParticipants(list, groupPath = []) { | |
| let result = []; | |
| list.forEach(item => { | |
| if (typeof item === 'string') { | |
| result.push({ | |
| name: item, | |
| active: true, | |
| isTemp: false, | |
| id: 'p_' + Math.random().toString(36).substr(2, 9), | |
| groupPath: [...groupPath] | |
| }); | |
| } else if (typeof item === 'object' && item.children) { | |
| const newPath = [...groupPath, item.label || 'Group']; | |
| result = result.concat(parseParticipants(item.children, newPath)); | |
| } | |
| }); | |
| return result; | |
| } | |
| function applyJsonConfig(data) { | |
| state.configMode = 'json'; | |
| state.theme = data.theme || 'red'; | |
| state.titleText = data.title || 'LUCKY DRAW'; | |
| state.mode = data.mode || 'name'; | |
| state.fullScreenBg = data.fullScreenBg ? `url(${data.fullScreenBg})` : ''; | |
| // Apply new switches from JSON | |
| if (data.noRepeat !== undefined) els.noRepeat.checked = data.noRepeat; | |
| if (data.autoFullscreen !== undefined) els.autoFullscreen.checked = data.autoFullscreen; | |
| if (data.bgImg) document.body.style.backgroundImage = `url(${data.bgImg})`; | |
| if (data.bottomImg) { | |
| els.titleImgDisplay.src = data.bottomImg; | |
| els.titleImgDisplay.style.display = 'block'; | |
| els.stageArea.classList.add('has-img'); | |
| } else { | |
| els.titleImgDisplay.style.display = 'none'; | |
| els.stageArea.classList.remove('has-img'); | |
| } | |
| // Auto Draw Settings from JSON | |
| if (data.autoCount !== undefined) els.autoCount.value = data.autoCount; | |
| if (data.drawWait !== undefined) els.drawWait.value = data.drawWait; | |
| if (data.autoInterval !== undefined) els.autoInterval.value = data.autoInterval; | |
| if (data.names && Array.isArray(data.names)) { | |
| state.participants = parseParticipants(data.names); | |
| } | |
| if (data.min) els.minVal.value = data.min; | |
| if (data.max) els.maxVal.value = data.max; | |
| state.presets = []; | |
| state.currentPresetIndex = -1; | |
| if (data.prizes && Array.isArray(data.prizes)) { | |
| state.presets = data.prizes; | |
| renderPresetsUI(); | |
| if (state.presets.length > 0) switchPreset(0); | |
| } else { | |
| els.presetSelector.style.display = 'none'; | |
| } | |
| state.history = []; | |
| applyConfigUI(); | |
| updatePool(); | |
| // Force save to local storage immediately so refresh keeps the new auto settings | |
| saveLocalState(); | |
| const url = new URL(window.location); | |
| url.searchParams.delete('config'); | |
| window.history.replaceState({}, '', url); | |
| alert('配置加载成功!'); | |
| } | |
| function renderPresetsUI() { | |
| els.presetSelector.innerHTML = ''; | |
| if (state.presets.length === 0) { | |
| els.presetSelector.style.display = 'none'; | |
| return; | |
| } | |
| state.presets.forEach((p, index) => { | |
| const opt = document.createElement('option'); | |
| opt.value = index; | |
| opt.innerText = p.label || `奖项 ${index+1}`; | |
| els.presetSelector.appendChild(opt); | |
| }); | |
| els.presetSelector.style.display = 'block'; | |
| els.presetSelector.value = state.currentPresetIndex; | |
| } | |
| function switchPreset(index) { | |
| index = parseInt(index); | |
| if (index < 0 || index >= state.presets.length) return; | |
| state.currentPresetIndex = index; | |
| const p = state.presets[index]; | |
| if (p.title) updateTitle(p.title); | |
| if (p.image) { | |
| els.titleImgDisplay.src = p.image; | |
| els.titleImgDisplay.style.display = 'block'; | |
| els.stageArea.classList.add('has-img'); | |
| } else { | |
| els.titleImgDisplay.style.display = 'none'; | |
| els.stageArea.classList.remove('has-img'); | |
| } | |
| els.presetSelector.value = index; | |
| saveLocalState(); | |
| } | |
| function exitJsonMode() { | |
| if(confirm('确定退出配置模式?将清除当前的 JSON 配置数据并恢复为普通模式。')) { | |
| state.configMode = 'local'; | |
| state.participants = []; | |
| state.presets = []; | |
| els.presetSelector.style.display = 'none'; | |
| els.titleImgDisplay.src = ''; | |
| els.titleImgDisplay.style.display = 'none'; | |
| els.stageArea.classList.remove('has-img'); | |
| document.body.style.backgroundImage = ''; | |
| state.fullScreenBg = ''; | |
| state.currentPresetIndex = -1; | |
| resetAutoSettings(); | |
| // Fixed: Explicitly reset title before saving state | |
| state.titleText = 'LUCKY DRAW'; | |
| saveLocalState(); | |
| loadLocalState(); | |
| location.reload(); | |
| } | |
| } | |
| // === Prize Manager (V3.6) === | |
| function openPrizeManager() { | |
| renderPrizeList(); | |
| document.getElementById('prize-modal').style.display = 'flex'; | |
| document.getElementById('prize-edit-index').value = "-1"; | |
| document.getElementById('prize-edit-label').value = ""; | |
| document.getElementById('prize-edit-title').value = ""; | |
| document.getElementById('prize-edit-img').value = ""; | |
| } | |
| function renderPrizeList() { | |
| els.prizeList.innerHTML = ''; | |
| state.presets.forEach((p, index) => { | |
| const div = document.createElement('div'); | |
| div.className = 'prize-item'; | |
| div.innerHTML = ` | |
| <div class="prize-info"> | |
| <h4>${p.label}</h4> | |
| <p>${p.title}</p> | |
| </div> | |
| <div style="display:flex; gap:5px;"> | |
| <button class="btn-small" onclick="editPrizeItem(${index})">编辑</button> | |
| <button class="btn-small" onclick="deletePrizeItem(${index})" style="color:red;border-color:red">删</button> | |
| </div> | |
| `; | |
| els.prizeList.appendChild(div); | |
| }); | |
| } | |
| function editPrizeItem(index) { | |
| const p = state.presets[index]; | |
| document.getElementById('prize-edit-index').value = index; | |
| document.getElementById('prize-edit-label').value = p.label || ""; | |
| document.getElementById('prize-edit-title').value = p.title || ""; | |
| document.getElementById('prize-edit-img').value = p.image || ""; | |
| } | |
| function handlePrizeImgUpload(event) { | |
| const file = event.target.files[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| document.getElementById('prize-edit-img').value = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| } | |
| function savePrizeItem() { | |
| const index = parseInt(document.getElementById('prize-edit-index').value); | |
| const label = document.getElementById('prize-edit-label').value.trim(); | |
| const title = document.getElementById('prize-edit-title').value.trim(); | |
| const image = document.getElementById('prize-edit-img').value.trim(); | |
| if (!label || !title) { alert('名称和标题不能为空'); return; } | |
| const newItem = { label, title, image }; | |
| if (index === -1) { | |
| state.presets.push(newItem); | |
| } else { | |
| state.presets[index] = newItem; | |
| } | |
| renderPrizeList(); | |
| renderPresetsUI(); | |
| if (index === state.currentPresetIndex) { | |
| switchPreset(index); | |
| } | |
| if (index === -1 && state.presets.length === 1) { | |
| switchPreset(0); | |
| } | |
| document.getElementById('prize-edit-index').value = "-1"; | |
| document.getElementById('prize-edit-label').value = ""; | |
| document.getElementById('prize-edit-title').value = ""; | |
| document.getElementById('prize-edit-img').value = ""; | |
| saveLocalState(); | |
| } | |
| function deletePrizeItem(index) { | |
| if (!confirm('确认删除此奖项预设?')) return; | |
| state.presets.splice(index, 1); | |
| renderPrizeList(); | |
| renderPresetsUI(); | |
| if (state.presets.length === 0) { | |
| switchPreset(-1); // Clear | |
| } else if (index === state.currentPresetIndex) { | |
| switchPreset(0); // Reset to first | |
| } else if (index < state.currentPresetIndex) { | |
| state.currentPresetIndex--; // Shift | |
| } | |
| saveLocalState(); | |
| } | |
| // === Pool Manager Renderer === | |
| function openPoolManager() { | |
| renderPoolManagerTree(); | |
| document.getElementById('pool-modal').style.display = 'flex'; | |
| } | |
| function renderPoolManagerTree() { | |
| els.pmList.innerHTML = ''; | |
| const root = { items: [], groups: {} }; | |
| state.participants.forEach(p => { | |
| let current = root; | |
| p.groupPath.forEach(groupName => { | |
| if (!current.groups[groupName]) { | |
| current.groups[groupName] = { items: [], groups: {} }; | |
| } | |
| current = current.groups[groupName]; | |
| }); | |
| current.items.push(p); | |
| }); | |
| function renderNode(node, container, level = 0) { | |
| node.items.forEach(p => { | |
| const div = document.createElement('div'); | |
| div.className = 'pm-item'; | |
| div.style.paddingLeft = (level * 20 + 10) + 'px'; | |
| div.innerHTML = ` | |
| <span class="pm-name" style="opacity:${p.active?1:0.5}">${p.name} ${p.isTemp?'<small>(临时)</small>':''}</span> | |
| <div style="display:flex; gap:10px; align-items:center;"> | |
| ${p.isTemp ? `<button class="btn-small" onclick="removeTempPerson('${p.id}')" style="color:red;border-color:red">删</button>` : ''} | |
| <label class="pm-switch"> | |
| <input type="checkbox" ${p.active ? 'checked' : ''} onchange="togglePerson('${p.id}')"> | |
| <span class="slider"></span> | |
| </label> | |
| </div> | |
| `; | |
| container.appendChild(div); | |
| }); | |
| for (const [groupName, groupNode] of Object.entries(node.groups)) { | |
| const details = document.createElement('details'); | |
| details.className = 'pm-group'; | |
| details.style.marginLeft = (level * 10) + 'px'; | |
| details.open = true; | |
| const summary = document.createElement('summary'); | |
| summary.innerHTML = `<span>📁 ${groupName}</span>`; | |
| const contentDiv = document.createElement('div'); | |
| contentDiv.className = 'pm-group-content'; | |
| renderNode(groupNode, contentDiv, level + 1); | |
| details.appendChild(summary); | |
| details.appendChild(contentDiv); | |
| container.appendChild(details); | |
| } | |
| } | |
| renderNode(root, els.pmList); | |
| } | |
| function togglePerson(id) { | |
| const p = state.participants.find(x => x.id === id); | |
| if (p) { | |
| p.active = !p.active; | |
| updatePool(); | |
| const nameSpan = document.querySelector(`input[onchange="togglePerson('${id}')"]`).closest('.pm-item').querySelector('.pm-name'); | |
| if(nameSpan) nameSpan.style.opacity = p.active ? 1 : 0.5; | |
| saveLocalState(); | |
| } | |
| } | |
| function addTempPerson() { | |
| const name = document.getElementById('temp-add-name').value.trim(); | |
| if(name) { | |
| state.participants.unshift({ | |
| name, active: true, isTemp: true, | |
| id: 'temp_' + Date.now(), | |
| groupPath: [] | |
| }); | |
| document.getElementById('temp-add-name').value = ''; | |
| renderPoolManagerTree(); | |
| updatePool(); | |
| saveLocalState(); | |
| } | |
| } | |
| function removeTempPerson(id) { | |
| const idx = state.participants.findIndex(p => p.id === id); | |
| if (idx > -1) { | |
| state.participants.splice(idx, 1); | |
| renderPoolManagerTree(); | |
| updatePool(); | |
| saveLocalState(); | |
| } | |
| } | |
| // === Standard Functions === | |
| function updateTitle(val) { | |
| const text = val.trim() || 'LUCKY DRAW'; | |
| state.titleText = text; | |
| els.mainTitle.innerText = text; | |
| saveLocalState(); | |
| } | |
| function handleTitleImg(event) { | |
| const file = event.target.files[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| els.titleImgDisplay.src = e.target.result; | |
| els.titleImgDisplay.style.display = 'block'; | |
| els.stageArea.classList.add('has-img'); | |
| saveLocalState(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| } | |
| function clearTitleImg() { | |
| els.titleImgDisplay.src = ''; | |
| els.titleImgDisplay.style.display = 'none'; | |
| els.stageArea.classList.remove('has-img'); | |
| document.getElementById('title-img-upload').value = ''; | |
| saveLocalState(); | |
| } | |
| function handleBgUpload(event) { | |
| const file = event.target.files[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| document.body.style.backgroundImage = `url(${e.target.result})`; | |
| saveLocalState(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| } | |
| function handleFsBgUpload(event) { | |
| const file = event.target.files[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| state.fullScreenBg = `url(${e.target.result})`; | |
| saveLocalState(); | |
| alert('全屏背景已设置'); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| } | |
| function clearCustomBg() { | |
| document.body.style.backgroundImage = ''; | |
| state.fullScreenBg = ''; | |
| document.getElementById('bg-file-input').value = ''; | |
| changeTheme(state.theme); | |
| } | |
| function changeTheme(themeName) { | |
| state.theme = themeName; | |
| document.body.setAttribute('data-theme', themeName); | |
| saveLocalState(); | |
| } | |
| function switchTab(mode) { | |
| state.mode = mode; | |
| if (mode === 'number') { | |
| els.tabNum.classList.add('active'); | |
| els.tabName.classList.remove('active'); | |
| els.setNum.style.display = 'block'; | |
| els.setName.style.display = 'none'; | |
| } else { | |
| els.tabNum.classList.remove('active'); | |
| els.tabName.classList.add('active'); | |
| els.setNum.style.display = 'none'; | |
| els.setName.style.display = 'block'; | |
| } | |
| if(state.configMode === 'json') { | |
| els.jsonPoolBtn.style.display = mode === 'name' ? 'flex' : 'none'; | |
| } | |
| updatePool(); | |
| saveLocalState(); | |
| } | |
| function updatePool() { | |
| if (state.isRunning) return; | |
| const noRepeat = els.noRepeat.checked; | |
| if (state.mode === 'number') { | |
| const min = parseInt(els.minVal.value) || 1; | |
| const max = parseInt(els.maxVal.value) || 10; | |
| let temp = []; | |
| for(let i=min; i<=max; i++) temp.push(i); | |
| state.pool = noRepeat ? temp.filter(x => !state.history.includes(x)) : temp; | |
| } else { | |
| if (state.configMode === 'json') { | |
| state.pool = state.participants | |
| .filter(p => p.active) | |
| .map(p => p.name); | |
| if (noRepeat) { | |
| state.pool = state.pool.filter(name => !state.history.includes(name)); | |
| } | |
| } else { | |
| const text = els.nameList.value; | |
| let temp = text.split(/[\n,,]/).map(s=>s.trim()).filter(s=>s); | |
| state.pool = noRepeat ? temp.filter(x => !state.history.includes(x)) : temp; | |
| } | |
| } | |
| els.poolInfo.innerText = `池内: ${state.pool.length}`; | |
| els.poolInfo.style.color = state.pool.length === 0 ? 'var(--accent)' : 'var(--text-main)'; | |
| } | |
| // Add listener for the new checkbox | |
| [els.minVal, els.maxVal, els.noRepeat, els.nameList, els.autoCount, els.drawWait, els.autoInterval, els.autoFullscreen].forEach(el => { | |
| el.addEventListener(el.tagName === 'INPUT' ? 'change' : 'input', () => { updatePool(); saveLocalState(); }); | |
| }); | |
| function resetAutoSettings() { | |
| els.autoCount.value = ''; | |
| els.drawWait.value = ''; | |
| els.autoInterval.value = ''; | |
| updatePool(); | |
| saveLocalState(); | |
| } | |
| function startDraw() { | |
| updatePool(); | |
| if (state.pool.length === 0) { alert('池子空了!'); return; } | |
| // Initialize Auto sequence if not already running recursively | |
| if (!state.isRunning) { | |
| const countVal = els.autoCount.value; | |
| const waitVal = els.drawWait.value; | |
| // If both are empty, it's fully manual (default behavior) | |
| // If either is set, we treat it as auto mode | |
| if (countVal || waitVal) { | |
| if (!state.isAutoSequence) { | |
| // First start of an auto sequence | |
| state.autoRemaining = countVal ? parseInt(countVal) : 1; | |
| state.isAutoSequence = true; | |
| } | |
| } | |
| } | |
| state.isRunning = true; | |
| els.btnStart.style.display = 'none'; | |
| els.btnStop.style.display = 'block'; | |
| els.display.classList.remove('winner'); | |
| els.display.classList.add('animate'); | |
| let speed = 50; | |
| function loop() { | |
| const r = Math.floor(Math.random() * state.pool.length); | |
| els.display.innerText = state.pool[r]; | |
| if (speed > 15) speed -= 0.5; | |
| state.timer = setTimeout(loop, speed); | |
| } | |
| loop(); | |
| // Handle Auto Stop Timer | |
| const waitTime = parseFloat(els.drawWait.value); | |
| if (waitTime > 0 && state.isAutoSequence) { | |
| state.autoStopTimer = setTimeout(() => { | |
| stopDraw(true); // true means triggered by auto timer | |
| }, waitTime * 1000); | |
| } | |
| } | |
| function stopDraw(isAutoTriggered = false) { | |
| clearTimeout(state.timer); | |
| clearTimeout(state.autoStopTimer); | |
| state.isRunning = false; | |
| els.btnStart.style.display = 'block'; | |
| els.btnStop.style.display = 'none'; | |
| els.display.classList.remove('animate'); | |
| els.display.classList.add('winner'); | |
| const r = Math.floor(Math.random() * state.pool.length); | |
| const winner = state.pool[r]; | |
| els.display.innerText = winner; | |
| state.history.unshift(winner); | |
| renderHistory(); | |
| saveLocalState(); | |
| fireConfetti(); | |
| updatePool(); | |
| if (!isAutoTriggered) { | |
| // User clicked STOP manually | |
| state.autoRemaining = 0; | |
| state.isAutoSequence = false; | |
| clearTimeout(state.nextDrawTimer); | |
| } else { | |
| // Was stopped automatically | |
| state.autoRemaining--; | |
| if (state.autoRemaining > 0 && state.pool.length > 0) { | |
| els.btnStart.style.display = 'none'; | |
| els.btnStop.style.display = 'block'; | |
| const interval = parseFloat(els.autoInterval.value) || 1; | |
| state.nextDrawTimer = setTimeout(() => { | |
| startDraw(); | |
| }, interval * 1000); | |
| } else { | |
| // Sequence finished | |
| state.isAutoSequence = false; | |
| state.autoRemaining = 0; | |
| // NEW LOGIC: Check if auto fullscreen is enabled | |
| if (els.autoFullscreen.checked) { | |
| const interval = parseFloat(els.autoInterval.value) || 1; | |
| setTimeout(() => { | |
| showAllModal(); | |
| }, interval * 1000); | |
| } | |
| } | |
| } | |
| } | |
| function renderHistory() { | |
| els.historyList.innerHTML = ''; | |
| state.history.forEach((item, index) => { | |
| const div = document.createElement('div'); | |
| div.className = 'history-item'; | |
| div.innerHTML = ` | |
| <div class="history-content" onclick="showOneModal('${item}', ${index})"> | |
| <span class="history-index">#${state.history.length - index}</span> | |
| <span class="history-val">${item}</span> | |
| </div> | |
| <button class="btn-delete-item" onclick="deleteItem(event, ${index})" title="移除此记录">×</button> | |
| `; | |
| els.historyList.appendChild(div); | |
| }); | |
| } | |
| function confirmClearHistory() { | |
| if (state.history.length === 0) return; | |
| els.confirmModal.style.display = 'flex'; | |
| } | |
| function closeConfirmModal() { els.confirmModal.style.display = 'none'; } | |
| function performClear(removeWinners) { | |
| closeConfirmModal(); | |
| if (removeWinners) { | |
| if (state.mode === 'name') { | |
| if (state.configMode === 'local') { | |
| let text = els.nameList.value; | |
| state.history.forEach(winner => { | |
| const regex = new RegExp(`(^|[\\n,,])${winner}(?=$|[\\n,,])`, 'g'); | |
| text = text.replace(regex, ''); | |
| }); | |
| els.nameList.value = text.split(/[\n,,]/).filter(s=>s.trim()).join('\n'); | |
| } else { | |
| // JSON模式下从 active 状态移除 | |
| state.history.forEach(winner => { | |
| const p = state.participants.find(p => p.name == winner); | |
| if (p) p.active = false; | |
| }); | |
| } | |
| } | |
| } | |
| state.history = []; | |
| renderHistory(); | |
| saveLocalState(); | |
| updatePool(); | |
| } | |
| function deleteItem(e, index) { | |
| e.stopPropagation(); | |
| if (confirm('确认移除这条记录吗?')) { | |
| state.history.splice(index, 1); | |
| renderHistory(); | |
| saveLocalState(); | |
| updatePool(); | |
| } | |
| } | |
| function deleteFromModal(e, index) { | |
| e.stopPropagation(); | |
| if (confirm('确认移除这条记录吗?')) { | |
| state.history.splice(index, 1); | |
| renderHistory(); | |
| saveLocalState(); | |
| updatePool(); | |
| closeModal(); | |
| } | |
| } | |
| function showOneModal(text, index) { | |
| els.modal.style.display = 'flex'; | |
| els.modalDelBtn.style.display = 'block'; | |
| els.modalDelBtn.onclick = (e) => deleteFromModal(e, index); | |
| els.modalContent.className = 'modal-content-single no-close'; | |
| els.modalContent.innerHTML = text; | |
| } | |
| function showAllModal() { | |
| if (state.history.length === 0) return; | |
| els.modal.style.display = 'flex'; | |
| els.modalDelBtn.style.display = 'none'; | |
| els.modalContent.className = 'modal-full-layout'; | |
| if (state.fullScreenBg) els.modalContent.style.backgroundImage = state.fullScreenBg; | |
| else els.modalContent.style.backgroundImage = ''; | |
| const imgEl = els.titleImgDisplay; | |
| const hasImg = imgEl.style.display === 'block' && imgEl.src.length > 0; | |
| let html = ''; | |
| html += `<div class="modal-full-title no-close">${state.titleText}</div>`; | |
| html += '<div class="modal-full-list">'; | |
| html += state.history.map(h => `<span class="no-close">${h}</span>`).join(''); | |
| html += '</div>'; | |
| if (hasImg) { | |
| html += `<img src="${imgEl.src}" class="modal-full-img no-close">`; | |
| } | |
| els.modalContent.innerHTML = html; | |
| } | |
| function closeModal(e) { | |
| if (!e) { els.modal.style.display = 'none'; return; } | |
| if (e.target.closest('.no-close')) return; | |
| els.modal.style.display = 'none'; | |
| } | |
| function fireConfetti() { | |
| const rect = els.display.getBoundingClientRect(); | |
| const x = (rect.left + rect.width / 2) / window.innerWidth; | |
| const y = (rect.top + rect.height / 2) / window.innerHeight; | |
| let colors = []; | |
| switch(state.theme) { | |
| case 'red': colors = ['#ffd700', '#ff0000', '#ffffff']; break; | |
| case 'green': colors = ['#43a047', '#81c784', '#ffffff']; break; | |
| case 'cyber': colors = ['#00f3ff', '#ff00ff', '#ffff00']; break; | |
| default: colors = ['#3498db', '#2980b9', '#ecf0f1']; break; | |
| } | |
| const defaults = { spread: 360, ticks: 100, gravity: 0.6, decay: 0.94, startVelocity: 30, colors: colors, zIndex: 10000 }; | |
| const offset = 0.15; | |
| confetti({ ...defaults, particleCount: 30, origin: { x: x - offset, y: y - offset } }); | |
| confetti({ ...defaults, particleCount: 30, origin: { x: x + offset, y: y - offset } }); | |
| confetti({ ...defaults, particleCount: 30, origin: { x: x - offset, y: y + offset } }); | |
| confetti({ ...defaults, particleCount: 30, origin: { x: x + offset, y: y + offset } }); | |
| setTimeout(() => confetti({ ...defaults, particleCount: 80, startVelocity: 45, origin: { x, y } }), 150); | |
| } | |
| function copyList(btn) { | |
| if (!state.history.length) return; | |
| navigator.clipboard.writeText(state.history.join(' ')).then(() => { | |
| const original = btn.innerHTML; | |
| btn.innerHTML = '✔'; | |
| btn.style.opacity = '1'; | |
| setTimeout(() => { | |
| btn.innerHTML = original; | |
| btn.style.opacity = ''; | |
| }, 1500); | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment