Spaces:
Running
Running
| <html lang="zh-Hant"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>單字閃卡</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> | |
| <!-- 【新增】載入 Google Handwriting Input API (IME) --> | |
| <script src="https://www.google.com/inputtools/request?ime=handwriting&app=gws&cs=1&oe=UTF-8&ss=1&v=3" async></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-input { | |
| font-size: 1.5rem; /* 放大字體 */ | |
| padding: 1rem; /* 增加內邊距 */ | |
| text-align: center; | |
| border: 3px solid #4f46e5; /* 醒目的邊框 */ | |
| box-shadow: 0 0 10px rgba(79, 70, 229, 0.5); /* 陰影 */ | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen flex flex-col items-center p-4 sm:p-8 font-sans"> | |
| <!-- 主選單:新增單字 & 模式選擇 --> | |
| <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> | |
| <input type="text" id="title-input" placeholder="輸入您的單字本標題" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 text-lg mb-3"> | |
| <button id="save-title-btn" class="w-full bg-indigo-500 text-white p-3 rounded-lg font-semibold hover:bg-indigo-600 transition-colors">儲存標題</button> | |
| </div> | |
| <!-- 【新增】版本鎖定設定區塊 --> | |
| <div class="mb-6 p-4 bg-indigo-50 rounded-2xl"> | |
| <h3 class="text-xl font-semibold mb-4 text-gray-700">版本鎖定設定 (分享時生效)</h3> | |
| <div class="flex items-center space-x-4"> | |
| <label class="flex items-center"> | |
| <input type="radio" name="version-lock" value="auto" id="lock-auto" class="h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500" checked> | |
| <span class="ml-2 text-gray-700">自動判斷 (預設)</span> | |
| </label> | |
| <label class="flex items-center"> | |
| <input type="radio" name="version-lock" value="pc" id="lock-pc" class="h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"> | |
| <span class="ml-2 text-gray-700">鎖定 PC 版 (打字)</span> | |
| </label> | |
| <label class="flex items-center"> | |
| <input type="radio" name="version-lock" value="mobile" id="lock-mobile" class="h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"> | |
| <span class="ml-2 text-gray-700">鎖定 行動版 (手寫)</span> | |
| </label> | |
| </div> | |
| </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="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4"> | |
| <input type="text" id="new-english" placeholder="英文單字 (含詞性)" class="p-3 border border-gray-300 rounded-lg"> | |
| <input type="text" id="new-chinese" placeholder="中文翻譯" class="p-3 border border-gray-300 rounded-lg"> | |
| </div> | |
| <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4"> | |
| <input type="text" id="new-sentence-en" placeholder="英文例句 (用___代替單字)" class="p-3 border border-gray-300 rounded-lg col-span-2"> | |
| <input type="text" id="new-sentence-answer" placeholder="克漏字答案" class="p-3 border border-gray-300 rounded-lg"> | |
| </div> | |
| <input type="text" id="new-sentence-zh" placeholder="中文例句翻譯" class="w-full p-3 border border-gray-300 rounded-lg mb-4"> | |
| <button id="add-word-btn" class="w-full bg-green-500 text-white p-3 rounded-lg font-semibold hover:bg-green-600 transition-colors">新增單字</button> | |
| </div> | |
| <!-- PDF/文字解析區塊 --> | |
| <div class="mb-6 p-4 bg-gray-50 rounded-2xl"> | |
| <h3 class="text-xl font-semibold mb-4 text-gray-700">PDF/文字 AI 解析新增</h3> | |
| <div class="flex items-center space-x-4 mb-4"> | |
| <input type="text" id="api-key-input" placeholder="輸入您的 Gemini API Key" class="flex-1 p-3 border border-gray-300 rounded-lg"> | |
| <button id="save-api-key-btn" class="bg-blue-500 text-white p-3 rounded-lg font-semibold hover:bg-blue-600 transition-colors">儲存 Key</button> | |
| </div> | |
| <input type="file" id="pdf-upload-input" accept=".pdf" class="w-full p-3 border border-gray-300 rounded-lg mb-4 bg-white"> | |
| <textarea id="text-input" placeholder="或直接貼上文字內容" rows="5" class="w-full p-3 border border-gray-300 rounded-lg mb-4"></textarea> | |
| <button id="parse-pdf-btn" class="w-full bg-purple-500 text-white p-3 rounded-lg font-semibold hover:bg-purple-600 transition-colors">開始 AI 解析</button> | |
| <p id="parse-status" class="mt-3 text-center text-sm text-gray-600"></p> | |
| <!-- AI 進度條 --> | |
| <div id="ai-progress-container" class="hidden mt-4"> | |
| <p class="text-sm text-center text-gray-500 mb-1">AI 正在努力解析中...</p> | |
| <div class="w-full bg-gray-200 rounded-full h-2.5"> | |
| <div id="ai-progress-bar-inner" class="bg-purple-600 h-2.5 rounded-full" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 單字列表 --> | |
| <div class="mb-6"> | |
| <h3 class="text-xl font-semibold mb-4 text-gray-700">已儲存單字列表 (<span id="word-count">0</span> 個)</h3> | |
| <div id="word-list" class="space-y-3"> | |
| <!-- 單字列表將由 JS 渲染 --> | |
| </div> | |
| </div> | |
| <!-- 清除所有單字按鈕 --> | |
| <button id="clear-all-words-btn" class="w-full bg-red-500 text-white p-3 rounded-lg font-semibold hover:bg-red-600 transition-colors">清除所有單字和紀錄</button> | |
| </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-3xl max-h-[90vh] overflow-y-auto"> | |
| <h3 class="text-2xl font-bold mb-4 text-gray-800">AI 解析結果確認</h3> | |
| <p class="text-gray-600 mb-4">請檢查以下單字列表,取消勾選不需要新增的項目。</p> | |
| <div class="flex items-center mb-4"> | |
| <input type="checkbox" id="select-all-checkbox" class="h-5 w-5 rounded text-indigo-600 focus:ring-indigo-500 border-gray-300"> | |
| <label for="select-all-checkbox" class="ml-2 font-semibold text-gray-700">全選/全不選</label> | |
| </div> | |
| <div id="parse-results-container" class="space-y-3 mb-6"> | |
| <!-- AI 解析結果將由 JS 渲染 --> | |
| </div> | |
| <div class="flex justify-end gap-4 mt-6"> | |
| <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-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">確認新增所選單字</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 學習介面 (預設隱藏) --> | |
| <div id="learning-view" class="hidden w-full max-w-2xl"> | |
| <!-- 極速挑戰 HUD --> | |
| <div id="speed-hud" class="hidden fixed top-0 left-0 right-0 bg-white shadow-lg p-4 z-10 flex justify-between items-center"> | |
| <div class="text-xl font-bold text-gray-800">分數: <span id="speed-score">0</span></div> | |
| <div class="text-2xl font-extrabold text-red-600">時間: <span id="speed-timer">60</span>s</div> | |
| </div> | |
| <div id="hud-placeholder" class="h-16 hidden"></div> <!-- 佔位符 --> | |
| <!-- 進度條 --> | |
| <div id="progress-bar-container" class="w-full mb-6"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <span id="progress-text" class="text-lg font-semibold text-gray-700">進度: 0/0</span> | |
| <span id="mistake-count" class="text-lg font-semibold text-red-500">錯誤: 0</span> | |
| </div> | |
| <div class="w-full bg-gray-200 rounded-full h-3"> | |
| <div id="progress-bar" class="bg-indigo-500 h-3 rounded-full transition-all duration-300" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| <!-- 閃卡區塊 --> | |
| <div id="flashcard-container" class="flip-container w-full h-96 mb-6"> | |
| <div class="flipper w-full h-full"> | |
| <!-- 正面 (中文) --> | |
| <div id="card-front" class="front text-4xl font-bold bg-indigo-500 text-white"> | |
| <span id="card-zh">中文翻譯</span> | |
| </div> | |
| <!-- 背面 (英文) --> | |
| <div id="card-back" class="back text-4xl font-bold bg-violet-400 text-white"> | |
| <span id="card-en">English Word</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 測驗區塊 --> | |
| <div id="quiz-container" class="w-full max-w-md mx-auto"> | |
| <div id="quiz-completion-message" class="hidden text-center p-8 bg-green-100 rounded-xl shadow-lg"> | |
| <p class="text-3xl font-bold text-green-700 mb-4">測驗完成!🎉</p> | |
| <p id="quiz-summary" class="text-xl text-gray-700"></p> | |
| </div> | |
| <!-- 題目顯示 --> | |
| <div id="question-display" class="text-center mb-6 p-4 bg-white rounded-xl shadow-md"> | |
| <p id="question-text" class="text-2xl font-semibold text-gray-800">題目</p> | |
| <button id="speak-btn" class="speak-btn mt-2 p-2 bg-indigo-100 text-indigo-600 rounded-full hover:bg-indigo-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <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.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1V9a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 3v18a1 1 0 01-1.707.707L5.586 15z" /></svg> | |
| </button> | |
| </div> | |
| <!-- 答案輸入區塊 --> | |
| <div class="flex flex-col gap-4"> | |
| <!-- 【修改】答案輸入框,移除 type="text" 以便手寫辨識 API 介入 --> | |
| <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"> | |
| <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> | |
| <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> | |
| </div> | |
| </div> | |
| <!-- 複習模式導航 --> | |
| <div id="review-nav-container" class="hidden w-full max-w-md mx-auto mt-6 flex justify-between gap-4"> | |
| <button id="prev-card-btn" class="flex-1 bg-gray-500 text-white p-3 rounded-xl text-lg font-bold hover:bg-gray-600 transition-colors">上一張</button> | |
| <button id="flip-card-btn" class="flex-1 bg-indigo-500 text-white p-3 rounded-xl text-lg font-bold hover:bg-indigo-600 transition-colors">翻轉</button> | |
| <button id="next-card-btn" class="flex-1 bg-green-500 text-white p-3 rounded-xl text-lg font-bold hover:bg-green-600 transition-colors">下一張</button> | |
| </div> | |
| <!-- 返回主選單 --> | |
| <button id="back-to-menu-btn" class="mt-8 px-6 py-3 bg-gray-200 text-gray-800 rounded-full font-semibold hover:bg-gray-300 transition-colors">返回主選單</button> | |
| </div> | |
| <!-- 成績報告介面 (預設隱藏) --> | |
| <div id="grade-report-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-report-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="grid grid-cols-1 md: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> | |
| <!-- 清除確認 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> | |
| <!-- 分享 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-md"> | |
| <h3 class="text-2xl font-bold mb-4 text-gray-800">分享您的單字本</h3> | |
| <p class="text-gray-600 mb-6">將以下連結或 QR Code 分享給您的學生。</p> | |
| <div id="qrcode-container" class="mb-6 p-4 bg-gray-100 rounded-lg"> | |
| <!-- QR Code 將由 JS 渲染 --> | |
| </div> | |
| <div class="flex items-center border border-gray-300 rounded-lg p-3 mb-6 bg-gray-50"> | |
| <input type="text" id="share-link-input" readonly class="flex-1 bg-transparent text-gray-700 truncate"> | |
| <button id="copy-link-btn" class="ml-3 bg-indigo-500 text-white p-2 rounded-lg text-sm font-semibold hover:bg-indigo-600 transition-colors">複製連結</button> | |
| </div> | |
| <div class="flex justify-end"> | |
| <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> | |
| <script> | |
| // --- 全域變數 --- | |
| let words = []; | |
| let quizQueue = []; | |
| let wordsForCurrentMode = []; | |
| let currentCardIndex = 0; | |
| let currentMode = ''; | |
| let quizIncorrectCount = 0; | |
| let quizStartTime = 0; | |
| let completionStatus = {}; // { 'zh-en': true, 'en-zh': false, 'listen': true } | |
| let highScore = 0; | |
| let gradeReports = {}; // { 'zh-en': [], 'en-zh': [], 'listen': [] } | |
| let parsedWordsFromAI = []; | |
| let timerInterval; | |
| let timeLeft = 60; | |
| let speedScore = 0; | |
| let currentSpeedCard = null; | |
| let currentSpeedQuestionType = ''; | |
| let isMobileDevice = /Mobi|Android/i.test(navigator.userAgent); // 簡易判斷是否為行動裝置 | |
| let versionLock = 'auto'; // 【新增】版本鎖定設定: 'auto', 'pc', 'mobile' | |
| let effectiveVersion = 'pc'; // 【新增】實際運行的版本: 'pc' (打字) 或 'mobile' (手寫) | |
| // --- DOM 元素 --- | |
| const mainMenu = document.getElementById('main-menu'); | |
| const mainTitle = document.getElementById('main-title'); | |
| const wordManagementView = document.getElementById('word-management-view'); | |
| const learningView = document.getElementById('learning-view'); | |
| const flashcardContainer = document.getElementById('flashcard-container'); | |
| const cardZh = document.getElementById('card-zh'); | |
| const cardEn = document.getElementById('card-en'); | |
| const flipCardBtn = document.getElementById('flip-card-btn'); | |
| const prevCardBtn = document.getElementById('prev-card-btn'); | |
| const nextCardBtn = document.getElementById('next-card-btn'); | |
| const reviewNavContainer = document.getElementById('review-nav-container'); | |
| const quizContainer = document.getElementById('quiz-container'); | |
| const questionText = document.getElementById('question-text'); | |
| const answerInput = document.getElementById('answer-input'); | |
| const submitAnswerBtn = document.getElementById('submit-answer-btn'); | |
| const backToMenuBtn = document.getElementById('back-to-menu-btn'); | |
| const progressBar = document.getElementById('progress-bar'); | |
| const progressText = document.getElementById('progress-text'); | |
| const mistakeCount = document.getElementById('mistake-count'); | |
| const progressBarContainer = document.getElementById('progress-bar-container'); | |
| const quizCompletionMessage = document.getElementById('quiz-completion-message'); | |
| const quizSummary = document.getElementById('quiz-summary'); | |
| const speakBtn = document.getElementById('speak-btn'); | |
| const wordList = document.getElementById('word-list'); | |
| const wordCount = document.getElementById('word-count'); | |
| const newEnglish = document.getElementById('new-english'); | |
| const newChinese = document.getElementById('new-chinese'); | |
| const newSentenceEn = document.getElementById('new-sentence-en'); | |
| const newSentenceZh = document.getElementById('new-sentence-zh'); | |
| const newSentenceAnswer = document.getElementById('new-sentence-answer'); | |
| const addWordBtn = document.getElementById('add-word-btn'); | |
| const clearAllWordsBtn = document.getElementById('clear-all-words-btn'); | |
| 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 cancelDeleteBtn = document.getElementById('cancel-delete-btn'); | |
| const confirmDeleteBtn = document.getElementById('confirm-delete-btn'); | |
| const wordToDeleteSpan = document.getElementById('word-to-delete'); | |
| const manageWordsBtn = document.getElementById('manage-words-btn'); | |
| const backToModesFromManageBtn = document.getElementById('back-to-modes-from-manage-btn'); | |
| const titleInput = document.getElementById('title-input'); | |
| const saveTitleBtn = document.getElementById('save-title-btn'); | |
| const highscoreDisplay = document.getElementById('highscore-display'); | |
| const speedHud = document.getElementById('speed-hud'); | |
| const speedScoreDisplay = document.getElementById('speed-score'); | |
| const speedTimerDisplay = document.getElementById('speed-timer'); | |
| const hudPlaceholder = document.getElementById('hud-placeholder'); | |
| const hintBtn = document.getElementById('hint-btn'); | |
| const resetCrownsBtn = document.getElementById('reset-crowns-btn'); | |
| const shareGameBtn = document.getElementById('share-game-btn'); | |
| const shareModal = document.getElementById('share-modal'); | |
| const closeShareModalBtn = document.getElementById('close-share-modal-btn'); | |
| const shareLinkInput = document.getElementById('share-link-input'); | |
| const copyLinkBtn = document.getElementById('copy-link-btn'); | |
| const qrcodeContainer = document.getElementById('qrcode-container'); | |
| const gradeReportBtn = document.getElementById('grade-report-btn'); | |
| const gradeReportView = document.getElementById('grade-report-view'); | |
| const backToModesFromReportBtn = document.getElementById('back-to-modes-from-report-btn'); | |
| const reportZhEn = document.getElementById('report-zh-en'); | |
| const reportEnZh = document.getElementById('report-en-zh'); | |
| const reportListen = document.getElementById('report-listen'); | |
| const startRangeInput = document.getElementById('start-range'); | |
| const endRangeInput = document.getElementById('end-range'); | |
| const randomQuestionsCheckbox = document.getElementById('random-questions-checkbox'); | |
| const randomQuestionsCountInput = document.getElementById('random-questions-count'); | |
| const audioPreloadContainer = document.getElementById('audio-preload-container'); | |
| const audioPreloadBar = document.getElementById('audio-preload-bar'); | |
| const audioPreloadText = document.getElementById('audio-preload-text'); | |
| // AI 解析相關 | |
| const apiKeyInput = document.getElementById('api-key-input'); | |
| const saveApiKeyBtn = document.getElementById('save-api-key-btn'); | |
| const pdfUploadInput = document.getElementById('pdf-upload-input'); | |
| const textInput = document.getElementById('text-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'); | |
| const parseConfirmModal = document.getElementById('parse-confirm-modal'); | |
| const parseResultsContainer = document.getElementById('parse-results-container'); | |
| const selectAllCheckbox = document.getElementById('select-all-checkbox'); | |
| const confirmParseBtn = document.getElementById('confirm-parse-btn'); | |
| const cancelParseBtn = document.getElementById('cancel-parse-btn'); | |
| // 【新增】版本鎖定 DOM | |
| const lockAuto = document.getElementById('lock-auto'); | |
| const lockPc = document.getElementById('lock-pc'); | |
| const lockMobile = document.getElementById('lock-mobile'); | |
| // --- 音效 --- | |
| const correctAudio = new Audio('correct.mp3'); | |
| const wrongAudio = new Audio('wrong.mp3'); | |
| // --- 輔助函式 --- | |
| // 【新增】初始化版本設定 | |
| function initializeVersion() { | |
| // 1. 檢查 URL 參數是否有版本鎖定 | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const urlLock = urlParams.get('version'); | |
| if (urlLock === 'pc' || urlLock === 'mobile') { | |
| versionLock = urlLock; | |
| } else { | |
| // 2. 檢查 localStorage 是否有教師設定 | |
| const storedLock = localStorage.getItem('versionLock'); | |
| if (storedLock === 'pc' || storedLock === 'mobile') { | |
| versionLock = storedLock; | |
| } | |
| } | |
| // 3. 根據鎖定和裝置類型決定實際版本 | |
| if (versionLock === 'pc') { | |
| effectiveVersion = 'pc'; | |
| } else if (versionLock === 'mobile') { | |
| effectiveVersion = 'mobile'; | |
| } else { | |
| // auto 模式 | |
| effectiveVersion = isMobileDevice ? 'mobile' : 'pc'; | |
| } | |
| // 4. 應用版本設定 | |
| applyVersionSettings(); | |
| // 5. 更新管理介面的 radio button 狀態 | |
| if (versionLock === 'pc') lockPc.checked = true; | |
| else if (versionLock === 'mobile') lockMobile.checked = true; | |
| else lockAuto.checked = true; | |
| } | |
| // 【新增】應用版本設定 | |
| function applyVersionSettings() { | |
| if (effectiveVersion === 'mobile') { | |
| // 行動版:強制手寫辨識 | |
| answerInput.classList.add('handwriting-input'); | |
| answerInput.setAttribute('inputmode', 'none'); // 禁用原生鍵盤 | |
| answerInput.setAttribute('lang', 'en'); // 設置語言為英文 | |
| answerInput.setAttribute('data-ime', 'handwriting'); // 標記為手寫輸入 | |
| // 嘗試啟用 Google Handwriting Input API | |
| if (window.google && window.google.ime) { | |
| window.google.ime.setOptions({ | |
| ime: 'handwriting', | |
| language: 'en', | |
| // 確保在行動裝置上點擊輸入框時,手寫板會彈出 | |
| trigger: answerInput | |
| }); | |
| } else { | |
| // 如果 API 載入失敗,給予提示 | |
| answerInput.placeholder = '手寫輸入載入中... (請點擊輸入框)'; | |
| } | |
| } else { | |
| // PC 版:標準打字輸入 | |
| answerInput.classList.remove('handwriting-input'); | |
| answerInput.removeAttribute('inputmode'); | |
| answerInput.removeAttribute('lang'); | |
| answerInput.removeAttribute('data-ime'); | |
| answerInput.placeholder = '輸入你的答案 (按 Enter 提交)'; | |
| // 嘗試禁用 Google Handwriting Input API (如果已啟用) | |
| if (window.google && window.google.ime) { | |
| window.google.ime.setOptions({ | |
| ime: 'none', | |
| trigger: null | |
| }); | |
| } | |
| } | |
| // 提示使用者當前版本 | |
| const versionText = effectiveVersion === 'mobile' ? '行動版 (手寫)' : 'PC 版 (打字)'; | |
| console.log(`當前版本: ${versionText} (鎖定設定: ${versionLock})`); | |
| } | |
| // 【新增】儲存版本鎖定設定 | |
| function saveVersionLock() { | |
| const selectedLock = document.querySelector('input[name="version-lock"]:checked').value; | |
| versionLock = selectedLock; | |
| if (versionLock === 'auto') { | |
| localStorage.removeItem('versionLock'); | |
| } else { | |
| localStorage.setItem('versionLock', versionLock); | |
| } | |
| // 重新應用設定以更新當前介面 | |
| initializeVersion(); | |
| const originalText = saveTitleBtn.textContent; | |
| saveTitleBtn.textContent = '設定已儲存!'; | |
| setTimeout(() => { | |
| saveTitleBtn.textContent = originalText; | |
| }, 1500); | |
| } | |
| // 【新增】版本鎖定事件監聽 | |
| document.querySelectorAll('input[name="version-lock"]').forEach(radio => { | |
| radio.addEventListener('change', saveVersionLock); | |
| }); | |
| // 確保在頁面載入後初始化版本設定 | |
| window.addEventListener('load', initializeVersion); | |
| // 由於 Google Handwriting Input API 是異步載入,我們需要一個回調函數 | |
| window.googleImeLoaded = function() { | |
| console.log('Google Handwriting Input API 載入完成。'); | |
| // 重新應用設定以確保手寫功能正確啟用 | |
| applyVersionSettings(); | |
| }; | |
| // 檢查 Google Handwriting Input API 是否已載入 | |
| if (window.google && window.google.ime) { | |
| window.googleImeLoaded(); | |
| } else { | |
| // 監聽 API 載入事件 (如果 API 支援) | |
| // 由於我們是使用 <script async> 載入,這裡可能需要一個更可靠的監聽機制 | |
| // 暫時依賴 window.googleImeLoaded 函數,並在 applyVersionSettings 中處理未載入的情況 | |
| } | |
| function 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; | |
| } | |
| function showView(viewId) { | |
| [mainMenu, wordManagementView, learningView, gradeReportView].forEach(view => { | |
| view.classList.add('hidden'); | |
| }); | |
| document.getElementById(viewId).classList.remove('hidden'); | |
| // 確保在切換到學習介面時,版本設定被正確應用 | |
| if (viewId === 'learning-view') { | |
| applyVersionSettings(); | |
| } | |
| } | |
| function saveWordsToStorage() { | |
| localStorage.setItem('flashcardsWords', JSON.stringify(words)); | |
| localStorage.setItem('flashcardsReports', JSON.stringify(gradeReports)); | |
| localStorage.setItem('flashcardsTitle', mainTitle.textContent); | |
| localStorage.setItem('flashcardsHighScore', highScore); | |
| localStorage.setItem('completionStatus', JSON.stringify(completionStatus)); | |
| } | |
| function loadWordsFromStorage() { | |
| const storedWords = localStorage.getItem('flashcardsWords'); | |
| if (storedWords) { | |
| words = JSON.parse(storedWords); | |
| } | |
| const storedReports = localStorage.getItem('flashcardsReports'); | |
| if (storedReports) { | |
| gradeReports = JSON.parse(storedReports); | |
| } | |
| const storedTitle = localStorage.getItem('flashcardsTitle'); | |
| if (storedTitle) { | |
| mainTitle.textContent = storedTitle; | |
| titleInput.value = storedTitle; | |
| } else { | |
| mainTitle.textContent = '單字閃卡'; | |
| titleInput.value = '單字閃卡'; | |
| } | |
| const storedHighScore = localStorage.getItem('flashcardsHighScore'); | |
| if (storedHighScore) { | |
| highScore = parseInt(storedHighScore, 10); | |
| highscoreDisplay.textContent = highScore; | |
| } | |
| const storedCompletion = localStorage.getItem('completionStatus'); | |
| if (storedCompletion) { | |
| completionStatus = JSON.parse(storedCompletion); | |
| } | |
| // 【新增】載入版本鎖定設定 | |
| const storedLock = localStorage.getItem('versionLock'); | |
| if (storedLock === 'pc' || storedLock === 'mobile') { | |
| versionLock = storedLock; | |
| } else { | |
| versionLock = 'auto'; | |
| } | |
| // 處理 URL 參數中的單字資料 | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const encodedData = urlParams.get('data'); | |
| const urlTitle = urlParams.get('title'); | |
| if (encodedData) { | |
| try { | |
| const compressed = atob(encodedData); | |
| const decompressed = pako.inflate(compressed.split('').map(c => c.charCodeAt(0)), { to: 'string' }); | |
| const urlWords = JSON.parse(decompressed); | |
| if (urlWords.length > 0) { | |
| // 僅在本地沒有單字時才載入 URL 中的單字 | |
| if (words.length === 0) { | |
| words = urlWords; | |
| saveWordsToStorage(); // 儲存從 URL 載入的單字 | |
| } | |
| if (urlTitle) { | |
| mainTitle.textContent = decodeURIComponent(urlTitle); | |
| titleInput.value = decodeURIComponent(urlTitle); | |
| localStorage.setItem('flashcardsTitle', mainTitle.textContent); | |
| } | |
| } | |
| } catch (e) { | |
| console.error("解碼 URL 資料失敗:", e); | |
| } | |
| } | |
| renderWordList(); | |
| updateCompletionUI(); | |
| preloadAudioFiles(); | |
| } | |
| function updateCompletionUI() { | |
| document.querySelectorAll('.mode-btn').forEach(btn => { | |
| const mode = btn.dataset.mode; | |
| const crown = btn.querySelector('.crown-icon'); | |
| if (crown) { | |
| if (completionStatus[mode]) { | |
| crown.classList.remove('hidden'); | |
| } else { | |
| crown.classList.add('hidden'); | |
| } | |
| } | |
| }); | |
| } | |
| function renderWordList() { | |
| wordList.innerHTML = ''; | |
| wordCount.textContent = words.length; | |
| words.forEach((word, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'list-item p-4 bg-white rounded-xl shadow-md flex justify-between items-center transition-all duration-300'; | |
| item.innerHTML = ` | |
| <div class="flex-1 mr-4"> | |
| <p class="text-lg font-bold text-gray-800">${index + 1}. ${word.english}</p> | |
| <p class="text-gray-600">${word.chinese}</p> | |
| ${word.sentence ? `<p class="text-sm text-blue-500 mt-1">例句: ${word.sentence.en.replace('___', `(${word.sentence.answer})`)}</p>` : ''} | |
| <p class="text-xs text-gray-400 mt-1">熟練度: ${word.proficiency} | 錯: ${word.incorrectCount}</p> | |
| </div> | |
| <button data-index="${index}" class="delete-word-btn bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors flex-shrink-0"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg> | |
| </button> | |
| `; | |
| wordList.appendChild(item); | |
| }); | |
| document.querySelectorAll('.delete-word-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const index = parseInt(e.currentTarget.dataset.index, 10); | |
| showDeleteConfirmation(index); | |
| }); | |
| }); | |
| } | |
| function showDeleteConfirmation(index) { | |
| const word = words[index]; | |
| wordToDeleteSpan.textContent = `${word.english} (${word.chinese})`; | |
| confirmDeleteModal.classList.remove('hidden'); | |
| confirmDeleteBtn.onclick = () => { | |
| // 執行刪除動畫 | |
| const itemToRemove = wordList.children[index]; | |
| itemToRemove.classList.add('removing'); | |
| setTimeout(() => { | |
| words.splice(index, 1); | |
| saveWordsToStorage(); | |
| renderWordList(); | |
| confirmDeleteModal.classList.add('hidden'); | |
| preloadAudioFiles(); // 刪除後重新預載音檔 | |
| }, 300); | |
| }; | |
| cancelDeleteBtn.onclick = () => { | |
| confirmDeleteModal.classList.add('hidden'); | |
| }; | |
| } | |
| function updateProgressBar() { | |
| const total = wordsForCurrentMode.length; | |
| const remaining = quizQueue.length; | |
| const completed = total - remaining; | |
| const percentage = total > 0 ? (completed / total) * 100 : 0; | |
| progressBar.style.width = `${percentage}%`; | |
| progressText.textContent = `進度: ${completed}/${total}`; | |
| mistakeCount.textContent = `錯誤: ${quizIncorrectCount}`; | |
| } | |
| function updateHintButtonVisibility() { | |
| const isQuiz = !['review', 'speed', ''].includes(currentMode); | |
| const isCloze = currentMode === 'sentence-cloze'; | |
| if (isQuiz && !isCloze) { | |
| hintBtn.classList.remove('hidden'); | |
| } else { | |
| hintBtn.classList.add('hidden'); | |
| } | |
| } | |
| function displayCard() { | |
| answerInput.value = ''; | |
| answerInput.focus(); | |
| const isReview = currentMode === 'review'; | |
| const isSpeed = currentMode === 'speed'; | |
| let card; | |
| if (isReview) { | |
| card = wordsForCurrentMode[currentCardIndex]; | |
| reviewNavContainer.classList.remove('hidden'); | |
| quizContainer.classList.add('hidden'); | |
| flashcardContainer.classList.remove('flipped'); | |
| prevCardBtn.disabled = currentCardIndex === 0; | |
| nextCardBtn.disabled = currentCardIndex === wordsForCurrentMode.length - 1; | |
| cardZh.textContent = card.chinese; | |
| cardEn.textContent = card.english; | |
| // 複習模式不顯示問題區塊 | |
| questionText.textContent = ''; | |
| speakBtn.classList.add('hidden'); | |
| } else if (isSpeed) { | |
| // 極速挑戰模式 | |
| card = currentSpeedCard; | |
| reviewNavContainer.classList.add('hidden'); | |
| quizContainer.classList.remove('hidden'); | |
| flashcardContainer.classList.add('hidden'); | |
| // 隨機決定題目類型 | |
| const questionTypes = ['zh-en', 'en-zh', 'listen']; | |
| currentSpeedQuestionType = questionTypes[Math.floor(Math.random() * questionTypes.length)]; | |
| let question; | |
| let isListen = false; | |
| switch (currentSpeedQuestionType) { | |
| case 'zh-en': | |
| question = `中翻英: ${card.chinese}`; | |
| break; | |
| case 'en-zh': | |
| question = `英翻中: ${card.english}`; | |
| break; | |
| case 'listen': | |
| question = `聽力測驗 (請聽音檔)`; | |
| isListen = true; | |
| break; | |
| } | |
| questionText.textContent = question; | |
| speakBtn.classList.toggle('hidden', !isListen); | |
| if (isListen) { | |
| speakWord(card.english); | |
| } | |
| } else { | |
| // 測驗模式 | |
| card = quizQueue[0]; | |
| reviewNavContainer.classList.add('hidden'); | |
| quizContainer.classList.remove('hidden'); | |
| flashcardContainer.classList.add('hidden'); | |
| let question; | |
| let isListen = false; | |
| switch (currentMode) { | |
| case 'zh-en': | |
| case 'hard': | |
| question = `中翻英: ${card.chinese}`; | |
| break; | |
| case 'en-zh': | |
| question = `英翻中: ${card.english}`; | |
| break; | |
| case 'listen': | |
| question = `聽力測驗 (請聽音檔)`; | |
| isListen = true; | |
| break; | |
| case 'sentence-cloze': | |
| question = `克漏字: ${card.sentence.en.replace('___', ' (___) ')} (${card.sentence.zh})`; | |
| break; | |
| } | |
| questionText.textContent = question; | |
| speakBtn.classList.toggle('hidden', !isListen); | |
| if (isListen) { | |
| speakWord(card.english); | |
| } | |
| updateProgressBar(); | |
| } | |
| // 確保在顯示新卡片時,版本設定被正確應用 | |
| applyVersionSettings(); | |
| } | |
| function setupLearningView(mode) { | |
| if (words.length === 0) { | |
| alert("請先新增單字!"); | |
| return; | |
| } | |
| currentMode = mode; | |
| quizCompletionMessage.classList.add('hidden'); | |
| // 確保輸入框在開始測驗前是可用的 | |
| answerInput.disabled = false; | |
| submitAnswerBtn.disabled = false; | |
| 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) { | |
| alert("題庫中沒有附帶完整例句(包含克漏字答案)的單字可供此模式使用。"); | |
| 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 { | |
| alert('請輸入有效的隨機題數。題數不能為零或超過所選範圍的單字總數。'); | |
| return; | |
| } | |
| } else { | |
| wordsForCurrentMode = wordPool; | |
| } | |
| if (wordsForCurrentMode.length === 0) { | |
| alert("選定的範圍內沒有單字,請重新選擇或新增。"); | |
| 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) { | |
| alert('太棒了!目前沒有需要特別複習的困難單字。'); 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-view'); | |
| } | |
| 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 possibleAnswers = correctAnswer.split(/[;;]/).map(a => a.trim()).filter(a => a); | |
| isCorrect = possibleAnswers.some(ans => ans === userAnswer); | |
| } else { | |
| isCorrect = userAnswer === correctAnswer; | |
| } | |
| if (isCorrect) { | |
| correctAudio.play(); | |
| answerInput.classList.remove('border-red-500'); | |
| answerInput.classList.add('border-green-500'); | |
| if (!isReview) { | |
| // 更新熟練度 | |
| if (wordIndex !== -1) { | |
| words[wordIndex].proficiency = (words[wordIndex].proficiency || 0) + 1; | |
| words[wordIndex].incorrectCount = Math.max(0, (words[wordIndex].incorrectCount || 0) - 1); | |
| } | |
| if (isSpeed) { | |
| speedScore++; | |
| speedScoreDisplay.textContent = speedScore; | |
| startNextSpeedCard(); | |
| } else { | |
| quizQueue.shift(); | |
| if (quizQueue.length === 0) { | |
| endQuiz(); | |
| } else { | |
| displayCard(); | |
| } | |
| } | |
| } | |
| } else { | |
| wrongAudio.play(); | |
| answerInput.classList.remove('border-green-500'); | |
| answerInput.classList.add('border-red-500'); | |
| // 晃動效果 | |
| quizContainer.classList.add('shake'); | |
| setTimeout(() => quizContainer.classList.remove('shake'), 820); | |
| if (!isReview) { | |
| quizIncorrectCount++; | |
| // 更新錯誤次數 | |
| if (wordIndex !== -1) { | |
| words[wordIndex].incorrectCount = (words[wordIndex].incorrectCount || 0) + 1; | |
| } | |
| if (isSpeed) { | |
| // 極速挑戰答錯不扣分,但會換下一題 | |
| startNextSpeedCard(); | |
| } else { | |
| // 測驗模式答錯,將題目移到隊列尾部 | |
| const failedCard = quizQueue.shift(); | |
| quizQueue.push(failedCard); | |
| displayCard(); | |
| } | |
| } | |
| } | |
| // 清空輸入框 | |
| answerInput.value = ''; | |
| answerInput.focus(); | |
| if (wordIndex !== -1) { | |
| saveWordsToStorage(); | |
| } | |
| }; | |
| function endQuiz() { | |
| const total = wordsForCurrentMode.length; | |
| const mistakes = quizIncorrectCount; | |
| const elapsedTime = Math.round((Date.now() - quizStartTime) / 1000); | |
| answerInput.disabled = true; | |
| submitAnswerBtn.disabled = true; | |
| quizSummary.innerHTML = ` | |
| <p>總題數: ${total}</p> | |
| <p>錯誤次數: <span class="font-bold text-red-500">${mistakes}</span></p> | |
| <p>花費時間: ${elapsedTime} 秒</p> | |
| `; | |
| quizCompletionMessage.classList.remove('hidden'); | |
| // 儲存報告 | |
| const reportData = { | |
| total: total, | |
| attempted: total, | |
| mistakes: mistakes, | |
| time: elapsedTime, | |
| date: new Date().toISOString(), | |
| status: 'completed' | |
| }; | |
| const history = gradeReports[currentMode] || []; | |
| history.push(reportData); | |
| gradeReports[currentMode] = history; | |
| saveWordsToStorage(); | |
| // 檢查是否完成 | |
| if (mistakes === 0) { | |
| completionStatus[currentMode] = true; | |
| updateCompletionUI(); | |
| confetti({ | |
| particleCount: 100, | |
| spread: 70, | |
| origin: { y: 0.6 } | |
| }); | |
| } | |
| } | |
| function startSpeedChallenge(pool) { | |
| speedScore = 0; | |
| timeLeft = 60; | |
| speedScoreDisplay.textContent = speedScore; | |
| speedTimerDisplay.textContent = timeLeft; | |
| // 隨機選取第一張卡片 | |
| currentSpeedCard = pool[Math.floor(Math.random() * pool.length)]; | |
| displayCard(); | |
| // 啟動計時器 | |
| clearInterval(timerInterval); | |
| timerInterval = setInterval(() => { | |
| timeLeft--; | |
| speedTimerDisplay.textContent = timeLeft; | |
| if (timeLeft <= 10) { | |
| speedTimerDisplay.classList.add('timer-highlight'); | |
| } else { | |
| speedTimerDisplay.classList.remove('timer-highlight'); | |
| } | |
| if (timeLeft <= 0) { | |
| clearInterval(timerInterval); | |
| endSpeedChallenge(); | |
| } | |
| }, 1000); | |
| } | |
| function startNextSpeedCard() { | |
| // 隨機選取下一張卡片 | |
| currentSpeedCard = wordsForCurrentMode[Math.floor(Math.random() * wordsForCurrentMode.length)]; | |
| displayCard(); | |
| } | |
| function endSpeedChallenge() { | |
| answerInput.disabled = true; | |
| submitAnswerBtn.disabled = true; | |
| if (speedScore > highScore) { | |
| highScore = speedScore; | |
| localStorage.setItem('flashcardsHighScore', highScore); | |
| highscoreDisplay.textContent = highScore; | |
| confetti({ | |
| particleCount: 150, | |
| spread: 100, | |
| origin: { y: 0.6 } | |
| }); | |
| } | |
| quizSummary.innerHTML = ` | |
| <p>最終得分: <span class="text-3xl font-bold text-amber-500">${speedScore}</span></p> | |
| <p class="mt-2">歷史最高分: ${highScore}</p> | |
| `; | |
| quizCompletionMessage.classList.remove('hidden'); | |
| // 儲存報告 | |
| const reportData = { | |
| total: wordsForCurrentMode.length, // 這裡 total 意義不大,用 pool size | |
| attempted: speedScore, // 答對題數 | |
| mistakes: 0, // 極速挑戰不計入錯誤 | |
| time: 60, | |
| score: speedScore, | |
| date: new Date().toISOString(), | |
| status: 'completed' | |
| }; | |
| const history = gradeReports[currentMode] || []; | |
| history.push(reportData); | |
| gradeReports[currentMode] = history; | |
| saveWordsToStorage(); | |
| } | |
| function speakWord(word) { | |
| if ('speechSynthesis' in window) { | |
| const utterance = new SpeechSynthesisUtterance(word); | |
| utterance.lang = 'en-US'; | |
| utterance.rate = 0.8; // 稍微慢一點 | |
| speakBtn.disabled = true; | |
| utterance.onend = () => { | |
| speakBtn.disabled = false; | |
| }; | |
| utterance.onerror = () => { | |
| speakBtn.disabled = false; | |
| }; | |
| window.speechSynthesis.speak(utterance); | |
| } else { | |
| alert("您的瀏覽器不支援語音合成。"); | |
| } | |
| } | |
| // --- PDF/AI 解析相關函式 --- | |
| function updateAIProgressBar(percentage) { | |
| aiProgressBarInner.style.width = `${percentage}%`; | |
| } | |
| function loadApiKey() { | |
| const storedKey = localStorage.getItem('geminiApiKey'); | |
| if (storedKey) { | |
| apiKeyInput.value = storedKey; | |
| } | |
| } | |
| function saveApiKey() { | |
| const key = apiKeyInput.value.trim(); | |
| if (key) { | |
| localStorage.setItem('geminiApiKey', key); | |
| alert('API Key 已儲存!'); | |
| } else { | |
| localStorage.removeItem('geminiApiKey'); | |
| alert('API Key 已清除!'); | |
| } | |
| } | |
| async function callGeminiToParseText(text) { | |
| const apiKey = apiKeyInput.value.trim(); | |
| if (!apiKey) { | |
| parseStatus.textContent = '錯誤:請先輸入您的 Gemini API 金鑰並儲存。'; | |
| parsePdfBtn.disabled = false; | |
| aiProgressContainer.classList.add('hidden'); | |
| return; | |
| } | |
| const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20: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; | |
| } | |
| } | |
| 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'); | |
| } | |
| // --- 音檔預載相關函式 --- | |
| let audioPreloadQueue = []; | |
| let preloadedAudio = {}; | |
| let totalAudioCount = 0; | |
| let loadedAudioCount = 0; | |
| function preloadAudioFiles() { | |
| audioPreloadQueue = []; | |
| preloadedAudio = {}; | |
| // 收集所有需要語音的單字 | |
| const wordsToSpeak = new Set(); | |
| words.forEach(word => { | |
| if (word.english) { | |
| wordsToSpeak.add(word.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim()); | |
| } | |
| }); | |
| audioPreloadQueue = Array.from(wordsToSpeak); | |
| totalAudioCount = audioPreloadQueue.length; | |
| loadedAudioCount = 0; | |
| if (totalAudioCount === 0) { | |
| audioPreloadContainer.classList.add('hidden'); | |
| return; | |
| } | |
| audioPreloadContainer.classList.remove('hidden'); | |
| audioPreloadText.textContent = `正在預載語音檔案... (0/${totalAudioCount})`; | |
| audioPreloadBar.style.width = '0%'; | |
| preloadNextAudio(); | |
| } | |
| function preloadNextAudio() { | |
| if (audioPreloadQueue.length === 0) { | |
| audioPreloadText.textContent = `語音檔案預載完成! (${totalAudioCount}/${totalAudioCount})`; | |
| setTimeout(() => audioPreloadContainer.classList.add('hidden'), 2000); | |
| return; | |
| } | |
| const word = audioPreloadQueue.shift(); | |
| const url = `https://api.dictionaryapi.dev/api/v2/entries/en/${word}`; | |
| fetch(url) | |
| .then(response => response.json()) | |
| .then(data => { | |
| let audioUrl = null; | |
| if (Array.isArray(data) && data.length > 0) { | |
| for (const meaning of data) { | |
| if (meaning.phonetics && meaning.phonetics.length > 0) { | |
| for (const phonetic of meaning.phonetics) { | |
| if (phonetic.audio) { | |
| audioUrl = phonetic.audio; | |
| break; | |
| } | |
| } | |
| } | |
| if (audioUrl) break; | |
| } | |
| } | |
| if (audioUrl) { | |
| const audio = new Audio(audioUrl); | |
| audio.preload = 'auto'; | |
| audio.oncanplaythrough = () => { | |
| preloadedAudio[word] = audio; | |
| loadedAudioCount++; | |
| updateAudioPreloadProgress(); | |
| preloadNextAudio(); | |
| }; | |
| audio.onerror = () => { | |
| // 載入失敗也算完成,繼續下一個 | |
| loadedAudioCount++; | |
| updateAudioPreloadProgress(); | |
| preloadNextAudio(); | |
| }; | |
| } else { | |
| // 找不到音檔也算完成,繼續下一個 | |
| loadedAudioCount++; | |
| updateAudioPreloadProgress(); | |
| preloadNextAudio(); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error(`預載 ${word} 音檔失敗:`, error); | |
| loadedAudioCount++; | |
| updateAudioPreloadProgress(); | |
| preloadNextAudio(); | |
| }); | |
| } | |
| function updateAudioPreloadProgress() { | |
| const percentage = totalAudioCount > 0 ? (loadedAudioCount / totalAudioCount) * 100 : 100; | |
| audioPreloadBar.style.width = `${percentage}%`; | |
| audioPreloadText.textContent = `正在預載語音檔案... (${loadedAudioCount}/${totalAudioCount})`; | |
| } | |
| function speakWord(word) { | |
| const cleanWord = word.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); | |
| if (preloadedAudio[cleanWord]) { | |
| preloadedAudio[cleanWord].currentTime = 0; | |
| preloadedAudio[cleanWord].play(); | |
| speakBtn.disabled = true; | |
| preloadedAudio[cleanWord].onended = () => { | |
| speakBtn.disabled = false; | |
| }; | |
| preloadedAudio[cleanWord].onerror = () => { | |
| speakBtn.disabled = false; | |
| }; | |
| return; | |
| } | |
| // 如果沒有預載,則嘗試即時獲取 | |
| const url = `https://api.dictionaryapi.dev/api/v2/entries/en/${cleanWord}`; | |
| speakBtn.disabled = true; | |
| fetch(url) | |
| .then(response => response.json()) | |
| .then(data => { | |
| let audioUrl = null; | |
| if (Array.isArray(data) && data.length > 0) { | |
| for (const meaning of data) { | |
| if (meaning.phonetics && meaning.phonetics.length > 0) { | |
| for (const phonetic of meaning.phonetics) { | |
| if (phonetic.audio) { | |
| audioUrl = phonetic.audio; | |
| break; | |
| } | |
| } | |
| } | |
| if (audioUrl) break; | |
| } | |
| } | |
| if (audioUrl) { | |
| const audio = new Audio(audioUrl); | |
| audio.play(); | |
| audio.onended = () => { speakBtn.disabled = false; }; | |
| audio.onerror = () => { speakBtn.disabled = false; }; | |
| } else { | |
| // 如果找不到音檔,使用瀏覽器內建的語音合成 | |
| if ('speechSynthesis' in window) { | |
| const utterance = new SpeechSynthesisUtterance(cleanWord); | |
| utterance.lang = 'en-US'; | |
| utterance.rate = 0.8; | |
| window.speechSynthesis.speak(utterance); | |
| utterance.onend = () => { speakBtn.disabled = false; }; | |
| utterance.onerror = () => { speakBtn.disabled = false; }; | |
| } else { | |
| alert("找不到音檔且瀏覽器不支援語音合成。"); | |
| speakBtn.disabled = false; | |
| } | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('即時獲取音檔失敗:', error); | |
| // 失敗後嘗試使用瀏覽器內建的語音合成 | |
| if ('speechSynthesis' in window) { | |
| const utterance = new SpeechSynthesisUtterance(cleanWord); | |
| utterance.lang = 'en-US'; | |
| utterance.rate = 0.8; | |
| window.speechSynthesis.speak(utterance); | |
| utterance.onend = () => { speakBtn.disabled = false; }; | |
| utterance.onerror = () => { speakBtn.disabled = false; }; | |
| } else { | |
| alert("即時獲取音檔失敗且瀏覽器不支援語音合成。"); | |
| speakBtn.disabled = false; | |
| } | |
| }); | |
| } | |
| // --- 事件監聽 --- | |
| // 確保在頁面載入後初始化版本設定 | |
| window.addEventListener('load', initializeVersion); | |
| 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; | |
| saveWordsToStorage(); | |
| } | |
| } | |
| currentMode = ''; | |
| showView('main-menu'); | |
| }); | |
| flipCardBtn.addEventListener('click', () => { | |
| flashcardContainer.classList.toggle('flipped'); | |
| }); | |
| prevCardBtn.addEventListener('click', () => { | |
| if (currentCardIndex > 0) { | |
| currentCardIndex--; | |
| flashcardContainer.classList.remove('flipped'); | |
| displayCard(); | |
| } | |
| }); | |
| nextCardBtn.addEventListener('click', () => { | |
| if (currentCardIndex < wordsForCurrentMode.length - 1) { | |
| currentCardIndex++; | |
| flashcardContainer.classList.remove('flipped'); | |
| displayCard(); | |
| } | |
| }); | |
| submitAnswerBtn.addEventListener('click', checkAnswer); | |
| // 允許按 Enter 提交答案 | |
| answerInput.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') { | |
| checkAnswer(); | |
| } | |
| }); | |
| hintBtn.addEventListener('click', () => { | |
| const card = quizQueue[0]; | |
| if (!card) return; | |
| let answer; | |
| switch (currentMode) { | |
| case 'zh-en': | |
| case 'listen': | |
| case 'hard': | |
| answer = card.english.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); | |
| break; | |
| case 'en-zh': | |
| answer = card.chinese.replace(/[\((\[【].*?[\))\]】]/g, "").trim(); | |
| break; | |
| default: | |
| return; | |
| } | |
| if (answer.length > 0) { | |
| const hint = answer.charAt(0) + '...'; | |
| answerInput.value = hint; | |
| answerInput.focus(); | |
| } | |
| }); | |
| // --- 單字管理事件 --- | |
| manageWordsBtn.addEventListener('click', () => { | |
| loadApiKey(); // 載入 API Key | |
| showView('word-management-view'); | |
| }); | |
| backToModesFromManageBtn.addEventListener('click', () => { | |
| showView('main-menu'); | |
| }); | |
| addWordBtn.addEventListener('click', () => { | |
| const english = newEnglish.value.trim(); | |
| const chinese = newChinese.value.trim(); | |
| const sentenceEn = newSentenceEn.value.trim(); | |
| const sentenceZh = newSentenceZh.value.trim(); | |
| const sentenceAnswer = newSentenceAnswer.value.trim(); | |
| if (!english || !chinese) { | |
| alert("英文單字和中文翻譯為必填項。"); | |
| return; | |
| } | |
| const newWord = { | |
| english: english, | |
| chinese: chinese, | |
| proficiency: 0, | |
| incorrectCount: 0 | |
| }; | |
| if (sentenceEn && sentenceZh && sentenceAnswer) { | |
| newWord.sentence = { | |
| en: sentenceEn, | |
| zh: sentenceZh, | |
| answer: sentenceAnswer | |
| }; | |
| } | |
| words.push(newWord); | |
| saveWordsToStorage(); | |
| renderWordList(); | |
| preloadAudioFiles(); // 新增單字後重新預載音檔 | |
| // 清空輸入框 | |
| newEnglish.value = ''; | |
| newChinese.value = ''; | |
| newSentenceEn.value = ''; | |
| newSentenceZh.value = ''; | |
| newSentenceAnswer.value = ''; | |
| alert("單字新增成功!"); | |
| }); | |
| clearAllWordsBtn.addEventListener('click', () => { | |
| confirmClearModal.classList.remove('hidden'); | |
| }); | |
| cancelClearBtn.addEventListener('click', () => { | |
| confirmClearModal.classList.add('hidden'); | |
| }); | |
| confirmClearBtn.addEventListener('click', () => { | |
| words = []; | |
| gradeReports = {}; | |
| completionStatus = {}; | |
| highScore = 0; | |
| localStorage.removeItem('flashcardsWords'); | |
| localStorage.removeItem('flashcardsReports'); | |
| localStorage.removeItem('flashcardsTitle'); | |
| localStorage.removeItem('flashcardsHighScore'); | |
| localStorage.removeItem('completionStatus'); | |
| mainTitle.textContent = '單字閃卡'; | |
| titleInput.value = '單字閃卡'; | |
| highscoreDisplay.textContent = 0; | |
| renderWordList(); | |
| updateCompletionUI(); | |
| confirmClearModal.classList.add('hidden'); | |
| preloadAudioFiles(); // 清除後重置音檔預載 | |
| alert("所有單字和紀錄已清除!"); | |
| }); | |
| saveTitleBtn.addEventListener('click', () => { | |
| const newTitle = titleInput.value.trim() || '單字閃卡'; | |
| mainTitle.textContent = newTitle; | |
| localStorage.setItem('flashcardsTitle', newTitle); | |
| const originalText = saveTitleBtn.textContent; | |
| saveTitleBtn.textContent = '標題已儲存!'; | |
| setTimeout(() => { | |
| saveTitleBtn.textContent = originalText; | |
| }, 1500); | |
| }); | |
| // --- 分享功能事件 --- | |
| shareGameBtn.addEventListener('click', async () => { | |
| if (words.length === 0) { | |
| alert("請先新增單字才能分享!"); | |
| return; | |
| } | |
| // 1. 壓縮單字資料 | |
| const jsonString = JSON.stringify(words); | |
| const compressed = pako.deflate(jsonString, { to: 'string' }); | |
| const encodedData = btoa(compressed); | |
| // 2. 取得標題 | |
| const title = encodeURIComponent(mainTitle.textContent); | |
| // 3. 取得版本鎖定設定 | |
| const lockSetting = document.querySelector('input[name="version-lock"]:checked').value; | |
| let versionParam = ''; | |
| if (lockSetting !== 'auto') { | |
| versionParam = `&version=${lockSetting}`; | |
| } | |
| // 4. 組合分享連結 | |
| const shareLink = `${window.location.origin}${window.location.pathname}?title=${title}&data=${encodedData}${versionParam}`; | |
| shareLinkInput.value = shareLink; | |
| // 5. 產生 QR Code | |
| qrcodeContainer.innerHTML = ''; | |
| const qr = qrcode(0, 'M'); | |
| qr.addData(shareLink); | |
| qr.make(); | |
| const imgTag = qr.createImgTag(6, 10); // 6: 模組大小, 10: 邊界 | |
| qrcodeContainer.innerHTML = imgTag; | |
| shareModal.classList.remove('hidden'); | |
| }); | |
| closeShareModalBtn.addEventListener('click', () => { | |
| shareModal.classList.add('hidden'); | |
| }); | |
| copyLinkBtn.addEventListener('click', () => { | |
| shareLinkInput.select(); | |
| shareLinkInput.setSelectionRange(0, 99999); // For mobile devices | |
| document.execCommand('copy'); | |
| const originalText = copyLinkBtn.textContent; | |
| copyLinkBtn.textContent = '已複製!'; | |
| setTimeout(() => { | |
| copyLinkBtn.textContent = originalText; | |
| }, 1500); | |
| }); | |
| // --- 成績報告事件 --- | |
| gradeReportBtn.addEventListener('click', () => { | |
| renderGradeReport(); | |
| showView('grade-report-view'); | |
| }); | |
| backToModesFromReportBtn.addEventListener('click', () => { | |
| showView('main-menu'); | |
| }); | |
| function renderGradeReport() { | |
| const formatTime = (seconds) => { | |
| const min = Math.floor(seconds / 60); | |
| const sec = seconds % 60; | |
| return `${min}分${sec}秒`; | |
| }; | |
| const renderModeReport = (container, mode, title) => { | |
| container.innerHTML = ''; | |
| const reports = gradeReports[mode] || []; | |
| if (reports.length === 0) { | |
| container.innerHTML = `<p class="text-center text-gray-500">尚無 ${title} 測驗紀錄。</p>`; | |
| return; | |
| } | |
| reports.sort((a, b) => new Date(b.date) - new Date(a.date)); | |
| reports.forEach((report, index) => { | |
| const date = new Date(report.date).toLocaleString('zh-TW', { | |
| year: 'numeric', month: 'numeric', day: 'numeric', | |
| hour: '2-digit', minute: '2-digit' | |
| }); | |
| let statusClass = 'bg-green-100 text-green-800'; | |
| let statusText = '完成'; | |
| let summary = ''; | |
| if (mode === 'speed') { | |
| summary = `得分: <span class="font-bold text-amber-600">${report.score}</span>`; | |
| statusText = '極速挑戰'; | |
| statusClass = 'bg-slate-100 text-slate-800'; | |
| } else { | |
| if (report.status === 'abandoned') { | |
| statusClass = 'bg-yellow-100 text-yellow-800'; | |
| statusText = '中途放棄'; | |
| summary = `已作答 ${report.attempted} 題,錯誤 ${report.mistakes} 次`; | |
| } else { | |
| summary = `總題數 ${report.total} 題,錯誤 <span class="font-bold text-red-500">${report.mistakes}</span> 次`; | |
| if (report.mistakes === 0) { | |
| statusClass = 'bg-green-100 text-green-800'; | |
| summary += ' (👑 完美)'; | |
| } else { | |
| statusClass = 'bg-red-100 text-red-800'; | |
| } | |
| } | |
| summary += `, 耗時 ${formatTime(report.time)}`; | |
| } | |
| const item = document.createElement('div'); | |
| item.className = 'p-3 border rounded-lg bg-white shadow-sm'; | |
| item.innerHTML = ` | |
| <div class="flex justify-between items-center mb-1"> | |
| <span class="text-xs text-gray-500">${date}</span> | |
| <span class="text-xs font-semibold px-2 py-0.5 rounded-full ${statusClass}">${statusText}</span> | |
| </div> | |
| <p class="text-sm text-gray-700">${summary}</p> | |
| `; | |
| container.appendChild(item); | |
| }); | |
| }; | |
| renderModeReport(reportZhEn, 'zh-en', '中翻英'); | |
| renderModeReport(reportEnZh, 'en-zh', '英翻中'); | |
| renderModeReport(reportListen, 'listen', '聽力測驗'); | |
| renderModeReport(document.createElement('div'), 'speed', '極速挑戰'); // 極速挑戰的報告不顯示在主報告區,但會被儲存 | |
| } | |
| // --- AI 解析流程事件 --- | |
| saveApiKeyBtn.addEventListener('click', saveApiKey); | |
| parsePdfBtn.addEventListener('click', async () => { | |
| parsePdfBtn.disabled = true; | |
| aiProgressContainer.classList.remove('hidden'); | |
| updateAIProgressBar(0); | |
| parseStatus.textContent = '正在準備解析...'; | |
| let textToParse = textInput.value.trim(); | |
| if (pdfUploadInput.files.length > 0) { | |
| const file = pdfUploadInput.files[0]; | |
| parseStatus.textContent = `正在讀取 PDF 檔案: ${file.name}...`; | |
| updateAIProgressBar(10); | |
| try { | |
| const pdf = await pdfjsLib.getDocument(URL.createObjectURL(file)).promise; | |
| let fullText = ''; | |
| for (let i = 1; i <= pdf.numPages; i++) { | |
| const page = await pdf.getPage(i); | |
| const textContent = await page.getTextContent(); | |
| fullText += textContent.items.map(item => item.str).join(' ') + '\n\n'; | |
| updateAIProgressBar(10 + (i / pdf.numPages) * 40); // 讀取進度到 50% | |
| } | |
| textToParse = fullText.trim(); | |
| parseStatus.textContent = 'PDF 讀取完成,開始 AI 分析...'; | |
| updateAIProgressBar(50); | |
| } catch (error) { | |
| console.error('PDF 讀取失敗:', error); | |
| parseStatus.textContent = `PDF 讀取失敗: ${error.message}`; | |
| parsePdfBtn.disabled = false; | |
| aiProgressContainer.classList.add('hidden'); | |
| return; | |
| } | |
| } | |
| if (textToParse) { | |
| await callGeminiToParseText(textToParse); | |
| } else { | |
| parseStatus.textContent = '請上傳 PDF 檔案或貼上文字內容。'; | |
| parsePdfBtn.disabled = false; | |
| aiProgressContainer.classList.add('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 = ''; | |
| textInput.value = ''; | |
| parsedWordsFromAI = []; | |
| }); | |
| cancelParseBtn.addEventListener('click', () => { | |
| parseConfirmModal.classList.add('hidden'); | |
| parseStatus.textContent = '操作已取消。'; | |
| pdfUploadInput.value = ''; | |
| textInput.value = ''; | |
| parsedWordsFromAI = []; | |
| }); | |
| // --- 範圍選擇與隨機題數連動 --- | |
| randomQuestionsCheckbox.addEventListener('change', (e) => { | |
| randomQuestionsCountInput.disabled = !e.target.checked; | |
| if (e.target.checked) { | |
| randomQuestionsCountInput.focus(); | |
| } | |
| }); | |
| // --- 初始化 --- | |
| loadWordsFromStorage(); | |
| </script> | |
| </body> | |
| </html> | |