From be7fdbd71c7b776a5c6a52939b9629f2a3be51a5 Mon Sep 17 00:00:00 2001 From: apple246680 <97295135+apple246680@users.noreply.github.com> Date: Fri, 31 Oct 2025 20:43:57 +0800 Subject: [PATCH] Add responsive quiz interface with multiple practice modes --- index.html | 123 ++++++++++++++ script.js | 460 +++++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 380 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 963 insertions(+) create mode 100644 index.html create mode 100644 script.js create mode 100644 styles.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..642275c --- /dev/null +++ b/index.html @@ -0,0 +1,123 @@ + + + + + + 學科刷題系統 + + + +
+

學科刷題系統

+
+ + +
+
+ +
+ + + + + + + + + +
+ + + + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..740a909 --- /dev/null +++ b/script.js @@ -0,0 +1,460 @@ +const state = { + questions: [], + currentQuestions: [], + currentIndex: 0, + answers: new Map(), + bookmarks: new Set(), + mode: null, + showSolutions: false, +}; + +const elements = {}; + +function cacheElements() { + Object.assign(elements, { + body: document.body, + mainMenu: document.getElementById("mainMenu"), + rangeForm: document.getElementById("rangeForm"), + rangeSelector: document.getElementById("rangeSelector"), + rangeStart: document.getElementById("rangeStart"), + rangeEnd: document.getElementById("rangeEnd"), + cancelRange: document.getElementById("cancelRange"), + practiceMode: document.getElementById("practiceMode"), + customMode: document.getElementById("customMode"), + examMode: document.getElementById("examMode"), + quizContainer: document.getElementById("quizContainer"), + questionText: document.getElementById("questionText"), + questionImage: document.getElementById("questionImage"), + optionList: document.getElementById("optionList"), + noteText: document.getElementById("noteText"), + questionCounter: document.getElementById("questionCounter"), + bookmarkBtn: document.getElementById("bookmarkBtn"), + progressBar: document.getElementById("progressBar"), + feedback: document.getElementById("feedback"), + prevQuestion: document.getElementById("prevQuestion"), + nextQuestion: document.getElementById("nextQuestion"), + jumpInput: document.getElementById("jumpInput"), + jumpBtn: document.getElementById("jumpBtn"), + toggleProgress: document.getElementById("toggleProgress"), + progressPanel: document.getElementById("progressPanel"), + progressList: document.getElementById("progressList"), + closeProgress: document.getElementById("closeProgress"), + submitExam: document.getElementById("submitExam"), + backToMenu: document.getElementById("backToMenu"), + themeToggle: document.getElementById("themeToggle"), + resultPanel: document.getElementById("resultPanel"), + scoreSummary: document.getElementById("scoreSummary"), + resultBreakdown: document.getElementById("resultBreakdown"), + reviewAnswers: document.getElementById("reviewAnswers"), + finishReview: document.getElementById("finishReview"), + }); +} + +async function loadQuestions() { + const response = await fetch("question.json"); + if (!response.ok) { + throw new Error("無法載入題庫資料"); + } + const data = await response.json(); + state.questions = data; +} + +function setupEventListeners() { + elements.practiceMode.addEventListener("click", () => startPracticeMode()); + elements.customMode.addEventListener("click", () => showRangeForm()); + elements.examMode.addEventListener("click", () => startExamMode()); + elements.cancelRange.addEventListener("click", () => hideRangeForm()); + + elements.rangeSelector.addEventListener("submit", (event) => { + event.preventDefault(); + startCustomRangeMode(); + }); + + elements.bookmarkBtn.addEventListener("click", toggleBookmark); + elements.prevQuestion.addEventListener("click", () => moveQuestion(-1)); + elements.nextQuestion.addEventListener("click", () => moveQuestion(1)); + elements.jumpBtn.addEventListener("click", jumpToQuestion); + elements.jumpInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + jumpToQuestion(); + } + }); + + elements.toggleProgress.addEventListener("click", () => { + elements.progressPanel.hidden = false; + renderProgressPanel(); + }); + + elements.closeProgress.addEventListener("click", () => { + elements.progressPanel.hidden = true; + }); + + elements.progressPanel.addEventListener("click", (event) => { + if (event.target === elements.progressPanel) { + elements.progressPanel.hidden = true; + } + }); + + elements.submitExam.addEventListener("click", () => finishExam()); + + elements.backToMenu.addEventListener("click", returnToMenu); + + elements.themeToggle.addEventListener("click", toggleTheme); + + elements.reviewAnswers.addEventListener("click", () => { + state.showSolutions = true; + elements.resultPanel.hidden = true; + elements.quizContainer.hidden = false; + elements.finishReview.hidden = false; + elements.reviewAnswers.hidden = true; + renderCurrentQuestion(); + }); + + elements.finishReview.addEventListener("click", () => { + state.showSolutions = false; + elements.quizContainer.hidden = true; + elements.resultPanel.hidden = false; + elements.finishReview.hidden = true; + elements.reviewAnswers.hidden = false; + }); +} + +function initializeTheme() { + const saved = localStorage.getItem("quiz-theme") || "light"; + if (saved === "dark") { + elements.body.classList.add("dark"); + } + updateThemeButton(); +} + +function toggleTheme() { + elements.body.classList.toggle("dark"); + const current = elements.body.classList.contains("dark") ? "dark" : "light"; + localStorage.setItem("quiz-theme", current); + updateThemeButton(); +} + +function updateThemeButton() { + const isDark = elements.body.classList.contains("dark"); + elements.themeToggle.textContent = isDark ? "切換為亮色" : "切換為暗色"; +} + +function showRangeForm() { + elements.mainMenu.hidden = true; + elements.rangeForm.hidden = false; + elements.rangeStart.value = ""; + elements.rangeEnd.value = ""; +} + +function hideRangeForm() { + elements.mainMenu.hidden = false; + elements.rangeForm.hidden = true; +} + +function returnToMenu() { + state.currentQuestions = []; + state.answers.clear(); + state.bookmarks.clear(); + state.mode = null; + state.showSolutions = false; + elements.quizContainer.hidden = true; + elements.resultPanel.hidden = true; + elements.progressPanel.hidden = true; + elements.mainMenu.hidden = false; + elements.backToMenu.hidden = true; + elements.submitExam.hidden = true; + elements.reviewAnswers.hidden = false; + elements.finishReview.hidden = true; + elements.feedback.textContent = ""; +} + +function startPracticeMode() { + resetState(); + state.mode = "practice"; + state.currentQuestions = [...state.questions]; + activateQuiz(); +} + +function startCustomRangeMode() { + const start = Number(elements.rangeStart.value); + const end = Number(elements.rangeEnd.value); + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < 1 || end < start) { + alert("請輸入正確的題號範圍"); + return; + } + if (start > state.questions.length) { + alert("起始題號超出題庫範圍"); + return; + } + const rangeQuestions = state.questions.slice(start - 1, Math.min(end, state.questions.length)); + if (!rangeQuestions.length) { + alert("選定範圍內沒有題目"); + return; + } + resetState(); + state.mode = "custom"; + state.currentQuestions = pickRandom(rangeQuestions, 50); + elements.rangeForm.hidden = true; + activateQuiz(); +} + +function startExamMode() { + resetState(); + state.mode = "exam"; + state.currentQuestions = pickRandom(state.questions, 50); + activateQuiz(); +} + +function resetState() { + state.currentIndex = 0; + state.answers = new Map(); + state.bookmarks = new Set(); + state.showSolutions = false; + elements.feedback.textContent = ""; +} + +function activateQuiz() { + elements.mainMenu.hidden = true; + elements.rangeForm.hidden = true; + elements.quizContainer.hidden = false; + elements.backToMenu.hidden = false; + elements.resultPanel.hidden = true; + elements.reviewAnswers.hidden = state.mode === "practice"; + elements.finishReview.hidden = true; + elements.submitExam.hidden = state.mode === "practice"; + renderCurrentQuestion(); + updateNavigationButtons(); + updateProgressBar(); +} + +function moveQuestion(step) { + const newIndex = state.currentIndex + step; + if (newIndex < 0 || newIndex >= state.currentQuestions.length) { + return; + } + state.currentIndex = newIndex; + renderCurrentQuestion(); + updateNavigationButtons(); +} + +function jumpToQuestion() { + const value = Number(elements.jumpInput.value); + if (!Number.isInteger(value) || value < 1 || value > state.currentQuestions.length) { + alert("請輸入有效的題號"); + return; + } + state.currentIndex = value - 1; + renderCurrentQuestion(); + updateNavigationButtons(); + elements.jumpInput.value = ""; +} + +function toggleBookmark() { + const idx = state.currentIndex; + if (state.bookmarks.has(idx)) { + state.bookmarks.delete(idx); + } else { + state.bookmarks.add(idx); + } + renderBookmarkState(); + renderProgressPanel(); +} + +function renderBookmarkState() { + const isBookmarked = state.bookmarks.has(state.currentIndex); + elements.bookmarkBtn.textContent = isBookmarked ? "移除書籤" : "加入書籤"; +} + +function renderCurrentQuestion() { + const question = state.currentQuestions[state.currentIndex]; + if (!question) return; + + elements.questionCounter.textContent = `第 ${state.currentIndex + 1} / ${state.currentQuestions.length} 題`; + elements.questionText.textContent = question.question; + if (question.questionimage) { + elements.questionImage.src = `image/${question.questionimage}`; + elements.questionImage.hidden = false; + } else { + elements.questionImage.hidden = true; + } + + elements.optionList.innerHTML = ""; + question.option.forEach((choice, index) => { + const li = document.createElement("li"); + li.className = "option-item"; + + const input = document.createElement("input"); + input.type = "radio"; + input.name = "option"; + input.value = index; + input.id = `option-${state.currentIndex}-${index}`; + input.checked = state.answers.get(state.currentIndex) === index; + input.disabled = state.mode !== "practice" && state.showSolutions; + input.addEventListener("change", () => handleOptionSelection(index)); + + const label = document.createElement("label"); + label.className = "option-label"; + label.setAttribute("for", input.id); + label.textContent = choice + (question.optionend || ""); + + li.append(input, label); + elements.optionList.appendChild(li); + }); + if (question.ps) { + elements.noteText.textContent = question.ps; + elements.noteText.hidden = false; + } else { + elements.noteText.hidden = true; + elements.noteText.textContent = ""; + } + renderBookmarkState(); + renderFeedback(); + updateProgressBar(); +} + +function handleOptionSelection(index) { + if (state.mode !== "practice" && state.showSolutions) { + return; + } + state.answers.set(state.currentIndex, index); + renderFeedback(index); + renderProgressPanel(); + updateProgressBar(); +} + +function renderFeedback(selectedIndex) { + const question = state.currentQuestions[state.currentIndex]; + const storedAnswer = state.answers.get(state.currentIndex); + const userAnswer = selectedIndex ?? storedAnswer; + const correct = question.answer; + + const shouldReveal = state.mode === "practice" ? userAnswer != null : state.showSolutions; + Array.from(elements.optionList.children).forEach((li, idx) => { + li.classList.remove("correct", "incorrect"); + if (shouldReveal) { + if (idx === correct) { + li.classList.add("correct"); + } + if (userAnswer != null && idx === userAnswer && userAnswer !== correct) { + li.classList.add("incorrect"); + } + } + }); + + if (state.mode === "practice") { + if (userAnswer == null) { + elements.feedback.textContent = ""; + return; + } + if (userAnswer === correct) { + elements.feedback.innerHTML = `答對了! ${question.explain || ""}`; + } else { + elements.feedback.innerHTML = `答錯了,正確答案為 ${String.fromCharCode(65 + correct)}。 ${question.explain || ""}`; + } + } else if (state.showSolutions) { + const correctLabel = String.fromCharCode(65 + correct); + const explainText = question.explain ? `,${question.explain}` : ""; + elements.feedback.textContent = `正確答案:${correctLabel}${explainText}`; + } else { + elements.feedback.textContent = userAnswer != null ? "已作答" : ""; + } +} + +function updateNavigationButtons() { + elements.prevQuestion.disabled = state.currentIndex === 0; + elements.nextQuestion.disabled = state.currentIndex === state.currentQuestions.length - 1; +} + +function renderProgressPanel() { + if (elements.progressPanel.hidden) return; + elements.progressList.innerHTML = ""; + state.currentQuestions.forEach((_, idx) => { + const li = document.createElement("li"); + li.className = "progress-item"; + const answered = state.answers.has(idx); + li.classList.add(answered ? "answered" : "unanswered"); + if (state.bookmarks.has(idx)) { + li.classList.add("bookmarked"); + } + li.textContent = idx + 1; + li.addEventListener("click", () => { + state.currentIndex = idx; + elements.progressPanel.hidden = true; + renderCurrentQuestion(); + updateNavigationButtons(); + }); + elements.progressList.appendChild(li); + }); +} + +function updateProgressBar() { + const total = state.currentQuestions.length || 1; + const answered = state.answers.size; + const progress = Math.round((answered / total) * 100); + elements.progressBar.style.width = `${progress}%`; +} + +function pickRandom(arr, count) { + if (arr.length <= count) { + return [...arr]; + } + const clone = [...arr]; + for (let i = clone.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [clone[i], clone[j]] = [clone[j], clone[i]]; + } + return clone.slice(0, count); +} + +function finishExam() { + const total = state.currentQuestions.length; + const answered = state.answers.size; + if (answered < total && !confirm("仍有未作答題目,確定要交卷嗎?")) { + return; + } + + let correctCount = 0; + const breakdown = []; + state.currentQuestions.forEach((question, idx) => { + const userAnswer = state.answers.get(idx); + const isCorrect = userAnswer === question.answer; + if (isCorrect) correctCount += 1; + breakdown.push({ + index: idx, + userAnswer, + isCorrect, + correct: question.answer, + }); + }); + + const incorrect = breakdown.filter((item) => item.userAnswer != null && !item.isCorrect).length; + const unanswered = total - state.answers.size; + const score = Math.round((correctCount / total) * 100); + + elements.scoreSummary.textContent = `共 ${total} 題,答對 ${correctCount} 題,答錯 ${incorrect} 題,未作答 ${unanswered} 題,得分 ${score} 分。`; + elements.resultBreakdown.innerHTML = ""; + breakdown.forEach((item) => { + const li = document.createElement("li"); + const questionNumber = item.index + 1; + const userText = item.userAnswer != null ? String.fromCharCode(65 + item.userAnswer) : "未作答"; + const correctText = String.fromCharCode(65 + item.correct); + li.innerHTML = `第 ${questionNumber} 題:${item.isCorrect ? "✔" : "✘"} 選擇 ${userText},正確為 ${correctText}`; + elements.resultBreakdown.appendChild(li); + }); + + elements.quizContainer.hidden = true; + elements.resultPanel.hidden = false; + elements.progressPanel.hidden = true; +} + +document.addEventListener("DOMContentLoaded", async () => { + cacheElements(); + initializeTheme(); + setupEventListeners(); + try { + await loadQuestions(); + } catch (error) { + alert(error.message); + } +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..515e2a4 --- /dev/null +++ b/styles.css @@ -0,0 +1,380 @@ +:root { + color-scheme: light dark; + --bg: #f7f7fb; + --surface: #ffffff; + --text: #1f1f2a; + --muted: #4a4a5a; + --primary: #3450d3; + --primary-contrast: #ffffff; + --border: #d7d7e0; + --shadow: 0 10px 30px rgba(28, 28, 54, 0.1); +} + +body.dark { + --bg: #101321; + --surface: #181b2f; + --text: #f0f3ff; + --muted: #b6b9d4; + --primary: #5c7cfa; + --primary-contrast: #111320; + --border: #2c3250; + --shadow: 0 12px 30px rgba(0, 0, 0, 0.35); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Noto Sans TC", "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; + transition: background 0.3s ease, color 0.3s ease; +} + +main { + flex: 1; + width: min(1100px, 94vw); + margin: 0 auto; + padding: 1.5rem 0 3rem; + display: grid; + gap: 1.5rem; +} + +.app-header, +.app-footer { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 1rem 4vw; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: var(--shadow); + z-index: 3; +} + +.app-footer { + border-top: 1px solid var(--border); + border-bottom: none; + justify-content: center; + box-shadow: none; +} + +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 1rem; + padding: clamp(1.2rem, 3vw, 2rem); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.menu-grid { + display: grid; + gap: 1rem; +} + +.menu-grid button { + text-align: left; +} + +.menu-grid .desc { + display: block; + color: var(--muted); + font-size: 0.95rem; + margin-top: 0.25rem; +} + +button { + font: inherit; + border-radius: 999px; + border: 1px solid transparent; + padding: 0.65rem 1.3rem; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.3s ease; +} + +button.primary { + background: var(--primary); + color: var(--primary-contrast); + box-shadow: 0 6px 18px rgba(52, 80, 211, 0.25); +} + +button.secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +button:not(:disabled):hover { + transform: translateY(-1px); + box-shadow: 0 8px 24px rgba(52, 80, 211, 0.15); +} + +button.secondary:not(:disabled):hover { + box-shadow: none; + background: rgba(128, 139, 255, 0.08); +} + +.progress-wrapper { + position: relative; + height: 10px; + background: rgba(128, 139, 255, 0.15); + border-radius: 999px; + overflow: hidden; +} + +#progressBar { + height: 100%; + width: 0; + background: var(--primary); + transition: width 0.3s ease; +} + +.quiz-top, +.quiz-bottom { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; +} + +#questionText { + font-size: clamp(1.2rem, 2.5vw, 1.5rem); + line-height: 1.6; +} + +#questionImage { + max-width: min(100%, 420px); + align-self: center; + border-radius: 0.75rem; + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +#optionList { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.75rem; +} + +.option-item { + border: 1px solid var(--border); + border-radius: 0.9rem; + padding: 0.9rem 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + background: rgba(112, 125, 255, 0.05); + transition: border 0.2s ease, background 0.2s ease; +} + +.option-item:hover { + border-color: var(--primary); +} + +.option-item input { + pointer-events: none; +} + +.option-item.correct { + border-color: #38c172; + background: rgba(56, 193, 114, 0.15); +} + +.option-item.incorrect { + border-color: #f05a7e; + background: rgba(240, 90, 126, 0.12); +} + +.option-label { + flex: 1; + cursor: pointer; +} + +.option-suffix { + color: var(--muted); +} + +#feedback { + min-height: 2.5rem; + color: var(--muted); + font-size: 0.95rem; +} + +#feedback strong { + color: var(--primary); +} + +.nav-group, +.jump-group { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.jump-group input { + width: 5rem; + border-radius: 0.65rem; + border: 1px solid var(--border); + padding: 0.4rem 0.6rem; + background: transparent; + color: inherit; +} + +#progressPanel { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: grid; + place-items: center; + padding: 1.5rem; + z-index: 5; +} + +#progressPanel .panel { + max-width: 500px; + width: min(100%, 500px); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +#progressList { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(60px, 1fr)); + gap: 0.6rem; + list-style: none; + padding: 0; + margin: 0; +} + +.progress-item { + border-radius: 0.75rem; + border: 1px solid var(--border); + padding: 0.5rem; + text-align: center; + font-size: 0.9rem; + background: rgba(112, 125, 255, 0.04); + cursor: pointer; + position: relative; +} + +.progress-item.bookmarked::after { + content: "★"; + position: absolute; + top: 6px; + right: 8px; + font-size: 0.8rem; + color: #f8c346; +} + +.progress-item.answered { + border-color: #38c172; +} + +.progress-item.unanswered { + border-color: #f05a7e; +} + +.legend { + display: flex; + gap: 1rem; + font-size: 0.9rem; + color: var(--muted); +} + +.dot { + width: 10px; + height: 10px; + display: inline-block; + border-radius: 50%; + margin-right: 0.3rem; +} + +.dot.answered { + background: #38c172; +} + +.dot.unanswered { + background: #f05a7e; +} + +.dot.bookmarked { + background: #f8c346; +} + +#resultPanel ul { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.5rem; +} + +#resultPanel li { + border-radius: 0.75rem; + border: 1px solid var(--border); + padding: 0.75rem 1rem; + background: rgba(112, 125, 255, 0.05); +} + +.form-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +@media (min-width: 768px) { + .menu-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 600px) { + .app-header, + .app-footer { + flex-direction: column; + gap: 0.75rem; + text-align: center; + } + + .quiz-top, + .quiz-bottom { + flex-direction: column; + align-items: stretch; + } + + button { + width: 100%; + } + + .nav-group, + .jump-group { + width: 100%; + justify-content: space-between; + } + + .jump-group input { + width: 100%; + } +}