Add wrong-question history and mobile quiz settings
This commit is contained in:
parent
64745ad9ae
commit
2050406306
@ -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')]
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user