Spaces:
Running
Running
| <html lang="zh-Hant"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>單字閃卡 (TrOCR AI 版)</title> | |
| <!-- 載入 Tailwind CSS CDN --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- 載入彩帶效果庫 --> | |
| <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"></script> | |
| <!-- 載入 QR Code 產生器 --> | |
| <script src="https://cdn.jsdelivr.net/npm/qrcode-generator/qrcode.js"></script> | |
| <!-- 載入 Pako 壓縮函式庫 --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script> | |
| <!-- 載入 PDF.js 函式庫 --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.min.js"></script> | |
| <!-- 【新】Firebase SDK --> | |
| <script type="module"> | |
| import { initializeApp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app.js"; | |
| import { getFirestore, collection, addDoc, getDoc, doc } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js"; | |
| // 您的 Firebase 設定金鑰 | |
| const firebaseConfig = { | |
| apiKey: "AIzaSyAIRUONOLmA1pe42cmH_MNPgGC3oHKuceo", | |
| authDomain: "englishflashcardbackstage.firebaseapp.com", | |
| projectId: "englishflashcardbackstage", | |
| storageBucket: "englishflashcardbackstage.firebasestorage.app", | |
| messagingSenderId: "911755356145", | |
| appId: "1:911755356145:web:a4a5b0245b3bb9f15d4650" | |
| }; | |
| // 初始化 Firebase | |
| const app = initializeApp(firebaseConfig); | |
| const db = getFirestore(app); | |
| // 將 db 實例暴露到全域,以便主腳本可以存取 | |
| window.firebaseDB = db; | |
| window.firebaseFirestore = { collection, addDoc, getDoc, doc }; | |
| </script> | |
| <style> | |
| /* 定義閃卡的翻轉效果 */ | |
| .flip-container { | |
| perspective: 1000px; | |
| } | |
| .flipper { | |
| transition: transform 0.6s; | |
| transform-style: preserve-3d; | |
| position: relative; | |
| } | |
| .flip-container.flipped .flipper { | |
| transform: rotateY(180deg); | |
| } | |
| .front, .back { | |
| backface-visibility: hidden; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 1.5rem; | |
| border-radius: 1.5rem; | |
| box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); | |
| color: white; | |
| text-align: center; | |
| background: linear-gradient(135deg, var(--tw-gradient-from), var(--tw-gradient-to)); | |
| } | |
| .front { | |
| z-index: 2; | |
| transform: rotateY(0deg); | |
| --tw-gradient-from: #6366f1; /* Indigo-500 */ | |
| --tw-gradient-to: #4f46e5; /* Indigo-600 */ | |
| } | |
| .back { | |
| transform: rotateY(180deg); | |
| --tw-gradient-from: #a78bfa; /* Violet-400 */ | |
| --tw-gradient-to: #c4b5fd; /* Violet-300 */ | |
| } | |
| /* 答錯時的晃動動畫 */ | |
| @keyframes shake { | |
| 10%, 90% { transform: translate3d(-1px, 0, 0); } | |
| 20%, 80% { transform: translate3d(2px, 0, 0); } | |
| 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } | |
| 40%, 60% { transform: translate3d(4px, 0, 0); } | |
| } | |
| .shake { | |
| animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both; | |
| } | |
| .timer-highlight { | |
| animation: pulse 1.5s infinite; | |
| } | |
| @keyframes pulse { | |
| 50% { transform: scale(1.1); } | |
| } | |
| .speak-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| /* 列表動畫 */ | |
| .list-item { | |
| transition: all 0.3s ease-out; | |
| opacity: 1; | |
| transform: translateX(0); | |
| } | |
| .list-item.removing { | |
| opacity: 0; | |
| transform: translateX(100%); | |
| } | |
| #qrcode-container img { | |
| margin: auto; | |
| border: 6px solid white; | |
| border-radius: 10px; | |
| box-shadow: 0 4px 6px rgba(0,0,0,0.1); | |
| } | |
| /* 進度條動畫 */ | |
| #ai-progress-bar-inner, #audio-preload-bar { | |
| transition: width 0.3s ease-in-out; | |
| } | |
| /* [第二步] 新增手寫板樣式 */ | |
| #handwriting-container { | |
| transition: all 0.3s ease; | |
| } | |
| /* 讓答案框在唯讀模式下看起來像「顯示區」 */ | |
| input[readonly].handwriting-mode { | |
| background-color: #f3f4f6; | |
| color: #4f46e5; | |
| font-weight: bold; | |
| border-color: #818cf8; | |
| cursor: not-allowed; | |
| } | |
| /* 觸摸手寫板時的視覺回饋類別 */ | |
| .canvas-container { | |
| border: 2px dashed #cbd5e1; | |
| border-radius: 0.75rem; | |
| overflow: hidden; | |
| transition: all 0.2s; | |
| } | |
| .canvas-container.active-canvas { | |
| border-color: #6366f1 ; /* Indigo-500 */ | |
| box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); | |
| } | |
| .canvas-active-interaction { | |
| background-color: #f0fdf4 ; /* Green-50 */ | |
| } | |
| /* 候選字列樣式 */ | |
| #candidate-bar { | |
| display: flex; | |
| gap: 0.5rem; | |
| overflow-x: auto; | |
| padding: 0.5rem 0; | |
| margin-top: 0.5rem; | |
| min-height: 40px; | |
| } | |
| .candidate-btn { | |
| background-color: #e0e7ff; | |
| color: #4338ca; | |
| padding: 0.25rem 0.75rem; | |
| border-radius: 9999px; | |
| font-weight: 600; | |
| white-space: nowrap; | |
| border: 1px solid #c7d2fe; | |
| transition: all 0.2s; | |
| } | |
| .candidate-btn:hover { | |
| background-color: #c7d2fe; | |
| } | |
| /* [新增] Toast 通知樣式 (取代 alert) */ | |
| #toast { | |
| visibility: hidden; | |
| min-width: 280px; | |
| background-color: rgba(31, 41, 55, 0.95); /* Gray-900 with opacity */ | |
| color: #fff; | |
| text-align: center; | |
| border-radius: 12px; | |
| padding: 16px; | |
| position: fixed; | |
| z-index: 100; | |
| left: 50%; | |
| bottom: 30px; | |
| transform: translateX(-50%); | |
| box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); | |
| font-size: 16px; | |
| font-weight: 500; | |
| opacity: 0; | |
| transition: opacity 0.3s, bottom 0.3s; | |
| } | |
| #toast.show { | |
| visibility: visible; | |
| opacity: 1; | |
| bottom: 50px; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen flex flex-col items-center p-4 sm:p-8 font-sans"> | |
| <!-- [新增] Toast 通知元素 --> | |
| <div id="toast"></div> | |
| <!-- 主選單:新增單字 & 模式選擇 --> | |
| <div id="main-menu" class="w-full max-w-2xl bg-white p-6 rounded-3xl shadow-2xl transition-all duration-500"> | |
| <h1 id="main-title" class="text-4xl font-extrabold text-center text-gray-900 mb-6"></h1> | |
| <!-- 模式選擇區塊 --> | |
| <div id="mode-selection-section"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-2xl font-bold text-gray-800">選擇學習模式</h2> | |
| <div class="text-right"> | |
| <p class="text-sm text-gray-500">極速挑戰最高分</p> | |
| <p id="highscore-display" class="text-lg font-bold text-amber-500">0</p> | |
| </div> | |
| </div> | |
| <!-- 單字範圍與隨機出題選擇 --> | |
| <div id="settings-container" class="my-4 p-4 bg-gray-100 rounded-2xl space-y-3"> | |
| <div class="flex items-center justify-center gap-4"> | |
| <label for="start-range" class="font-semibold text-gray-700">單字範圍:</label> | |
| <input type="number" id="start-range" min="1" class="w-20 p-2 border border-gray-300 rounded-lg text-center" placeholder="從"> | |
| <span class="font-semibold text-gray-700">-</span> | |
| <input type="number" id="end-range" min="1" class="w-20 p-2 border border-gray-300 rounded-lg text-center" placeholder="到"> | |
| </div> | |
| <div class="flex items-center justify-center gap-4"> | |
| <input type="checkbox" id="random-questions-checkbox" class="h-5 w-5 rounded text-indigo-600 focus:ring-indigo-500 border-gray-300"> | |
| <label for="random-questions-checkbox" class="font-semibold text-gray-700">隨機出題:</label> | |
| <input type="number" id="random-questions-count" min="1" class="w-20 p-2 border border-gray-300 rounded-lg text-center disabled:bg-gray-200 disabled:cursor-not-allowed" placeholder="題數" disabled> | |
| <span class="font-semibold text-gray-700">題</span> | |
| </div> | |
| </div> | |
| <!-- 主要功能 --> | |
| <div class="mb-4"> | |
| <div class="relative mb-3 border-b pb-2"> | |
| <h3 class="text-lg font-semibold text-gray-600 text-center">主要功能</h3> | |
| <button id="reset-crowns-btn" class="absolute right-0 top-1/2 -translate-y-1/2 text-xs bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-1 px-3 rounded-full transition-colors disabled:bg-gray-300">重置進度</button> | |
| </div> | |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <button data-mode="review" class="mode-btn bg-sky-500 hover:bg-sky-600">複習模式</button> | |
| <button data-mode="zh-en" class="mode-btn bg-teal-500 hover:bg-teal-600 relative"> | |
| 中翻英測驗 | |
| <span class="crown-icon hidden absolute -top-2 -right-1 text-4xl" style="transform: rotate(15deg);">👑</span> | |
| </button> | |
| <button data-mode="en-zh" class="mode-btn bg-amber-500 hover:bg-amber-600 relative"> | |
| 英翻中測驗 | |
| <span class="crown-icon hidden absolute -top-2 -right-1 text-4xl" style="transform: rotate(15deg);">👑</span> | |
| </button> | |
| <button data-mode="listen" class="mode-btn bg-rose-500 hover:bg-rose-600 relative"> | |
| 聽力測驗 | |
| <span class="crown-icon hidden absolute -top-2 -right-1 text-4xl" style="transform: rotate(15deg);">👑</span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 補充功能 --> | |
| <div class="mb-4"> | |
| <h3 class="text-lg font-semibold text-gray-600 mb-3 text-center border-b pb-2">補充功能</h3> | |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <button data-mode="hard" class="mode-btn bg-red-700 hover:bg-red-800">🧠 困難單字複習</button> | |
| <button data-mode="speed" class="mode-btn bg-slate-700 hover:bg-slate-800">⚡ 極速挑戰 ⚡</button> | |
| <button data-mode="sentence-cloze" class="mode-btn bg-cyan-600 hover:bg-cyan-700">📝 情境克漏字</button> | |
| </div> | |
| </div> | |
| <!-- Audio Preload Progress --> | |
| <div id="audio-preload-container" class="hidden mt-4"> | |
| <p id="audio-preload-text" class="text-sm text-center text-gray-500 mb-1">正在預載語音檔案...</p> | |
| <div class="w-full bg-gray-200 rounded-full h-2.5"> | |
| <div id="audio-preload-bar" class="bg-sky-600 h-2.5 rounded-full" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| <!-- 教師工具 --> | |
| <div id="teacher-tools" class="mt-8 pt-4 border-t flex justify-end items-center gap-3"> | |
| <button id="grade-report-btn" class="text-white font-semibold py-2 px-5 rounded-full transition-transform hover:scale-105 bg-blue-500 hover:bg-blue-600 text-sm">📊 成績報告</button> | |
| <button id="share-game-btn" class="text-white font-semibold py-2 px-5 rounded-full transition-transform hover:scale-105 bg-violet-500 hover:bg-violet-600 text-sm">🔗 分享</button> | |
| <button id="manage-words-btn" class="text-white font-semibold py-2 px-5 rounded-full transition-transform hover:scale-105 bg-gray-500 hover:bg-gray-600 text-sm">⚙️ 管理</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 單字管理介面 (預設隱藏) --> | |
| <div id="word-management-view" class="hidden w-full max-w-4xl bg-white p-6 rounded-3xl shadow-2xl"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 class="text-3xl font-bold text-gray-800">單字列表管理</h2> | |
| <button id="back-to-modes-from-manage-btn" class="bg-gray-200 text-gray-800 p-3 rounded-full hover:bg-gray-300 transition-colors"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg> | |
| </button> | |
| </div> | |
| <!-- [已移除] 手動版本鎖定設定區塊 --> | |
| <!-- 標題設定區塊 --> | |
| <div class="mb-6 p-4 bg-gray-50 rounded-2xl"> | |
| <h3 class="text-xl font-semibold mb-4 text-gray-700">標題設定</h3> | |
| <div class="flex items-center gap-4"> | |
| <input type="text" id="book-input" class="w-full p-2 border border-gray-300 rounded-lg" placeholder="冊次 (例如 B3)"> | |
| <input type="text" id="lesson-input" class="w-full p-2 border border-gray-300 rounded-lg" placeholder="課次 (例如 L1)"> | |
| <button id="save-title-btn" class="bg-indigo-600 text-white font-bold py-2 px-6 rounded-full hover:bg-indigo-700 shrink-0 transition-colors">儲存標題</button> | |
| </div> | |
| </div> | |
| <!-- AI 解析區塊 --> | |
| <div class="mb-6 p-4 bg-violet-50 rounded-2xl border-2 border-dashed border-violet-200"> | |
| <h3 class="text-xl font-semibold mb-2 text-violet-800">🚀 透過 AI 從 PDF 建立單字庫</h3> | |
| <div class="mb-4"> | |
| <label for="api-key-input" class="block text-sm font-semibold text-violet-700 mb-1">您的 Gemini API 金鑰</label> | |
| <div class="flex items-center gap-2"> | |
| <input type="password" id="api-key-input" class="w-full p-2 border border-violet-300 rounded-lg" placeholder="請在此貼上您的 API 金鑰"> | |
| <button id="save-api-key-btn" class="bg-violet-500 text-white font-semibold py-2 px-4 rounded-lg hover:bg-violet-600 shrink-0">儲存</button> | |
| </div> | |
| <p class="text-xs text-gray-500 mt-1">金鑰將會儲存在您的瀏覽器中,方便下次使用。可從 <a href="https://aistudio.google.com/app/apikey" target="_blank" class="text-blue-500 hover:underline">Google AI Studio</a> 取得。</p> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <input type="file" id="pdf-upload-input" accept=".pdf" class="block w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-violet-100 file:text-violet-700 hover:file:bg-violet-200"/> | |
| <button id="parse-pdf-btn" class="bg-violet-600 text-white font-bold py-2 px-6 rounded-full hover:bg-violet-700 shrink-0 transition-colors disabled:bg-violet-300">AI智慧解析</button> | |
| </div> | |
| <div id="ai-progress-container" class="mt-3 hidden"> | |
| <div class="w-full bg-gray-200 rounded-full h-2.5"> | |
| <div id="ai-progress-bar-inner" class="bg-violet-600 h-2.5 rounded-full" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| <p id="parse-status" class="text-center text-sm text-violet-600 mt-2 h-5"></p> | |
| </div> | |
| <!-- 新增單字區塊 --> | |
| <div id="add-words-section" class="mb-6 p-4 bg-gray-50 rounded-2xl hidden"> | |
| <h3 class="text-xl font-semibold mb-4 text-gray-700">新增單字</h3> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <label for="english-words" class="block text-sm font-semibold text-gray-700 mb-2">英文 (每行一個)</label> | |
| <textarea id="english-words" rows="4" class="w-full p-3 border border-gray-300 rounded-xl"></textarea> | |
| </div> | |
| <div> | |
| <label for="chinese-explanations" class="block text-sm font-semibold text-gray-700 mb-2">中文 (每行一個)</label> | |
| <textarea id="chinese-explanations" rows="4" class="w-full p-3 border border-gray-300 rounded-xl"></textarea> | |
| </div> | |
| </div> | |
| <div id="error-message" class="text-red-500 text-center font-semibold h-6 mt-2"></div> | |
| <div class="flex justify-end gap-4 mt-2"> | |
| <button id="cancel-add-words-btn" class="bg-gray-200 text-gray-700 font-bold py-2 px-6 rounded-full hover:bg-gray-300">取消</button> | |
| <button id="add-words-btn" class="bg-indigo-600 text-white font-bold py-2 px-6 rounded-full hover:bg-indigo-700">確認新增</button> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4"> | |
| <button id="show-add-words-section-btn" class="w-full bg-green-500 text-white font-bold py-3 rounded-full hover:bg-green-600 transition-all">+ 新增單字</button> | |
| <button id="clear-all-words-btn" class="w-full bg-red-600 text-white font-bold py-3 rounded-full hover:bg-red-700 transition-all">🗑️ 清除所有單字</button> | |
| </div> | |
| <!-- 單字列表容器 --> | |
| <div id="word-list-container" class="space-y-2 max-h-[50vh] overflow-y-auto pr-2"> | |
| <!-- 單字會動態生成於此 --> | |
| </div> | |
| </div> | |
| <!-- 學習介面 (預設隱藏) --> | |
| <div id="learning-mode" class="hidden w-full max-w-2xl flex flex-col items-center transition-all duration-500"> | |
| <div class="w-full flex justify-between items-center mb-2"> | |
| <button id="back-to-menu-btn" class="bg-gray-200 text-gray-800 p-3 rounded-full hover:bg-gray-300 transition-colors"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg> | |
| </button> | |
| <h1 id="mode-title" class="text-3xl font-extrabold text-center text-gray-900"></h1> | |
| <!-- 計時挑戰計分板 --> | |
| <div id="speed-hud" class="hidden text-right"> | |
| <p id="timer-display" class="text-2xl font-bold text-rose-500">60</p> | |
| <p id="score-display" class="text-lg font-semibold text-gray-700">得分: 0</p> | |
| </div> | |
| <div class="w-12 sm:w-20" id="hud-placeholder"></div> <!-- 用於對齊的空白 div --> | |
| </div> | |
| <!-- 測驗進度條 --> | |
| <div id="progress-bar-container" class="w-full max-w-lg my-4 hidden"> | |
| <div class="bg-gray-200 rounded-full h-3"> | |
| <div id="progress-bar" class="bg-teal-500 h-3 rounded-full transition-all duration-300 ease-in-out" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| <!-- 閃卡主顯示區 --> | |
| <div class="w-full max-w-lg h-80 sm:h-72 flex-grow flex items-center justify-center relative"> | |
| <div id="flashcard-container" class="flip-container w-full h-full transform transition-transform duration-300"> | |
| <div class="flipper w-full h-full"> | |
| <div class="front flex flex-col justify-center items-center"> | |
| <div id="cloze-question-container" class="hidden w-full text-center"> | |
| <p id="sentence-en-display" class="text-3xl mb-2"></p> | |
| <div class="text-center"> | |
| <button id="toggle-translation-btn" class="text-sm bg-white/20 hover:bg-white/30 text-white py-1 px-3 rounded-full mb-2">顯示翻譯</button> | |
| <p id="sentence-zh-display" class="text-xl font-light hidden"></p> | |
| </div> | |
| </div> | |
| <span id="front-display" class="text-5xl font-bold text-center"></span> | |
| <div class="absolute bottom-6 right-6 flex items-center gap-2"> | |
| <button id="speak-slow-btn" class="speak-btn p-4 rounded-full bg-white/30 hover:bg-white/50 transition-colors hidden"> | |
| <span class="h-8 w-8 flex items-center justify-center text-white text-2xl font-bold">慢</span> | |
| </button> | |
| <button id="speak-btn" class="speak-btn p-4 rounded-full bg-white/30 hover:bg-white/50 transition-colors hidden"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="back flex flex-col justify-center items-center"> | |
| <span id="back-display" class="text-3xl font-light text-center px-4"></span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- [修正] 移除舊的隱藏提示區塊,將 hint-display 移至下方 quiz-container 內 --> | |
| <!-- [第三步] 插入手寫板介面 (取代舊的 input form) --> | |
| <div id="quiz-container" class="w-full max-w-md mt-4"> | |
| <div class="flex flex-col gap-4"> | |
| <input id="answer-input" placeholder="輸入你的答案 (按 Enter 提交)" class="w-full p-4 border-2 border-gray-300 rounded-xl text-2xl focus:border-indigo-500 focus:ring-indigo-500 transition-colors" autocomplete="off"> | |
| <div id="handwriting-container" class="hidden flex-col gap-2"> | |
| <!-- 使用 wrapper 包裹 canvas,並強制設定固定高度,防止高度塌陷 --> | |
| <!-- [修改] 改為 flex 容器以容納多個畫布 --> | |
| <div id="canvas-wrapper" class="relative w-full min-h-[250px] flex flex-wrap gap-2 justify-center items-center"> | |
| <!-- Canvas 將會由 JS 動態生成插入 --> | |
| </div> | |
| <!-- 候選字列 --> | |
| <div id="candidate-bar" class="hidden"></div> | |
| <div class="flex gap-2 w-full"> | |
| <button id="hw-clear-btn" class="flex-1 py-3 bg-gray-200 text-gray-700 rounded-xl font-semibold hover:bg-gray-300 transition-colors flex items-center justify-center gap-1"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" /></svg> | |
| 重寫 | |
| </button> | |
| <button id="hw-undo-btn" class="flex-1 py-3 bg-yellow-100 text-yellow-700 rounded-xl font-semibold hover:bg-yellow-200 transition-colors flex items-center justify-center gap-1"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" /></svg> | |
| 復原 | |
| </button> | |
| <button id="hw-recognize-btn" class="flex-[2] py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 shadow-md transition-colors flex items-center justify-center gap-1"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg> | |
| 辨識 (TrOCR AI) | |
| </button> | |
| </div> | |
| </div> | |
| <button id="submit-answer-btn" class="w-full bg-indigo-500 text-white p-4 rounded-xl text-xl font-bold hover:bg-indigo-600 transition-colors">提交答案</button> | |
| <div class="flex flex-col items-center gap-2"> | |
| <button id="hint-btn" class="w-full bg-yellow-500 text-white p-3 rounded-xl text-lg font-bold hover:bg-yellow-600 transition-colors hidden">顯示提示</button> | |
| <!-- [修正] hint-display 移到這裡,確保它在可見區域 --> | |
| <p id="hint-display" class="text-gray-600 font-semibold min-h-[1.5rem]"></p> | |
| </div> | |
| </div> | |
| <div id="feedback-display" class="text-center mt-1 h-6 font-semibold"></div> | |
| </div> | |
| <!-- 答錯確認區塊 (間隔重複模式使用) --> | |
| <div id="wrong-answer-feedback" class="hidden w-full max-w-md mt-4 flex flex-col items-center"> | |
| <p class="text-red-500 font-bold mb-4">答錯了!請記住正確答案,稍後會再出現。</p> | |
| <button id="confirm-wrong-btn" class="bg-amber-500 text-white font-bold py-3 px-8 rounded-full hover:bg-amber-600 transition-colors"> | |
| 下一題 | |
| </button> | |
| </div> | |
| <!-- 極速挑戰結束畫面 --> | |
| <div id="speed-result-view" class="hidden w-full max-w-md mt-4 flex flex-col items-center text-center"> | |
| <h2 class="text-3xl font-bold">時間到!</h2> | |
| <p class="text-xl mt-2">你的得分是:<span id="final-score" class="font-bold text-indigo-600 text-2xl"></span></p> | |
| <p id="new-highscore-msg" class="text-amber-500 font-semibold mt-1"></p> | |
| <button id="play-again-btn" class="mt-6 bg-slate-700 text-white font-bold py-3 px-8 rounded-full hover:bg-slate-800 transition-colors"> | |
| 再玩一次 | |
| </button> | |
| </div> | |
| <!-- 測驗完成提示訊息 --> | |
| <div id="quiz-completion-message" class="hidden w-full max-w-md mt-4 flex flex-col items-center text-center p-6 bg-white rounded-2xl shadow-lg"> | |
| <h2 class="text-3xl font-bold text-green-600">測驗完成!</h2> | |
| <p class="mt-4 text-lg text-gray-700">成績已記錄,即將返回主選單...</p> | |
| </div> | |
| <!-- 複習模式導覽 --> | |
| <div id="review-nav-container" class="flex items-center justify-between w-full max-w-md mt-10 space-x-4"> | |
| <button id="prev-btn" class="nav-btn"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg> | |
| </button> | |
| <span id="progress-display" class="text-xl font-bold text-gray-700"></span> | |
| <button id="next-btn" class="nav-btn bg-indigo-600 text-white hover:bg-indigo-700"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 密碼輸入 Modal --> | |
| <div id="password-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4"> | |
| <div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-sm"> | |
| <h3 class="text-2xl font-bold mb-4 text-gray-800">需要管理員密碼</h3> | |
| <p class="text-gray-600 mb-6">請輸入密碼以進入單字管理頁面。</p> | |
| <form id="password-form"> | |
| <input id="password-input" type="password" class="w-full p-3 border border-gray-300 rounded-lg text-lg focus:ring-4 focus:ring-indigo-200 focus:border-indigo-500" placeholder="請輸入密碼..."> | |
| <p id="password-error" class="text-red-500 text-sm h-5 mt-2"></p> | |
| <div class="flex justify-end gap-4 mt-6"> | |
| <button type="button" id="cancel-password-btn" class="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">取消</button> | |
| <button type="submit" class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">確認</button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| <!-- 編輯單字 Modal --> | |
| <div id="edit-word-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4"> | |
| <div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-lg"> | |
| <h3 class="text-2xl font-bold mb-6 text-gray-800">編輯單字</h3> | |
| <div class="space-y-4"> | |
| <input type="hidden" id="edit-word-index"> | |
| <div> | |
| <label for="edit-english-input" class="block text-sm font-semibold text-gray-700 mb-1">英文</label> | |
| <input id="edit-english-input" type="text" class="w-full p-3 border border-gray-300 rounded-lg text-lg"> | |
| </div> | |
| <div> | |
| <label for="edit-chinese-input" class="block text-sm font-semibold text-gray-700 mb-1">中文</label> | |
| <input id="edit-chinese-input" type="text" class="w-full p-3 border border-gray-300 rounded-lg text-lg"> | |
| </div> | |
| <div> | |
| <label for="edit-sentence-en-input" class="block text-sm font-semibold text-gray-700 mb-1">英文例句 (用 ___ 代表單字)</label> | |
| <input id="edit-sentence-en-input" type="text" class="w-full p-3 border border-gray-300 rounded-lg text-lg"> | |
| </div> | |
| <div> | |
| <label for="edit-sentence-zh-input" class="block text-sm font-semibold text-gray-700 mb-1">中文例句</label> | |
| <input id="edit-sentence-zh-input" type="text" class="w-full p-3 border border-gray-300 rounded-lg text-lg"> | |
| </div> | |
| <!-- 【修改】新增例句答案輸入框 --> | |
| <div> | |
| <label for="edit-sentence-answer-input" class="block text-sm font-semibold text-gray-700 mb-1">例句正確答案 (克漏字用)</label> | |
| <input id="edit-sentence-answer-input" type="text" class="w-full p-3 border border-gray-300 rounded-lg text-lg bg-yellow-50" placeholder="例如 watches, jogging..."> | |
| </div> | |
| </div> | |
| <div class="flex justify-end gap-4 mt-8"> | |
| <button type="button" id="cancel-edit-btn" class="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">取消</button> | |
| <button type="button" id="save-edit-btn" class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">儲存</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- AI 解析確認 Modal --> | |
| <div id="parse-confirm-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4"> | |
| <div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-2xl font-bold text-gray-800">AI 解析結果預覽</h3> | |
| <div class="flex items-center"> | |
| <input type="checkbox" id="select-all-checkbox" class="h-4 w-4 rounded text-indigo-600 focus:ring-indigo-500 border-gray-300 mr-2"> | |
| <label for="select-all-checkbox" class="text-sm font-medium text-gray-700">全選/取消全選</label> | |
| </div> | |
| </div> | |
| <p class="text-gray-600 mb-6">請勾選您要新增至單字庫的項目。</p> | |
| <div id="parse-results-container" class="flex-grow overflow-y-auto space-y-2 pr-2 border-t pt-4"> | |
| <!-- AI 解析結果將會顯示於此 --> | |
| </div> | |
| <div class="flex justify-end gap-4 mt-8 pt-4 border-t"> | |
| <button type="button" id="cancel-parse-btn" class="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">取消</button> | |
| <button type="button" id="confirm-parse-btn" class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">確認新增</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 分享連結 Modal --> | |
| <div id="share-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4"> | |
| <div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-lg"> | |
| <h3 class="text-2xl font-bold mb-4 text-gray-800">分享遊戲連結</h3> | |
| <p class="text-gray-600 mb-6">請選擇分享選項,然後產生連結。</p> | |
| <div class="my-4"> | |
| <label class="flex items-center text-lg"> | |
| <input type="checkbox" id="lock-settings-checkbox" class="h-5 w-5 rounded text-indigo-600 focus:ring-indigo-500 border-gray-300"> | |
| <span class="ml-3 text-gray-700">禁止學生變更設定 (鎖定範圍與題數)</span> | |
| </label> | |
| </div> | |
| <button id="generate-link-btn" class="w-full mb-4 bg-indigo-600 text-white font-bold py-3 rounded-lg hover:bg-indigo-700 transition-colors disabled:bg-indigo-400"> | |
| 產生分享連結 | |
| </button> | |
| <div id="share-result-container" class="hidden"> | |
| <p class="text-sm text-gray-500 mb-2">連結已產生:</p> | |
| <div class="flex items-center gap-2"> | |
| <input id="share-link-input" type="text" readonly class="w-full p-3 border border-gray-300 rounded-lg bg-gray-100"> | |
| <button id="copy-link-btn" class="px-6 py-3 bg-violet-600 text-white rounded-lg hover:bg-violet-700 shrink-0">複製</button> | |
| </div> | |
| <p id="copy-feedback" class="text-green-600 text-sm h-5 mt-2 text-center font-semibold"></p> | |
| <div id="qrcode-container" class="mt-4 flex justify-center"></div> | |
| </div> | |
| <div class="flex justify-end mt-4"> | |
| <button type="button" id="close-share-modal-btn" class="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">關閉</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 成績報告 Modal --> | |
| <div id="grade-report-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4"> | |
| <div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-4xl"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h3 class="text-3xl font-bold text-gray-800">成績報告</h3> | |
| <button type="button" id="close-report-modal-btn" class="p-2 rounded-full hover:bg-gray-200 transition-colors"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg> | |
| </button> | |
| </div> | |
| <div id="report-content" class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| <!-- Report for zh-en --> | |
| <div class="p-4 bg-teal-50 rounded-lg border border-teal-200"> | |
| <h4 class="text-xl font-bold text-teal-800 mb-4">中翻英測驗</h4> | |
| <div id="report-zh-en" class="space-y-3 text-left text-gray-700 max-h-80 overflow-y-auto pr-2"> | |
| <!-- Content will be generated by JS --> | |
| </div> | |
| </div> | |
| <!-- Report for en-zh --> | |
| <div class="p-4 bg-amber-50 rounded-lg border border-amber-200"> | |
| <h4 class="text-xl font-bold text-amber-800 mb-4">英翻中測驗</h4> | |
| <div id="report-en-zh" class="space-y-3 text-left text-gray-700 max-h-80 overflow-y-auto pr-2"> | |
| <!-- Content will be generated by JS --> | |
| </div> | |
| </div> | |
| <!-- Report for listen --> | |
| <div class="p-4 bg-rose-50 rounded-lg border border-rose-200"> | |
| <h4 class="text-xl font-bold text-rose-800 mb-4">聽力測驗</h4> | |
| <div id="report-listen" class="space-y-3 text-left text-gray-700 max-h-80 overflow-y-auto pr-2"> | |
| <!-- Content will be generated by JS --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 清除確認 Modal --> | |
| <div id="confirm-clear-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4"> | |
| <div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-sm"> | |
| <h3 class="text-2xl font-bold mb-4 text-gray-800">確認操作</h3> | |
| <p class="text-gray-600 mb-6">您確定要清除所有單字和成績紀錄嗎?此操作無法復原。</p> | |
| <div class="flex justify-end gap-4 mt-6"> | |
| <button type="button" id="cancel-clear-btn" class="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">取消</button> | |
| <button type="button" id="confirm-clear-btn" class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">確認清除</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 單字刪除確認 Modal --> | |
| <div id="confirm-delete-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-50 p-4"> | |
| <div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-sm"> | |
| <h3 class="text-2xl font-bold mb-4 text-gray-800">確認刪除</h3> | |
| <p class="text-gray-600 mb-6">您確定要刪除單字 "<span id="word-to-delete" class="font-bold"></span>" 嗎?此操作無法復原。</p> | |
| <div class="flex justify-end gap-4 mt-6"> | |
| <button type="button" id="cancel-delete-btn" class="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">取消</button> | |
| <button type="button" id="confirm-delete-btn" class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">確認刪除</button> | |
| </div> | |
| </div> | |
| </div> | |
| <audio id="correct-sound" src="correct.mp3" preload="auto"></audio> | |
| <audio id="wrong-sound" src="wrong.mp3" preload="auto"></audio> | |
| <audio id="api-audio-player" preload="auto"></audio> | |
| <footer class="fixed bottom-4 right-4 text-xs text-gray-500 text-right z-50"> | |
| <p>遊戲設計者:新竹縣精華國中藍星宇</p> | |
| <p>FB教育社群:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:underline">萬物皆數</a></p> | |
| </footer> | |
| <script type="module"> | |
| // This is a placeholder for the Firebase SDK script in the head | |
| // The main logic is in the script tag below | |
| </script> | |
| <script> | |
| // [第四步] JavaScript 邏輯 (Script) | |
| let effectiveVersion = 'pc'; // 預設為 PC 版 | |
| // [修改] 移除單一畫布變數,改用陣列管理多個畫布實例 | |
| let isDrawing = false; | |
| // hwInstances 存放物件: { id, element, ctx, strokes: [], history: [] } | |
| let hwInstances = []; | |
| let activeHwIndex = 0; // 當前選取的畫布索引 | |
| // [新增] TrOCR 相關變數 | |
| let ocrPipeline = null; // 存放載入好的模型 | |
| let isModelLoading = false; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // DOM 元素 | |
| const mainMenu = document.getElementById('main-menu'); | |
| const mainTitle = document.getElementById('main-title'); | |
| const learningMode = document.getElementById('learning-mode'); | |
| const modeSelectionSection = document.getElementById('mode-selection-section'); | |
| const highscoreDisplay = document.getElementById('highscore-display'); | |
| const settingsContainer = document.getElementById('settings-container'); | |
| const startRangeInput = document.getElementById('start-range'); | |
| const endRangeInput = document.getElementById('end-range'); | |
| const teacherTools = document.getElementById('teacher-tools'); | |
| const manageWordsBtn = document.getElementById('manage-words-btn'); | |
| const shareGameBtn = document.getElementById('share-game-btn'); | |
| const gradeReportBtn = document.getElementById('grade-report-btn'); | |
| const randomQuestionsCheckbox = document.getElementById('random-questions-checkbox'); | |
| const randomQuestionsCountInput = document.getElementById('random-questions-count'); | |
| const resetCrownsBtn = document.getElementById('reset-crowns-btn'); | |
| // 單字管理介面 | |
| const wordManagementView = document.getElementById('word-management-view'); | |
| const bookInput = document.getElementById('book-input'); | |
| const lessonInput = document.getElementById('lesson-input'); | |
| const saveTitleBtn = document.getElementById('save-title-btn'); | |
| const backToModesFromManageBtn = document.getElementById('back-to-modes-from-manage-btn'); | |
| const addWordsSection = document.getElementById('add-words-section'); | |
| const showAddWordsSectionBtn = document.getElementById('show-add-words-section-btn'); | |
| const clearAllWordsBtn = document.getElementById('clear-all-words-btn'); | |
| const englishWordsInput = document.getElementById('english-words'); | |
| const chineseExplanationsInput = document.getElementById('chinese-explanations'); | |
| const errorMessage = document.getElementById('error-message'); | |
| const addWordsBtn = document.getElementById('add-words-btn'); | |
| const cancelAddWordsBtn = document.getElementById('cancel-add-words-btn'); | |
| const wordListContainer = document.getElementById('word-list-container'); | |
| const apiKeyInput = document.getElementById('api-key-input'); | |
| const saveApiKeyBtn = document.getElementById('save-api-key-btn'); | |
| const pdfUploadInput = document.getElementById('pdf-upload-input'); | |
| const parsePdfBtn = document.getElementById('parse-pdf-btn'); | |
| const parseStatus = document.getElementById('parse-status'); | |
| const aiProgressContainer = document.getElementById('ai-progress-container'); | |
| const aiProgressBarInner = document.getElementById('ai-progress-bar-inner'); | |
| // [新增] 自動偵測裝置類型 | |
| function checkDeviceType() { | |
| const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); | |
| const isMobileUserAgent = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); | |
| // 如果是行動裝置 UserAgent 或者 (有觸控功能且螢幕寬度小於 1024px,排除大螢幕觸控筆電) | |
| if (isMobileUserAgent || (isTouch && window.innerWidth < 1024)) { | |
| return 'mobile'; | |
| } | |
| return 'pc'; | |
| } | |
| effectiveVersion = checkDeviceType(); // 啟動時自動判定 | |
| console.log("偵測到裝置模式:", effectiveVersion); | |
| // 學習介面 | |
| const flashcardContainer = document.getElementById('flashcard-container'); | |
| const frontDisplay = document.getElementById('front-display'); | |
| const backDisplay = document.getElementById('back-display'); | |
| const clozeQuestionContainer = document.getElementById('cloze-question-container'); | |
| const sentenceEnDisplay = document.getElementById('sentence-en-display'); | |
| const sentenceZhDisplay = document.getElementById('sentence-zh-display'); | |
| const toggleTranslationBtn = document.getElementById('toggle-translation-btn'); | |
| const speakBtn = document.getElementById('speak-btn'); | |
| const speakSlowBtn = document.getElementById('speak-slow-btn'); | |
| const modeTitle = document.getElementById('mode-title'); | |
| const backToMenuBtn = document.getElementById('back-to-menu-btn'); | |
| const progressBarContainer = document.getElementById('progress-bar-container'); | |
| const progressBar = document.getElementById('progress-bar'); | |
| const audioPreloadContainer = document.getElementById('audio-preload-container'); | |
| const audioPreloadBar = document.getElementById('audio-preload-bar'); | |
| const audioPreloadText = document.getElementById('audio-preload-text'); | |
| // 測驗相關 | |
| const quizContainer = document.getElementById('quiz-container'); | |
| // 注意:原本的 quizForm 已經被移除,改用直接監聽按鈕 | |
| const answerInput = document.getElementById('answer-input'); | |
| const submitAnswerBtn = document.getElementById('submit-answer-btn'); // [新增] | |
| const feedbackDisplay = document.getElementById('feedback-display'); | |
| const wrongAnswerFeedback = document.getElementById('wrong-answer-feedback'); | |
| const confirmWrongBtn = document.getElementById('confirm-wrong-btn'); | |
| // const hintSection = document.getElementById('hint-section'); // 舊的提示區塊,不再使用 | |
| const hintBtn = document.getElementById('hint-btn'); | |
| const hintDisplay = document.getElementById('hint-display'); // 這裡暫時共用舊的 display 元素或需修改 | |
| // 為了配合新版介面,建議將提示顯示區域也移動到新區塊內,但為了最小修改,我們這裡用 JS 控制 | |
| const quizCompletionMessage = document.getElementById('quiz-completion-message'); | |
| // 手寫功能按鈕 | |
| const hwClearBtn = document.getElementById('hw-clear-btn'); | |
| const hwUndoBtn = document.getElementById('hw-undo-btn'); | |
| const hwRecognizeBtn = document.getElementById('hw-recognize-btn'); | |
| const candidateBar = document.getElementById('candidate-bar'); | |
| // 複習模式導覽 | |
| const reviewNavContainer = document.getElementById('review-nav-container'); | |
| const prevBtn = document.getElementById('prev-btn'); | |
| const nextBtn = document.getElementById('next-btn'); | |
| const progressDisplay = document.getElementById('progress-display'); | |
| // 極速挑戰 | |
| const speedHud = document.getElementById('speed-hud'); | |
| const hudPlaceholder = document.getElementById('hud-placeholder'); | |
| const timerDisplay = document.getElementById('timer-display'); | |
| const scoreDisplay = document.getElementById('score-display'); | |
| const speedResultView = document.getElementById('speed-result-view'); | |
| const finalScore = document.getElementById('final-score'); | |
| const newHighscoreMsg = document.getElementById('new-highscore-msg'); | |
| const playAgainBtn = document.getElementById('play-again-btn'); | |
| // Modals | |
| const passwordModal = document.getElementById('password-modal'); | |
| const passwordForm = document.getElementById('password-form'); | |
| const passwordInput = document.getElementById('password-input'); | |
| const passwordError = document.getElementById('password-error'); | |
| const cancelPasswordBtn = document.getElementById('cancel-password-btn'); | |
| const editWordModal = document.getElementById('edit-word-modal'); | |
| const editWordIndexInput = document.getElementById('edit-word-index'); | |
| const editEnglishInput = document.getElementById('edit-english-input'); | |
| const editChineseInput = document.getElementById('edit-chinese-input'); | |
| const editSentenceEnInput = document.getElementById('edit-sentence-en-input'); | |
| const editSentenceZhInput = document.getElementById('edit-sentence-zh-input'); | |
| const editSentenceAnswerInput = document.getElementById('edit-sentence-answer-input'); // 【新增】 | |
| const saveEditBtn = document.getElementById('save-edit-btn'); | |
| const cancelEditBtn = document.getElementById('cancel-edit-btn'); | |
| const shareModal = document.getElementById('share-modal'); | |
| const lockSettingsCheckbox = document.getElementById('lock-settings-checkbox'); | |
| const generateLinkBtn = document.getElementById('generate-link-btn'); | |
| const shareResultContainer = document.getElementById('share-result-container'); | |
| const shareLinkInput = document.getElementById('share-link-input'); | |
| const copyLinkBtn = document.getElementById('copy-link-btn'); | |
| const copyFeedback = document.getElementById('copy-feedback'); | |
| const closeShareModalBtn = document.getElementById('close-share-modal-btn'); | |
| const qrcodeContainer = document.getElementById('qrcode-container'); | |
| const gradeReportModal = document.getElementById('grade-report-modal'); | |
| const closeReportModalBtn = document.getElementById('close-report-modal-btn'); | |
| const reportZhEn = document.getElementById('report-zh-en'); | |
| const reportEnZh = document.getElementById('report-en-zh'); | |
| const reportListen = document.getElementById('report-listen'); | |
| const confirmClearModal = document.getElementById('confirm-clear-modal'); | |
| const cancelClearBtn = document.getElementById('cancel-clear-btn'); | |
| const confirmClearBtn = document.getElementById('confirm-clear-btn'); | |
| const confirmDeleteModal = document.getElementById('confirm-delete-modal'); | |
| const wordToDeleteSpan = document.getElementById('word-to-delete'); | |
| const cancelDeleteBtn = document.getElementById('cancel-delete-btn'); | |
| const confirmDeleteBtn = document.getElementById('confirm-delete-btn'); | |
| const parseConfirmModal = document.getElementById('parse-confirm-modal'); | |
| const parseResultsContainer = document.getElementById('parse-results-container'); | |
| const cancelParseBtn = document.getElementById('cancel-parse-btn'); | |
| const confirmParseBtn = document.getElementById('confirm-parse-btn'); | |
| const selectAllCheckbox = document.getElementById('select-all-checkbox'); | |
| // 音效元素 | |
| const correctSound = document.getElementById('correct-sound'); | |
| const wrongSound = document.getElementById('wrong-sound'); | |
| const apiAudioPlayer = document.getElementById('api-audio-player'); | |
| // 應用程式狀態 | |
| let words = []; | |
| let quizQueue = []; | |
| let wordsForCurrentMode = []; | |
| let gradeReports = {}; | |
| let quizIncorrectCount = 0; | |
| let currentCardIndex = 0; | |
| let currentMode = ''; | |
| let completionStatus = {}; | |
| let bookTitle = ''; | |
| let lessonTitle = ''; | |
| let indexToDelete = -1; | |
| const MANAGE_PASSWORD = 'Ghjh'; | |
| let highScore = 0; | |
| let currentScore = 0; | |
| let timerInterval; | |
| let timeLeft = 60; | |
| let currentSpeedCard = null; | |
| let currentSpeedQuestionType = ''; | |
| let quizStartTime = 0; | |
| let parsedWordsFromAI = []; | |
| // 用來存放多重手寫板的結構,例如 ['cheer', '...', 'on'] | |
| let currentHandwritingStructure = []; | |
| const modeDetails = { | |
| 'review': { title: '複習模式' }, 'zh-en': { title: '中翻英測驗' }, | |
| 'en-zh': { title: '英翻中測驗' }, 'listen': { title: '聽力測驗' }, | |
| 'speed': { title: '極速挑戰' }, 'hard': { title: '困難單字複習' }, | |
| 'sentence-cloze': { title: '情境克漏字' }, | |
| }; | |
| // [新增] Toast 通知函式 | |
| function showToast(message) { | |
| const toast = document.getElementById('toast'); | |
| toast.textContent = message; | |
| toast.classList.add('show'); | |
| setTimeout(() => { | |
| toast.classList.remove('show'); | |
| }, 3000); | |
| } | |
| // [修改] 核心重寫:初始化多重手寫板功能 | |
| function initMultiHandwritingBoard(structure) { | |
| const wrapper = document.getElementById('canvas-wrapper'); | |
| if (!wrapper) return; | |
| // 重置狀態 | |
| wrapper.innerHTML = ''; | |
| hwInstances = []; | |
| activeHwIndex = 0; | |
| // 根據傳入的結構 (例如 ['cheer', '...', 'on'] 或 ['come', 'true']) 建立手寫板 | |
| structure.forEach((part, index) => { | |
| if (part === '...') { | |
| // 插入省略號文字 | |
| const ellipsis = document.createElement('div'); | |
| ellipsis.className = 'text-3xl font-bold text-gray-400 mx-1 select-none w-full text-center py-2'; // [修改] 加寬與置中 | |
| ellipsis.textContent = '...'; | |
| wrapper.appendChild(ellipsis); | |
| // 省略號不佔用 hwInstances 索引 | |
| } else { | |
| // 建立 Canvas 容器 | |
| const container = document.createElement('div'); | |
| container.className = 'canvas-container relative'; | |
| // [修改] 強制設定寬度為 100%,讓多個畫布垂直堆疊,保持最大書寫空間 | |
| container.style.width = '100%'; | |
| container.style.height = '250px'; | |
| container.style.marginBottom = '10px'; // 畫布之間增加一點間距 | |
| // 建立 Canvas | |
| const canvas = document.createElement('canvas'); | |
| canvas.dataset.index = hwInstances.length; // 暫存索引 | |
| canvas.style.width = '100%'; | |
| canvas.style.height = '100%'; | |
| canvas.style.cursor = 'crosshair'; | |
| canvas.style.touchAction = 'none'; | |
| container.appendChild(canvas); | |
| wrapper.appendChild(container); | |
| // 等待 DOM 渲染後設定 Canvas 實際解析度 | |
| // 使用 requestAnimationFrame 確保樣式套用後再讀取尺寸 | |
| requestAnimationFrame(() => { | |
| const rect = container.getBoundingClientRect(); | |
| canvas.width = rect.width; | |
| canvas.height = 250; // 固定高度 | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| // 填滿白色背景 | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.lineWidth = 10; | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| ctx.strokeStyle = '#000000'; | |
| // 存入實例陣列 | |
| // 注意:由於 requestAnimationFrame 是非同步,這裡直接 push 可能會順序錯亂 | |
| // 但在這個簡單情境下,通常還好。嚴謹做法是預先建立物件佔位。 | |
| // 這裡我們直接修改已存在的物件引用 (稍微複雜,簡單點做) | |
| // 修正:同步建立物件,非同步設定 Context | |
| // 但為了簡化,我們在建立 element 時就同步 push 到 array | |
| }); | |
| // 同步建立資料結構 | |
| const instance = { | |
| id: hwInstances.length, | |
| element: canvas, | |
| container: container, | |
| ctx: null, // 稍後初始化 | |
| strokes: [], | |
| currentStroke: {x:[], y:[], t:[]} // 暫存筆畫 | |
| }; | |
| hwInstances.push(instance); | |
| // 延遲初始化 Context (確保 width 正確) | |
| setTimeout(() => { | |
| const rect = container.getBoundingClientRect(); | |
| if (rect.width > 0) { | |
| canvas.width = rect.width; | |
| canvas.height = 250; | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.lineWidth = 10; | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| ctx.strokeStyle = '#000000'; | |
| instance.ctx = ctx; | |
| // 預設第一個畫布為啟用狀態 | |
| if (instance.id === 0) setActiveCanvas(0); | |
| } | |
| }, 50); | |
| // 綁定事件 | |
| bindCanvasEvents(canvas, instance.id); | |
| } | |
| }); | |
| console.log('Multi-Handwriting board initialized with parts:', structure); | |
| } | |
| // 設定當前活躍的 Canvas | |
| function setActiveCanvas(index) { | |
| if (index < 0 || index >= hwInstances.length) return; | |
| activeHwIndex = index; | |
| // 更新視覺效果 | |
| hwInstances.forEach(inst => { | |
| if (inst.id === index) { | |
| inst.container.classList.add('active-canvas'); | |
| } else { | |
| inst.container.classList.remove('active-canvas'); | |
| } | |
| }); | |
| } | |
| // 綁定單一 Canvas 的事件 | |
| function bindCanvasEvents(canvas, index) { | |
| const start = (e) => { | |
| if (e.type === 'touchstart') e.preventDefault(); | |
| setActiveCanvas(index); | |
| startStroke(e, index); | |
| }; | |
| const move = (e) => { | |
| if (e.type === 'touchmove') e.preventDefault(); | |
| moveStroke(e, index); | |
| }; | |
| const end = (e) => { | |
| endStroke(e, index); | |
| }; | |
| canvas.addEventListener('mousedown', start); | |
| canvas.addEventListener('mousemove', move); | |
| canvas.addEventListener('mouseup', end); | |
| canvas.addEventListener('mouseleave', end); | |
| canvas.addEventListener('touchstart', start, {passive: false}); | |
| canvas.addEventListener('touchmove', move, {passive: false}); | |
| canvas.addEventListener('touchend', end); | |
| // 點擊容器也能啟用 | |
| canvas.parentElement.addEventListener('click', () => setActiveCanvas(index)); | |
| } | |
| // [修改] 筆畫開始 | |
| function startStroke(e, index) { | |
| isDrawing = true; | |
| const inst = hwInstances[index]; | |
| if (!inst || !inst.ctx) return; | |
| const pos = getPos(e, inst.element); | |
| inst.currentStroke = { x: [pos.x], y: [pos.y], t: [Date.now()] }; | |
| inst.ctx.beginPath(); | |
| inst.ctx.moveTo(pos.x, pos.y); | |
| inst.container.classList.add('canvas-active-interaction'); | |
| } | |
| // [修改] 筆畫移動 | |
| function moveStroke(e, index) { | |
| if (!isDrawing) return; | |
| const inst = hwInstances[index]; | |
| if (!inst || !inst.ctx) return; | |
| const pos = getPos(e, inst.element); | |
| inst.currentStroke.x.push(pos.x); | |
| inst.currentStroke.y.push(pos.y); | |
| inst.currentStroke.t.push(Date.now()); | |
| inst.ctx.lineTo(pos.x, pos.y); | |
| inst.ctx.stroke(); | |
| } | |
| // [修改] 筆畫結束 | |
| function endStroke(e, index) { | |
| if (!isDrawing) return; | |
| isDrawing = false; | |
| const inst = hwInstances[index]; | |
| if (!inst || !inst.ctx) return; | |
| inst.ctx.closePath(); | |
| if (inst.currentStroke.x.length > 0) { | |
| inst.strokes.push([ | |
| inst.currentStroke.x, | |
| inst.currentStroke.y, | |
| inst.currentStroke.t | |
| ]); | |
| } | |
| inst.container.classList.remove('canvas-active-interaction'); | |
| } | |
| // [修改] 取得座標 (需傳入特定 canvas 元素) | |
| function getPos(e, canvas) { | |
| const rect = canvas.getBoundingClientRect(); | |
| let clientX, clientY; | |
| if (e.touches && e.touches.length > 0) { | |
| clientX = e.touches[0].clientX; | |
| clientY = e.touches[0].clientY; | |
| } else { | |
| clientX = e.clientX; | |
| clientY = e.clientY; | |
| } | |
| return { | |
| x: clientX - rect.left, | |
| y: clientY - rect.top | |
| }; | |
| } | |
| // [修改] 清空畫布 (支援清除全部) | |
| function clearCanvas(forceClearAll = false) { | |
| // 如果是按鈕點擊事件 (Event) 或者 forceClearAll 為 true,則清除所有 | |
| const shouldClearAll = (forceClearAll === true) || (forceClearAll instanceof Event); | |
| if (shouldClearAll) { | |
| hwInstances.forEach(inst => { | |
| if (inst.ctx) { | |
| inst.ctx.fillStyle = '#ffffff'; | |
| inst.ctx.fillRect(0, 0, inst.element.width, inst.element.height); | |
| inst.strokes = []; | |
| } | |
| }); | |
| answerInput.value = ''; | |
| } else { | |
| // 只清除當前 (保留給特定邏輯使用) | |
| const inst = hwInstances[activeHwIndex]; | |
| if(!inst || !inst.ctx) return; | |
| inst.ctx.fillStyle = '#ffffff'; | |
| inst.ctx.fillRect(0, 0, inst.element.width, inst.element.height); | |
| inst.strokes = []; | |
| if (hwInstances.length === 1) { | |
| answerInput.value = ''; | |
| } | |
| } | |
| candidateBar.innerHTML = ''; | |
| candidateBar.classList.add('hidden'); | |
| } | |
| // [修改] 復原當前畫布上一筆 | |
| function undoStroke() { | |
| const inst = hwInstances[activeHwIndex]; | |
| if (!inst || inst.strokes.length === 0) return; | |
| inst.strokes.pop(); | |
| redrawCanvas(inst); | |
| } | |
| // [修改] 重繪特定畫布 | |
| function redrawCanvas(inst) { | |
| if(!inst || !inst.ctx) return; | |
| inst.ctx.fillStyle = '#ffffff'; | |
| inst.ctx.fillRect(0, 0, inst.element.width, inst.element.height); | |
| inst.ctx.beginPath(); | |
| inst.strokes.forEach(stroke => { | |
| const xs = stroke[0]; | |
| const ys = stroke[1]; | |
| if (xs.length > 0) { | |
| inst.ctx.moveTo(xs[0], ys[0]); | |
| for (let i = 1; i < xs.length; i++) { | |
| inst.ctx.lineTo(xs[i], ys[i]); | |
| } | |
| } | |
| }); | |
| inst.ctx.stroke(); | |
| } | |
| // [新增] 載入 TrOCR AI 模型 (使用 dynamic import) | |
| async function initOCR() { | |
| if (ocrPipeline) return; // 已載入 | |
| if (isModelLoading) return; // 載入中 | |
| isModelLoading = true; | |
| showToast("正在下載 AI 手寫模型 (首次需約 200MB)..."); | |
| try { | |
| // 使用動態導入載入 Transformers.js | |
| const { pipeline, env } = await import('https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2'); | |
| // 設定環境:不使用本地模型 (因為是網頁版),強制使用 Browser Cache | |
| env.allowLocalModels = false; | |
| env.useBrowserCache = true; | |
| // 載入 TrOCR 模型 (手寫文字辨識) | |
| // Xenova/trocr-small-handwritten 是量化後的版本,適合瀏覽器 | |
| ocrPipeline = await pipeline('image-to-text', 'Xenova/trocr-small-handwritten', { | |
| progress_callback: (data) => { | |
| if (data.status === 'progress') { | |
| console.log(`Model loading: ${Math.round(data.progress || 0)}%`); | |
| } | |
| } | |
| }); | |
| showToast("AI 模型載入完成!"); | |
| isModelLoading = false; | |
| } catch (error) { | |
| console.error("AI 模型載入失敗:", error); | |
| showToast("模型載入失敗,請檢查網路連線"); | |
| isModelLoading = false; | |
| } | |
| } | |
| // [修正] 辨識手寫 (改用 TrOCR) | |
| async function recognizeHandwriting() { | |
| // 檢查是否有任何筆跡 | |
| const hasStrokes = hwInstances.some(inst => inst.strokes.length > 0); | |
| if (!hasStrokes) { | |
| showToast("請先手寫內容!"); | |
| return; | |
| } | |
| // 2. 確保模型已載入 | |
| if (!ocrPipeline) { | |
| await initOCR(); | |
| if (!ocrPipeline) return; // 載入失敗 | |
| } | |
| hwRecognizeBtn.disabled = true; | |
| hwRecognizeBtn.textContent = "AI 辨識中..."; | |
| try { | |
| let finalParts = []; | |
| let hwIndex = 0; | |
| // 依據 currentHandwritingStructure 組裝答案 | |
| // 結構範例: ['cheer', '...', 'on'] | |
| // hwInstances 對應: [Canvas1(cheer), Canvas2(on)] | |
| // 為了平行處理提升速度,先建立所有的 Promise | |
| const recognitionPromises = hwInstances.map(async (inst) => { | |
| if (inst.strokes.length === 0) return ""; // 空畫布回傳空字串 | |
| const imageData = inst.element.toDataURL("image/png"); | |
| const output = await ocrPipeline(imageData); | |
| let text = output[0]?.generated_text?.trim() || ""; | |
| return text.replace(/\.$/, ''); // 移除句點 | |
| }); | |
| // 等待所有畫布辨識完成 | |
| const recognizedTexts = await Promise.all(recognitionPromises); | |
| // 依照結構組裝 | |
| let recIndex = 0; | |
| currentHandwritingStructure.forEach(part => { | |
| if (part === '...') { | |
| finalParts.push('...'); | |
| } else { | |
| // 取出對應的辨識結果 | |
| let text = recognizedTexts[recIndex] || ""; | |
| if (text) finalParts.push(text); | |
| recIndex++; | |
| } | |
| }); | |
| // 組合最終字串 (如果是單字就是單字,片語中間加空白,省略符號兩邊加空白) | |
| // 這裡的邏輯:把 finalParts 用空白連接即可,因為結構已經決定了順序 | |
| // 例如: ['recognized_cheer', '...', 'recognized_on'] -> "recognized_cheer ... recognized_on" | |
| // 例如: ['come', 'true'] -> "come true" | |
| const cleanedText = finalParts.join(' '); | |
| if (cleanedText.trim()) { | |
| answerInput.value = cleanedText; | |
| answerInput.classList.add('border-green-500', 'bg-green-50'); | |
| setTimeout(() => answerInput.classList.remove('border-green-500', 'bg-green-50'), 500); | |
| showToast("辨識結果:" + cleanedText); | |
| showCandidates([cleanedText]); | |
| } else { | |
| showToast("未能辨識出文字"); | |
| } | |
| } catch (error) { | |
| console.error("AI 辨識失敗:", error); | |
| showToast("辨識發生錯誤: " + error.message); | |
| } finally { | |
| hwRecognizeBtn.disabled = false; | |
| hwRecognizeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg> 辨識 (TrOCR)`; | |
| } | |
| } | |
| // [新增] 顯示候選字 | |
| function showCandidates(candidates) { | |
| candidateBar.innerHTML = ''; | |
| candidateBar.classList.remove('hidden'); | |
| candidates.forEach(text => { | |
| const btn = document.createElement('button'); | |
| btn.textContent = text; | |
| btn.className = 'candidate-btn'; | |
| btn.onclick = () => { | |
| answerInput.value = text; | |
| answerInput.focus(); | |
| }; | |
| candidateBar.appendChild(btn); | |
| }); | |
| } | |
| // 綁定手寫按鈕事件 | |
| if (hwClearBtn) hwClearBtn.addEventListener('click', clearCanvas); | |
| if (hwUndoBtn) hwUndoBtn.addEventListener('click', undoStroke); | |
| if (hwRecognizeBtn) hwRecognizeBtn.addEventListener('click', recognizeHandwriting); | |
| // [新增] 更新輸入介面 (控制手寫板顯示與輸入法) | |
| // 參數 targetAnswer: 正確答案字串,用來判斷是否需要多重畫布 | |
| function updateInputInterface(targetLanguage, targetAnswer = "") { | |
| const hwContainer = document.getElementById('handwriting-container'); | |
| // [修改] 為了方便在 PC 上除錯,暫時移除了 effectiveVersion === 'mobile' 的檢查 | |
| // 現在只要是輸入英文模式 (expectedLang === 'en'),都會顯示手寫板 | |
| const shouldUseHandwriting = (targetLanguage === 'en'); | |
| if (shouldUseHandwriting) { | |
| // --- 啟用手寫模式 --- | |
| hwContainer.classList.remove('hidden'); | |
| hwContainer.classList.add('flex'); | |
| // 分析目標單字結構 | |
| // 1. 移除詞性標記 (v.) 等 | |
| let cleanAnswer = targetAnswer.replace(/\(.*\)/g, '').trim(); | |
| // 2. 判斷結構 | |
| let structure = []; | |
| if (cleanAnswer.includes('...')) { | |
| // 分離式片語: cheer ... on -> ['cheer', '...', 'on'] | |
| // 使用正則分割,但保留分隔符 | |
| // 簡單做法:先拆分 | |
| const parts = cleanAnswer.split('...'); | |
| parts.forEach((p, i) => { | |
| if (p.trim()) structure.push(p.trim()); | |
| if (i < parts.length - 1) structure.push('...'); | |
| }); | |
| } else if (cleanAnswer.includes(' ')) { | |
| // 一般片語: come true -> ['come', 'true'] | |
| structure = cleanAnswer.split(/\s+/).filter(s => s); | |
| } else { | |
| // 單字: apple -> ['apple'] | |
| structure = [cleanAnswer]; | |
| } | |
| // 如果 structure 是空的 (例如意外狀況),預設一個 | |
| if (structure.length === 0) structure = ['word']; | |
| currentHandwritingStructure = structure; | |
| // 2. 初始化 (延遲以確保 flex 渲染完成) | |
| setTimeout(() => initMultiHandwritingBoard(structure), 400); | |
| // 3. 觸發模型預載 (Lazy Load) | |
| if (!ocrPipeline && !isModelLoading) { | |
| initOCR(); | |
| } | |
| // 4. 鎖定輸入框 | |
| answerInput.readOnly = true; | |
| answerInput.placeholder = "請在下方手寫英文..."; | |
| answerInput.classList.add('handwriting-mode'); | |
| if (document.activeElement === answerInput) answerInput.blur(); | |
| } else { | |
| // --- 啟用鍵盤模式 (PC 或 輸入中文) --- | |
| hwContainer.classList.add('hidden'); | |
| hwContainer.classList.remove('flex'); | |
| answerInput.readOnly = false; | |
| answerInput.placeholder = (targetLanguage === 'zh') ? "輸入中文答案 (按 Enter 提交)" : "輸入英文答案 (按 Enter 提交)"; | |
| answerInput.classList.remove('handwriting-mode'); | |
| } | |
| console.log(`輸入模式: ${shouldUseHandwriting ? '手寫' : '鍵盤'}, 結構:`, currentHandwritingStructure); | |
| } | |
| // --- 視圖管理 --- | |
| const showView = (view) => { | |
| mainMenu.classList.toggle('hidden', view !== 'menu'); | |
| learningMode.classList.toggle('hidden', view !== 'learning'); | |
| wordManagementView.classList.toggle('hidden', view !== 'manage'); | |
| }; | |
| const updateMainTitle = () => { | |
| if (bookTitle && lessonTitle) { | |
| mainTitle.textContent = `${bookTitle} ${lessonTitle} 單字閃卡`; | |
| } else { | |
| mainTitle.textContent = '單字閃卡'; | |
| } | |
| }; | |
| // --- 資料處理 --- | |
| const saveWordsToStorage = () => { | |
| const wordsToSave = words.map(({audioUrl, ...rest}) => rest); | |
| localStorage.setItem('flashcards', JSON.stringify(wordsToSave)); | |
| }; | |
| const shuffleArray = (array) => { | |
| for (let i = array.length - 1; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| [array[i], array[j]] = [array[j], array[i]]; | |
| } | |
| return array; | |
| }; | |
| // --- 單字管理功能 --- | |
| const renderWordList = () => { | |
| wordListContainer.innerHTML = ''; | |
| words.forEach((word, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'list-item p-3 bg-gray-100 rounded-lg flex items-center justify-between'; | |
| const hasSentenceIcon = word.sentence && word.sentence.en ? | |
| `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-cyan-500 ml-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd" /></svg>` : ''; | |
| item.innerHTML = ` | |
| <div class="flex items-center"> | |
| <span class="w-8 text-sm text-gray-500">${index + 1}.</span> | |
| <p class="font-semibold text-gray-800">${word.english}</p> | |
| ${hasSentenceIcon} | |
| <p class="text-gray-600 ml-4">${word.chinese}</p> | |
| </div> | |
| <div class="flex gap-2"> | |
| <button data-index="${index}" class="edit-btn p-2 text-blue-500 hover:bg-blue-100 rounded-full"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" /><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" /></svg></button> | |
| <button data-index="${index}" class="delete-btn p-2 text-red-500 hover:bg-red-100 rounded-full"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" /></svg></button> | |
| </div> | |
| `; | |
| wordListContainer.appendChild(item); | |
| }); | |
| }; | |
| wordListContainer.addEventListener('click', (e) => { | |
| const button = e.target.closest('button'); | |
| if (!button) return; | |
| const index = parseInt(button.dataset.index, 10); | |
| if (button.classList.contains('edit-btn')) { | |
| const word = words[index]; | |
| editWordIndexInput.value = index; | |
| editEnglishInput.value = word.english; | |
| editChineseInput.value = word.chinese; | |
| editSentenceEnInput.value = word.sentence?.en || ''; | |
| editSentenceZhInput.value = word.sentence?.zh || ''; | |
| editSentenceAnswerInput.value = word.sentence?.answer || ''; // 【新增】 | |
| editWordModal.classList.remove('hidden'); | |
| } else if (button.classList.contains('delete-btn')) { | |
| indexToDelete = index; | |
| wordToDeleteSpan.textContent = words[index].english; | |
| confirmDeleteModal.classList.remove('hidden'); | |
| } | |
| }); | |
| saveEditBtn.addEventListener('click', () => { | |
| const index = parseInt(editWordIndexInput.value, 10); | |
| const newEnglish = editEnglishInput.value.trim(); | |
| const newChinese = editChineseInput.value.trim(); | |
| const newSentenceEn = editSentenceEnInput.value.trim(); | |
| const newSentenceZh = editSentenceZhInput.value.trim(); | |
| const newSentenceAnswer = editSentenceAnswerInput.value.trim(); // 【新增】 | |
| if (index >= 0 && newEnglish && newChinese) { | |
| words[index].english = newEnglish; | |
| words[index].chinese = newChinese; | |
| // 【修改】更新 sentence 物件 | |
| if (newSentenceEn && newSentenceZh) { | |
| words[index].sentence = { | |
| en: newSentenceEn, | |
| zh: newSentenceZh | |
| }; | |
| // 只有在答案存在時才加入 | |
| if (newSentenceAnswer) { | |
| words[index].sentence.answer = newSentenceAnswer; | |
| } | |
| } else { | |
| delete words[index].sentence; | |
| } | |
| saveWordsToStorage(); | |
| renderWordList(); | |
| editWordModal.classList.add('hidden'); | |
| } | |
| }); | |
| cancelEditBtn.addEventListener('click', () => editWordModal.classList.add('hidden')); | |
| const handleAddWords = () => { | |
| errorMessage.textContent = ''; | |
| const englishText = englishWordsInput.value.trim(); | |
| const chineseText = chineseExplanationsInput.value.trim(); | |
| if (!englishText || !chineseText) { | |
| errorMessage.textContent = '請填入英文單字和中文解釋。'; return; | |
| } | |
| const englishArray = englishText.split('\n').filter(line => line.trim() !== ''); | |
| const chineseArray = chineseText.split('\n').filter(line => line.trim() !== ''); | |
| if (englishArray.length !== chineseArray.length) { | |
| errorMessage.textContent = '英文單字和中文解釋的數量必須相同。'; return; | |
| } | |
| const newWords = englishArray.map((english, index) => ({ | |
| english: english.trim(), chinese: chineseArray[index].trim(), | |
| proficiency: 0, incorrectCount: 0 | |
| })); | |
| words.push(...newWords); | |
| saveWordsToStorage(); | |
| renderWordList(); | |
| englishWordsInput.value = ''; | |
| chineseExplanationsInput.value = ''; | |
| addWordsSection.classList.add('hidden'); | |
| }; | |
| addWordsBtn.addEventListener('click', handleAddWords); | |
| showAddWordsSectionBtn.addEventListener('click', () => addWordsSection.classList.remove('hidden')); | |
| cancelAddWordsBtn.addEventListener('click', () => addWordsSection.classList.add('hidden')); | |
| manageWordsBtn.addEventListener('click', () => { | |
| passwordInput.value = ''; | |
| passwordError.textContent = ''; | |
| bookInput.value = bookTitle; | |
| lessonInput.value = lessonTitle; | |
| passwordModal.classList.remove('hidden'); | |
| setTimeout(() => passwordInput.focus(), 50); | |
| }); | |
| backToModesFromManageBtn.addEventListener('click', () => { | |
| initializeApp(); | |
| showView('menu'); | |
| }); | |
| // --- 學習模式核心邏輯 --- | |
| const displayCard = () => { | |
| if (wordsForCurrentMode.length === 0 && quizQueue.length === 0) return; | |
| const updateCardContent = () => { | |
| const isReview = currentMode === 'review'; | |
| const isSpeed = currentMode === 'speed'; | |
| let card; | |
| if (isReview) { | |
| card = wordsForCurrentMode[currentCardIndex]; | |
| } else if (isSpeed) { | |
| card = currentSpeedCard; | |
| } else { | |
| if (quizQueue.length === 0) return; | |
| card = quizQueue[0]; | |
| } | |
| if (!isReview && !isSpeed) { | |
| setProgress(wordsForCurrentMode.length - quizQueue.length, wordsForCurrentMode.length); | |
| } | |
| flashcardContainer.style.cursor = 'default'; | |
| flashcardContainer.classList.remove('hidden'); | |
| quizCompletionMessage.classList.add('hidden'); | |
| [speakBtn, speakSlowBtn].forEach(btn => btn.classList.add('hidden')); | |
| feedbackDisplay.textContent = ''; | |
| answerInput.value = ''; | |
| answerInput.disabled = false; | |
| submitAnswerBtn.disabled = false; // [修改] | |
| wrongAnswerFeedback.classList.add('hidden'); | |
| quizContainer.classList.remove('hidden'); | |
| speedResultView.classList.add('hidden'); | |
| hintDisplay.textContent = ''; | |
| hintBtn.disabled = false; | |
| frontDisplay.classList.add('hidden'); | |
| clozeQuestionContainer.classList.add('hidden'); | |
| let frontText, backText; | |
| let targetLanguage = 'en'; // 預設輸入英文 | |
| // [新增] 紀錄正確答案以便生成手寫板結構 | |
| let targetAnswerForHandwriting = ''; | |
| if (isSpeed) { | |
| // 極速挑戰 | |
| switch (currentSpeedQuestionType) { | |
| case 'zh-en': | |
| frontText = card.chinese; | |
| backText = card.english; | |
| targetLanguage = 'en'; | |
| targetAnswerForHandwriting = card.english; | |
| break; | |
| case 'en-zh': | |
| frontText = card.english; | |
| backText = card.chinese; | |
| [speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden')); | |
| targetLanguage = 'zh'; | |
| break; | |
| case 'listen': | |
| frontText = '請聽發音'; | |
| backText = card.english; | |
| [speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden')); | |
| speakWord(card); | |
| targetLanguage = 'en'; | |
| targetAnswerForHandwriting = card.english; | |
| break; | |
| } | |
| } else { | |
| switch (currentMode) { | |
| case 'review': | |
| frontText = card.english; backText = card.chinese; | |
| [speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden')); | |
| flashcardContainer.style.cursor = 'pointer'; | |
| progressDisplay.textContent = `第 ${currentCardIndex + 1}/${wordsForCurrentMode.length} 張`; | |
| prevBtn.disabled = currentCardIndex === 0; | |
| nextBtn.disabled = currentCardIndex === wordsForCurrentMode.length - 1; | |
| // 複習模式下:沒翻面(看英文)=輸入中文,翻面(看中文)=輸入英文 | |
| const isFlipped = flashcardContainer.classList.contains('flipped'); | |
| targetLanguage = isFlipped ? 'en' : 'zh'; | |
| // 複習模式翻面輸入英文時,目標答案是英文 | |
| if (targetLanguage === 'en') targetAnswerForHandwriting = card.english; | |
| break; | |
| case 'zh-en': case 'hard': | |
| frontText = card.chinese; backText = card.english; | |
| targetLanguage = 'en'; | |
| targetAnswerForHandwriting = card.english; | |
| break; | |
| case 'en-zh': | |
| frontText = card.english; backText = card.chinese; | |
| [speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden')); | |
| targetLanguage = 'zh'; | |
| break; | |
| case 'listen': | |
| frontText = '請聽發音'; backText = card.english; | |
| [speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden')); | |
| speakWord(card); | |
| targetLanguage = 'en'; | |
| targetAnswerForHandwriting = card.english; | |
| break; | |
| case 'sentence-cloze': | |
| clozeQuestionContainer.classList.remove('hidden'); | |
| sentenceEnDisplay.textContent = card.sentence.en; | |
| sentenceZhDisplay.textContent = `(${card.sentence.zh})`; | |
| sentenceZhDisplay.classList.add('hidden'); | |
| toggleTranslationBtn.textContent = '顯示翻譯'; | |
| backText = card.sentence.answer || card.english; | |
| targetLanguage = 'en'; | |
| targetAnswerForHandwriting = card.sentence.answer; | |
| break; | |
| } | |
| } | |
| // [新增] 傳入目標答案以判斷手寫板數量 | |
| updateInputInterface(targetLanguage, targetAnswerForHandwriting); | |
| if (frontText) { | |
| frontDisplay.classList.remove('hidden'); | |
| frontDisplay.textContent = frontText; | |
| } | |
| backDisplay.textContent = backText; | |
| // 如果不是唯讀(手寫模式),才聚焦 | |
| if(!answerInput.readOnly) { | |
| setTimeout(() => answerInput.focus(), 100); | |
| } | |
| }; | |
| if (flashcardContainer.classList.contains('flipped')) { | |
| flashcardContainer.classList.remove('flipped'); | |
| setTimeout(updateCardContent, 600); | |
| } else { | |
| updateCardContent(); | |
| } | |
| }; | |
| const setupLearningView = (mode) => { | |
| currentMode = mode; | |
| modeTitle.textContent = modeDetails[mode].title; | |
| const isReview = mode === 'review'; | |
| const isSpeed = mode === 'speed'; | |
| const isQuiz = !isReview && !isSpeed; | |
| quizContainer.classList.remove('hidden'); | |
| reviewNavContainer.classList.toggle('hidden', !isReview); | |
| progressBarContainer.classList.toggle('hidden', isReview || isSpeed); | |
| speedHud.classList.toggle('hidden', !isSpeed); | |
| hudPlaceholder.classList.toggle('hidden', isSpeed); | |
| quizCompletionMessage.classList.add('hidden'); | |
| const start = parseInt(startRangeInput.value, 10); | |
| const end = parseInt(endRangeInput.value, 10); | |
| let wordPool; | |
| if (!isNaN(start) && !isNaN(end) && start > 0 && end >= start && end <= words.length) { | |
| wordPool = words.slice(start - 1, end); | |
| } else { | |
| wordPool = [...words]; | |
| } | |
| // 【修改】過濾克漏字模式的單字,必須包含 sentence 和 answer | |
| if (mode === 'sentence-cloze') { | |
| wordsForCurrentMode = wordPool.filter(w => w.sentence && w.sentence.en && w.sentence.zh && w.sentence.answer); | |
| if (wordsForCurrentMode.length === 0) { | |
| showToast("題庫中沒有附帶完整例句(包含克漏字答案)的單字可供此模式使用。"); | |
| return; | |
| } | |
| } else if (mode === 'review') { | |
| wordsForCurrentMode = wordPool; | |
| } else if (randomQuestionsCheckbox.checked) { | |
| const randomCount = parseInt(randomQuestionsCountInput.value, 10); | |
| if (!isNaN(randomCount) && randomCount > 0 && randomCount <= wordPool.length) { | |
| wordsForCurrentMode = shuffleArray([...wordPool]).slice(0, randomCount); | |
| } else { | |
| showToast('請輸入有效的隨機題數。題數不能為零或超過所選範圍的單字總數。'); | |
| return; | |
| } | |
| } else { | |
| wordsForCurrentMode = wordPool; | |
| } | |
| if (wordsForCurrentMode.length === 0) { | |
| showToast("選定的範圍內沒有單字,請重新選擇或新增。"); | |
| return; | |
| } | |
| if (mode === 'hard') { | |
| const hardWords = words.filter(w => w.incorrectCount > 0) | |
| .sort((a, b) => b.incorrectCount - a.incorrectCount).slice(0, 10); | |
| if (hardWords.length === 0) { | |
| showToast('太棒了!目前沒有需要特別複習的困難單字。'); return; | |
| } | |
| wordsForCurrentMode = hardWords; | |
| quizQueue = shuffleArray([...wordsForCurrentMode]); | |
| } else if (isQuiz) { | |
| quizQueue = shuffleArray([...wordsForCurrentMode]); | |
| } | |
| if (isQuiz) { | |
| quizStartTime = Date.now(); | |
| } | |
| quizIncorrectCount = 0; | |
| updateHintButtonVisibility(); | |
| if (isSpeed) startSpeedChallenge(wordsForCurrentMode); | |
| else { currentCardIndex = 0; displayCard(); } | |
| showView('learning'); | |
| }; | |
| const checkAnswer = () => { | |
| const userAnswer = answerInput.value.trim(); | |
| if (!userAnswer) return; | |
| const isSpeed = currentMode === 'speed'; | |
| const isReview = currentMode === 'review'; | |
| if (isSpeed && timeLeft <= 0) return; | |
| const card = isReview ? wordsForCurrentMode[currentCardIndex] : (isSpeed ? currentSpeedCard : quizQueue[0]); | |
| if (!card) return; | |
| const wordIndex = words.findIndex(w => w.english === card.english && w.chinese === card.chinese); | |
| let correctAnswer, answerLang; | |
| if (isReview) { | |
| const isFlipped = flashcardContainer.classList.contains('flipped'); | |
| let rawAnswer = isFlipped ? card.english : card.chinese; | |
| correctAnswer = rawAnswer.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); | |
| answerLang = isFlipped ? 'en' : 'zh'; | |
| } else { | |
| let effectiveMode = isSpeed ? currentSpeedQuestionType : (currentMode === 'hard' ? 'zh-en' : currentMode); | |
| let rawAnswer; | |
| switch (effectiveMode) { | |
| // 【修改】此處是核心邏輯修改 | |
| case 'sentence-cloze': | |
| // 直接使用 sentence.answer 作為正確答案 | |
| rawAnswer = card.sentence.answer; | |
| answerLang = 'en'; | |
| break; | |
| case 'zh-en': case 'listen': | |
| rawAnswer = card.english; | |
| answerLang = 'en'; | |
| break; | |
| case 'en-zh': | |
| rawAnswer = card.chinese; | |
| answerLang = 'zh'; | |
| break; | |
| } | |
| // 如果 rawAnswer 存在才進行處理 | |
| if (rawAnswer) { | |
| correctAnswer = rawAnswer.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); | |
| } else { | |
| // 預防性程式碼,如果找不到答案就判定為錯 | |
| correctAnswer = `__NO_ANSWER_DEFINED__${Date.now()}`; | |
| } | |
| } | |
| let isCorrect; | |
| if (answerLang === 'en') { | |
| const normalize = (str) => { | |
| return str.toLowerCase() | |
| .replace(/\s*\.{3}\s*/g, '') // 處理 '...',移除點及周圍空格 | |
| .replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "") // 移除標點符號 | |
| .replace(/\s+/g, ''); // 移除所有空格 | |
| }; | |
| isCorrect = normalize(userAnswer) === normalize(correctAnswer); | |
| } else if (answerLang === 'zh') { | |
| // [修改] 寬鬆模式:去除標點符號與特殊字元後比對 | |
| // 使用者輸入需被包含在正確答案中 (例如輸入「除了之外」可對應「除了...之外(還有)...」) | |
| // 保留中文、英文與數字 | |
| const normalize = (str) => str.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, ""); | |
| const normUser = normalize(userAnswer); | |
| const possibleAnswers = correctAnswer.split(/[;;]/).map(a => a.trim()).filter(a => a); | |
| isCorrect = possibleAnswers.some(ans => { | |
| const normAns = normalize(ans); | |
| // 確保使用者輸入不為空,且被包含在標準化後的答案中 | |
| return normUser.length > 0 && normAns.includes(normUser); | |
| }); | |
| } | |
| if (isCorrect) { | |
| correctSound.play(); | |
| answerInput.disabled = true; | |
| submitAnswerBtn.disabled = true; // [修改] | |
| feedbackDisplay.textContent = '答對了!'; | |
| feedbackDisplay.classList.remove('text-red-500'); | |
| feedbackDisplay.classList.add('text-green-600'); | |
| triggerConfetti(); | |
| // [修正] 答對時清除所有畫布 (傳入 true) | |
| clearCanvas(true); | |
| if (!flashcardContainer.classList.contains('flipped')) { | |
| flashcardContainer.classList.add('flipped'); | |
| } | |
| if (wordIndex !== -1) words[wordIndex].proficiency = (words[wordIndex].proficiency || 0) + 1; | |
| if (isSpeed) { | |
| currentScore++; | |
| scoreDisplay.textContent = `得分: ${currentScore}`; | |
| setTimeout(startSpeedCard, 500); | |
| } else if (!isReview) { | |
| quizQueue.shift(); | |
| setTimeout(() => { | |
| if (quizQueue.length > 0) { | |
| displayCard(); | |
| } else { | |
| // 測驗完成 | |
| const elapsedTime = Math.round((Date.now() - quizStartTime) / 1000); | |
| const reportData = { | |
| total: wordsForCurrentMode.length, | |
| mistakes: quizIncorrectCount, | |
| time: elapsedTime, | |
| date: new Date().toISOString(), | |
| status: 'completed' | |
| }; | |
| const history = gradeReports[currentMode] || []; | |
| history.push(reportData); | |
| gradeReports[currentMode] = history; | |
| localStorage.setItem('gradeReports', JSON.stringify(gradeReports)); | |
| quizStartTime = 0; // Reset timer | |
| if (!['hard', 'sentence-cloze'].includes(currentMode)) { | |
| completionStatus[currentMode] = true; | |
| localStorage.setItem('completionStatus', JSON.stringify(completionStatus)); | |
| updateCompletionUI(); | |
| } | |
| [flashcardContainer, quizContainer, progressBarContainer].forEach(el => el.classList.add('hidden')); | |
| // 提示功能已經移入 quizContainer,所以不需要單獨隱藏 | |
| // if (hintSection) hintSection.classList.add('hidden'); | |
| quizCompletionMessage.classList.remove('hidden'); | |
| setTimeout(() => showView('menu'), 3000); | |
| } | |
| }, 1500); | |
| } else { // Review mode | |
| answerInput.disabled = false; | |
| submitAnswerBtn.disabled = false; // [修改] | |
| } | |
| } else { // Incorrect answer | |
| wrongSound.play(); | |
| quizIncorrectCount++; | |
| updateHintButtonVisibility(); | |
| answerInput.classList.add('shake'); | |
| if (wordIndex !== -1) { | |
| words[wordIndex].proficiency = 0; | |
| words[wordIndex].incorrectCount = (words[wordIndex].incorrectCount || 0) + 1; | |
| } | |
| if (isSpeed) { | |
| feedbackDisplay.textContent = '答錯了,再試一次!'; | |
| feedbackDisplay.classList.add('text-red-500'); | |
| setTimeout(() => { answerInput.classList.remove('shake'); feedbackDisplay.textContent = ''; }, 1000); | |
| } else if (isReview) { | |
| feedbackDisplay.textContent = '答錯了!'; | |
| feedbackDisplay.classList.add('text-red-500'); | |
| if (!flashcardContainer.classList.contains('flipped')) { | |
| flashcardContainer.classList.add('flipped'); | |
| } | |
| setTimeout(() => { answerInput.classList.remove('shake'); }, 820); | |
| } else { // Other quiz modes | |
| answerInput.disabled = true; | |
| submitAnswerBtn.disabled = true; // [修改] | |
| flashcardContainer.classList.add('flipped'); | |
| quizContainer.classList.add('hidden'); | |
| wrongAnswerFeedback.classList.remove('hidden'); | |
| setTimeout(() => { answerInput.classList.remove('shake'); }, 820); | |
| } | |
| } | |
| if (!isSpeed) saveWordsToStorage(); | |
| }; | |
| const updateHintButtonVisibility = () => { | |
| const isQuiz = !['review', 'speed'].includes(currentMode); | |
| if (hintBtn) { | |
| hintBtn.classList.toggle('hidden', !(isQuiz && quizIncorrectCount >= 2)); | |
| } | |
| }; | |
| confirmWrongBtn.addEventListener('click', () => { | |
| const wrongCard = quizQueue.shift(); | |
| const reinsertIndex = Math.min(quizQueue.length, 3); | |
| quizQueue.splice(reinsertIndex, 0, wrongCard); | |
| // [修正] 清空手寫板 | |
| clearCanvas(true); | |
| displayCard(); | |
| }); | |
| // --- 極速挑戰專用 --- | |
| const startSpeedCard = () => { | |
| if (wordsForCurrentMode.length > 0) { | |
| currentSpeedCard = wordsForCurrentMode[Math.floor(Math.random() * wordsForCurrentMode.length)]; | |
| const speedQuestionTypes = ['zh-en', 'en-zh', 'listen']; | |
| currentSpeedQuestionType = speedQuestionTypes[Math.floor(Math.random() * speedQuestionTypes.length)]; | |
| displayCard(); | |
| } | |
| }; | |
| const startSpeedChallenge = (wordSet) => { | |
| currentScore = 0; timeLeft = 60; | |
| wordsForCurrentMode = wordSet || words; | |
| scoreDisplay.textContent = `得分: 0`; timerDisplay.textContent = timeLeft; | |
| timerDisplay.classList.remove('timer-highlight', 'text-red-500'); | |
| clearInterval(timerInterval); | |
| timerInterval = setInterval(updateTimer, 1000); | |
| startSpeedCard(); | |
| }; | |
| const updateTimer = () => { | |
| timeLeft--; | |
| timerDisplay.textContent = timeLeft; | |
| if (timeLeft <= 10) { | |
| timerDisplay.classList.add('timer-highlight', 'text-red-500'); | |
| } | |
| if (timeLeft <= 0) { | |
| endSpeedChallenge(); | |
| } | |
| }; | |
| const endSpeedChallenge = () => { | |
| clearInterval(timerInterval); | |
| answerInput.disabled = true; submitAnswerBtn.disabled = true; | |
| quizContainer.classList.add('hidden'); speedResultView.classList.remove('hidden'); | |
| finalScore.textContent = currentScore; | |
| if(currentScore > highScore) { | |
| highScore = currentScore; | |
| localStorage.setItem('flashcardsHighScore', highScore); | |
| newHighscoreMsg.textContent = '新高分!🎉'; | |
| highscoreDisplay.textContent = highScore; | |
| } else { | |
| newHighscoreMsg.textContent = ''; | |
| } | |
| }; | |
| playAgainBtn.addEventListener('click', () => startSpeedChallenge(wordsForCurrentMode)); | |
| // --- 其他輔助函式 --- | |
| const setProgress = (completed, total) => { | |
| const percentage = total > 0 ? (completed / total) * 100 : 0; | |
| progressBar.style.width = `${percentage}%`; | |
| }; | |
| const triggerConfetti = () => { | |
| confetti({ particleCount: 150, spread: 70, origin: { y: 0.6 } }); | |
| }; | |
| const speakWithBrowserTTS = (wordText, rate = 1) => { | |
| if ('speechSynthesis' in window) { | |
| window.speechSynthesis.cancel(); | |
| const wordToSpeak = wordText.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); | |
| const utterance = new SpeechSynthesisUtterance(wordToSpeak); | |
| utterance.lang = 'en-US'; | |
| utterance.rate = rate; | |
| window.speechSynthesis.speak(utterance); | |
| } | |
| }; | |
| const speakWord = async (word) => { | |
| speakBtn.disabled = true; | |
| speakSlowBtn.disabled = true; | |
| if (word.audioUrl) { | |
| apiAudioPlayer.src = word.audioUrl; | |
| apiAudioPlayer.play().finally(() => { | |
| speakBtn.disabled = false; | |
| speakSlowBtn.disabled = false; | |
| }); | |
| return; | |
| } | |
| const wordToSpeak = word.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); | |
| if (!wordToSpeak) { | |
| speakBtn.disabled = false; | |
| speakSlowBtn.disabled = false; | |
| return; | |
| } | |
| const cleanedWord = wordToSpeak.replace(/[^a-zA-Z\s-]/g, ''); | |
| if (cleanedWord.includes(' ')) { | |
| speakWithBrowserTTS(cleanedWord, 0.75); | |
| speakBtn.disabled = false; | |
| speakSlowBtn.disabled = false; | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${cleanedWord}`); | |
| if (!response.ok) throw new Error('API request failed'); | |
| const data = await response.json(); | |
| let audioUrl = ''; | |
| if (data && data.length > 0) { | |
| for (const entry of data) { | |
| for (const phonetic of entry.phonetics || []) { | |
| if (phonetic.audio) { | |
| if (phonetic.audio.includes('-us.mp3')) { | |
| audioUrl = phonetic.audio; | |
| break; | |
| } | |
| if (!audioUrl) audioUrl = phonetic.audio; | |
| } | |
| } | |
| if (audioUrl) break; | |
| } | |
| } | |
| if (audioUrl) { | |
| word.audioUrl = audioUrl; // Cache the URL | |
| apiAudioPlayer.src = audioUrl; | |
| apiAudioPlayer.play().catch(error => { | |
| console.error("Audio playback error:", error); | |
| speakWithBrowserTTS(cleanedWord, 0.75); | |
| }); | |
| } else { | |
| speakWithBrowserTTS(cleanedWord, 0.75); | |
| } | |
| } catch (error) { | |
| console.error("Dictionary API error:", error); | |
| speakWithBrowserTTS(cleanedWord, 0.75); | |
| } finally { | |
| speakBtn.disabled = false; | |
| speakSlowBtn.disabled = false; | |
| } | |
| }; | |
| const getCurrentWordToSpeak = () => { | |
| if (currentMode === 'speed' && currentSpeedCard) return currentSpeedCard; | |
| if (currentMode === 'review' && wordsForCurrentMode[currentCardIndex]) return wordsForCurrentMode[currentCardIndex]; | |
| if (quizQueue.length > 0) return quizQueue[0]; | |
| return null; | |
| }; | |
| const updateCompletionUI = () => { | |
| document.querySelectorAll('.mode-btn[data-mode]').forEach(btn => { | |
| const mode = btn.dataset.mode; | |
| const crown = btn.querySelector('.crown-icon'); | |
| if (crown) crown.classList.toggle('hidden', !completionStatus[mode]); | |
| }); | |
| }; | |
| const renderGradeReport = () => { | |
| const reportTargets = { 'zh-en': reportZhEn, 'en-zh': reportEnZh, 'listen': reportListen }; | |
| for (const mode in reportTargets) { | |
| const history = gradeReports[mode] || []; | |
| const targetDiv = reportTargets[mode]; | |
| targetDiv.innerHTML = ''; // Clear previous content | |
| if (history.length === 0) { | |
| targetDiv.innerHTML = `<p class="text-gray-500 p-4 text-center">尚未有紀錄</p>`; | |
| } else { | |
| history.slice().reverse().forEach(report => { | |
| const minutes = Math.floor(report.time / 60); | |
| const seconds = report.time % 60; | |
| const isCompleted = report.status === 'completed'; | |
| const reportElement = document.createElement('div'); | |
| reportElement.className = 'p-3 mb-3 bg-white rounded-lg shadow-sm border'; | |
| reportElement.innerHTML = ` | |
| <div class="flex justify-between items-center mb-2"> | |
| <p class="text-sm font-semibold">${new Date(report.date).toLocaleString('zh-TW', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</p> | |
| <span class="px-2 py-1 text-xs font-bold rounded-full ${isCompleted ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}"> | |
| ${isCompleted ? '已完成' : '未完成'} | |
| </span> | |
| </div> | |
| <div class="text-sm space-y-1"> | |
| <p><strong>題目:</strong> ${isCompleted ? report.total : `${report.attempted || 0}/${report.total}`} 題</p> | |
| <p><strong>答錯:</strong> <span class="font-bold text-red-500">${report.mistakes} 次</span></p> | |
| <p><strong>時間:</strong> ${minutes} 分 ${seconds} 秒</p> | |
| </div> | |
| `; | |
| targetDiv.appendChild(reportElement); | |
| }); | |
| } | |
| } | |
| }; | |
| const generateQRCode = (text) => { | |
| qrcodeContainer.innerHTML = ''; | |
| try { | |
| const typeNumber = 4; | |
| const errorCorrectionLevel = 'L'; | |
| const qr = qrcode(typeNumber, errorCorrectionLevel); | |
| qr.addData(text); | |
| qr.make(); | |
| qrcodeContainer.innerHTML = qr.createImgTag(6, 12); | |
| } catch(e) { | |
| console.error("QR Code generation failed:", e); | |
| qrcodeContainer.textContent = 'QR碼產生失敗,網址可能過長。'; | |
| } | |
| }; | |
| // --- AI PDF 解析功能 --- | |
| const updateAIProgressBar = (percentage) => { | |
| aiProgressBarInner.style.width = `${percentage}%`; | |
| }; | |
| pdfUploadInput.addEventListener('change', () => { | |
| parseStatus.textContent = ''; | |
| aiProgressContainer.classList.add('hidden'); | |
| }); | |
| parsePdfBtn.addEventListener('click', async () => { | |
| const file = pdfUploadInput.files[0]; | |
| if (!file) { | |
| parseStatus.textContent = '請先選擇一個 PDF 檔案。'; | |
| return; | |
| } | |
| parseStatus.textContent = '準備開始解析...'; | |
| parsePdfBtn.disabled = true; | |
| aiProgressContainer.classList.remove('hidden'); | |
| updateAIProgressBar(0); | |
| try { | |
| const fileReader = new FileReader(); | |
| fileReader.onload = async (event) => { | |
| const typedarray = new Uint8Array(event.target.result); | |
| pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.worker.min.js`; | |
| const pdf = await pdfjsLib.getDocument(typedarray).promise; | |
| let fullText = ''; | |
| for (let i = 1; i <= pdf.numPages; i++) { | |
| parseStatus.textContent = `讀取 PDF 頁面 (${i}/${pdf.numPages})...`; | |
| updateAIProgressBar((i / pdf.numPages) * 70); // PDF 讀取佔 70% 進度 | |
| const page = await pdf.getPage(i); | |
| const textContent = await page.getTextContent(); | |
| const pageText = textContent.items.map(item => item.str).join(' '); | |
| fullText += pageText + '\n\n'; | |
| } | |
| parseStatus.textContent = 'AI 分析中,請稍候...'; | |
| updateAIProgressBar(85); // 準備呼叫 AI | |
| await callGeminiToParseText(fullText); | |
| }; | |
| fileReader.readAsArrayBuffer(file); | |
| } catch (error) { | |
| console.error('PDF 解析失敗:', error); | |
| parseStatus.textContent = 'PDF 解析失敗,請檢查檔案或控制台錯誤訊息。'; | |
| parsePdfBtn.disabled = false; | |
| aiProgressContainer.classList.add('hidden'); | |
| } | |
| }); | |
| // 【修改】升級 AI Prompt 和 Schema | |
| async function callGeminiToParseText(text) { | |
| const apiKey = apiKeyInput.value.trim(); | |
| if (!apiKey) { | |
| showToast('錯誤:請先輸入您的 Gemini API 金鑰並儲存。'); | |
| parsePdfBtn.disabled = false; | |
| aiProgressContainer.classList.add('hidden'); | |
| return; | |
| } | |
| const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; | |
| const prompt = ` | |
| 請分析以下從英文單字學習講義擷取的文字。你的任務是辨識出每一個單字條目,並為每個條目提取以下資訊: | |
| 1. "english": 完整的英文單字,包含詞性標註,例如 "yesterday (adv.)" 或 "study (v.)"。 | |
| 2. "chinese": 對應的中文翻譯。 | |
| 3. "sentence": 一個包含例句的物件。這個物件應該有以下三個屬性: | |
| - "en": 英文例句。請將該條目的主要英文單字(不含詞性)替換成 "___"。 | |
| - "zh": 完整的中文例句翻譯。 | |
| - "answer": 在 "en" 屬性的 "___" 空格中,文法正確的答案。例如,如果單字是 "watch",例句是 "He ___ TV.",那 "answer" 就應該是 "watches"。 | |
| 規則: | |
| - 如果一個單字條目有多個例句,請只選擇第一個或最能代表該單字用法的例句。 | |
| - 如果同一個單字因為詞性不同而有多個條目(例如 "study (v.)" 和 "study (n.)"),請將它們視為獨立的條目,並各自尋找一個代表性的例句。 | |
| - 如果一個單字條目沒有提供例句,則省略 "sentence" 物件。 | |
| 請將所有解析出的單字條目,以一個 JSON 陣列的格式回傳。每一個陣列中的物件都應該符合上述的結構。 | |
| 這是你要分析的文字內容: | |
| --- | |
| ${text} | |
| --- | |
| `; | |
| const payload = { | |
| contents: [{ parts: [{ text: prompt }] }], | |
| generationConfig: { | |
| responseMimeType: "application/json", | |
| responseSchema: { | |
| type: "ARRAY", | |
| items: { | |
| type: "OBJECT", | |
| properties: { | |
| english: { type: "STRING" }, | |
| chinese: { type: "STRING" }, | |
| sentence: { | |
| type: "OBJECT", | |
| properties: { | |
| en: { type: "STRING" }, | |
| zh: { type: "STRING" }, | |
| answer: { type: "STRING" } // 新增 answer 欄位 | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }; | |
| try { | |
| const response = await fetch(apiUrl, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) { | |
| const errorBody = await response.json(); | |
| console.error('API Error:', errorBody); | |
| throw new Error(`API 請求失敗,狀態碼:${response.status}. 錯誤訊息: ${errorBody.error.message}`); | |
| } | |
| const result = await response.json(); | |
| if (result.candidates && result.candidates.length > 0 && result.candidates[0].content?.parts?.[0]?.text) { | |
| const jsonText = result.candidates[0].content.parts[0].text; | |
| parsedWordsFromAI = JSON.parse(jsonText); | |
| updateAIProgressBar(100); | |
| parseStatus.textContent = 'AI 分析完成!請確認結果。'; | |
| showParseConfirmation(parsedWordsFromAI); | |
| setTimeout(() => { | |
| aiProgressContainer.classList.add('hidden'); | |
| parseStatus.textContent = ''; | |
| }, 2000); | |
| } else { | |
| throw new Error("從 AI 收到無效的回應格式。"); | |
| } | |
| } catch (error) { | |
| console.error('AI 解析失敗:', error); | |
| parseStatus.textContent = `AI 解析失敗: ${error.message}`; | |
| aiProgressContainer.classList.add('hidden'); | |
| } finally { | |
| parsePdfBtn.disabled = false; | |
| } | |
| } | |
| // 【修改】更新 AI 結果預覽畫面 | |
| function showParseConfirmation(parsedWords) { | |
| parseResultsContainer.innerHTML = ''; | |
| selectAllCheckbox.checked = true; | |
| parsedWords.forEach((word, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'p-3 bg-gray-50 rounded-md border flex items-start'; | |
| item.innerHTML = ` | |
| <input type="checkbox" checked id="word-checkbox-${index}" data-index="${index}" class="word-checkbox h-5 w-5 rounded text-indigo-600 focus:ring-indigo-500 border-gray-300 mr-4 mt-1"> | |
| <div class="flex-1"> | |
| <p><strong>英文:</strong> ${word.english}</p> | |
| <p><strong>中文:</strong> ${word.chinese}</p> | |
| ${word.sentence ? ` | |
| <div class="mt-2 pt-2 border-t border-gray-200"> | |
| <p class="text-sm text-gray-600"><strong>例句 (英):</strong> ${word.sentence.en}</p> | |
| <p class="text-sm text-gray-600"><strong>例句 (中):</strong> ${word.sentence.zh}</p> | |
| ${word.sentence.answer ? `<p class="text-sm text-blue-700 font-semibold"><strong>克漏字答案:</strong> ${word.sentence.answer}</p>` : ''} | |
| </div> | |
| ` : ''} | |
| </div> | |
| `; | |
| parseResultsContainer.appendChild(item); | |
| }); | |
| parseConfirmModal.classList.remove('hidden'); | |
| } | |
| selectAllCheckbox.addEventListener('change', (e) => { | |
| const isChecked = e.target.checked; | |
| parseResultsContainer.querySelectorAll('.word-checkbox').forEach(checkbox => { | |
| checkbox.checked = isChecked; | |
| }); | |
| }); | |
| confirmParseBtn.addEventListener('click', () => { | |
| const selectedWords = []; | |
| const checkboxes = parseResultsContainer.querySelectorAll('.word-checkbox:checked'); | |
| checkboxes.forEach(checkbox => { | |
| const index = parseInt(checkbox.dataset.index, 10); | |
| selectedWords.push(parsedWordsFromAI[index]); | |
| }); | |
| const newWords = selectedWords.map(word => ({ | |
| ...word, | |
| proficiency: 0, | |
| incorrectCount: 0 | |
| })); | |
| words.push(...newWords); | |
| saveWordsToStorage(); | |
| renderWordList(); | |
| preloadAudioFiles(); // 解析完後也預載新的音檔 | |
| parseConfirmModal.classList.add('hidden'); | |
| parseStatus.textContent = `成功新增 ${selectedWords.length} 個單字!`; | |
| pdfUploadInput.value = ''; | |
| parsedWordsFromAI = []; | |
| }); | |
| cancelParseBtn.addEventListener('click', () => { | |
| parseConfirmModal.classList.add('hidden'); | |
| parseStatus.textContent = '操作已取消。'; | |
| pdfUploadInput.value = ''; | |
| parsedWordsFromAI = []; | |
| }); | |
| // --- 事件監聽 --- | |
| document.querySelectorAll('.mode-btn[data-mode]').forEach(btn => { | |
| btn.addEventListener('click', () => setupLearningView(btn.dataset.mode)); | |
| }); | |
| resetCrownsBtn.addEventListener('click', () => { | |
| completionStatus = {}; | |
| localStorage.removeItem('completionStatus'); | |
| updateCompletionUI(); | |
| highScore = 0; | |
| localStorage.removeItem('flashcardsHighScore'); | |
| highscoreDisplay.textContent = highScore; | |
| const originalText = resetCrownsBtn.textContent; | |
| resetCrownsBtn.textContent = '已重置!'; | |
| resetCrownsBtn.disabled = true; | |
| setTimeout(() => { | |
| resetCrownsBtn.textContent = originalText; | |
| resetCrownsBtn.disabled = false; | |
| }, 2000); | |
| }); | |
| backToMenuBtn.addEventListener('click', () => { | |
| clearInterval(timerInterval); | |
| const isQuiz = !['review', 'speed', ''].includes(currentMode); | |
| if (isQuiz && quizStartTime > 0) { | |
| const attempted = wordsForCurrentMode.length - quizQueue.length; | |
| if (attempted > 0) { | |
| const elapsedTime = Math.round((Date.now() - quizStartTime) / 1000); | |
| const reportData = { | |
| total: wordsForCurrentMode.length, | |
| attempted: attempted, | |
| mistakes: quizIncorrectCount, | |
| time: elapsedTime, | |
| date: new Date().toISOString(), | |
| status: 'abandoned' | |
| }; | |
| const history = gradeReports[currentMode] || []; | |
| history.push(reportData); | |
| gradeReports[currentMode] = history; | |
| localStorage.setItem('gradeReports', JSON.stringify(gradeReports)); | |
| } | |
| } | |
| quizStartTime = 0; | |
| currentMode = ''; | |
| showView('menu'); | |
| }); | |
| flashcardContainer.addEventListener('click', () => { | |
| if (currentMode === 'review') { | |
| flashcardContainer.classList.toggle('flipped'); | |
| const isFlipped = flashcardContainer.classList.contains('flipped'); | |
| // [智慧輸入切換] 複習模式下:翻面後看到中文->要輸入英文 | |
| const targetLanguage = isFlipped ? 'en' : 'zh'; | |
| // 複習模式下,翻到英文面輸入中文,翻到中文面輸入英文 | |
| // 這裡的邏輯是: 沒翻面(看英文)->輸入中文(zh), 翻面(看中文)->輸入英文(en) | |
| // 所以 targetLanguage 已經是正確的目標語言 | |
| // 但我們需要知道正確答案是什麼,才能判斷手寫板結構 | |
| let targetAnswer = ''; | |
| if (targetLanguage === 'en') { | |
| targetAnswer = wordsForCurrentMode[currentCardIndex].english; | |
| } | |
| updateInputInterface(targetLanguage, targetAnswer); | |
| feedbackDisplay.textContent = ''; | |
| setTimeout(() => answerInput.focus(), 100); | |
| } | |
| }); | |
| toggleTranslationBtn.addEventListener('click', () => { | |
| const isHidden = sentenceZhDisplay.classList.toggle('hidden'); | |
| toggleTranslationBtn.textContent = isHidden ? '顯示翻譯' : '隱藏翻譯'; | |
| }); | |
| prevBtn.addEventListener('click', () => { if (currentCardIndex > 0) { currentCardIndex--; displayCard(); }}); | |
| nextBtn.addEventListener('click', () => { if (currentCardIndex < wordsForCurrentMode.length - 1) { currentCardIndex++; displayCard(); }}); | |
| // [重要修改] 表單移除後,改用按鈕監聽 | |
| // quizForm.addEventListener('submit', (e) => { e.preventDefault(); checkAnswer(); }); | |
| submitAnswerBtn.addEventListener('click', checkAnswer); | |
| // 允許在 PC 模式下按 Enter 送出 | |
| answerInput.addEventListener('keydown', (e) => { | |
| if(e.key === 'Enter') { | |
| e.preventDefault(); | |
| if(!submitAnswerBtn.disabled) checkAnswer(); | |
| } | |
| }); | |
| speakBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const word = getCurrentWordToSpeak(); | |
| if(word) speakWord(word); | |
| }); | |
| speakSlowBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const word = getCurrentWordToSpeak(); | |
| if(word) speakWithBrowserTTS(word.english, 0.2); | |
| }); | |
| if(hintBtn) { | |
| hintBtn.addEventListener('click', () => { | |
| const card = currentMode === 'speed' ? currentSpeedCard : quizQueue[0]; | |
| if (!card) return; | |
| const questionType = currentMode === 'speed' ? currentSpeedQuestionType : (currentMode === 'hard' ? 'zh-en' : currentMode); | |
| let correctAnswer; | |
| // 【修改】提示功能現在也使用正確的答案來源 | |
| switch (questionType) { | |
| case 'sentence-cloze': correctAnswer = card.sentence.answer; break; | |
| case 'zh-en': case 'listen': correctAnswer = card.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); break; | |
| case 'en-zh': correctAnswer = card.chinese.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); break; | |
| default: return; | |
| } | |
| if (correctAnswer) { | |
| hintDisplay.textContent = `提示:答案以 '${correctAnswer[0]}' 開頭。`; | |
| hintBtn.disabled = true; | |
| } | |
| }); | |
| } | |
| passwordForm.addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| if (passwordInput.value === MANAGE_PASSWORD) { | |
| passwordModal.classList.add('hidden'); | |
| renderWordList(); | |
| // 在進入管理畫面時也更新一下設定狀態 | |
| updateInputInterface('pc'); // 管理介面不需要手寫 | |
| showView('manage'); | |
| } else { | |
| passwordError.textContent = '密碼錯誤!'; | |
| setTimeout(() => { passwordError.textContent = ''}, 2000); | |
| } | |
| }); | |
| cancelPasswordBtn.addEventListener('click', () => passwordModal.classList.add('hidden')); | |
| saveTitleBtn.addEventListener('click', () => { | |
| const newBook = bookInput.value.trim(); | |
| const newLesson = lessonInput.value.trim(); | |
| if (newBook && newLesson) { | |
| bookTitle = newBook; | |
| lessonTitle = newLesson; | |
| localStorage.setItem('flashcardsBookTitle', bookTitle); | |
| localStorage.setItem('flashcardsLessonTitle', lessonTitle); | |
| updateMainTitle(); | |
| const originalText = saveTitleBtn.textContent; | |
| saveTitleBtn.textContent = '已儲存!'; | |
| saveTitleBtn.classList.remove('bg-indigo-600', 'hover:bg-indigo-700'); | |
| saveTitleBtn.classList.add('bg-green-500', 'hover:bg-green-600'); | |
| setTimeout(() => { | |
| saveTitleBtn.textContent = originalText; | |
| saveTitleBtn.classList.remove('bg-green-500', 'hover:bg-green-600'); | |
| saveTitleBtn.classList.add('bg-indigo-600', 'hover:bg-indigo-700'); | |
| }, 2000); | |
| } else { | |
| showToast('冊次和課次不能為空!'); | |
| } | |
| }); | |
| shareGameBtn.addEventListener('click', () => { | |
| shareResultContainer.classList.add('hidden'); | |
| shareLinkInput.value = ''; | |
| copyFeedback.textContent = ''; | |
| qrcodeContainer.innerHTML = ''; | |
| generateLinkBtn.textContent = '產生分享連結'; | |
| generateLinkBtn.disabled = false; | |
| shareModal.classList.remove('hidden'); | |
| }); | |
| gradeReportBtn.addEventListener('click', () => { | |
| renderGradeReport(); | |
| gradeReportModal.classList.remove('hidden'); | |
| }); | |
| closeReportModalBtn.addEventListener('click', () => gradeReportModal.classList.add('hidden')); | |
| generateLinkBtn.addEventListener('click', async () => { | |
| generateLinkBtn.textContent = '上傳中...'; | |
| generateLinkBtn.disabled = true; | |
| const db = window.firebaseDB; | |
| const { collection, addDoc } = window.firebaseFirestore; | |
| if (!db) { | |
| showToast("Firebase 初始化失敗,無法分享。"); | |
| generateLinkBtn.textContent = '產生分享連結'; | |
| generateLinkBtn.disabled = false; | |
| return; | |
| } | |
| try { | |
| const start = startRangeInput.value || ''; | |
| const end = endRangeInput.value || ''; | |
| const isLocked = lockSettingsCheckbox.checked; | |
| const randomCount = randomQuestionsCheckbox.checked ? randomQuestionsCountInput.value : ''; | |
| const deckData = { | |
| words: words.map(({audioUrl, ...rest}) => rest), // Don't save audioUrl to Firestore | |
| settings: { start, end, isLocked, randomCount }, | |
| createdAt: new Date() | |
| }; | |
| const docRef = await addDoc(collection(db, "shared_decks"), deckData); | |
| const baseUrl = window.location.href.split('?')[0]; | |
| const url = new URL(baseUrl); | |
| url.searchParams.set('deck', docRef.id); | |
| const finalUrl = url.toString(); | |
| shareLinkInput.value = finalUrl; | |
| generateQRCode(finalUrl); | |
| shareResultContainer.classList.remove('hidden'); | |
| } catch (error) { | |
| console.error("分享至 Firebase 失敗:", error); | |
| showToast("分享失敗,請檢查您的網路連線或 Firebase 設定。"); | |
| } finally { | |
| generateLinkBtn.textContent = '重新產生'; | |
| generateLinkBtn.disabled = false; | |
| } | |
| }); | |
| copyLinkBtn.addEventListener('click', () => { | |
| shareLinkInput.select(); | |
| shareLinkInput.setSelectionRange(0, 99999); | |
| try { | |
| document.execCommand('copy'); | |
| copyFeedback.textContent = '已成功複製!'; | |
| setTimeout(() => { copyFeedback.textContent = ''; }, 2000); | |
| } catch (err) { | |
| console.error('複製失敗: ', err); | |
| copyFeedback.textContent = '複製失敗!'; | |
| } | |
| }); | |
| closeShareModalBtn.addEventListener('click', () => { | |
| shareModal.classList.add('hidden'); | |
| }); | |
| clearAllWordsBtn.addEventListener('click', () => { | |
| confirmClearModal.classList.remove('hidden'); | |
| }); | |
| cancelClearBtn.addEventListener('click', () => { | |
| confirmClearModal.classList.add('hidden'); | |
| }); | |
| confirmClearBtn.addEventListener('click', () => { | |
| words = []; | |
| gradeReports = {}; | |
| saveWordsToStorage(); | |
| localStorage.removeItem('gradeReports'); | |
| renderWordList(); | |
| confirmClearModal.classList.add('hidden'); | |
| }); | |
| cancelDeleteBtn.addEventListener('click', () => { | |
| confirmDeleteModal.classList.add('hidden'); | |
| indexToDelete = -1; | |
| }); | |
| confirmDeleteBtn.addEventListener('click', () => { | |
| if (indexToDelete > -1) { | |
| const indexToRemove = indexToDelete; | |
| const itemElement = wordListContainer.querySelector(`.list-item button[data-index="${indexToRemove}"]`)?.closest('.list-item'); | |
| if (itemElement) itemElement.classList.add('removing'); | |
| setTimeout(() => { | |
| words.splice(indexToRemove, 1); | |
| saveWordsToStorage(); | |
| renderWordList(); | |
| }, 300); | |
| } | |
| confirmDeleteModal.classList.add('hidden'); | |
| indexToDelete = -1; | |
| }); | |
| randomQuestionsCheckbox.addEventListener('change', () => { | |
| randomQuestionsCountInput.disabled = !randomQuestionsCheckbox.checked; | |
| if (!randomQuestionsCheckbox.checked) { | |
| randomQuestionsCountInput.value = ''; | |
| } else { | |
| randomQuestionsCountInput.focus(); | |
| } | |
| }); | |
| const updateRandomCountMax = () => { | |
| const start = parseInt(startRangeInput.value, 10) || 1; | |
| const end = parseInt(endRangeInput.value, 10) || words.length; | |
| if (end >= start) { | |
| const rangeSize = end - start + 1; | |
| randomQuestionsCountInput.max = rangeSize; | |
| } | |
| }; | |
| startRangeInput.addEventListener('input', updateRandomCountMax); | |
| endRangeInput.addEventListener('input', updateRandomCountMax); | |
| saveApiKeyBtn.addEventListener('click', () => { | |
| const apiKey = apiKeyInput.value.trim(); | |
| if (apiKey) { | |
| localStorage.setItem('geminiApiKey', apiKey); | |
| const originalText = saveApiKeyBtn.textContent; | |
| saveApiKeyBtn.textContent = '已儲存!'; | |
| setTimeout(() => { | |
| saveApiKeyBtn.textContent = originalText; | |
| }, 2000); | |
| } else { | |
| localStorage.removeItem('geminiApiKey'); | |
| showToast('API 金鑰已清除。'); | |
| } | |
| }); | |
| async function preloadAudioFiles() { | |
| const wordsToPreload = words.filter(word => { | |
| const wordToSpeak = word.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); | |
| const cleanedWord = wordToSpeak.replace(/[^a-zA-Z\s-]/g, ''); | |
| // 只預載沒有快取、且不是片語的單字 | |
| return !word.audioUrl && !cleanedWord.includes(' '); | |
| }); | |
| if (wordsToPreload.length === 0) return; | |
| audioPreloadContainer.classList.remove('hidden'); | |
| let loadedCount = 0; | |
| for (const word of wordsToPreload) { | |
| try { | |
| const wordToSpeak = word.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); | |
| const cleanedWord = wordToSpeak.replace(/[^a-zA-Z\s-]/g, ''); | |
| const response = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${cleanedWord}`); | |
| if (!response.ok) continue; | |
| const data = await response.json(); | |
| let audioUrl = ''; | |
| if (data && data.length > 0) { | |
| for (const entry of data) { | |
| for (const phonetic of entry.phonetics || []) { | |
| if (phonetic.audio) { | |
| if (phonetic.audio.includes('-us.mp3')) { | |
| audioUrl = phonetic.audio; | |
| break; | |
| } | |
| if (!audioUrl) audioUrl = phonetic.audio; | |
| } | |
| } | |
| if (audioUrl) break; | |
| } | |
| } | |
| if (audioUrl) { | |
| word.audioUrl = audioUrl; // Cache the URL | |
| } | |
| } catch (error) { | |
| console.warn(`Could not preload audio for "${word.english}":`, error); | |
| } | |
| loadedCount++; | |
| const percentage = Math.round((loadedCount / wordsToPreload.length) * 100); | |
| audioPreloadBar.style.width = `${percentage}%`; | |
| } | |
| audioPreloadText.textContent = '語音檔案預載完成!'; | |
| setTimeout(() => { | |
| audioPreloadContainer.classList.add('hidden'); | |
| }, 2000); | |
| } | |
| // --- 應用程式初始化 --- | |
| const initializeApp = async () => { | |
| const savedApiKey = localStorage.getItem('geminiApiKey'); | |
| if (savedApiKey) { | |
| apiKeyInput.value = savedApiKey; | |
| } | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const deckId = urlParams.get('deck'); | |
| let loadedFromUrl = false; | |
| if (deckId) { | |
| const db = window.firebaseDB; | |
| const { getDoc, doc } = window.firebaseFirestore; | |
| if (db) { | |
| try { | |
| const docRef = doc(db, "shared_decks", deckId); | |
| const docSnap = await getDoc(docRef); | |
| if (docSnap.exists()) { | |
| const data = docSnap.data(); | |
| if (data.words && Array.isArray(data.words)) { | |
| words = data.words.map(word => ({ ...word, proficiency: 0, incorrectCount: 0 })); | |
| saveWordsToStorage(); | |
| const { start, end, isLocked, randomCount } = data.settings || {}; | |
| if (start) startRangeInput.value = start; | |
| if (end) endRangeInput.value = end; | |
| if (randomCount) { | |
| randomQuestionsCheckbox.checked = true; | |
| randomQuestionsCountInput.value = randomCount; | |
| randomQuestionsCountInput.disabled = false; | |
| } | |
| if (isLocked) { | |
| settingsContainer.classList.add('opacity-50', 'pointer-events-none'); | |
| if (shareGameBtn) shareGameBtn.classList.add('hidden'); | |
| if (manageWordsBtn) manageWordsBtn.classList.add('hidden'); | |
| } | |
| loadedFromUrl = true; | |
| } | |
| } else { | |
| showToast("找不到分享的單字庫,請確認連結是否正確。"); | |
| } | |
| } catch (error) { | |
| console.error("從 Firebase 讀取資料失敗:", error); | |
| showToast("讀取分享資料時發生錯誤。"); | |
| } | |
| } | |
| } | |
| if (!loadedFromUrl) { | |
| const storedWords = localStorage.getItem('flashcards'); | |
| if (storedWords && JSON.parse(storedWords).length > 0) { | |
| words = JSON.parse(storedWords).map(word => ({ | |
| ...word, | |
| proficiency: word.proficiency || 0, | |
| incorrectCount: word.incorrectCount || 0 | |
| })); | |
| } else { | |
| // 【修改】使用新的預設單字,展示文法感知功能 | |
| const defaultWords = [ | |
| { | |
| english: 'cheer ... on', | |
| chinese: '為...加油', | |
| sentence: { | |
| en: 'We ___ the team ___.', | |
| zh: '我們為這支隊伍加油。', | |
| answer: 'cheered ... on' | |
| } | |
| }, | |
| { | |
| english: 'come true', | |
| chinese: '成真' | |
| }, | |
| { | |
| english: 'watch (v.)', | |
| chinese: '觀看', | |
| sentence: { | |
| en: 'Mike usually ___ TV on Mondays.', | |
| zh: '麥克通常在週一會看電視。', | |
| answer: 'watches' | |
| } | |
| }, | |
| { english: 'study (v.)', chinese: '研讀' }, | |
| { english: 'last (adj.)', chinese: '前一個的' }, | |
| ]; | |
| words = defaultWords.map(word => ({ ...word, proficiency: 0, incorrectCount: 0 })); | |
| saveWordsToStorage(); | |
| } | |
| } | |
| bookTitle = localStorage.getItem('flashcardsBookTitle') || 'B3'; | |
| lessonTitle = localStorage.getItem('flashcardsLessonTitle') || 'L1'; | |
| updateMainTitle(); | |
| gradeReports = JSON.parse(localStorage.getItem('gradeReports')) || {}; | |
| completionStatus = JSON.parse(localStorage.getItem('completionStatus')) || {}; | |
| updateCompletionUI(); | |
| highScore = parseInt(localStorage.getItem('flashcardsHighScore') || '0', 10); | |
| highscoreDisplay.textContent = highScore; | |
| if (urlParams.has('deck')) { | |
| history.replaceState(null, '', window.location.pathname); | |
| } | |
| startRangeInput.max = words.length; | |
| endRangeInput.max = words.length; | |
| endRangeInput.placeholder = `到 ${words.length}`; | |
| updateRandomCountMax(); | |
| preloadAudioFiles(); | |
| showView('menu'); | |
| }; | |
| initializeApp(); | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| .mode-btn { color: white; font-weight: bold; padding: 1rem 1.5rem; border-radius: 1.5rem; transition: all 0.3s; transform: translateY(0); display: flex; justify-content: center; align-items: center; text-align: center; height: 100%;} | |
| .mode-btn:hover { transform: translateY(-5px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); } | |
| .nav-btn { background-color: #e5e7eb; color: #1f2937; padding: 1rem; border-radius: 9999px; transition: background-color 0.2s; } | |
| .nav-btn:hover { background-color: #d1d5db; } | |
| .nav-btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| `; | |
| document.head.appendChild(style); | |
| }); | |
| </script> | |
| </body> | |
| </html> |