Pi-Search2 / index.html
Lashtw's picture
Upload index.html
382cf91 verified
<!DOCTYPE html>
<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>