Spaces:
Sleeping
Sleeping
| <html lang="zh-Hant"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>太魯閣語數位學習 - 本機旗艦版 (ASR)</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <style> | |
| :root { --main: #2980b9; --accent: #e67e22; --danger: #e74c3c; --success: #27ae60; } | |
| body { font-family: 'PingFang TC', 'Microsoft JhengHei', sans-serif; background: #fff8f0; margin: 0; padding-top: 220px; } | |
| /* 頂部固定列 (紅色邊框代表本機開發版) */ | |
| .header-fixed { position: fixed; top: 0; left: 0; right: 0; background: white; padding: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); z-index: 1000; text-align: center; border-bottom: 5px solid var(--danger); } | |
| .mode-switch { margin-bottom: 10px; } | |
| .btn-mode { padding: 10px 20px; border-radius: 30px; border: 2px solid var(--main); background: white; color: var(--main); font-weight: bold; cursor: pointer; margin: 0 5px; transition:0.2s; } | |
| .btn-mode.active { background: var(--main); color: white; } | |
| .search-box { width: 90%; max-width: 300px; padding: 8px 15px; border-radius: 20px; border: 1px solid #ccc; margin-bottom: 5px; outline:none; } | |
| .filter-container { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 10px; } | |
| .filter-btn { padding: 5px 12px; border-radius: 15px; border: 1px solid #ddd; background: white; cursor: pointer; font-size: 0.9em; } | |
| .filter-btn.active { background: var(--main); color: white; } | |
| .timer-container { width: 90%; max-width: 600px; height: 10px; background: #eee; border-radius: 5px; margin: 10px auto; overflow: hidden; display: none; } | |
| .timer-bar { height: 100%; background: var(--danger); width: 100%; transition: linear; } | |
| .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; } | |
| .word-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; padding-bottom: 50px; } | |
| .word-card { background: white; border-radius: 15px; padding: 20px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; box-shadow: 0 4px 6px rgba(0,0,0,0.1); transition:0.2s; } | |
| .word-card:hover { transform: translateY(-3px); box-shadow: 0 8px 15px rgba(0,0,0,0.15); } | |
| .card-info .word { font-size: 1.5em; font-weight: bold; color: #2c3e50; } | |
| .card-info .zh { color: var(--accent); } | |
| #quiz-area { display: none; background: white; padding: 30px; border-radius: 25px; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,0.1); margin-top: 20px; } | |
| .quiz-nav { display: flex; justify-content: center; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; } | |
| /* 按鈕樣式 (全名版) */ | |
| .btn-type { padding: 8px 12px; border: 1px solid #aaa; border-radius: 10px; background: white; cursor: pointer; font-weight:bold; font-size: 0.95em; margin-bottom: 5px; } | |
| .btn-type.active { background: var(--main); color: white; border-color: var(--main); } | |
| .img-card { width: 220px; height: 220px; object-fit: cover; border-radius: 15px; border: 5px solid #eee; margin-bottom: 15px; } | |
| .big-word { font-size: 2.5em; font-weight: bold; margin-bottom: 15px; color: #333; } | |
| /* 錄音按鈕 (本機版專用) */ | |
| #record-area { margin-top: 25px; display: none; } | |
| #btnRecord { | |
| font-size: 1.4em; padding: 15px 40px; border-radius: 50px; border: none; | |
| background: var(--danger); color: white; cursor: pointer; box-shadow: 0 4px 15px rgba(231, 76, 60, 0.4); | |
| transition: 0.2s; display: inline-flex; align-items: center; gap: 10px; | |
| } | |
| #btnRecord:active { transform: scale(0.95); background: #c0392b; } | |
| #btnRecord.recording { animation: pulse 1.5s infinite; background: #c0392b; } | |
| @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7); } 70% { box-shadow: 0 0 0 15px rgba(231, 76, 60, 0); } 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0); } } | |
| .quiz-input { font-size: 1.5em; padding: 10px; width: 80%; max-width: 400px; text-align: center; border: 3px solid var(--main); border-radius: 15px; } | |
| .opt-grid { display: grid; gap: 12px; margin-top: 20px; width: 100%; max-width: 600px; margin-left: auto; margin-right: auto; } | |
| .opt-btn { padding: 15px; font-size: 1.2em; border: 2px solid var(--main); background: white; border-radius: 12px; cursor: pointer; transition: 0.2s; } | |
| .opt-btn:hover { background: #f0f7ff; transform: translateY(-2px); } | |
| .feedback { font-size: 1.6em; font-weight: bold; margin: 20px 0; min-height: 1.6em; } | |
| .next-btn { padding: 12px 40px; background: var(--success); color: white; border: none; border-radius: 30px; font-size: 1.2em; cursor: pointer; display: none; box-shadow: 0 4px 10px rgba(39, 174, 96, 0.4); } | |
| #audioPlayer { display: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header-fixed"> | |
| <div style="font-size: 0.8em; color: var(--danger); font-weight: bold;">🔴 V15 本機旗艦版 (ASR + 錄音啟用)</div> | |
| <div class="mode-switch"> | |
| <button id="mode-dict" class="btn-mode active" onclick="setMode('dict')">📚 詞彙辭典</button> | |
| <button id="mode-quiz" class="btn-mode" onclick="setMode('quiz')">🎮 競賽模式</button> | |
| </div> | |
| <div id="dict-tools"><input type="text" id="searchInput" class="search-box" placeholder="搜尋..." oninput="renderDict()"></div> | |
| <div id="quiz-tools" style="display:none;"> | |
| <div class="quiz-nav"> | |
| <button onclick="switchType(1)" class="btn-type active">題型一:看圖說族語</button> | |
| <button onclick="switchType(2)" class="btn-type">題型二:看中文寫族語</button> | |
| <button onclick="switchType(3)" class="btn-type">題型三:聽中文說族語</button> | |
| <button onclick="switchType(4)" class="btn-type">題型四:聽族語選回答</button> | |
| </div> | |
| <div class="timer-container" id="timerCont"><div id="tBar" class="timer-bar"></div></div> | |
| </div> | |
| </div> | |
| <div class="container"> | |
| <div id="filterBtns" class="filter-container"></div> | |
| <div id="wordList" class="word-list"></div> | |
| <div id="quiz-area"> | |
| <div id="instr" style="color:#777; font-weight:bold; margin-bottom:15px; font-size:1.1em;">準備作答...</div> | |
| <div id="display-content"></div> | |
| <div id="record-area"> | |
| <button id="btnRecord" onmousedown="startRecording()" onmouseup="stopRecording()" ontouchstart="startRecording()" ontouchend="stopRecording()">🎙️ 按住說話</button> | |
| <div id="score-display" style="margin-top:15px; font-size:1.3em; font-weight:bold; min-height: 1.5em; color: var(--main);"></div> | |
| </div> | |
| <div id="feedback" class="feedback"></div> | |
| <button id="nextBtn" class="next-btn" onclick="nextQuestion()">下一題 ➔</button> | |
| </div> | |
| </div> | |
| <audio id="audioPlayer"></audio> | |
| <script> | |
| let allData = [], currentFilter = '全部', currentMode = 'dict', quizType = 1, currentQ = null; | |
| let usedIds = { 1: [], 2: [], 3: [], 4: [] }; | |
| let mediaRecorder, audioChunks = []; | |
| let timerObj; | |
| const TRUKU_DIR = "truku_final_audio/"; | |
| const ZH_DIR = "chinese_audio/"; | |
| // 音效 (本地優先) | |
| const soundCorrect = new Audio("correct.mp3"); | |
| const soundWrong = new Audio("wrong.mp3"); | |
| const backupCorrect = "https://actions.google.com/sounds/v1/cartoon/pop.ogg"; | |
| const backupWrong = "https://www.soundjay.com/buttons/sounds/button-10.mp3"; | |
| soundCorrect.onerror = () => { soundCorrect.src = backupCorrect; }; | |
| soundWrong.onerror = () => { soundWrong.src = backupWrong; }; | |
| function playSound(isCorrect) { | |
| let audio = isCorrect ? soundCorrect : soundWrong; | |
| audio.currentTime = 0; audio.volume = 0.5; | |
| audio.play().catch(e => console.log("音效需互動")); | |
| } | |
| async function init() { | |
| try { | |
| const res = await fetch('index.json?t=' + Date.now()); | |
| allData = await res.json(); | |
| renderCategories(); | |
| renderDict(); | |
| } catch(e) { alert("讀取資料失敗,請確認 index.json 存在"); } | |
| } | |
| // ✨✨✨ 錄音核心 (連接 server.py) ✨✨✨ | |
| async function startRecording() { | |
| clearTimeout(timerObj); | |
| document.getElementById('tBar').style.transition = 'none'; | |
| const btn = document.getElementById('btnRecord'); | |
| btn.classList.add('recording'); btn.innerText = "🔴 錄音中..."; | |
| document.getElementById('score-display').innerText = ""; | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| mediaRecorder = new MediaRecorder(stream); | |
| audioChunks = []; | |
| mediaRecorder.ondataavailable = e => audioChunks.push(e.data); | |
| mediaRecorder.start(); | |
| } catch(err) { | |
| alert("無法存取麥克風!"); | |
| btn.classList.remove('recording'); btn.innerText = "🎙️ 按住說話"; | |
| } | |
| } | |
| async function stopRecording() { | |
| const btn = document.getElementById('btnRecord'); | |
| btn.classList.remove('recording'); btn.innerText = "🎙️ 按住說話"; | |
| if(!mediaRecorder || mediaRecorder.state === "inactive") return; | |
| mediaRecorder.stop(); | |
| mediaRecorder.onstop = async () => { | |
| const display = document.getElementById('score-display'); | |
| display.innerHTML = "⏳ AI 老師正在聽..."; | |
| const blob = new Blob(audioChunks, { type: 'audio/webm' }); | |
| const fd = new FormData(); | |
| fd.append("file", blob); fd.append("target_word", currentQ.word); | |
| try { | |
| // 呼叫 server.py | |
| const res = await fetch("/api/score", { method: "POST", body: fd }); | |
| const r = await res.json(); | |
| if(r.status === "success") { | |
| let color = r.score >= 80 ? "green" : (r.score >= 60 ? "orange" : "red"); | |
| display.innerHTML = `<span style="color:${color}; font-size:1.5em;">${r.score}分</span> <span style="font-size:0.8em; color:#555;">${r.comment}</span>`; | |
| if(r.score >= 60) showResult("✅ 正確!", "green", true); | |
| else showResult("❌ 請再試一次", "red", false); | |
| } else { | |
| display.innerText = "❌ " + r.comment; playSound(false); | |
| } | |
| } catch(e) { display.innerText = "❌ 連線錯誤"; playSound(false); } | |
| }; | |
| } | |
| function setMode(mode) { | |
| currentMode = mode; | |
| document.getElementById('mode-dict').classList.toggle('active', mode==='dict'); | |
| document.getElementById('mode-quiz').classList.toggle('active', mode==='quiz'); | |
| document.getElementById('wordList').style.display = mode==='dict'?'grid':'none'; | |
| document.getElementById('dict-tools').style.display = mode==='dict'?'block':'none'; | |
| document.getElementById('filterBtns').style.display = mode==='dict'?'flex':'none'; // 競賽時隱藏 | |
| document.getElementById('quiz-area').style.display = mode==='quiz'?'block':'none'; | |
| document.getElementById('quiz-tools').style.display = mode==='quiz'?'block':'none'; | |
| document.getElementById('timerCont').style.display = mode==='quiz'?'block':'none'; | |
| if(mode==='quiz') switchType(1); else renderDict(); | |
| } | |
| function switchType(t) { | |
| quizType = t; | |
| document.querySelectorAll('.btn-type').forEach((b, i) => b.classList.toggle('active', (i+1)===t)); | |
| document.getElementById('instr').innerText = "準備好了嗎?"; | |
| document.getElementById('display-content').innerHTML = `<button onclick="nextQuestion()" class="btn-mode active" style="margin-top:20px;">開始挑戰</button>`; | |
| document.getElementById('record-area').style.display = 'none'; | |
| document.getElementById('feedback').innerText = ""; | |
| document.getElementById('nextBtn').style.display = 'none'; | |
| document.getElementById('score-display').innerText = ""; | |
| clearTimeout(timerObj); | |
| document.getElementById('tBar').style.width = '100%'; | |
| } | |
| function startTimer(seconds) { | |
| const bar = document.getElementById('tBar'); | |
| bar.style.transition = 'none'; bar.style.width = '100%'; | |
| setTimeout(() => { | |
| bar.style.transition = `width ${seconds}s linear`; | |
| bar.style.width = '0%'; | |
| }, 50); | |
| clearTimeout(timerObj); | |
| timerObj = setTimeout(() => { | |
| let ans = currentQ.answer || currentQ.word; | |
| showResult(`⏰ 時間到!答案是:${ans}`, "#7f8c8d", false); | |
| document.getElementById('record-area').style.display = 'none'; | |
| }, seconds * 1000); | |
| } | |
| function nextQuestion() { | |
| document.getElementById('feedback').innerText = ""; | |
| document.getElementById('nextBtn').style.display = 'none'; | |
| document.getElementById('score-display').innerText = ""; | |
| const targetType = (quizType <= 2) ? 'word' : 'sentence'; | |
| let pool = allData.filter(i => { | |
| const matchCat = (currentFilter === '全部' || i.category === currentFilter); | |
| const matchType = (i.type && i.type.toLowerCase() === targetType); | |
| return matchCat && matchType; | |
| }); | |
| let avail = pool.filter(i => !usedIds[quizType].includes(i.id)); | |
| if (avail.length === 0) { | |
| if(pool.length===0) { alert(`此分類下沒有符合的題目!`); return; } | |
| alert("題目做完了,重新洗牌!"); usedIds[quizType] = []; avail = pool; | |
| } | |
| currentQ = avail[Math.floor(Math.random()*avail.length)]; | |
| usedIds[quizType].push(currentQ.id); | |
| renderQuestionInterface(); | |
| } | |
| function renderQuestionInterface() { | |
| const area = document.getElementById('display-content'); | |
| const recordArea = document.getElementById('record-area'); | |
| area.innerHTML = ''; | |
| let timeLimit = 10; | |
| if(quizType === 1) { | |
| // 題型一:錄音 | |
| document.getElementById('instr').innerText = "【題型一】看圖卡,按住說出族語:"; | |
| let fid = String(currentQ.id).padStart(4, '0'); | |
| area.innerHTML = `<img src="images/${fid}.jpg" class="img-card" onerror="this.src='https://via.placeholder.com/220?text=NoImage'">`; | |
| recordArea.style.display = 'block'; | |
| timeLimit = 3; | |
| } else if(quizType === 2) { | |
| // 題型二:拼寫 | |
| document.getElementById('instr').innerText = "【題型二】看中文,拼出族語:"; | |
| area.innerHTML = `<div class="big-word">${currentQ.chinese}</div><input type="text" id="spellBox" class="quiz-input" placeholder="輸入..." onkeypress="if(event.key==='Enter') checkSpell()">`; | |
| recordArea.style.display = 'none'; | |
| timeLimit = 5; | |
| } else if(quizType === 3) { | |
| // 題型三:錄音 | |
| document.getElementById('instr').innerText = "【題型三】聽中文,按住說出族語:"; | |
| area.innerHTML = `<div class="big-word">${currentQ.chinese}</div>`; | |
| playAudio(ZH_DIR + currentQ.zh_audio); | |
| recordArea.style.display = 'block'; | |
| timeLimit = 6; | |
| } else if(quizType === 4) { | |
| // 題型四:選擇 | |
| document.getElementById('instr').innerText = "【題型四】聽族語,選出正確回答:"; | |
| playAudio(TRUKU_DIR + currentQ.audio); | |
| recordArea.style.display = 'none'; | |
| timeLimit = 6; | |
| let sentencePool = allData.filter(i => i.type && i.type.toLowerCase() === 'sentence' && i.id !== currentQ.id); | |
| if (sentencePool.length < 2) sentencePool = allData.filter(i => i.id !== currentQ.id); | |
| let wrong = sentencePool.sort(()=>0.5-Math.random()).slice(0,2); | |
| let opts = [...wrong, currentQ].sort(()=>0.5-Math.random()); | |
| let html = '<div class="opt-grid">'; | |
| opts.forEach(o => { | |
| let label = o.answer || o.word; | |
| html += `<button class="opt-btn" onclick="checkOpt('${o.id}', \`${label}\`)">${label}</button>`; | |
| }); | |
| html += '</div>'; | |
| area.innerHTML = html; | |
| } | |
| startTimer(timeLimit); | |
| } | |
| function renderCategories() { | |
| const cats = ['全部', ...new Set(allData.map(i => i.category))]; | |
| const c = document.getElementById('filterBtns'); c.innerHTML = ''; | |
| cats.forEach(x => { | |
| const b = document.createElement('button'); b.className = `filter-btn ${x==='全部'?'active':''}`; b.innerText = x; | |
| b.onclick=()=>{currentFilter=x; document.querySelectorAll('.filter-btn').forEach(z=>z.classList.remove('active')); b.classList.add('active'); if(currentMode==='dict') renderDict();}; | |
| c.appendChild(b); | |
| }); | |
| } | |
| function renderDict() { | |
| const s = document.getElementById('searchInput').value.toLowerCase(); | |
| const l = document.getElementById('wordList'); l.innerHTML = ''; | |
| allData.filter(i=>(i.word.toLowerCase().includes(s)||i.chinese.includes(s))&&(currentFilter==='全部'||i.category===currentFilter)).forEach(i=>{ | |
| const d=document.createElement('div'); d.className='word-card'; | |
| d.onclick=()=>{playAudio(TRUKU_DIR+i.audio)}; | |
| d.innerHTML=`<div class="card-info"><span style="font-size:0.8em; color:#2980b9; background:#ebf5fb; padding:2px 8px; border-radius:10px;">${i.category}</span><div class="word">${i.word}</div><div class="zh">${i.chinese}</div></div><div style="font-size:1.5em">🔊</div>`; | |
| l.appendChild(d); | |
| }); | |
| } | |
| function playAudio(src) { const p=document.getElementById('audioPlayer'); p.src=src; p.play(); } | |
| function checkSpell() { | |
| clearTimeout(timerObj); | |
| const v = document.getElementById('spellBox').value.trim().toLowerCase(); | |
| let isRight = v===currentQ.word.toLowerCase(); | |
| showResult(isRight?"✅ 正確!":"❌ 錯誤", isRight?"green":"red", isRight); | |
| } | |
| function checkOpt(id, ansText) { | |
| clearTimeout(timerObj); | |
| let isRight = (id == currentQ.id); | |
| let correctLabel = currentQ.answer || currentQ.word; | |
| showResult(isRight?"✅ 正確!":"❌ 錯誤,答案是: "+correctLabel, isRight?"green":"red", isRight); | |
| } | |
| function showResult(t, c, isCorrect) { | |
| const f = document.getElementById('feedback'); | |
| f.innerText = t; f.style.color = c; | |
| document.getElementById('nextBtn').style.display='inline-block'; | |
| playSound(isCorrect); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> |