Add wrong-question history and mobile quiz settings

This commit is contained in:
Boen_Shi 2026-06-29 16:28:24 +08:00
parent 64745ad9ae
commit 2050406306
10 changed files with 468 additions and 24 deletions

View File

@ -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')]

View File

@ -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;
}

View File

@ -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<Array<{ id: number; name: string }>>(`/api/app/banks/${bankId}/tags`)
}
export function fetchBankAttemptHistory(bankId: number) {
return apiGet<QuizAttemptHistory[]>(`/api/app/banks/${bankId}/attempts/history`)
}
export function startBankAttempt(bankId: number, payload: Record<string, unknown>) {
return apiPost<QuizAttempt>(`/api/app/banks/${bankId}/attempts`, payload)
}
@ -41,6 +45,10 @@ export function fetchWrongQuestions(params?: Record<string, unknown>) {
return apiGet<PageData<WrongQuestion>>('/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)
}

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import { computed, onMounted, onUnmounted, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { Collection, DataAnalysis, Files, House, Key, Menu, Notebook, OfficeBuilding, PriceTag, Setting, SwitchButton, Tickets, User } from '@element-plus/icons-vue'
@ -9,6 +9,7 @@ const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const collapsed = shallowRef(false)
const mobileMenuOpen = shallowRef(false)
const activeMenu = computed(() => route.path)
const menus = [
@ -39,16 +40,41 @@ async function logout() {
auth.clearSession()
await router.replace('/login')
}
function syncMobileMenu() {
if (window.innerWidth > 760) {
mobileMenuOpen.value = false
} else {
collapsed.value = true
}
}
function toggleMenu() {
if (window.innerWidth <= 760) {
mobileMenuOpen.value = !mobileMenuOpen.value
return
}
collapsed.value = !collapsed.value
}
onMounted(() => {
syncMobileMenu()
window.addEventListener('resize', syncMobileMenu)
})
onUnmounted(() => {
window.removeEventListener('resize', syncMobileMenu)
})
</script>
<template>
<div class="admin-shell">
<aside class="admin-aside" :class="{ 'admin-aside--collapsed': collapsed }">
<aside class="admin-aside" :class="{ 'admin-aside--collapsed': collapsed, 'admin-aside--mobile-open': mobileMenuOpen }">
<div class="brand">
<span class="brand-mark">Q</span>
<span v-show="!collapsed" class="brand-name">QuickQuiz</span>
</div>
<ElMenu :default-active="activeMenu" class="admin-menu" :collapse="collapsed" router>
<ElMenu :default-active="activeMenu" class="admin-menu" :collapse="collapsed" router @select="mobileMenuOpen = false">
<ElMenuItem v-for="item in visibleMenus" :key="item.path" :index="item.path">
<ElIcon><component :is="item.icon" /></ElIcon>
<span>{{ item.label }}</span>
@ -57,7 +83,7 @@ async function logout() {
</aside>
<main class="admin-main">
<header class="admin-topbar">
<ElButton :icon="Menu" circle @click="collapsed = !collapsed" />
<ElButton :icon="Menu" circle @click="toggleMenu" />
<div>
<p class="page-title">题库工作台</p>
<p class="muted text-sm">管理题库导入题目查看学习数据</p>
@ -147,15 +173,69 @@ async function logout() {
.admin-shell {
display: block;
}
.admin-aside {
width: 100%;
min-height: auto;
position: fixed;
inset: 0 auto 0 0;
z-index: 30;
width: min(82vw, 280px);
min-height: 100vh;
transform: translateX(-100%);
box-shadow: 16px 0 32px rgba(23, 33, 27, 0.14);
transition:
transform 0.18s ease,
width 0.18s ease;
}
.admin-aside--collapsed .admin-menu {
.admin-aside--collapsed {
width: min(82vw, 280px);
}
.admin-aside--mobile-open {
transform: translateX(0);
}
.admin-aside--collapsed .brand-name {
display: inline;
}
.admin-aside :deep(.el-menu--collapse) {
width: 100%;
}
.admin-aside :deep(.el-menu--collapse .el-menu-item span) {
display: inline-block;
height: auto;
overflow: visible;
visibility: visible;
width: auto;
}
.admin-topbar {
position: sticky;
top: 0;
z-index: 20;
flex-wrap: wrap;
gap: 10px;
padding: 10px 12px;
}
.admin-topbar > div {
min-width: 0;
flex: 1 1 calc(100% - 54px);
}
.admin-topbar > .el-button:not(:first-child) {
flex: 1;
min-width: 0;
}
.admin-topbar .muted {
display: none;
}
.admin-content {
padding: 14px;
padding: 12px;
}
}
</style>

View File

@ -2,14 +2,18 @@
import { computed, onMounted, onUnmounted, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { Collection, FullScreen, ScaleToOriginal, Setting, SwitchButton } from '@element-plus/icons-vue'
import { Collection, FullScreen, Monitor, ScaleToOriginal, Setting, SwitchButton } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useQuizStore } from '@/stores/quiz'
const router = useRouter()
const auth = useAuthStore()
const quiz = useQuizStore()
const fullscreen = shallowRef(false)
const settingsVisible = shallowRef(false)
const fullscreenIcon = computed(() => (fullscreen.value ? ScaleToOriginal : FullScreen))
const fullscreenTitle = computed(() => (fullscreen.value ? '退出全屏' : '全屏'))
const canEnterAdmin = computed(() => auth.user?.role === 'admin' || auth.permissionCodes.size > 0)
function syncFullscreenState() {
fullscreen.value = Boolean(document.fullscreenElement)
@ -58,11 +62,41 @@ onUnmounted(() => {
<ElTooltip :content="fullscreenTitle" placement="bottom">
<ElButton :icon="fullscreenIcon" circle @click="toggleFullscreen" />
</ElTooltip>
<ElButton :icon="Setting" circle @click="router.push('/admin')" />
<ElTooltip content="学习设置" placement="bottom">
<ElButton :icon="Setting" circle @click="settingsVisible = true" />
</ElTooltip>
<ElTooltip v-if="canEnterAdmin" content="管理后台" placement="bottom">
<ElButton :icon="Monitor" circle @click="router.push('/admin')" />
</ElTooltip>
<ElButton :icon="SwitchButton" circle @click="logout" />
</div>
</header>
<RouterView />
<ElDialog v-model="settingsVisible" title="学习设置" width="min(420px, calc(100vw - 24px))">
<ElForm label-position="top">
<ElFormItem label="答对后自动下一题">
<ElSwitch
:model-value="quiz.autoNextOnCorrect"
active-text="开启"
inactive-text="关闭"
@update:model-value="(value: boolean) => quiz.updateSettings({ autoNextOnCorrect: value })"
/>
</ElFormItem>
<ElFormItem label="切题动画时长">
<ElInputNumber
:model-value="quiz.animationDuration"
:min="0"
:max="600"
:step="20"
controls-position="right"
class="w-full"
@update:model-value="(value: number | undefined) => quiz.updateSettings({ animationDuration: value ?? 0 })"
/>
<p class="setting-tip">单位毫秒设置为 0 时关闭动画</p>
</ElFormItem>
</ElForm>
</ElDialog>
</div>
</template>
@ -101,4 +135,10 @@ onUnmounted(() => {
align-items: center;
gap: 8px;
}
.setting-tip {
margin: 8px 0 0;
color: var(--qq-muted);
font-size: 13px;
}
</style>

View File

@ -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<Promise<unknown>>()
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,
}
})

View File

@ -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

View File

@ -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;
}
}
</style>

View File

@ -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) => {
</script>
<template>
<main class="quiz-page" v-if="!loadingAttempt && quiz.attempt && item">
<main class="quiz-page" v-if="!loadingAttempt && quiz.attempt && item" :class="quizPageClass" :style="slideStyle">
<section class="question-panel" @touchstart.passive="handleTouchStart" @touchend.passive="handleTouchEnd">
<Transition :name="slideName" mode="out-in">
<article :key="item.id" class="question-slide">
@ -332,6 +366,9 @@ watch(remainingSeconds, async (value) => {
</div>
<div class="footer-group footer-group--center">
<ElButton class="footer-icon footer-sheet" :icon="Grid" circle @click="sheetOpen = true" />
<ElButton v-if="isWrongReview" class="footer-mastered" type="warning" plain @click="markCurrentMastered">
已掌握
</ElButton>
</div>
<div class="footer-group footer-group--right">
<ElButton class="footer-nav footer-next" type="primary" plain :disabled="isLastQuestion" @click="go(quiz.currentIndex + 1)">
@ -342,6 +379,7 @@ watch(remainingSeconds, async (value) => {
确认本题
</ElButton>
<ElButton
v-if="!isReadonlyReview"
class="footer-finish"
:class="{ 'footer-finish--defer': showConfirmAnswer }"
:icon="Finished"
@ -479,7 +517,7 @@ watch(remainingSeconds, async (value) => {
.option-list :deep(.el-radio__label),
.option-list :deep(.el-checkbox__label) {
display: flex;
align-items: flex-start;
align-items: center;
min-width: 0;
white-space: normal;
}
@ -597,7 +635,8 @@ watch(remainingSeconds, async (value) => {
.footer-nav,
.footer-primary,
.footer-finish {
.footer-finish,
.footer-mastered {
height: 44px;
border-radius: 8px;
font-weight: 700;
@ -615,6 +654,10 @@ watch(remainingSeconds, async (value) => {
width: 96px;
}
.footer-mastered {
width: 108px;
}
.footer-primary {
box-shadow: 0 8px 18px rgba(31, 111, 91, 0.18);
}
@ -632,8 +675,8 @@ watch(remainingSeconds, async (value) => {
.slide-prev-enter-active,
.slide-prev-leave-active {
transition:
transform 160ms ease,
opacity 130ms ease;
transform var(--quiz-slide-duration, 160ms) ease,
opacity var(--quiz-slide-opacity-duration, 130ms) ease;
}
.slide-next-enter-from,
@ -648,6 +691,21 @@ watch(remainingSeconds, async (value) => {
transform: translateX(-42px);
}
.quiz-page--no-motion .slide-next-enter-active,
.quiz-page--no-motion .slide-next-leave-active,
.quiz-page--no-motion .slide-prev-enter-active,
.quiz-page--no-motion .slide-prev-leave-active {
transition: none;
}
.quiz-page--no-motion .slide-next-enter-from,
.quiz-page--no-motion .slide-prev-leave-to,
.quiz-page--no-motion .slide-next-leave-to,
.quiz-page--no-motion .slide-prev-enter-from {
opacity: 1;
transform: none;
}
.answer-sheet {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
@ -752,6 +810,12 @@ watch(remainingSeconds, async (value) => {
height: 40px;
}
.footer-mastered {
grid-area: finish;
width: 100%;
height: 42px;
}
.footer-finish--defer {
display: none;
}

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { fetchBankTags, startBankAttempt, startPaperAttempt } from '@/api/quiz'
import type { Paper, QuestionBank } from '@/types/api'
import { ElMessage } from 'element-plus'
import { fetchBankAttemptHistory, fetchBankTags, startBankAttempt, startPaperAttempt } from '@/api/quiz'
import type { Paper, QuestionBank, QuizAttemptHistory } from '@/types/api'
import { fetchResources } from '@/api/quiz'
const router = useRouter()
@ -10,7 +11,11 @@ const banks = shallowRef<QuestionBank[]>([])
const papers = shallowRef<Paper[]>([])
const loading = shallowRef(false)
const randomDialogVisible = shallowRef(false)
const historyDialogVisible = shallowRef(false)
const randomBank = shallowRef<QuestionBank | null>(null)
const historyBank = shallowRef<QuestionBank | null>(null)
const historyAttempts = shallowRef<QuizAttemptHistory[]>([])
const historyLoading = shallowRef(false)
const tags = shallowRef<Array<{ id: number; name: string }>>([])
const randomForm = reactive({
limit: 20,
@ -26,6 +31,7 @@ const typeOptions = [
]
const randomTitle = computed(() => randomBank.value ? `${randomBank.value.name} - 随机刷题` : '随机刷题')
const historyTitle = computed(() => historyBank.value ? `${historyBank.value.name} - 历史回顾` : '历史回顾')
async function loadResources() {
loading.value = true
@ -62,6 +68,31 @@ async function submitRandom() {
})
}
async function reviewWrong(bank: QuestionBank) {
if (!bank.wrong_questions_count) return
await start(bank, 'wrong_practice')
}
async function openHistory(bank: QuestionBank) {
historyBank.value = bank
historyAttempts.value = []
historyDialogVisible.value = true
historyLoading.value = true
try {
historyAttempts.value = (await fetchBankAttemptHistory(bank.id)).data
} finally {
historyLoading.value = false
}
}
async function reviewAttempt(attempt: QuizAttemptHistory) {
if (attempt.status !== 'submitted') {
ElMessage.warning('只能回顾已提交的记录')
return
}
await router.push(`/quiz/${attempt.id}`)
}
async function startPaper(paper: Paper) {
const response = await startPaperAttempt(paper.id)
await router.push(`/quiz/${response.data.id}`)
@ -91,7 +122,8 @@ onMounted(loadResources)
<ElButton @click="start(bank, 'memorize')">顺序背题</ElButton>
<ElButton type="primary" @click="start(bank, 'sequence')">顺序刷题</ElButton>
<ElButton plain @click="openRandom(bank)">随机刷题</ElButton>
<ElButton plain type="warning" :disabled="!bank.wrong_questions_count" @click="router.push(`/quiz/wrong-questions?bank_id=${bank.id}`)">错题回顾</ElButton>
<ElButton plain type="warning" :disabled="!bank.wrong_questions_count" @click="reviewWrong(bank)">回顾错题</ElButton>
<ElButton plain @click="openHistory(bank)">历史回顾</ElButton>
</div>
</article>
</div>
@ -147,6 +179,22 @@ onMounted(loadResources)
<ElButton type="primary" @click="submitRandom">开始</ElButton>
</template>
</ElDialog>
<ElDialog v-model="historyDialogVisible" :title="historyTitle" width="min(560px, calc(100vw - 24px))">
<div v-loading="historyLoading" class="history-list">
<ElEmpty v-if="!historyLoading && historyAttempts.length === 0" description="暂无已提交的刷题记录" />
<article v-for="attempt in historyAttempts" :key="attempt.id" class="history-item">
<div>
<strong>{{ attempt.mode === 'random' ? '随机刷题' : '顺序刷题' }}</strong>
<p>{{ attempt.submitted_at || attempt.started_at || '-' }}</p>
</div>
<div class="history-meta">
<ElTag effect="plain">{{ attempt.correct_count }} / {{ attempt.total_questions }}</ElTag>
<ElButton type="primary" plain @click="reviewAttempt(attempt)">回顾</ElButton>
</div>
</article>
</div>
</ElDialog>
</template>
<style scoped>
@ -231,6 +279,31 @@ onMounted(loadResources)
gap: 8px;
}
.history-list {
min-height: 120px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid var(--qq-line);
}
.history-item p {
margin: 4px 0 0;
color: var(--qq-muted);
font-size: 13px;
}
.history-meta {
display: flex;
align-items: center;
gap: 8px;
}
.resource-actions :deep(.el-button) {
width: 100%;
justify-content: center;
@ -251,6 +324,15 @@ onMounted(loadResources)
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.history-item {
align-items: stretch;
flex-direction: column;
}
.history-meta {
justify-content: space-between;
}
:global(.random-dialog) {
margin-top: 8vh;
}