flashcards / index.html
Lashtw's picture
Update index.html
72482ec verified
<!DOCTYPE html>
<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 !important; /* Indigo-500 */
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.canvas-active-interaction {
background-color: #f0fdf4 !important; /* 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>