diff --git a/index.html b/index.html new file mode 100644 index 0000000..0d07995 --- /dev/null +++ b/index.html @@ -0,0 +1,122 @@ + + + + + + 學科刷題系統 + + + +
+
+
+

學科刷題系統

+ +
+
+ + +
+
+ + + +
+ + + +
+ + + + +
+ + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..84e3f0e --- /dev/null +++ b/script.js @@ -0,0 +1,469 @@ +const elements = { + menu: document.getElementById("menu"), + questionSection: document.getElementById("questionSection"), + questionNumber: document.getElementById("questionNumber"), + questionText: document.getElementById("questionText"), + questionFigure: document.getElementById("questionFigure"), + questionImage: document.getElementById("questionImage"), + imageCaption: document.getElementById("imageCaption"), + options: document.getElementById("optionsContainer"), + feedback: document.getElementById("feedback"), + prevBtn: document.getElementById("prevBtn"), + nextBtn: document.getElementById("nextBtn"), + jumpForm: document.getElementById("jumpForm"), + jumpInput: document.getElementById("jumpInput"), + bookmarkBtn: document.getElementById("bookmarkBtn"), + backToMenu: document.getElementById("backToMenu"), + statusToggle: document.getElementById("statusToggle"), + statusPanel: document.getElementById("statusPanel"), + closeStatus: document.getElementById("closeStatus"), + statusList: document.getElementById("statusList"), + progressContainer: document.getElementById("progressContainer"), + progressCount: document.getElementById("progressCount"), + progressTotal: document.getElementById("progressTotal"), + progressFill: document.getElementById("progressFill"), + finishBtn: document.getElementById("finishBtn"), + modeLabel: document.getElementById("modeLabel"), + resultsModal: document.getElementById("resultsModal"), + closeResults: document.getElementById("closeResults"), + resultsBody: document.getElementById("resultsBody"), + modalBack: document.getElementById("modalBack"), + modalReview: document.getElementById("modalReview"), + themeToggle: document.getElementById("themeToggle") +}; + +const state = { + questions: [], + mode: null, + questionOrder: [], + currentIndex: 0, + userAnswers: [], + bookmarks: new Set(), + showSolutions: false +}; + +const MODE_TEXT = { + practice: "自由參考模式", + custom: "自訂範圍測驗", + exam: "模擬考試模式" +}; + +fetch("question.json") + .then((res) => { + if (!res.ok) { + throw new Error("無法載入題庫"); + } + return res.json(); + }) + .then((data) => { + state.questions = Array.isArray(data) ? data : []; + if (!state.questions.length) { + showMessage("尚未提供題目資料。", true); + } + }) + .catch((err) => { + console.error(err); + showMessage("題庫載入失敗,請稍後再試。", true); + }); + +function showMessage(message, sticky = false) { + elements.feedback.textContent = message; + if (sticky) { + elements.feedback.classList.remove("correct", "incorrect"); + } +} + +function startPractice() { + startMode("practice", state.questions.map((_, idx) => idx)); +} + +function startCustomRange(start, end) { + const total = state.questions.length; + if (!total) return; + const realStart = Math.max(1, Math.min(start, end)); + const realEnd = Math.min(total, Math.max(start, end)); + const available = []; + for (let i = realStart - 1; i < realEnd; i += 1) { + available.push(i); + } + if (!available.length) { + alert("指定範圍內沒有題目。"); + return; + } + const selection = shuffleArray(available).slice(0, Math.min(50, available.length)); + startMode("custom", selection); +} + +function startExam() { + if (!state.questions.length) return; + const indexes = state.questions.map((_, idx) => idx); + const selection = shuffleArray(indexes).slice(0, Math.min(50, indexes.length)); + startMode("exam", selection); +} + +function shuffleArray(arr) { + const copy = [...arr]; + for (let i = copy.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +} + +function startMode(mode, order) { + if (!order.length) { + alert("沒有可使用的題目。"); + return; + } + state.mode = mode; + state.questionOrder = order; + state.currentIndex = 0; + state.userAnswers = Array(order.length).fill(null); + state.bookmarks = new Set(); + state.showSolutions = mode === "practice"; + elements.modeLabel.textContent = `模式:${MODE_TEXT[mode] || ""}`; + + elements.menu.classList.add("hidden"); + elements.questionSection.classList.remove("hidden"); + elements.progressContainer.classList.remove("hidden"); + elements.finishBtn.classList.toggle("hidden", mode === "practice"); + elements.feedback.textContent = ""; + elements.feedback.classList.remove("correct", "incorrect"); + + elements.progressTotal.textContent = state.questionOrder.length; + updateQuestionView(); + updateProgress(); + updateStatusPanel(); + closeStatusPanel(); +} + +function updateQuestionView() { + const qIndex = state.questionOrder[state.currentIndex]; + const originalNumber = qIndex + 1; + const total = state.questionOrder.length; + const currentDisplay = state.currentIndex + 1; + elements.questionNumber.textContent = `第 ${originalNumber} 題(${currentDisplay}/${total})`; + + const question = state.questions[qIndex]; + elements.questionText.textContent = question.question || ""; + + if (question.questionimage) { + elements.questionFigure.classList.remove("hidden"); + elements.questionImage.src = `image/${question.questionimage}`; + elements.questionImage.alt = question.question || "題目圖片"; + elements.imageCaption.textContent = ""; + } else { + elements.questionFigure.classList.add("hidden"); + elements.questionImage.src = ""; + elements.imageCaption.textContent = ""; + } + + elements.options.innerHTML = ""; + const optionSuffix = question.optionend || ""; + question.option.forEach((text, idx) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "option-btn"; + btn.dataset.optionIndex = idx; + const prefix = String.fromCharCode(65 + idx); + const prefixSpan = document.createElement("span"); + prefixSpan.className = "option-prefix"; + prefixSpan.textContent = `${prefix}.`; + const textSpan = document.createElement("span"); + textSpan.className = "option-text"; + textSpan.textContent = `${text}${optionSuffix}`; + btn.append(prefixSpan, textSpan); + if (state.userAnswers[state.currentIndex] === idx) { + btn.classList.add("selected"); + } + btn.addEventListener("click", () => handleAnswer(idx)); + elements.options.appendChild(btn); + }); + + const isBookmarked = state.bookmarks.has(state.currentIndex); + elements.bookmarkBtn.setAttribute("aria-pressed", isBookmarked); + elements.bookmarkBtn.classList.toggle("active", isBookmarked); + + renderFeedback(); + updateNavButtons(); + updateStatusPanel(); +} + +function handleAnswer(optionIndex) { + state.userAnswers[state.currentIndex] = optionIndex; + updateOptionSelection(); + if (state.mode === "practice") { + renderFeedback(); + } else if (state.showSolutions) { + renderFeedback(); + } else { + elements.feedback.textContent = `已選擇選項 ${String.fromCharCode(65 + optionIndex)}`; + elements.feedback.classList.remove("correct", "incorrect"); + } + updateProgress(); + updateStatusPanel(); +} + +function updateOptionSelection() { + const buttons = elements.options.querySelectorAll(".option-btn"); + buttons.forEach((btn) => { + const idx = Number(btn.dataset.optionIndex); + btn.classList.toggle("selected", state.userAnswers[state.currentIndex] === idx); + }); +} + +function renderFeedback() { + const qIndex = state.questionOrder[state.currentIndex]; + const question = state.questions[qIndex]; + const answer = question.answer; + const selected = state.userAnswers[state.currentIndex]; + const optionSuffix = question.optionend || ""; + + elements.feedback.classList.remove("correct", "incorrect"); + elements.feedback.innerHTML = ""; + + if (selected == null) { + if (state.showSolutions && state.mode !== "practice") { + const correctText = question.option[answer] + optionSuffix; + elements.feedback.innerHTML = `
正確答案:${String.fromCharCode(65 + answer)}. ${correctText}
`; + } + return; + } + + const isCorrect = selected === answer; + const correctText = question.option[answer] + optionSuffix; + const explainBlock = question.explain ? `
${question.explain}
` : ""; + const psBlock = question.ps ? `
${question.ps}
` : ""; + + if (state.mode === "practice" || state.showSolutions) { + elements.feedback.classList.add(isCorrect ? "correct" : "incorrect"); + const statusLine = isCorrect ? "答對了!" : "答錯了。"; + elements.feedback.innerHTML = `
${statusLine}
正確答案:${String.fromCharCode(65 + answer)}. ${correctText}
${explainBlock}${psBlock}`; + } else { + elements.feedback.textContent = `已選擇選項 ${String.fromCharCode(65 + selected)}`; + } +} + +function updateProgress() { + const answered = state.userAnswers.filter((ans) => ans != null).length; + const total = state.questionOrder.length; + elements.progressCount.textContent = answered; + elements.progressTotal.textContent = total; + const percent = total ? (answered / total) * 100 : 0; + elements.progressFill.style.width = `${percent}%`; +} + +function updateNavButtons() { + elements.prevBtn.disabled = state.currentIndex === 0; + elements.nextBtn.disabled = state.currentIndex === state.questionOrder.length - 1; +} + +function goToQuestion(targetIndex) { + if (targetIndex < 0 || targetIndex >= state.questionOrder.length) return; + state.currentIndex = targetIndex; + updateQuestionView(); + closeStatusPanel(); +} + +function updateStatusPanel() { + elements.statusList.innerHTML = ""; + state.questionOrder.forEach((originalIndex, idx) => { + const item = document.createElement("button"); + item.type = "button"; + item.className = "status-item"; + if (idx === state.currentIndex) { + item.classList.add("current"); + } + if (state.userAnswers[idx] != null) { + item.classList.add("answered"); + } + if (state.bookmarks.has(idx)) { + item.classList.add("bookmarked"); + } + item.innerHTML = `#${originalIndex + 1}`; + if (state.bookmarks.has(idx)) { + item.innerHTML += '🔖'; + } + item.addEventListener("click", () => goToQuestion(idx)); + elements.statusList.appendChild(item); + }); +} + +function toggleBookmark() { + if (state.bookmarks.has(state.currentIndex)) { + state.bookmarks.delete(state.currentIndex); + } else { + state.bookmarks.add(state.currentIndex); + } + updateQuestionView(); + updateStatusPanel(); +} + +function nextQuestion() { + if (state.currentIndex < state.questionOrder.length - 1) { + state.currentIndex += 1; + updateQuestionView(); + } +} + +function prevQuestion() { + if (state.currentIndex > 0) { + state.currentIndex -= 1; + updateQuestionView(); + } +} + +function handleJump(event) { + event.preventDefault(); + const value = Number(elements.jumpInput.value); + if (!value) return; + const targetOriginal = value - 1; + const targetIndex = state.questionOrder.indexOf(targetOriginal); + if (targetIndex >= 0) { + goToQuestion(targetIndex); + elements.jumpInput.value = ""; + } else { + alert("目前測驗中不包含此題號。"); + } +} + +function finishAssessment() { + if (state.mode === "practice") return; + const total = state.questionOrder.length; + const results = state.questionOrder.map((questionIdx, idx) => { + const question = state.questions[questionIdx]; + const userAnswer = state.userAnswers[idx]; + const correct = userAnswer === question.answer; + return { + questionNumber: questionIdx + 1, + userAnswer, + correctAnswer: question.answer, + correct + }; + }); + + const answered = results.filter((item) => item.userAnswer != null).length; + const correctCount = results.filter((item) => item.correct).length; + const unanswered = total - answered; + const score = Math.round((correctCount / total) * 100); + + elements.resultsBody.innerHTML = ` +
+
得分:${score} 分
+ +
+ `; + + elements.resultsModal.classList.remove("hidden"); + state.showSolutions = true; + renderFeedback(); +} + +function closeResultsModal() { + elements.resultsModal.classList.add("hidden"); +} + +function returnToMenu() { + closeResultsModal(); + state.mode = null; + state.questionOrder = []; + state.userAnswers = []; + state.bookmarks = new Set(); + state.showSolutions = false; + elements.modeLabel.textContent = ""; + elements.questionSection.classList.add("hidden"); + elements.menu.classList.remove("hidden"); + elements.progressContainer.classList.add("hidden"); + closeStatusPanel(); +} + +function closeStatusPanel() { + elements.statusPanel.classList.remove("open"); + elements.statusPanel.classList.add("hidden"); + elements.statusPanel.setAttribute("aria-hidden", "true"); + elements.statusToggle.setAttribute("aria-expanded", "false"); +} + +function toggleStatusPanel() { + if (elements.statusPanel.classList.contains("open")) { + closeStatusPanel(); + } else { + elements.statusPanel.classList.remove("hidden"); + elements.statusPanel.classList.add("open"); + elements.statusPanel.setAttribute("aria-hidden", "false"); + elements.statusToggle.setAttribute("aria-expanded", "true"); + } +} + +function applySavedTheme() { + const saved = localStorage.getItem("quiz-theme"); + if (saved) { + document.documentElement.setAttribute("data-theme", saved); + } +} + +function toggleTheme() { + const current = document.documentElement.getAttribute("data-theme") || "light"; + const next = current === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + localStorage.setItem("quiz-theme", next); +} + +applySavedTheme(); + +document.querySelectorAll("button[data-mode]").forEach((btn) => { + btn.addEventListener("click", (event) => { + const mode = event.currentTarget.dataset.mode; + if (!state.questions.length) { + alert("題庫尚未載入完成。"); + return; + } + if (mode === "practice") { + startPractice(); + } else if (mode === "exam") { + startExam(); + } + }); +}); + +document.getElementById("customForm").addEventListener("submit", (event) => { + event.preventDefault(); + if (!state.questions.length) { + alert("題庫尚未載入完成。"); + return; + } + const start = Number(document.getElementById("startInput").value); + const end = Number(document.getElementById("endInput").value); + if (!start || !end) { + alert("請輸入有效的題號範圍。"); + return; + } + startCustomRange(start, end); +}); + +elements.prevBtn.addEventListener("click", prevQuestion); +elements.nextBtn.addEventListener("click", nextQuestion); +elements.jumpForm.addEventListener("submit", handleJump); +elements.bookmarkBtn.addEventListener("click", toggleBookmark); +elements.backToMenu.addEventListener("click", returnToMenu); +elements.statusToggle.addEventListener("click", toggleStatusPanel); +elements.closeStatus.addEventListener("click", closeStatusPanel); +elements.finishBtn.addEventListener("click", finishAssessment); +elements.closeResults.addEventListener("click", closeResultsModal); +elements.modalReview.addEventListener("click", closeResultsModal); +elements.modalBack.addEventListener("click", returnToMenu); +elements.themeToggle.addEventListener("click", toggleTheme); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + if (!elements.resultsModal.classList.contains("hidden")) { + closeResultsModal(); + } else if (elements.statusPanel.classList.contains("open")) { + closeStatusPanel(); + } + } +}); diff --git a/style.css b/style.css new file mode 100644 index 0000000..c4b72c9 --- /dev/null +++ b/style.css @@ -0,0 +1,494 @@ +:root { + --bg: #f5f5f5; + --surface: #ffffff; + --text: #1f1f1f; + --subtle-text: #555; + --accent: #2563eb; + --accent-contrast: #fff; + --border: #d9d9d9; + --success: #16a34a; + --error: #dc2626; +} + +:root[data-theme="dark"], .dark { + --bg: #101418; + --surface: #1d242c; + --text: #e5ecf5; + --subtle-text: #9fb3c8; + --accent: #60a5fa; + --accent-contrast: #0b1120; + --border: #2d3845; + --success: #4ade80; + --error: #f87171; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Noto Sans TC", "Helvetica Neue", Arial, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.app { + max-width: 1200px; + margin: 0 auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.title-area { + display: flex; + align-items: baseline; + gap: 1rem; + flex-wrap: wrap; +} + +.mode-label { + font-size: 0.95rem; + color: var(--subtle-text); +} + +.header-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.icon-btn { + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + padding: 0.4rem 0.75rem; + border-radius: 999px; + cursor: pointer; + font-size: 0.95rem; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.icon-btn:hover, +button.primary:hover, +button.secondary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15); +} + +button.primary, +button.secondary { + border-radius: 999px; + border: 1px solid transparent; + padding: 0.6rem 1.25rem; + font-size: 1rem; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +button.primary { + background: var(--accent); + color: var(--accent-contrast); + border-color: var(--accent); +} + +button.secondary { + background: transparent; + color: var(--text); + border-color: var(--border); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.progress-container { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.progress-text { + font-weight: 600; +} + +.progress-bar { + width: 100%; + background: var(--border); + height: 12px; + border-radius: 999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + width: 0; + background: var(--accent); + transition: width 0.2s ease; +} + +.menu { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.menu-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 18px; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); +} + +.menu-card h2 { + margin: 0; +} + +.range-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +input[type="number"] { + padding: 0.6rem 0.75rem; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-size: 1rem; +} + +.question-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.question-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: 0 12px 35px rgba(15, 23, 42, 0.08); +} + +.question-text { + font-size: 1.15rem; + line-height: 1.7; +} + +.question-image { + margin: 0; + border: 1px solid var(--border); + border-radius: 16px; + overflow: hidden; + background: var(--bg); +} + +.question-image img { + width: 100%; + display: block; +} + +.options { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.option-btn { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 0.75rem; + border-radius: 16px; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + background: var(--surface); + cursor: pointer; + text-align: left; + transition: background 0.2s ease, border-color 0.2s ease; +} + +.option-btn:hover { + border-color: var(--accent); +} + +.option-btn.selected { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.option-prefix { + font-weight: 600; +} + +.feedback { + min-height: 2rem; + font-weight: 600; +} + +.feedback.correct { + color: var(--success); +} + +.feedback.incorrect { + color: var(--error); +} + +.feedback .explain, +.feedback .ps { + font-weight: 400; + margin-top: 0.5rem; + color: var(--subtle-text); + line-height: 1.6; +} + +.question-actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: space-between; +} + +.nav-buttons { + display: flex; + gap: 0.75rem; +} + +.jump-form { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.jump-form input { + width: 90px; +} + +.status-panel { + position: fixed; + top: 0; + right: 0; + width: min(320px, 90vw); + height: 100%; + background: var(--surface); + border-left: 1px solid var(--border); + box-shadow: -12px 0 30px rgba(15, 23, 42, 0.25); + transform: translateX(100%); + transition: transform 0.3s ease; + display: flex; + flex-direction: column; + padding: 1.25rem; + gap: 1rem; + z-index: 1000; +} + +.status-panel.open { + transform: translateX(0); +} + +.status-panel header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.status-legend { + display: flex; + gap: 0.75rem; + align-items: center; + font-size: 0.9rem; + flex-wrap: wrap; +} + +.status-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); + gap: 0.5rem; + overflow-y: auto; + padding-bottom: 1rem; +} + +.status-item { + padding: 0.65rem 0.4rem; + border-radius: 12px; + border: 1px solid var(--border); + text-align: center; + cursor: pointer; + font-size: 0.9rem; + background: var(--surface); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.status-item.current { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent); +} + +.status-item.answered { + border-color: var(--success); +} + +.status-item.bookmarked { + border-color: var(--accent); +} + +.status-item .bookmark-flag { + font-size: 0.75rem; +} + +.dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} + +.dot.answered { + background: var(--success); +} + +.dot.unanswered { + background: var(--border); +} + +.dot.bookmarked { + background: var(--accent); +} + +.modal { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1200; +} + +.modal.hidden, +.hidden { + display: none; +} + +.modal-content { + background: var(--surface); + border-radius: 20px; + padding: 1.5rem; + max-width: 520px; + width: 100%; + display: flex; + flex-direction: column; + gap: 1rem; + border: 1px solid var(--border); + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.25); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + flex-wrap: wrap; +} + +.results-summary { + display: grid; + gap: 0.75rem; +} + +.results-summary .score { + font-size: 1.4rem; + font-weight: 700; +} + +.results-summary ul { + margin: 0; + padding-left: 1.2rem; + color: var(--subtle-text); +} + +@media (max-width: 768px) { + .app { + padding: 1rem; + } + + .question-card { + padding: 1.1rem; + } + + .question-text { + font-size: 1.05rem; + } + + .question-actions { + flex-direction: column; + align-items: stretch; + } + + .nav-buttons, + .jump-form, + .modal-actions { + width: 100%; + } + + .jump-form { + justify-content: space-between; + } + + .jump-form input { + flex: 1; + } + + .nav-buttons { + justify-content: space-between; + } +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #101418; + --surface: #1d242c; + --text: #e5ecf5; + --subtle-text: #9fb3c8; + --accent: #60a5fa; + --accent-contrast: #0b1120; + --border: #2d3845; + --success: #4ade80; + --error: #f87171; + } +}