Spaces:
Running
Running
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>圓周率之神的眷顧 - 老師儀表板</title> | |
| <!-- 引入外部套件 --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script> | |
| <style> | |
| :root { | |
| /* 根據 pigod.png 的神秘金黃色澤進行主色彩調配 */ | |
| --primary: #d4af37; /* 金色 */ | |
| --primary-hover: #b49126; /* 稍暗金 */ | |
| --bg-overlay: rgba(20, 15, 10, 0.85); /* 黑色疊加層以確保文字清晰 */ | |
| --panel: rgba(45, 35, 25, 0.65); /* 牛皮紙質感的半透明版面 */ | |
| --border: rgba(212, 175, 55, 0.3); /* 金色邊框 */ | |
| --text: #fdf6e3; /* 羊皮紙白 */ | |
| --accent: #fcd34d; | |
| } | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| font-family: 'Inter', system-ui, -apple-system, sans-serif; | |
| background: var(--bg-overlay) url('pigod.png') center center / cover fixed; | |
| background-blend-mode: overlay; | |
| color: var(--text); | |
| min-height: 100vh; | |
| } | |
| .container { padding: 2rem; max-width: 1400px; margin: 0 auto; } | |
| /* 玻璃面板 */ | |
| .glass-panel { | |
| background: var(--panel); | |
| backdrop-filter: blur(16px); | |
| border: 1px solid var(--border); | |
| border-radius: 1rem; | |
| padding: 2rem; | |
| box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); | |
| margin-bottom: 2rem; | |
| } | |
| /* 步驟 1:建立房間 */ | |
| #setupScreen { | |
| max-width: 500px; | |
| margin: 15vh auto; | |
| text-align: center; | |
| } | |
| input.room-input { | |
| width: 100%; | |
| padding: 1rem; | |
| font-size: 1.5rem; | |
| text-align: center; | |
| background: rgba(0,0,0,0.3); | |
| border: 2px solid rgba(255,255,255,0.2); | |
| border-radius: 0.75rem; | |
| color: white; | |
| margin-bottom: 1.5rem; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| box-sizing: border-box; | |
| } | |
| button.btn { | |
| background: linear-gradient(135deg, var(--primary), var(--primary-hover)); | |
| color: #222; /* 金底黑字更有質感 */ | |
| border: 1px solid rgba(255,255,255,0.2); | |
| padding: 1rem 2rem; | |
| font-size: 1.25rem; | |
| border-radius: 0.75rem; | |
| cursor: pointer; | |
| font-weight: bold; | |
| transition: all 0.2s; | |
| width: 100%; | |
| box-shadow: 0 4px 6px rgba(0,0,0,0.5); | |
| } | |
| button.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 15px -3px var(--border); } | |
| button.btn:disabled { background: #6b7280; transform: none; cursor: not-allowed; } | |
| /* 步驟 2:儀表板 */ | |
| #dashboardScreen { display: none; } | |
| .header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| flex-wrap: wrap; | |
| gap: 2rem; | |
| } | |
| .header-left { display: flex; align-items: center; gap: 2rem; } | |
| #qrcodeArea { | |
| background: white; | |
| padding: 1rem; | |
| border-radius: 1rem; | |
| box-shadow: 0 10px 25px rgba(0,0,0,0.5); | |
| transition: transform 0.3s; | |
| } | |
| #qrcodeArea:hover { transform: scale(1.05); } | |
| .room-info h1 { margin: 0 0 0.5rem 0; font-size: 2.5rem; color: var(--accent); text-shadow: 0 2px 4px rgba(0,0,0,0.5); } | |
| .room-info p { margin: 0 0 0.5rem 0; color: #d1d5db; font-size: 1.2rem; } | |
| .controls select { | |
| padding: 0.75rem 1rem; | |
| background: rgba(0,0,0,0.5); | |
| border: 1px solid var(--border); | |
| color: white; | |
| border-radius: 0.5rem; | |
| font-size: 1.1rem; | |
| cursor: pointer; | |
| } | |
| /* 學生標籤網格 (瀑布流卡片) */ | |
| .students-grid { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 1.25rem; | |
| min-height: 200px; | |
| } | |
| .student-chip { | |
| background: rgba(255, 255, 255, 0.05); | |
| border: 1px solid var(--border); | |
| padding: 1.25rem; | |
| border-radius: 1rem; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 0.5rem; | |
| animation: fadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; | |
| transition: transform 0.3s, box-shadow 0.3s, background-color 0.3s; | |
| min-width: 140px; | |
| box-shadow: 0 4px 6px rgba(0,0,0,0.2); | |
| position: relative; | |
| } | |
| .student-chip .delete-btn { | |
| position: absolute; | |
| top: -10px; | |
| right: -10px; | |
| width: 28px; | |
| height: 28px; | |
| background: #ef4444; | |
| color: white; | |
| border-radius: 50%; | |
| border: 2px solid var(--bg); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| cursor: pointer; | |
| font-size: 14px; | |
| font-weight: bold; | |
| opacity: 0; | |
| transition: opacity 0.2s, transform 0.2s; | |
| z-index: 10; | |
| } | |
| .student-chip:hover .delete-btn { opacity: 1; } | |
| .student-chip .delete-btn:hover { transform: scale(1.1); background: #dc2626; } | |
| .student-chip:hover { transform: translateY(-5px); box-shadow: 0 15px 20px rgba(0,0,0,0.4); } | |
| .student-chip .name { font-weight: bold; font-size: 1.3rem; } | |
| .student-chip .number { color: var(--accent); font-family: monospace; font-size: 1.5rem; font-weight: bold; letter-spacing: 1px; text-shadow: 0 2px 2px rgba(0,0,0,0.5);} | |
| .student-chip .result { | |
| display: none; width: 100%; text-align: center; | |
| margin-top: 0.5rem; padding-top: 0.5rem; | |
| border-top: 1px dashed rgba(255,255,255,0.2); | |
| font-size: 1rem; font-weight: bold; color: #2dd4bf; text-shadow: 0 1px 2px rgba(0,0,0,0.5); | |
| } | |
| /* 狀態顏色 */ | |
| .student-chip.searching { background: rgba(59, 130, 246, 0.25); border-color: #60a5fa; } | |
| .student-chip.done { background: rgba(16, 185, 129, 0.25); border-color: #34d399; } | |
| .student-chip.error { background: rgba(239, 68, 68, 0.25); border-color: #f87171; } | |
| @keyframes fadeIn { from { opacity: 0; transform: scale(0.8) translateY(20px); } to { opacity: 1; transform: scale(1) translateY(0); } } | |
| /* Intro 動畫 */ | |
| @keyframes slowFadeIn { to { opacity: 1; transform: translateY(0); } from { opacity: 0; transform: translateY(20px); } } | |
| /* 文字容器等待 5 秒後浮現 */ | |
| @keyframes delayedFadeIn { from { opacity: 0; } to { opacity: 1; } } | |
| @keyframes blink { 0%, 100% { opacity: 1; text-shadow: 0 0 10px rgba(255,255,255,0.8); } 50% { opacity: 0.3; text-shadow: none; } } | |
| /* Loading 動畫 */ | |
| .spinner { | |
| width: 24px; height: 24px; margin: 0 auto; | |
| border: 3px solid rgba(255,255,255,0.2); border-top: 3px solid white; | |
| border-radius: 50%; animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { 100% { transform: rotate(360deg); } } | |
| /* 步驟 3:長條圖與頒獎台 */ | |
| #resultsArea { display: none; } | |
| .podium-container { | |
| display: flex; justify-content: center; align-items: flex-end; | |
| margin: 3rem 0; height: 300px; gap: 1rem; | |
| } | |
| .podium { | |
| display: flex; flex-direction: column; align-items: center; justify-content: flex-end; | |
| width: 140px; opacity: 0; transform: translateY(50px); | |
| } | |
| .podium-info { text-align: center; margin-bottom: 0.5rem; } | |
| .podium-name { font-weight: bold; font-size: 1.4rem; color: #fff; text-shadow: 0 2px 6px rgba(0,0,0,0.8); } | |
| .podium-val { color: var(--accent); font-size: 1rem; font-weight: bold; text-shadow: 0 1px 3px rgba(0,0,0,0.8); } | |
| .podium-step { | |
| width: 100%; display: flex; align-items: center; justify-content: center; | |
| font-weight: bold; color: #111; font-size: 2rem; | |
| border-top-left-radius: 1rem; border-top-right-radius: 1rem; | |
| box-shadow: inset 0 -10px 20px rgba(0,0,0,0.2), 0 10px 15px -3px rgba(0,0,0,0.5); | |
| } | |
| /* 頒獎台動畫與高度設定 */ | |
| .podium.show { animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; } | |
| .podium.first { animation-delay: 0.6s; } | |
| .podium.second { animation-delay: 0.3s; } | |
| .podium.third { animation-delay: 0s; } | |
| /* 一般頒獎台顏色 */ | |
| .podium.first .podium-step { height: 220px; background: linear-gradient(135deg, #fef08a, #ca8a04); border: 2px solid #fef08a; } | |
| .podium.second .podium-step { height: 160px; background: linear-gradient(135deg, #f3f4f6, #9ca3af); border: 2px solid #fff; } | |
| .podium.third .podium-step { height: 110px; background: linear-gradient(135deg, #fed7aa, #c2410c); border: 2px solid #fed7aa; } | |
| @keyframes slideUp { to { opacity: 1; transform: translateY(0); } } | |
| /* Hall of Fame Podium Specific Overrides */ | |
| .hof-podium-container { | |
| display: flex; justify-content: center; align-items: flex-end; | |
| margin: 3rem 0; height: 350px; gap: 1rem; | |
| } | |
| .podium.bronze-shared .podium-step { height: 110px; background: linear-gradient(135deg, #fed7aa, #c2410c); border: 2px solid #fed7aa; } | |
| .podium.silver-shared .podium-step { height: 160px; background: linear-gradient(135deg, #f3f4f6, #9ca3af); border: 2px solid #fff; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Intro 閃爍進場畫面 (保留 5 秒背景圖無濾鏡,第 5 秒後濾鏡與文字淡入) --> | |
| <div id="introScreen" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 10000; cursor: pointer; transition: opacity 1s; background: url('pigod.png') center center / cover fixed;"> | |
| <!-- 黑色漸變遮罩 --> | |
| <div style="position: absolute; top:0; left:0; width:100%; height:100%; background: var(--bg-overlay); animation: delayedFadeIn 2s ease-in 5s forwards; opacity: 0; pointer-events: none;"></div> | |
| <!-- 文字區 --> | |
| <div style="position: relative; z-index: 2; display: flex; flex-direction: column; justify-content: center; align-items: center; opacity: 0; animation: slowFadeIn 2s cubic-bezier(0.16, 1, 0.3, 1) 5s forwards;"> | |
| <h1 style="color: var(--accent); font-size: clamp(3rem, 8vw, 6rem); text-shadow: 0 4px 15px rgba(0,0,0,0.9); margin: 0; text-align: center;">圓周率之神的眷顧</h1> | |
| <p style="color: #fff; font-size: 2rem; margin-top: 3rem; animation: blink 2.5s infinite; letter-spacing: 2px;">- Click to Start -</p> | |
| </div> | |
| </div> | |
| <div class="container"> | |
| <!-- 畫面 1:設定房間 --> | |
| <div id="setupScreen" class="glass-panel" style="display: none; opacity: 0; transition: opacity 0.8s;"> | |
| <h1 style="color: var(--accent); font-size: 3rem; margin-top: 0; text-shadow: 0 4px 10px rgba(0,0,0,0.6);">圓周率之神的眷顧</h1> | |
| <p style="color: #d1d5db; margin-bottom: 2rem;">請為信徒們建立一個獨一無二的儀式代碼</p> | |
| <input type="text" id="roomInput" class="room-input" placeholder="例如: 805, MATH-1" autocomplete="off"> | |
| <div class="controls" style="margin-bottom: 2rem; text-align: left;"> | |
| <label style="display: block; margin-bottom: 0.5rem; color: #fbbf24; font-weight: bold; font-size: 1.1rem; padding-left: 0.5rem;">⚙️ 請選擇要讓學生尋找幾位數?</label> | |
| <select id="digitSelect" class="room-input" style="max-width: 100%; margin-bottom: 1rem; cursor: pointer;"> | |
| <option value="4">4 位數 (100% 機率找到)</option> | |
| <option value="5">5 位數 (100% 機率找到)</option> | |
| <option value="6">6 位數 (100% 機率找到)</option> | |
| <option value="7" selected>7 位數 (100% 機率找到)</option> | |
| <option value="8">8 位數 (86% 機率出現在2億位內)</option> | |
| <option value="9">9 位數 (機率偏低,可能無法找到)</option> | |
| <option value="10">10 位數 (極高機率查無此字串)</option> | |
| </select> | |
| </div> | |
| <div id="historyRooms" style="margin-bottom: 1.5rem; text-align: left; display: none;"> | |
| <p style="color: #9ca3af; font-size: 0.95rem; margin: 0 0 0.5rem 0.5rem;">🕰️ 近期輸入過的教室 (點擊可快速返回避免斷線掉位):</p> | |
| <div id="historyChips" style="display: flex; gap: 0.5rem; flex-wrap: wrap;"></div> | |
| </div> | |
| <button class="btn" id="startRoomBtn">進入教室</button> | |
| <div id="systemWarning" style="color: #ef4444; background: rgba(239, 68, 68, 0.1); padding: 1rem; border-radius: 0.5rem; margin-top: 1.5rem; display: none; text-align: left;"> | |
| ⚠️ <strong>警告:無法讀取網路 IP</strong><br>您目前似乎是直接點擊檔案 (file://) 開啟。<br>這會導致 QRCode 網址出錯,學生手機將無法連線掃描。<br>👉 <strong>解決方法</strong>:請使用 VScode 的 Live Server 或 Python 本機伺服器開啟。 | |
| </div> | |
| </div> | |
| <!-- 畫面 2:主儀表板 --> | |
| <div id="dashboardScreen"> | |
| <div class="header glass-panel"> | |
| <div class="header-left"> | |
| <div id="qrcodeArea" style="display: none;"></div> | |
| <div class="room-info"> | |
| <h1>教室代碼:<span id="displayRoomCode" style="padding: 0.2rem 1rem; background: rgba(0,0,0,0.3); border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.2);"></span> | |
| <button class="btn" id="openHofBtn" style="width: auto; background: rgba(30, 25, 15, 0.8); border: 1px solid rgba(252, 211, 77, 0.6); color: #fcd34d; padding: 0.3rem 0.6rem; font-size: 1rem; border-radius: 0.3rem; margin-left: 1rem; cursor: pointer; vertical-align: middle; box-shadow: none;">👑 名人堂</button> | |
| <button class="btn" id="muteBtn" style="width: auto; background: rgba(75, 85, 99, 0.8); border: 1px solid rgba(156, 163, 175, 0.5); color: white; padding: 0.3rem 0.6rem; font-size: 1rem; border-radius: 0.3rem; margin-left: 0.5rem; cursor: pointer; vertical-align: middle; box-shadow: none;">🔊 靜音</button> | |
| </h1> | |
| <p>這間教室已設定為尋找 <strong id="displayDigitLength" style="color: #34d399;">X</strong> 位數字。</p> | |
| <p id="qrHintText" style="color: #fbbf24; display: none;">👉 請讓學生用平板或手機掃描左側條碼加入</p> | |
| <p style="font-size: 0.9rem; color: #6b7280; display: none;" id="urlPContainer">連線網址:<span id="studentUrlDisplay" style="font-family: monospace;"></span></p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 名人堂面板 (新增,預設隱藏) --> | |
| <div class="glass-panel" id="hallOfFamePanel" style="margin-bottom: 2rem; background: rgba(30, 25, 15, 0.6); border: 1px solid rgba(252, 211, 77, 0.4); display: none;"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 1rem;"> | |
| <h2 style="color: #fcd34d; margin-top: 0; margin-bottom: 0; font-size: 1.8rem; text-shadow: 0 2px 4px rgba(0,0,0,0.5); display: flex; align-items: center; gap: 0.5rem;"> | |
| 👑 歷屆神諭名人堂 | |
| </h2> | |
| <div style="display: flex; gap: 0.5rem;"> | |
| <input type="file" id="champExcelFile" accept=".xlsx, .xls" style="display: none;" /> | |
| <button class="btn" id="importChampBtn" style="width: auto; padding: 0.5rem 1rem; font-size: 0.9rem; background: rgba(212, 175, 55, 0.2); border-color: rgba(212, 175, 55, 0.5); color: #fdf6e3; box-shadow: none;" title="匯入過去的名人堂 Excel 選單"> | |
| 📥 匯入歷史榜單 | |
| </button> | |
| <button class="btn" id="resetHofBtn" style="width: auto; padding: 0.5rem 1rem; font-size: 0.9rem; background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.5); color: #f87171; box-shadow: none;" title="清除所有紀錄,恢復預設"> | |
| 清空紀錄 | |
| </button> | |
| </div> | |
| </div> | |
| <p style="color: #d1d5db; margin-bottom: 1.5rem; margin-top: 0;">記錄歷年來獲得圓周率之神最高眷顧 (尋找深度最深) 的傳奇信徒。本次儀式若有突破,將會即時發佈「NEW!」洗榜!</p> | |
| <div id="hofPodiumArea" class="hof-podium-container"> | |
| <!-- 名單將以頒獎台方式生成 --> | |
| </div> | |
| </div> | |
| <!-- Excel 匯入面板 --> | |
| <div class="glass-panel" style="margin-bottom: 2rem; background: rgba(0,0,0,0.4); border: 1px dashed var(--primary);"> | |
| <h2 style="color: var(--accent); margin-top: 0; font-size: 1.8rem; text-shadow: 0 2px 4px rgba(0,0,0,0.5);">📁 賜下預先名冊 (Excel 匯入)</h2> | |
| <p style="color: #d1d5db; margin-bottom: 1rem;">可先從 Excel 匯入檔案自動尋找第一名。檔案需至少兩欄:第一欄姓名、第二欄數字(不含標頭第一行)。 | |
| <a href="https://docs.google.com/spreadsheets/d/11hXMTLU7rPeu9h6B-BNGF6esFWEXJSGo/edit?usp=drive_link&ouid=115947803934724851078&rtpof=true&sd=true" target="_blank" style="color: var(--accent); text-decoration: underline; margin-left: 0.5rem; white-space: nowrap;">📥 下載範例檔案</a> | |
| </p> | |
| <input type="file" id="excelFile" accept=".xlsx, .xls" style="color: white; margin-bottom: 1rem; width: 100%; padding: 0.5rem; border: 1px solid rgba(255,255,255,0.2); border-radius: 0.5rem; background: rgba(0,0,0,0.3);"/> | |
| <div style="display: flex; gap: 1rem; align-items: center; flex-wrap: wrap;"> | |
| <button class="btn" id="importBtn" style="width: auto; padding: 0.75rem 1.5rem; font-size: 1.2rem;">匯入並預先請求神諭</button> | |
| <span id="importStatus" style="color: #34d399; font-size: 1.1rem; font-weight: bold;"></span> | |
| </div> | |
| <div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1);"> | |
| <label style="color: white; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; font-size: 1.1rem; font-weight: bold;"> | |
| <input type="checkbox" id="mergeDataCheck" checked style="transform: scale(1.5);"> 當現場學生活動結束時,將現場新資料與 Excel 名冊「合併」結算 (若未勾選則現場資料將獨立覆蓋結果) | |
| </label> | |
| </div> | |
| </div> | |
| <div class="glass-panel" style="margin-bottom: 2rem;"> | |
| <div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 1.5rem; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 1rem; flex-wrap: wrap; gap: 1rem;"> | |
| <div> | |
| <h2 style="margin: 0; font-size: 1.8rem; text-shadow: 0 2px 4px rgba(0,0,0,0.5);">👨🎓 信徒等待區</h2> | |
| <p style="margin: 0.5rem 0 0 0; color: #d1d5db;">目前已收到 <strong id="studentCount" style="color: white; font-size: 1.2rem;">0</strong> 份幸運數字</p> | |
| </div> | |
| <div style="display: flex; gap: 1rem;"> | |
| <button class="btn" id="restartMusicBtn" style="width: auto; padding: 1rem 1.5rem; font-size: 1.2rem; background: rgba(59, 130, 246, 0.8); border-color: rgba(96, 165, 250, 0.5); color: white;" title="返回等候階段,重新播放背景音樂"> | |
| 🎵 重啟等候音樂 | |
| </button> | |
| <button class="btn" id="searchApiBtn" style="width: auto; padding: 1rem 3rem; font-size: 1.5rem;"> | |
| ✨ 祈求眷顧!開始搜尋 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 卡片列表動態產生於此 --> | |
| <div class="students-grid" id="studentsGrid"></div> | |
| </div> | |
| <!-- 畫面 3:搜尋結果與圖表 --> | |
| <div id="resultsArea" class="glass-panel"> | |
| <h1 style="text-align: center; color: var(--accent); font-size: 2.5rem; margin-top: 0; text-shadow: 0 4px 10px rgba(0,0,0,0.8);">🏆 獲得神之眷顧的贏家 🏆</h1> | |
| <div class="podium-container" id="podiumArea"> | |
| <!-- 頒獎台動態產生於此 --> | |
| </div> | |
| <div style="background: rgba(0,0,0,0.4); border-radius: 1rem; padding: 2rem; margin-top: 3rem; border: 1px solid var(--border);"> | |
| <h2 style="margin-top: 0; border-left: 4px solid var(--primary); padding-left: 1rem;">📊 詳細神諭結果 (位數越深排名越高)</h2> | |
| <canvas id="resultChart"></canvas> | |
| </div> | |
| </div> | |
| <!-- QR Code 放大顯示遮罩 --> | |
| <div id="qrModal" style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.95); z-index: 9999; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; backdrop-filter: blur(5px);"> | |
| <div id="qrModalContent" style="background: white; padding: 2rem; border-radius: 1rem; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);"></div> | |
| <div style="margin-top: 1.5rem; text-align: center;"> | |
| <p style="color: #9ca3af; font-size: 1.2rem; margin-top: 1rem;">(點選畫面任意處即可關閉)</p> | |
| </div> | |
| </div> | |
| <!-- Firebase 邏輯 --> | |
| <script type="module"> | |
| import { initializeApp } from "https://www.gstatic.com/firebasejs/10.8.1/firebase-app.js"; | |
| import { getFirestore, doc, collection, onSnapshot, query, orderBy, getDocs, limit, setDoc, deleteDoc, updateDoc, where } from "https://www.gstatic.com/firebasejs/10.8.1/firebase-firestore.js"; | |
| // --- 音樂資源設定 --- | |
| const audioDrift = new Audio('Singularity Drift.mp3'); | |
| audioDrift.loop = true; | |
| audioDrift.volume = 0; | |
| const audioDawn = new Audio('Sacred Dawn Ascending.mp3'); | |
| audioDawn.loop = true; | |
| audioDawn.volume = 0; | |
| let fadeInterval = null; | |
| function playDrift() { | |
| clearInterval(fadeInterval); | |
| audioDrift.play().catch(e => console.warn("Audio play error:", e)); | |
| fadeInterval = setInterval(() => { | |
| let driftVol = audioDrift.volume; | |
| let dawnVol = audioDawn.volume; | |
| let done = true; | |
| if (dawnVol > 0.05) { | |
| audioDawn.volume = Math.max(0, dawnVol - 0.05); | |
| done = false; | |
| } else { | |
| audioDawn.volume = 0; | |
| if(!audioDawn.paused) audioDawn.pause(); | |
| } | |
| if (driftVol < 0.95) { | |
| audioDrift.volume = Math.min(1, driftVol + 0.05); | |
| done = false; | |
| } else { | |
| audioDrift.volume = 1; | |
| } | |
| if (done) clearInterval(fadeInterval); | |
| }, 100); | |
| } | |
| function playDawn() { | |
| clearInterval(fadeInterval); | |
| audioDawn.play().catch(e => console.warn("Audio play error:", e)); | |
| fadeInterval = setInterval(() => { | |
| let driftVol = audioDrift.volume; | |
| let dawnVol = audioDawn.volume; | |
| let done = true; | |
| if (driftVol > 0.05) { | |
| audioDrift.volume = Math.max(0, driftVol - 0.05); | |
| done = false; | |
| } else { | |
| audioDrift.volume = 0; | |
| if(!audioDrift.paused) audioDrift.pause(); | |
| } | |
| if (dawnVol < 0.95) { | |
| audioDawn.volume = Math.min(1, dawnVol + 0.05); | |
| done = false; | |
| } else { | |
| audioDawn.volume = 1; | |
| } | |
| if (done) clearInterval(fadeInterval); | |
| }, 100); | |
| } | |
| const firebaseConfig = { | |
| apiKey: "AIzaSyA2fxxaGAdWSR_QOH2Hm92kttGZmLDH8-w", | |
| authDomain: "pi-search-89a08.firebaseapp.com", | |
| projectId: "pi-search-89a08", | |
| storageBucket: "pi-search-89a08.firebasestorage.app", | |
| messagingSenderId: "1003005654079", | |
| appId: "1:1003005654079:web:636235436f432376d8748a" | |
| }; | |
| let app, db; | |
| let isTestMode = false; | |
| try { | |
| if(firebaseConfig.apiKey === "YOUR_API_KEY") { | |
| isTestMode = true; | |
| console.warn("⚠️ Firebase 金鑰未設定,將以測試模式執行 (產生虛擬學生)。"); | |
| } else { | |
| app = initializeApp(firebaseConfig); | |
| db = getFirestore(app); | |
| } | |
| } catch(e) { console.error("Firebase 初始化失敗:", e); } | |
| let currentRoom = ""; | |
| let studentsData = []; // [{ id, name, number, paddedNumber, resultVal, status }] | |
| let excelStudentsData = []; // [{ id, name, number, paddedNumber, resultVal, status }] | |
| // UI 元件 | |
| const startRoomBtn = document.getElementById('startRoomBtn'); | |
| const searchApiBtn = document.getElementById('searchApiBtn'); | |
| const setupScreen = document.getElementById('setupScreen'); | |
| const dashboardScreen = document.getElementById('dashboardScreen'); | |
| const studentsGrid = document.getElementById('studentsGrid'); | |
| const resultsArea = document.getElementById('resultsArea'); | |
| // 警告 file:// 使用者 | |
| if (window.location.protocol === 'file:') { | |
| document.getElementById('systemWarning').style.display = 'block'; | |
| } | |
| // --- 讀取本地端教室歷史紀錄 (LocalStorage) --- | |
| function loadRoomHistory() { | |
| try { | |
| const history = JSON.parse(localStorage.getItem('pi_room_history') || '[]'); | |
| const historyDiv = document.getElementById('historyRooms'); | |
| const chipsDiv = document.getElementById('historyChips'); | |
| if (history.length > 0) { | |
| historyDiv.style.display = 'block'; | |
| chipsDiv.innerHTML = ''; | |
| history.forEach(room => { | |
| const chip = document.createElement('span'); | |
| chip.innerText = room; | |
| chip.style.cssText = 'background: rgba(245,158,11,0.2); border: 1px solid #fbbf24; color: #fbbf24; padding: 0.5rem 1rem; border-radius: 2rem; cursor: pointer; font-weight: bold; transition: all 0.2s;'; | |
| chip.onmouseover = () => { chip.style.background = 'rgba(245,158,11,0.4)'; chip.style.transform = 'translateY(-2px)'; }; | |
| chip.onmouseout = () => { chip.style.background = 'rgba(245,158,11,0.2)'; chip.style.transform = 'none'; }; | |
| chip.onclick = () => { | |
| document.getElementById('roomInput').value = room; | |
| startRoomBtn.click(); | |
| }; | |
| chipsDiv.appendChild(chip); | |
| }); | |
| } | |
| } catch(e) {} | |
| } | |
| function saveRoomHistory(room) { | |
| try { | |
| let history = JSON.parse(localStorage.getItem('pi_room_history') || '[]'); | |
| history = history.filter(r => r !== room); // 把存在過的先拔除 | |
| history.unshift(room); // 插入到最前面 | |
| history = history.slice(0, 5); // 最多保留 5 個避免太壅擠 | |
| localStorage.setItem('pi_room_history', JSON.stringify(history)); | |
| loadRoomHistory(); // 更新 UI | |
| } catch(e) {} | |
| } | |
| loadRoomHistory(); | |
| // --- 名人堂資料設定與渲染 --- | |
| const defaultHof = [ | |
| { name: "歷屆傳奇學姊", paddedNumber: "3141592", resultVal: 200000, year: 2023, isNew: false }, | |
| { name: "歷屆幸運學長", paddedNumber: "1234567", resultVal: 150000, year: 2024, isNew: false }, | |
| { name: "歷屆尋道者", paddedNumber: "7777777", resultVal: 50000, year: 2025, isNew: false }, | |
| { name: "歷屆虔信者", paddedNumber: "8888888", resultVal: 12000, year: 2022, isNew: false } | |
| ]; | |
| let hallOfFame = JSON.parse(localStorage.getItem('pi_hall_of_fame')) || defaultHof.map(h => ({...h})); | |
| // 隱藏/展開名人堂面版 | |
| let hofVisible = false; | |
| document.getElementById('openHofBtn').addEventListener('click', () => { | |
| hofVisible = !hofVisible; | |
| const panel = document.getElementById('hallOfFamePanel'); | |
| if (hofVisible) { | |
| panel.style.display = 'block'; | |
| // Trigger a render sequence to rerun animations | |
| renderHallOfFame(); | |
| } else { | |
| panel.style.display = 'none'; | |
| } | |
| }); | |
| // 靜音按鈕 | |
| let isMuted = false; | |
| document.getElementById('muteBtn').addEventListener('click', (e) => { | |
| isMuted = !isMuted; | |
| audioDrift.muted = isMuted; | |
| audioDawn.muted = isMuted; | |
| e.target.innerText = isMuted ? '🔇 取消靜音' : '🔊 靜音'; | |
| e.target.style.background = isMuted ? 'rgba(239, 68, 68, 0.8)' : 'rgba(75, 85, 99, 0.8)'; | |
| e.target.style.borderColor = isMuted ? 'rgba(248, 113, 113, 0.5)' : 'rgba(156, 163, 175, 0.5)'; | |
| }); | |
| // 匯入 champ.xlsx 名單 | |
| const importChampBtn = document.getElementById('importChampBtn'); | |
| const champExcelFile = document.getElementById('champExcelFile'); | |
| importChampBtn.addEventListener('click', () => { | |
| champExcelFile.click(); | |
| }); | |
| champExcelFile.addEventListener('change', async (e) => { | |
| const file = e.target.files[0]; | |
| if(!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = async (e) => { | |
| const data = new Uint8Array(e.target.result); | |
| const workbook = XLSX.read(data, {type: 'array'}); | |
| const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; | |
| const rows = XLSX.utils.sheet_to_json(firstSheet); | |
| let newCount = 0; | |
| let updateCount = 0; | |
| for(let row of rows) { | |
| if (row['姓名'] && row['圓周率中的位置']) { | |
| const cleanName = String(row['姓名']).trim(); | |
| const numStr = String(row['所選號碼'] || ''); | |
| const resultV = Number(row['圓周率中的位置']); | |
| const yearV = Number(row['參加年度'] || new Date().getFullYear()); | |
| const existIdx = hallOfFame.findIndex(h => h.name === cleanName && h.year === yearV); | |
| if (existIdx > -1) { | |
| // 若已存在,且有更高的分數則更新 | |
| if (resultV > hallOfFame[existIdx].resultVal) { | |
| hallOfFame[existIdx].resultVal = resultV; | |
| hallOfFame[existIdx].paddedNumber = numStr; | |
| hallOfFame[existIdx].number = numStr; | |
| updateCount++; | |
| } | |
| } else { | |
| hallOfFame.push({ | |
| name: cleanName, | |
| paddedNumber: numStr, | |
| number: numStr, | |
| resultVal: resultV, | |
| year: yearV, | |
| isNew: false | |
| }); | |
| newCount++; | |
| } | |
| } | |
| } | |
| if (newCount > 0 || updateCount > 0) { | |
| renderHallOfFame(); | |
| alert(`成功匯入榜單!(新增 ${newCount} 筆,更新 ${updateCount} 筆資料)`); | |
| } else { | |
| alert("檔案皆為重複資料,或格式可能有誤未能讀取。"); | |
| } | |
| } | |
| reader.readAsArrayBuffer(file); | |
| champExcelFile.value = ''; // reset so it can trigger again | |
| }); | |
| function renderHallOfFame() { | |
| const list = document.getElementById('hofPodiumArea'); | |
| if(!list) return; | |
| list.innerHTML = ''; | |
| // 根據深度(數字越大)進行降冪排序 | |
| hallOfFame.sort((a, b) => b.resultVal - a.resultVal); | |
| // 只保留前 5 名 | |
| hallOfFame = hallOfFame.slice(0, 5); | |
| localStorage.setItem('pi_hall_of_fame', JSON.stringify(hallOfFame)); | |
| if(hallOfFame.length === 0) { | |
| list.innerHTML = '<p style="color: #9ca3af; text-align: center; padding: 1rem; width: 100%;">尚無名人堂紀錄</p>'; | |
| return; | |
| } | |
| // 排列頒獎台順序: [第5名(銅), 第4名(銅), 第2名(銀), 第1名(金), 第3名(銀)] -> 視覺上讓最高的中間 | |
| /* | |
| 由於要呈現5筆資料: | |
| 1名: 金色中間 (高度 220) | |
| 2名、3名: 銀色兩側 (高度 160) | |
| 4名、5名: 銅色最外圍 (高度 110) | |
| 視覺排列我們使用 order 重新組合 : 4, 2, 1, 3, 5 | |
| */ | |
| const visualOrder = []; | |
| if(hallOfFame[3]) visualOrder.push({ data: hallOfFame[3], rankClass: 'bronze-shared', stepContent: '🥉<br>第4名', animationDelay: '0s' }); | |
| if(hallOfFame[1]) visualOrder.push({ data: hallOfFame[1], rankClass: 'silver-shared', stepContent: '🥈<br>第2名', animationDelay: '0.3s' }); | |
| if(hallOfFame[0]) visualOrder.push({ data: hallOfFame[0], rankClass: 'first', stepContent: '🥇<br>第1名', animationDelay: '0.6s' }); | |
| if(hallOfFame[2]) visualOrder.push({ data: hallOfFame[2], rankClass: 'silver-shared', stepContent: '🥈<br>第3名', animationDelay: '0.2s' }); | |
| if(hallOfFame[4]) visualOrder.push({ data: hallOfFame[4], rankClass: 'bronze-shared', stepContent: '🥉<br>第5名', animationDelay: '0s' }); | |
| visualOrder.forEach(item => { | |
| const record = item.data; | |
| const div = document.createElement('div'); | |
| div.className = `podium ${item.rankClass} show`; | |
| div.style.animationDelay = item.animationDelay; | |
| div.innerHTML = ` | |
| <div class="podium-info" style="margin-bottom: 0.8rem; transform: scale(0.9);"> | |
| <div style="font-size: 0.9rem; color: #d1d5db; margin-bottom: 0.2rem;">${record.year}</div> | |
| <div class="podium-name">${record.name} | |
| ${record.isNew ? '<br><span style="color: #ef4444; font-size: 0.8rem; font-weight: bold; animation: blink 1.5s infinite; text-shadow: 0 0 5px rgba(239,68,68,0.5);">NEW!</span>' : ''} | |
| </div> | |
| <div class="podium-val" style="margin-top: 0.3rem;">深度 ${record.resultVal.toLocaleString()}</div> | |
| </div> | |
| <div class="podium-step" style="font-size: 1.2rem; line-height: 1.2; text-align: center;">${item.stepContent}</div> | |
| `; | |
| list.appendChild(div); | |
| }); | |
| } | |
| document.getElementById('resetHofBtn').addEventListener('click', () => { | |
| if(confirm('確定要清空名人堂紀錄並恢復預設嗎?')) { | |
| localStorage.removeItem('pi_hall_of_fame'); | |
| hallOfFame = defaultHof.map(h => ({...h})); | |
| renderHallOfFame(); | |
| } | |
| }); | |
| function updateHallOfFame(validData) { | |
| if(!validData || validData.length === 0) return; | |
| const currentYear = new Date().getFullYear(); | |
| let hasNewRecord = false; | |
| // 先清除上一次因為 "isNew" 呈現的狀態 | |
| hallOfFame.forEach(h => h.isNew = false); | |
| validData.forEach(student => { | |
| const cleanName = student.name.replace(" (名冊)", ""); | |
| const numStr = String(student.paddedNumber || student.number); | |
| // 檢查是否已存在 | |
| const existingIdx = hallOfFame.findIndex(h => h.name === cleanName && (h.paddedNumber === numStr || h.number === numStr) && h.year === currentYear); | |
| if(existingIdx === -1) { | |
| hallOfFame.push({ | |
| name: cleanName, | |
| paddedNumber: numStr, | |
| number: student.number, | |
| resultVal: student.resultVal, | |
| year: currentYear, | |
| isNew: true | |
| }); | |
| hasNewRecord = true; | |
| } else { | |
| // 若存在,看結果是否更新?(原則上定值,但預防萬一) | |
| if (student.resultVal > hallOfFame[existingIdx].resultVal) { | |
| hallOfFame[existingIdx].resultVal = student.resultVal; | |
| hallOfFame[existingIdx].isNew = true; | |
| hasNewRecord = true; | |
| } | |
| } | |
| }); | |
| if (hasNewRecord || validData.length > 0) { | |
| renderHallOfFame(); | |
| } | |
| } | |
| // 初次渲染 | |
| renderHallOfFame(); | |
| // --- 0. 開場動畫點擊 --- | |
| const introScreen = document.getElementById('introScreen'); | |
| introScreen.addEventListener('click', () => { | |
| playDrift(); // 🎵 開始播放背景音樂 | |
| introScreen.style.opacity = '0'; | |
| setTimeout(() => { | |
| introScreen.style.display = 'none'; | |
| setupScreen.style.display = 'block'; | |
| // 稍微延遲讓 display block 作用後加上 opacity 動畫 | |
| setTimeout(() => { setupScreen.style.opacity = '1'; }, 50); | |
| }, 800); | |
| }); | |
| // --- 1. 建立房間 --- | |
| startRoomBtn.addEventListener('click', async () => { | |
| currentRoom = document.getElementById('roomInput').value.trim().toUpperCase(); | |
| if(!currentRoom) { alert("請輸入教室代碼!"); return; } | |
| // 取得選擇的位數 | |
| const selectedLength = parseInt(document.getElementById('digitSelect').value); | |
| // 防呆與檢查是否資料庫已有該教室 | |
| startRoomBtn.disabled = true; | |
| startRoomBtn.innerText = "正在設定教室與產生 QRCode..."; | |
| try { | |
| if (!isTestMode) { | |
| // 初始化或更新該房間設定 (包含指定的位數,讓學生端能讀取) | |
| await setDoc(doc(db, "rooms", currentRoom), { | |
| roomCode: currentRoom, | |
| digitLength: selectedLength, | |
| isSearching: false, | |
| updatedAt: new Date() | |
| }, { merge: true }); | |
| const qCheck = query(collection(db, "rooms", currentRoom, "students"), limit(1)); | |
| const snapshot = await getDocs(qCheck); | |
| if (!snapshot.empty) { | |
| const wantToContinue = confirm(`代碼「${currentRoom}」已經存在且包含先前的信徒資料!\n\n・點擊【確定】: 返回原有儀式,延續先前的記錄。\n・點擊【取消】: 取消進入,系統將自動為您建立新代碼 (加上序號)。`); | |
| if (!wantToContinue) { | |
| let suffix = 1; | |
| let newRoom = `${currentRoom}-${String(suffix).padStart(2, '0')}`; | |
| startRoomBtn.innerText = "正在自動尋找新房間空位..."; | |
| // 尋找尚未被使用的房號 | |
| while(true) { | |
| const checkNew = await getDocs(query(collection(db, "rooms", newRoom, "students"), limit(1))); | |
| if (checkNew.empty) break; | |
| suffix++; | |
| newRoom = `${currentRoom}-${String(suffix).padStart(2, '0')}`; | |
| } | |
| currentRoom = newRoom; | |
| // 更新為全新的房間標籤 | |
| await setDoc(doc(db, "rooms", currentRoom), { | |
| roomCode: currentRoom, | |
| digitLength: selectedLength, | |
| isSearching: false, | |
| updatedAt: new Date() | |
| }, { merge: true }); | |
| } | |
| } | |
| } | |
| } catch(e) { console.warn("檢查教室狀態遇到錯誤:", e); } | |
| startRoomBtn.disabled = false; | |
| startRoomBtn.innerText = "進入教室"; | |
| saveRoomHistory(currentRoom); | |
| document.getElementById('displayDigitLength').innerText = selectedLength; | |
| // 介面轉換 | |
| setupScreen.style.display = 'none'; | |
| dashboardScreen.style.display = 'block'; | |
| document.getElementById('displayRoomCode').innerText = currentRoom; | |
| // 產生 QRCode (指向同一網路位置的 student.html) | |
| const currentUrl = new URL(window.location.href); | |
| // 確保網址是把 index.html 替換成 student.html,並加上 room 參數 | |
| let studentPath = currentUrl.pathname.replace('index.html', 'student.html'); | |
| if (studentPath === currentUrl.pathname) { // 如果網址隱藏了 index.html (如 Hugging Face) | |
| if (!studentPath.endsWith('/')) { | |
| studentPath += '/'; | |
| } | |
| studentPath += 'student.html'; | |
| } | |
| const studentUrl = `${currentUrl.origin}${studentPath}?room=${encodeURIComponent(currentRoom)}`; | |
| document.getElementById('studentUrlDisplay').innerText = studentUrl; | |
| const qrBox = document.getElementById('qrcodeArea'); | |
| qrBox.innerHTML = ''; // 清空可能舊的QRcode | |
| qrBox.style.display = 'block'; | |
| document.getElementById('qrHintText').style.display = 'block'; | |
| document.getElementById('urlPContainer').style.display = 'block'; | |
| new QRCode(qrBox, { | |
| text: studentUrl, width: 150, height: 150, colorDark : "#000000", colorLight : "#ffffff", correctLevel : QRCode.CorrectLevel.L | |
| }); | |
| // 加上手指游標提示與 Title | |
| qrBox.style.cursor = 'pointer'; | |
| qrBox.title = '點擊方塊以全螢幕放大 QRCode'; | |
| // 繪製放大的 QRCode 到遮罩中並綁定點擊開關 | |
| document.getElementById('qrModalContent').innerHTML = ''; | |
| new QRCode(document.getElementById("qrModalContent"), { | |
| text: studentUrl, width: 300, height: 300, colorDark : "#000000", colorLight : "#ffffff", correctLevel : QRCode.CorrectLevel.L | |
| }); | |
| document.getElementById('qrcodeArea').onclick = () => { | |
| document.getElementById('qrModal').style.display = 'flex'; | |
| playDrift(); // 自動重新播放等候音樂 (第二階段掃碼時) | |
| }; | |
| document.getElementById('qrModal').onclick = () => document.getElementById('qrModal').style.display = 'none'; | |
| // 手動按鈕重新播放音樂 | |
| document.getElementById('restartMusicBtn').onclick = () => playDrift(); | |
| // 啟動資料庫監聽 | |
| if(isTestMode) { | |
| alert("目前為【測試模式】(未設定Firebase)\n系統即將自動產生三個虛擬同學加入。"); | |
| setTimeout(() => mockJoin("王小明", "1234"), 1500); | |
| setTimeout(() => mockJoin("陳大華", "7777777"), 3000); | |
| setTimeout(() => mockJoin("林老師", "1314"), 4500); | |
| } else { | |
| startListening(currentRoom); | |
| // 於背景自動清理過期房間 (超過 30 天無活動) | |
| cleanupOldRooms(); | |
| } | |
| }); | |
| async function cleanupOldRooms() { | |
| try { | |
| // 取得 30 天前的 Timestamp | |
| const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); | |
| const oldRoomsQuery = query(collection(db, "rooms"), where("updatedAt", "<", thirtyDaysAgo)); | |
| const oldSnapshot = await getDocs(oldRoomsQuery); | |
| oldSnapshot.forEach(async (roomDoc) => { | |
| try { | |
| // Firebase Firestore 必須手動遞迴刪除 Subcollection | |
| const studentsSnap = await getDocs(collection(db, "rooms", roomDoc.id, "students")); | |
| studentsSnap.forEach(async (s) => await deleteDoc(s.ref)); | |
| await deleteDoc(roomDoc.ref); | |
| console.log(`已清理過期房間: ${roomDoc.id}`); | |
| } catch(e) {} | |
| }); | |
| } catch(e) { console.warn("清除舊資料失敗", e); } | |
| } | |
| // --- Firebase 監聽器 --- | |
| function startListening(room) { | |
| const q = query(collection(db, "rooms", room, "students"), orderBy("timestamp", "asc")); | |
| onSnapshot(q, (snapshot) => { | |
| snapshot.docChanges().forEach((change) => { | |
| if (change.type === "added") { | |
| const data = change.doc.data(); | |
| addStudentChip(change.doc.id, data.name, data.number); | |
| } | |
| if (change.type === "removed") { | |
| removeStudentChip(change.doc.id); | |
| } | |
| }); | |
| }, (error) => { | |
| console.error("監聽 Firebase 失敗:", error); | |
| alert("無法連線到資料庫,請檢查網路或金鑰權限設定!"); | |
| }); | |
| } | |
| // 虛擬測試用 | |
| let mockId = 0; | |
| function mockJoin(name, num) { addStudentChip(`mock_${mockId++}`, name, num); } | |
| // --- 2. 顯示與刪除學生卡片 --- | |
| function removeStudentChip(id) { | |
| const index = studentsData.findIndex(s => s.id === id); | |
| if(index > -1) studentsData.splice(index, 1); | |
| const chip = document.getElementById(`chip_${id}`); | |
| if(chip) chip.remove(); | |
| document.getElementById('studentCount').innerText = studentsData.length; | |
| } | |
| function addStudentChip(id, name, number) { | |
| if(studentsData.find(s => s.id === id)) return; // 防呆:重複加入 | |
| studentsData.push({ id, name, number, resultVal: null }); | |
| document.getElementById('studentCount').innerText = studentsData.length; | |
| const div = document.createElement('div'); | |
| div.className = 'student-chip'; | |
| div.id = `chip_${id}`; | |
| div.innerHTML = ` | |
| <button class="delete-btn" title="踢除此學生">✕</button> | |
| <div class="name">${name}</div> | |
| <div class="number">${number}</div> | |
| <div class="result" id="res_${id}"></div> | |
| `; | |
| // 綁定剔除按鈕事件 | |
| const delBtn = div.querySelector('.delete-btn'); | |
| delBtn.onclick = async () => { | |
| if(confirm(`確定要剔除 ${name} (${number}) 嗎?`)) { | |
| if(!isTestMode) await deleteDoc(doc(db, "rooms", currentRoom, "students", id)); | |
| else removeStudentChip(id); // 測試模式本地刪除 | |
| } | |
| }; | |
| studentsGrid.appendChild(div); | |
| // 當新同學加入時捲動到最底 (若已顯示結果則不捲動,避免影響觀看開獎畫面) | |
| if (document.getElementById('resultsArea').style.display !== 'block') { | |
| window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | |
| } | |
| } | |
| // --- 3. 開始環遊圓周率 (API 批量處理) --- | |
| searchApiBtn.addEventListener('click', async () => { | |
| if(studentsData.length === 0) { alert("尚未有學生加入!"); return; } | |
| searchApiBtn.disabled = true; | |
| searchApiBtn.innerText = "⏳ 瘋狂搜尋圓周率中..."; | |
| // 告訴學生端「已經起飛」 | |
| if(!isTestMode) await updateDoc(doc(db, "rooms", currentRoom), { isSearching: true }); | |
| // 取得老師設定的位數 (抓取之前保存在UI上的值) | |
| const selectedLength = parseInt(document.getElementById('displayDigitLength').innerText); | |
| // Promise.allSettled 並以批次執行,防止一口氣發送 400 條請求被瀏覽器擋下或被 Pi/Proxy API 阻斷 | |
| const batchSize = 15; // 提升至每批 15 人同時運算,加快速度 | |
| for (let i = 0; i < studentsData.length; i += batchSize) { | |
| const batch = studentsData.slice(i, i + batchSize); | |
| await Promise.allSettled(batch.map(student => fetchPiForStudent(student, selectedLength))); | |
| // 加入 100 毫秒極短延遲,讓瀏覽器能在此空檔渲染畫面 (呈現一個個翻牌的酷炫效果) | |
| await new Promise(r => setTimeout(r, 100)); | |
| } | |
| searchApiBtn.disabled = false; | |
| searchApiBtn.innerText = "✨ 祈求眷顧!開始搜尋"; | |
| showCombinedResults(); | |
| }); | |
| // ====================================== | |
| // EXCEL 匯入與獨立搜尋邏輯 | |
| // ====================================== | |
| document.getElementById('importBtn').addEventListener('click', async () => { | |
| const file = document.getElementById('excelFile').files[0]; | |
| if(!file) { alert("請先選擇 Excel 檔案!"); return; } | |
| const selectedLength = parseInt(document.getElementById('displayDigitLength').innerText); | |
| if(isNaN(selectedLength)) { alert("請先設定教室並進入儀式後,再匯入名冊。"); return; } | |
| document.getElementById('importBtn').disabled = true; | |
| document.getElementById('importStatus').innerText = "⏳ 正在讀取名冊..."; | |
| const reader = new FileReader(); | |
| reader.onload = async (e) => { | |
| const data = new Uint8Array(e.target.result); | |
| // 透過 XLSX 函式庫解析二進位陣列 | |
| const workbook = XLSX.read(data, {type: 'array'}); | |
| const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; | |
| const rows = XLSX.utils.sheet_to_json(firstSheet, {header: 1}); | |
| excelStudentsData = []; | |
| for(let i=1; i<rows.length; i++) { // 從第二行開始,預設跳過表頭 | |
| const row = rows[i]; | |
| if(row.length >= 2 && row[0] !== undefined && row[1] !== undefined) { | |
| let rawNumber = String(row[1]).trim().replace(/\.0$/, ''); // 防呆: Excel 可能把純數字轉浮點 | |
| excelStudentsData.push({ | |
| id: `excel_${i}`, | |
| name: String(row[0]).trim(), | |
| number: rawNumber, | |
| resultVal: null, | |
| paddedNumber: '' | |
| }); | |
| } | |
| } | |
| if(excelStudentsData.length === 0) { | |
| document.getElementById('importStatus').innerText = "檔案內沒有有效資料。"; | |
| document.getElementById('importBtn').disabled = false; | |
| return; | |
| } | |
| document.getElementById('importStatus').innerText = `已讀取 ${excelStudentsData.length} 筆資料,正向神殿祈求神諭中...`; | |
| const batchSize = 15; | |
| for (let i = 0; i < excelStudentsData.length; i += batchSize) { | |
| const batch = excelStudentsData.slice(i, i + batchSize); | |
| await Promise.allSettled(batch.map(student => fetchPiForExcelStudent(student, selectedLength))); | |
| await new Promise(r => setTimeout(r, 100)); | |
| } | |
| document.getElementById('importBtn').disabled = false; | |
| showExcelOnlyResults(); | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| }); | |
| async function fetchPiForExcelStudent(student, selectedLength) { | |
| let luckyNum = String(student.number); | |
| if (luckyNum.length > selectedLength) { | |
| student.finalOutput = "字數過長不符規定"; | |
| return; | |
| } | |
| luckyNum = luckyNum.padStart(selectedLength, "0"); | |
| student.paddedNumber = luckyNum; | |
| try { | |
| const targetUrl = `https://www.angio.net/newpi/piquery?q=${luckyNum}`; | |
| let data = null; | |
| try { | |
| const res = await fetch(`https://corsproxy.io/?${encodeURIComponent(targetUrl)}`); | |
| if(!res.ok) throw new Error("px1 fail"); | |
| data = await res.json(); | |
| } catch(e1) { | |
| try { | |
| const res = await fetch(`https://api.codetabs.com/v1/proxy/?quest=${targetUrl}`); | |
| if(!res.ok) throw new Error("px2 fail"); | |
| data = await res.json(); | |
| } catch(e2) { | |
| const res = await fetch(`https://api.allorigins.win/raw?url=${encodeURIComponent(targetUrl)}`); | |
| if(!res.ok) throw new Error("px3 fail"); | |
| data = await res.json(); | |
| } | |
| } | |
| if (data && data.status === 'OK' && data.r && data.r.length > 0) { | |
| const resultData = data.r[0]; | |
| if (resultData.status === 'found') { | |
| student.resultVal = resultData.p; | |
| student.finalOutput = `在第 ${student.resultVal} 位`; | |
| } else { | |
| student.finalOutput = "未找到"; | |
| } | |
| } | |
| } catch (e) { | |
| student.finalOutput = "查詢失敗"; | |
| } | |
| } | |
| // ====================================== | |
| // 共用 API 查詢 (現場卡片繪圖邏輯) | |
| // ====================================== | |
| async function fetchPiForStudent(student, selectedLength) { | |
| const chip = document.getElementById(`chip_${student.id}`); | |
| const resDiv = document.getElementById(`res_${student.id}`); | |
| // 變更卡片 UI 為「搜尋中」 | |
| chip.classList.add('searching'); | |
| resDiv.style.display = 'block'; | |
| resDiv.innerHTML = '<div class="spinner"></div>'; // 顯示旋轉圈圈 | |
| let luckyNum = String(student.number); | |
| let finalOutput = null; // 最終顯示文字 | |
| let finalVal = null; // 最終數字結果 | |
| if (luckyNum.length > selectedLength) { | |
| finalOutput = "字數過長不符規定"; | |
| } else { | |
| luckyNum = luckyNum.padStart(selectedLength, "0"); | |
| student.paddedNumber = luckyNum; | |
| // 更新 UI 將數字補零呈現 | |
| chip.querySelector('.number').innerText = luckyNum; | |
| try { | |
| // 目標 API 網址 | |
| const targetUrl = `https://www.angio.net/newpi/piquery?q=${luckyNum}`; | |
| let data = null; | |
| // 實作多層 Proxy 備援機制,防止單一 Proxy 被阻礙或學校網路擋住 | |
| try { | |
| // 優先使用 corsproxy (最快) | |
| const res = await fetch(`https://corsproxy.io/?${encodeURIComponent(targetUrl)}`); | |
| if (!res.ok) throw new Error("Proxy 1 Fail"); | |
| data = await res.json(); | |
| } catch (e1) { | |
| try { | |
| // 備用 1: codetabs | |
| const res = await fetch(`https://api.codetabs.com/v1/proxy/?quest=${targetUrl}`); | |
| if (!res.ok) throw new Error("Proxy 2 Fail"); | |
| data = await res.json(); | |
| } catch (e2) { | |
| // 備用 2: allorigins | |
| const res = await fetch(`https://api.allorigins.win/raw?url=${encodeURIComponent(targetUrl)}`); | |
| if (!res.ok) throw new Error("Proxy 3 Fail"); | |
| data = await res.json(); | |
| } | |
| } | |
| if (data && data.status === 'OK' && data.r && data.r.length > 0) { | |
| const resultData = data.r[0]; | |
| if (resultData.status === 'found') { | |
| finalVal = resultData.p; | |
| finalOutput = `在第 ${finalVal} 位`; | |
| } else { | |
| finalOutput = "在圓周率中未找到"; | |
| } | |
| } else { | |
| throw new Error("Invalid API Response"); | |
| } | |
| } catch (e) { | |
| console.error("API Error", e); | |
| finalOutput = "查詢失敗 (網路/CORS/無回應)"; | |
| } | |
| } | |
| // 更新結果 (推播給 Firebase,讓該學生手機端也能看到) | |
| if(!isTestMode) { | |
| try { | |
| await updateDoc(doc(db, "rooms", currentRoom, "students", student.id), { | |
| resultVal: finalVal, | |
| finalOutput: finalOutput, | |
| status: 'done' | |
| }); | |
| } catch(e) { console.error("Update Student Error", e); } | |
| } | |
| student.resultVal = finalVal; | |
| chip.classList.remove('searching'); | |
| resDiv.innerHTML = ""; | |
| if (typeof finalVal === 'number') { | |
| chip.classList.add('done'); | |
| resDiv.innerText = finalOutput; | |
| } else { | |
| chip.classList.add('error'); | |
| resDiv.innerText = finalOutput; | |
| } | |
| } | |
| // --- 4. 繪圖與頒獎 --- | |
| let chartInstance = null; | |
| // 專門為 Excel 單獨查完後率先呼出圖表顯示第一名 | |
| function showExcelOnlyResults() { | |
| const validData = excelStudentsData.filter(s => typeof s.resultVal === 'number'); | |
| validData.sort((a,b) => b.resultVal - a.resultVal); | |
| resultsArea.style.display = 'block'; | |
| resultsArea.scrollIntoView({ behavior: 'smooth' }); | |
| if(validData.length > 0) { | |
| updateHallOfFame(validData); | |
| document.getElementById('importStatus').innerHTML = `✅ 預先查詢完畢!目前名冊榜首為:<strong style="color:var(--accent); font-size:1.3rem; margin-left: 0.5rem; text-shadow:0 0 5px rgba(252,211,77,0.5);">${validData[0].name}</strong> (隱藏深度 ${validData[0].resultVal})`; | |
| drawPodium(validData); | |
| // 發射紙花特效 | |
| setTimeout(() => { | |
| confetti({ particleCount: 200, spread: 100, origin: { y: 0.6 }, colors: ['#fde047', '#34d399', '#60a5fa'] }); | |
| }, 500); | |
| // 將 Excel 資料預先繪製長條圖 | |
| const ctx = document.getElementById('resultChart').getContext('2d'); | |
| if (chartInstance) chartInstance.destroy(); | |
| chartInstance = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: validData.map(s => `${s.name} (${s.paddedNumber})`), | |
| datasets: [{ | |
| label: '神之軌跡隱藏深度 (名冊)', | |
| data: validData.map(s => s.resultVal), | |
| backgroundColor: 'rgba(212, 175, 55, 0.7)', | |
| borderColor: '#fcd34d', | |
| borderWidth: 2, | |
| borderRadius: 6 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| plugins: { legend: { labels: { color: '#f9fafb', font: {size: 14} } } }, | |
| scales: { | |
| y: { ticks: { color: '#9ca3af' }, grid: { color: 'rgba(255,255,255,0.05)' } }, | |
| x: { ticks: { color: '#d1d5db', maxRotation: 45, minRotation: 45 }, grid: { display: false } } | |
| } | |
| } | |
| }); | |
| } else { | |
| document.getElementById('importStatus').innerHTML = `✅ 查詢完畢!可惜匯入名冊中無人獲得神之眷顧。`; | |
| drawPodium([]); | |
| if(chartInstance) { chartInstance.destroy(); chartInstance = null; } | |
| } | |
| } | |
| // 現場查詢後合併邏輯顯示結果 | |
| function showCombinedResults() { | |
| resultsArea.style.display = 'block'; | |
| resultsArea.scrollIntoView({ behavior: 'smooth' }); | |
| const isMerge = document.getElementById('mergeDataCheck').checked; | |
| // 基礎池:現場有找到的學生 | |
| let finalPool = studentsData.filter(s => typeof s.resultVal === 'number'); | |
| // 如果勾選合併 且 Excel內確實有資料 | |
| if (isMerge && excelStudentsData.length > 0) { | |
| const excelValid = excelStudentsData.filter(s => typeof s.resultVal === 'number'); | |
| // 為名冊學生的名字上個 (匯入) 標記以免與現場撞名 | |
| const wrappedExcel = excelValid.map(s => ({ | |
| ...s, | |
| name: s.name + " (名冊)" | |
| })); | |
| // 陣列合併 | |
| finalPool = finalPool.concat(wrappedExcel); | |
| } | |
| // 按數字大小從大排到小 (越深的人排名越高) | |
| finalPool.sort((a, b) => b.resultVal - a.resultVal); | |
| // 排列名次並將最終總排名推播至 Firebase 讓學生端更新 | |
| let currentRank = 1; | |
| for (let i = 0; i < finalPool.length; i++) { | |
| // 若分數與前一名不同,名次改為目前人數順序 (並列名次處理) | |
| if (i > 0 && finalPool[i].resultVal < finalPool[i-1].resultVal) { | |
| currentRank = i + 1; | |
| } | |
| finalPool[i].rank = currentRank; | |
| // 若非 Excel 資料 (即現場真實手機連線資料),回寫 Firebase | |
| if (!finalPool[i].id.startsWith('excel_') && !isTestMode) { | |
| try { | |
| updateDoc(doc(db, "rooms", currentRoom, "students", finalPool[i].id), { | |
| rank: currentRank | |
| }); | |
| } catch(e) {} | |
| } | |
| } | |
| updateHallOfFame(finalPool); | |
| drawPodium(finalPool); | |
| if(finalPool.length > 0) { | |
| // 發射紙花特效 (Premium UI 微互動) 🎉 | |
| setTimeout(() => { | |
| confetti({ particleCount: 200, spread: 100, origin: { y: 0.6 }, colors: ['#fde047', '#34d399', '#60a5fa'] }); | |
| }, 500); | |
| // 長條圖繪製 | |
| const ctx = document.getElementById('resultChart').getContext('2d'); | |
| if (chartInstance) chartInstance.destroy(); | |
| chartInstance = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: finalPool.map(s => `${s.name} (${s.paddedNumber})`), | |
| datasets: [{ | |
| label: '神之軌跡隱藏深度', | |
| data: finalPool.map(s => s.resultVal), | |
| backgroundColor: 'rgba(212, 175, 55, 0.7)', | |
| borderColor: '#fcd34d', | |
| borderWidth: 2, | |
| borderRadius: 6 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| plugins: { legend: { labels: { color: '#f9fafb', font: {size: 14} } } }, | |
| scales: { | |
| y: { ticks: { color: '#9ca3af' }, grid: { color: 'rgba(255,255,255,0.05)' } }, | |
| x: { ticks: { color: '#d1d5db', maxRotation: 45, minRotation: 45 }, grid: { display: false } } | |
| } | |
| } | |
| }); | |
| } else { | |
| if(chartInstance) { chartInstance.destroy(); chartInstance = null; } | |
| } | |
| } | |
| function drawPodium(validData) { | |
| playDawn(); // 🎵 結果出爐,切換為 Sacred Dawn Ascending.mp3 音樂 | |
| const podiumArea = document.getElementById('podiumArea'); | |
| podiumArea.innerHTML = ""; | |
| if(validData.length === 0) { | |
| podiumArea.innerHTML = "<h3 style='color:#fbbf24'>沒有人找到號碼,無頒獎台 QQ</h3>"; return; | |
| } | |
| const top3 = validData.slice(0, 3); | |
| // 順序: 2 -> 1 -> 3 | |
| const order = []; | |
| if(top3.length >= 2) order.push({ data: top3[1], rank: 'second', num: 2 }); | |
| if(top3.length >= 1) order.push({ data: top3[0], rank: 'first', num: 1 }); | |
| if(top3.length >= 3) order.push({ data: top3[2], rank: 'third', num: 3 }); | |
| order.forEach(item => { | |
| const div = document.createElement('div'); | |
| div.className = `podium ${item.rank} show`; | |
| div.innerHTML = ` | |
| <div class="podium-info"> | |
| <div class="podium-name">${item.data.name}</div> | |
| <div class="podium-val">深度 ${item.data.resultVal}</div> | |
| </div> | |
| <div class="podium-step">${item.num}</div> | |
| `; | |
| podiumArea.appendChild(div); | |
| }); | |
| } | |
| </script> | |
| <!-- 頁尾宣告資訊區 --> | |
| <style> | |
| .footer-credit { | |
| text-align: right; | |
| color: rgba(255, 255, 255, 0.3); | |
| font-size: 0.85rem; | |
| padding: 2rem 0; | |
| margin-top: 2rem; | |
| border-top: 1px solid rgba(255, 255, 255, 0.1); | |
| transition: color 0.3s; | |
| } | |
| .footer-credit:hover { | |
| color: rgba(255, 255, 255, 0.7); | |
| } | |
| .footer-link { | |
| color: inherit; | |
| text-decoration: underline; | |
| transition: color 0.2s; | |
| } | |
| .footer-link:hover { | |
| color: #fcd34d; | |
| } | |
| </style> | |
| <div class="footer-credit"> | |
| <div style="margin-bottom: 4px;"> | |
| 程式設計:新竹縣精華國中 藍星宇 | |
| </div> | |
| <div style="margin-bottom: 4px;"> | |
| 教育社群:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" class="footer-link">萬物皆數</a> | |
| </div> | |
| <div> | |
| Pi-search 使用資料:<a href="https://www.angio.net/pi/" target="_blank" class="footer-link">angio.net/pi/</a> | |
| </div> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |