diff --git a/app/Http/Controllers/Api/App/QuizController.php b/app/Http/Controllers/Api/App/QuizController.php index 8a1f6b3..8f06c0d 100644 --- a/app/Http/Controllers/Api/App/QuizController.php +++ b/app/Http/Controllers/Api/App/QuizController.php @@ -56,6 +56,27 @@ final class QuizController extends Controller return ApiResponse::success($bank->tags()->orderBy('name')->get()); } + #[Apidoc\Title('题库历史练习记录')] + #[Apidoc\Url('/app/banks/{bank}/attempts/history')] + #[Apidoc\Method('GET')] + public function bankAttemptHistory(Request $request, mixed $bank, LearningAccessService $access): JsonResponse + { + $bank = $this->resolveBank($bank); + $user = $this->currentUser($request); + abort_if(! $access->canAccessBank($user, $bank), 403); + + return ApiResponse::success( + QuizAttempt::query() + ->where('user_id', $user->id) + ->where('question_bank_id', $bank->id) + ->whereIn('mode', ['sequence', 'random']) + ->where('status', 'submitted') + ->latest('submitted_at') + ->limit(30) + ->get(['id', 'mode', 'status', 'total_questions', 'correct_count', 'score', 'started_at', 'submitted_at']), + ); + } + #[Apidoc\Title('开始整卷测试')] #[Apidoc\Url('/app/papers/{paper}/attempts')] #[Apidoc\Method('POST')] @@ -73,9 +94,22 @@ final class QuizController extends Controller public function show(Request $request, mixed $attempt): JsonResponse { $attempt = $this->resolveAttempt($attempt); - abort_if($attempt->user_id !== $this->currentUser($request)->id, 403); + $user = $this->currentUser($request); + abort_if($attempt->user_id !== $user->id, 403); - return ApiResponse::success($attempt->load('items.question.options')); + $attempt->load('items.question.options'); + + if (str_contains((string) $attempt->mode, 'wrong')) { + $wrongIds = WrongQuestion::query() + ->where('user_id', $user->id) + ->whereNull('mastered_at') + ->whereIn('question_id', $attempt->items->pluck('question_id')) + ->pluck('id', 'question_id'); + + $attempt->items->each(fn ($item) => $item->setAttribute('wrong_question_id', $wrongIds[$item->question_id] ?? null)); + } + + return ApiResponse::success($attempt); } #[Apidoc\Title('提交单题答案')] @@ -146,6 +180,22 @@ final class QuizController extends Controller ); } + #[Apidoc\Title('标记错题已掌握')] + #[Apidoc\Url('/app/wrong-questions/{wrongQuestion}/mastered')] + #[Apidoc\Method('POST')] + public function masterWrongQuestion(Request $request, mixed $wrongQuestion): JsonResponse + { + $wrongQuestion = WrongQuestion::query()->findOrFail((int) $wrongQuestion); + abort_if($wrongQuestion->user_id !== $this->currentUser($request)->id, 403); + + $wrongQuestion->update([ + 'mastered_at' => now(), + 'consecutive_correct_count' => max(3, (int) $wrongQuestion->consecutive_correct_count), + ]); + + return ApiResponse::success($wrongQuestion->fresh(), '已移出错题'); + } + #[Apidoc\Title('收藏和笔记')] #[Apidoc\Url('/app/favorites')] #[Apidoc\Method('POST')] diff --git a/app/Services/QuizService.php b/app/Services/QuizService.php index 84da066..e7084a1 100644 --- a/app/Services/QuizService.php +++ b/app/Services/QuizService.php @@ -80,7 +80,7 @@ final class QuizService private function findResumeAttempt(User $user, QuestionBank $bank, string $mode, array $filters): ?QuizAttempt { - if (in_array($mode, ['random', 'wrong_random'], true)) { + if (in_array($mode, ['random', 'wrong_memorize', 'wrong_practice', 'wrong_random'], true)) { return null; } diff --git a/frontend/src/api/quiz.ts b/frontend/src/api/quiz.ts index 43788cf..592410c 100644 --- a/frontend/src/api/quiz.ts +++ b/frontend/src/api/quiz.ts @@ -1,5 +1,5 @@ import { apiGet, apiPost, apiPut, apiPutKeepalive } from './http' -import type { PageData, QuizAttempt, WrongQuestion } from '@/types/api' +import type { PageData, QuizAttempt, QuizAttemptHistory, WrongQuestion } from '@/types/api' export function fetchResources() { return apiGet('/api/app/resources') @@ -9,6 +9,10 @@ export function fetchBankTags(bankId: number) { return apiGet>(`/api/app/banks/${bankId}/tags`) } +export function fetchBankAttemptHistory(bankId: number) { + return apiGet(`/api/app/banks/${bankId}/attempts/history`) +} + export function startBankAttempt(bankId: number, payload: Record) { return apiPost(`/api/app/banks/${bankId}/attempts`, payload) } @@ -41,6 +45,10 @@ export function fetchWrongQuestions(params?: Record) { return apiGet>('/api/app/wrong-questions', params) } +export function masterWrongQuestion(wrongQuestionId: number) { + return apiPost(`/api/app/wrong-questions/${wrongQuestionId}/mastered`) +} + export function saveFavorite(payload: { question_id: number; note?: string }) { return apiPost('/api/app/favorites', payload) } diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index e7f5781..54fe961 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -1,5 +1,5 @@ @@ -101,4 +135,10 @@ onUnmounted(() => { align-items: center; gap: 8px; } + +.setting-tip { + margin: 8px 0 0; + color: var(--qq-muted); + font-size: 13px; +} diff --git a/frontend/src/stores/quiz.ts b/frontend/src/stores/quiz.ts index 6eda5a2..f48f3a8 100644 --- a/frontend/src/stores/quiz.ts +++ b/frontend/src/stores/quiz.ts @@ -4,6 +4,7 @@ import type { QuizAttempt } from '@/types/api' import { answerQuestion, fetchAttempt, + fetchWrongQuestions, startBankAttempt, submitAttempt, updateAttemptPosition, @@ -16,6 +17,8 @@ export const useQuizStore = defineStore('quiz', () => { const pendingSyncs = new Set>() const lastSavedPosition = shallowRef(0) const positionDirty = shallowRef(false) + const autoNextOnCorrect = shallowRef(localStorage.getItem('qq_quiz_auto_next') === '1') + const animationDuration = shallowRef(Number(localStorage.getItem('qq_quiz_animation_duration') ?? '160')) const currentItem = computed(() => attempt.value?.items[currentIndex.value] ?? null) const answeredCount = computed(() => attempt.value?.items.filter((item) => item.answer && item.answer.length > 0).length ?? 0) @@ -38,7 +41,7 @@ export const useQuizStore = defineStore('quiz', () => { async function resume(attemptId: number) { const response = await fetchAttempt(attemptId) - attempt.value = response.data + attempt.value = await attachWrongQuestionIds(response.data) currentIndex.value = response.data.current_index || 0 lastSavedPosition.value = currentIndex.value positionDirty.value = false @@ -116,6 +119,55 @@ export const useQuizStore = defineStore('quiz', () => { positionDirty.value = false } + function updateSettings(settings: { autoNextOnCorrect?: boolean; animationDuration?: number }) { + if (settings.autoNextOnCorrect !== undefined) { + autoNextOnCorrect.value = settings.autoNextOnCorrect + localStorage.setItem('qq_quiz_auto_next', settings.autoNextOnCorrect ? '1' : '0') + } + if (settings.animationDuration !== undefined) { + const duration = Math.min(Math.max(Number(settings.animationDuration) || 0, 0), 600) + animationDuration.value = duration + localStorage.setItem('qq_quiz_animation_duration', String(duration)) + } + } + + async function removeCurrentWrongQuestion() { + if (!attempt.value || !currentItem.value) return + const questionId = currentItem.value.question_id + attempt.value = { + ...attempt.value, + total_questions: Math.max(0, attempt.value.total_questions - 1), + items: attempt.value.items.filter((item) => item.question_id !== questionId), + } + if (attempt.value.items.length === 0) { + currentIndex.value = 0 + return + } + currentIndex.value = Math.min(currentIndex.value, attempt.value.items.length - 1) + } + + async function attachWrongQuestionIds(currentAttempt: QuizAttempt) { + if (currentAttempt.items.some((item) => item.wrong_question_id)) return currentAttempt + if (!currentAttempt.question_bank_id && !currentAttempt.items.some((item) => item.question)) return currentAttempt + if (!currentAttempt.mode.includes('wrong')) return currentAttempt + + const response = await fetchWrongQuestions({ + question_bank_id: currentAttempt.question_bank_id, + per_page: 100, + }).catch(() => null) + const wrongQuestions = response?.data.items ?? [] + if (wrongQuestions.length === 0) return currentAttempt + const wrongByQuestion = new Map(wrongQuestions.map((wrong) => [wrong.question_id, wrong.id])) + + return { + ...currentAttempt, + items: currentAttempt.items.map((item) => ({ + ...item, + wrong_question_id: wrongByQuestion.get(item.question_id), + })), + } + } + function updateCorrectCount(currentAttempt: QuizAttempt, questionId: number, isCorrect: boolean) { const previousItem = currentAttempt.items.find((item) => item.question_id === questionId) const previousCorrect = previousItem?.is_correct === true ? 1 : 0 @@ -156,5 +208,9 @@ export const useQuizStore = defineStore('quiz', () => { setPosition, savePosition, savePositionOnUnload, + autoNextOnCorrect, + animationDuration, + updateSettings, + removeCurrentWrongQuestion, } }) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 13785d6..5de0d3b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -106,6 +106,7 @@ export interface TaxonomyItem { export interface AttemptItem { id: number question_id: number + wrong_question_id?: number answer?: number[] | string[] is_correct?: boolean explanation_viewed: boolean @@ -114,7 +115,8 @@ export interface AttemptItem { export interface QuizAttempt { id: number - mode: 'memorize' | 'practice' | 'random' | 'paper' + question_bank_id?: number + mode: 'memorize' | 'wrong_memorize' | 'practice' | 'wrong_practice' | 'sequence' | 'random' | 'wrong_random' | 'paper' status: 'in_progress' | 'submitted' total_questions: number correct_count: number @@ -124,6 +126,17 @@ export interface QuizAttempt { items: AttemptItem[] } +export interface QuizAttemptHistory { + id: number + mode: QuizAttempt['mode'] + status: QuizAttempt['status'] + total_questions: number + correct_count: number + score: string + started_at?: string + submitted_at?: string +} + export interface WrongQuestion { id: number question_id: number diff --git a/frontend/src/views/admin/ReportsView.vue b/frontend/src/views/admin/ReportsView.vue index 352b4e0..06eeca9 100644 --- a/frontend/src/views/admin/ReportsView.vue +++ b/frontend/src/views/admin/ReportsView.vue @@ -633,10 +633,18 @@ onMounted(loadAll) @media (max-width: 640px) { .toolbar { align-items: stretch; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + .toolbar .flex-1 { + grid-column: 1 / -1; } .report-filter { grid-template-columns: 1fr; + padding: 12px; } .section-head { @@ -644,8 +652,51 @@ onMounted(loadAll) flex-direction: column; } + .report-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + .report-stats.compact { grid-template-columns: 1fr; } + + .stat-card { + padding: 12px; + } + + .stat-card strong { + font-size: 20px; + } + + .trend-chart, + .mastery-chart, + .mini-chart { + height: 240px; + } + + .report-layout { + gap: 12px; + } + + .panel { + padding: 12px !important; + } + + .report-pagination { + justify-content: flex-start; + overflow-x: auto; + padding-bottom: 4px; + } + + .insight-row { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + + .insight-row span { + white-space: normal; + } } diff --git a/frontend/src/views/app/QuizView.vue b/frontend/src/views/app/QuizView.vue index d404af6..60cf78f 100644 --- a/frontend/src/views/app/QuizView.vue +++ b/frontend/src/views/app/QuizView.vue @@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { ArrowLeft, ArrowRight, Finished, Grid, House } from '@element-plus/icons-vue' +import { masterWrongQuestion } from '@/api/quiz' import { useQuizStore } from '@/stores/quiz' const route = useRoute() @@ -50,10 +51,19 @@ const showConfirmAnswer = computed(() => quiz.attempt?.status === 'in_progress' const optionsLocked = computed(() => quiz.attempt?.status !== 'in_progress' || showAnswer.value) const isFirstQuestion = computed(() => quiz.currentIndex === 0) const isLastQuestion = computed(() => !quiz.attempt || quiz.currentIndex >= quiz.attempt.items.length - 1) +const isWrongReview = computed(() => quiz.attempt?.mode === 'wrong_practice') +const isReadonlyReview = computed(() => quiz.attempt?.status === 'submitted') const isAnsweredWrong = computed(() => item.value?.is_correct === false) const answerPanelClass = computed(() => ({ 'answer-panel--wrong': isAnsweredWrong.value, })) +const slideStyle = computed(() => ({ + '--quiz-slide-duration': `${quiz.animationDuration}ms`, + '--quiz-slide-opacity-duration': `${Math.max(0, Math.round(quiz.animationDuration * 0.8))}ms`, +})) +const quizPageClass = computed(() => ({ + 'quiz-page--no-motion': quiz.animationDuration === 0, +})) const selectedOptionLabels = computed(() => optionLabels(selected.value)) const correctOptionLabels = computed(() => { if (!item.value) return [] @@ -126,6 +136,11 @@ async function submitAnswer() { } await quiz.answer(answer, Math.max(0, Math.floor((Date.now() - startedAt.value) / 1000))) syncLocalAnswer() + if (quiz.autoNextOnCorrect && item.value?.is_correct === true && !isLastQuestion.value) { + window.setTimeout(() => { + void go(quiz.currentIndex + 1) + }, Math.max(80, quiz.animationDuration)) + } } async function selectSingle(optionId: number) { @@ -203,6 +218,25 @@ async function submitPaper(auto = false) { ElMessage.success(auto ? '时间到,已自动完成' : '练习已完成') } +async function markCurrentMastered() { + const wrongQuestionId = item.value?.wrong_question_id + if (!wrongQuestionId) { + ElMessage.warning('没有找到这道错题的记录') + return + } + + await masterWrongQuestion(wrongQuestionId) + await quiz.removeCurrentWrongQuestion() + ElMessage.success('已移出错题') + + if (!quiz.currentItem) { + await router.replace('/quiz') + return + } + + syncLocalAnswer() +} + function savePositionOnUnload() { quiz.savePositionOnUnload() } @@ -255,7 +289,7 @@ watch(remainingSeconds, async (value) => {