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());
|
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\Title('开始整卷测试')]
|
||||||
#[Apidoc\Url('/app/papers/{paper}/attempts')]
|
#[Apidoc\Url('/app/papers/{paper}/attempts')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
@ -73,9 +94,22 @@ final class QuizController extends Controller
|
|||||||
public function show(Request $request, mixed $attempt): JsonResponse
|
public function show(Request $request, mixed $attempt): JsonResponse
|
||||||
{
|
{
|
||||||
$attempt = $this->resolveAttempt($attempt);
|
$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('提交单题答案')]
|
#[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\Title('收藏和笔记')]
|
||||||
#[Apidoc\Url('/app/favorites')]
|
#[Apidoc\Url('/app/favorites')]
|
||||||
#[Apidoc\Method('POST')]
|
#[Apidoc\Method('POST')]
|
||||||
|
|||||||
@ -80,7 +80,7 @@ final class QuizService
|
|||||||
|
|
||||||
private function findResumeAttempt(User $user, QuestionBank $bank, string $mode, array $filters): ?QuizAttempt
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { apiGet, apiPost, apiPut, apiPutKeepalive } from './http'
|
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() {
|
export function fetchResources() {
|
||||||
return apiGet('/api/app/resources')
|
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`)
|
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>) {
|
export function startBankAttempt(bankId: number, payload: Record<string, unknown>) {
|
||||||
return apiPost<QuizAttempt>(`/api/app/banks/${bankId}/attempts`, payload)
|
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)
|
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 }) {
|
export function saveFavorite(payload: { question_id: number; note?: string }) {
|
||||||
return apiPost('/api/app/favorites', payload)
|
return apiPost('/api/app/favorites', payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, shallowRef } from 'vue'
|
import { computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import { Collection, DataAnalysis, Files, House, Key, Menu, Notebook, OfficeBuilding, PriceTag, Setting, SwitchButton, Tickets, User } from '@element-plus/icons-vue'
|
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 router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const collapsed = shallowRef(false)
|
const collapsed = shallowRef(false)
|
||||||
|
const mobileMenuOpen = shallowRef(false)
|
||||||
|
|
||||||
const activeMenu = computed(() => route.path)
|
const activeMenu = computed(() => route.path)
|
||||||
const menus = [
|
const menus = [
|
||||||
@ -39,16 +40,41 @@ async function logout() {
|
|||||||
auth.clearSession()
|
auth.clearSession()
|
||||||
await router.replace('/login')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-shell">
|
<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">
|
<div class="brand">
|
||||||
<span class="brand-mark">Q</span>
|
<span class="brand-mark">Q</span>
|
||||||
<span v-show="!collapsed" class="brand-name">QuickQuiz</span>
|
<span v-show="!collapsed" class="brand-name">QuickQuiz</span>
|
||||||
</div>
|
</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">
|
<ElMenuItem v-for="item in visibleMenus" :key="item.path" :index="item.path">
|
||||||
<ElIcon><component :is="item.icon" /></ElIcon>
|
<ElIcon><component :is="item.icon" /></ElIcon>
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
@ -57,7 +83,7 @@ async function logout() {
|
|||||||
</aside>
|
</aside>
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
<header class="admin-topbar">
|
<header class="admin-topbar">
|
||||||
<ElButton :icon="Menu" circle @click="collapsed = !collapsed" />
|
<ElButton :icon="Menu" circle @click="toggleMenu" />
|
||||||
<div>
|
<div>
|
||||||
<p class="page-title">题库工作台</p>
|
<p class="page-title">题库工作台</p>
|
||||||
<p class="muted text-sm">管理题库、导入题目、查看学习数据</p>
|
<p class="muted text-sm">管理题库、导入题目、查看学习数据</p>
|
||||||
@ -147,15 +173,69 @@ async function logout() {
|
|||||||
.admin-shell {
|
.admin-shell {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-aside {
|
.admin-aside {
|
||||||
width: 100%;
|
position: fixed;
|
||||||
min-height: auto;
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -2,14 +2,18 @@
|
|||||||
import { computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
import { computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessageBox } from 'element-plus'
|
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 { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useQuizStore } from '@/stores/quiz'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const quiz = useQuizStore()
|
||||||
const fullscreen = shallowRef(false)
|
const fullscreen = shallowRef(false)
|
||||||
|
const settingsVisible = shallowRef(false)
|
||||||
const fullscreenIcon = computed(() => (fullscreen.value ? ScaleToOriginal : FullScreen))
|
const fullscreenIcon = computed(() => (fullscreen.value ? ScaleToOriginal : FullScreen))
|
||||||
const fullscreenTitle = computed(() => (fullscreen.value ? '退出全屏' : '全屏'))
|
const fullscreenTitle = computed(() => (fullscreen.value ? '退出全屏' : '全屏'))
|
||||||
|
const canEnterAdmin = computed(() => auth.user?.role === 'admin' || auth.permissionCodes.size > 0)
|
||||||
|
|
||||||
function syncFullscreenState() {
|
function syncFullscreenState() {
|
||||||
fullscreen.value = Boolean(document.fullscreenElement)
|
fullscreen.value = Boolean(document.fullscreenElement)
|
||||||
@ -58,11 +62,41 @@ onUnmounted(() => {
|
|||||||
<ElTooltip :content="fullscreenTitle" placement="bottom">
|
<ElTooltip :content="fullscreenTitle" placement="bottom">
|
||||||
<ElButton :icon="fullscreenIcon" circle @click="toggleFullscreen" />
|
<ElButton :icon="fullscreenIcon" circle @click="toggleFullscreen" />
|
||||||
</ElTooltip>
|
</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" />
|
<ElButton :icon="SwitchButton" circle @click="logout" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<RouterView />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -101,4 +135,10 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-tip {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--qq-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { QuizAttempt } from '@/types/api'
|
|||||||
import {
|
import {
|
||||||
answerQuestion,
|
answerQuestion,
|
||||||
fetchAttempt,
|
fetchAttempt,
|
||||||
|
fetchWrongQuestions,
|
||||||
startBankAttempt,
|
startBankAttempt,
|
||||||
submitAttempt,
|
submitAttempt,
|
||||||
updateAttemptPosition,
|
updateAttemptPosition,
|
||||||
@ -16,6 +17,8 @@ export const useQuizStore = defineStore('quiz', () => {
|
|||||||
const pendingSyncs = new Set<Promise<unknown>>()
|
const pendingSyncs = new Set<Promise<unknown>>()
|
||||||
const lastSavedPosition = shallowRef(0)
|
const lastSavedPosition = shallowRef(0)
|
||||||
const positionDirty = shallowRef(false)
|
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 currentItem = computed(() => attempt.value?.items[currentIndex.value] ?? null)
|
||||||
const answeredCount = computed(() => attempt.value?.items.filter((item) => item.answer && item.answer.length > 0).length ?? 0)
|
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) {
|
async function resume(attemptId: number) {
|
||||||
const response = await fetchAttempt(attemptId)
|
const response = await fetchAttempt(attemptId)
|
||||||
attempt.value = response.data
|
attempt.value = await attachWrongQuestionIds(response.data)
|
||||||
currentIndex.value = response.data.current_index || 0
|
currentIndex.value = response.data.current_index || 0
|
||||||
lastSavedPosition.value = currentIndex.value
|
lastSavedPosition.value = currentIndex.value
|
||||||
positionDirty.value = false
|
positionDirty.value = false
|
||||||
@ -116,6 +119,55 @@ export const useQuizStore = defineStore('quiz', () => {
|
|||||||
positionDirty.value = false
|
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) {
|
function updateCorrectCount(currentAttempt: QuizAttempt, questionId: number, isCorrect: boolean) {
|
||||||
const previousItem = currentAttempt.items.find((item) => item.question_id === questionId)
|
const previousItem = currentAttempt.items.find((item) => item.question_id === questionId)
|
||||||
const previousCorrect = previousItem?.is_correct === true ? 1 : 0
|
const previousCorrect = previousItem?.is_correct === true ? 1 : 0
|
||||||
@ -156,5 +208,9 @@ export const useQuizStore = defineStore('quiz', () => {
|
|||||||
setPosition,
|
setPosition,
|
||||||
savePosition,
|
savePosition,
|
||||||
savePositionOnUnload,
|
savePositionOnUnload,
|
||||||
|
autoNextOnCorrect,
|
||||||
|
animationDuration,
|
||||||
|
updateSettings,
|
||||||
|
removeCurrentWrongQuestion,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -106,6 +106,7 @@ export interface TaxonomyItem {
|
|||||||
export interface AttemptItem {
|
export interface AttemptItem {
|
||||||
id: number
|
id: number
|
||||||
question_id: number
|
question_id: number
|
||||||
|
wrong_question_id?: number
|
||||||
answer?: number[] | string[]
|
answer?: number[] | string[]
|
||||||
is_correct?: boolean
|
is_correct?: boolean
|
||||||
explanation_viewed: boolean
|
explanation_viewed: boolean
|
||||||
@ -114,7 +115,8 @@ export interface AttemptItem {
|
|||||||
|
|
||||||
export interface QuizAttempt {
|
export interface QuizAttempt {
|
||||||
id: number
|
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'
|
status: 'in_progress' | 'submitted'
|
||||||
total_questions: number
|
total_questions: number
|
||||||
correct_count: number
|
correct_count: number
|
||||||
@ -124,6 +126,17 @@ export interface QuizAttempt {
|
|||||||
items: AttemptItem[]
|
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 {
|
export interface WrongQuestion {
|
||||||
id: number
|
id: number
|
||||||
question_id: number
|
question_id: number
|
||||||
|
|||||||
@ -633,10 +633,18 @@ onMounted(loadAll)
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.toolbar {
|
.toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .flex-1 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-filter {
|
.report-filter {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
@ -644,8 +652,51 @@ onMounted(loadAll)
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.report-stats.compact {
|
.report-stats.compact {
|
||||||
grid-template-columns: 1fr;
|
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>
|
</style>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
|||||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ArrowLeft, ArrowRight, Finished, Grid, House } from '@element-plus/icons-vue'
|
import { ArrowLeft, ArrowRight, Finished, Grid, House } from '@element-plus/icons-vue'
|
||||||
|
import { masterWrongQuestion } from '@/api/quiz'
|
||||||
import { useQuizStore } from '@/stores/quiz'
|
import { useQuizStore } from '@/stores/quiz'
|
||||||
|
|
||||||
const route = useRoute()
|
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 optionsLocked = computed(() => quiz.attempt?.status !== 'in_progress' || showAnswer.value)
|
||||||
const isFirstQuestion = computed(() => quiz.currentIndex === 0)
|
const isFirstQuestion = computed(() => quiz.currentIndex === 0)
|
||||||
const isLastQuestion = computed(() => !quiz.attempt || quiz.currentIndex >= quiz.attempt.items.length - 1)
|
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 isAnsweredWrong = computed(() => item.value?.is_correct === false)
|
||||||
const answerPanelClass = computed(() => ({
|
const answerPanelClass = computed(() => ({
|
||||||
'answer-panel--wrong': isAnsweredWrong.value,
|
'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 selectedOptionLabels = computed(() => optionLabels(selected.value))
|
||||||
const correctOptionLabels = computed(() => {
|
const correctOptionLabels = computed(() => {
|
||||||
if (!item.value) return []
|
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)))
|
await quiz.answer(answer, Math.max(0, Math.floor((Date.now() - startedAt.value) / 1000)))
|
||||||
syncLocalAnswer()
|
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) {
|
async function selectSingle(optionId: number) {
|
||||||
@ -203,6 +218,25 @@ async function submitPaper(auto = false) {
|
|||||||
ElMessage.success(auto ? '时间到,已自动完成' : '练习已完成')
|
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() {
|
function savePositionOnUnload() {
|
||||||
quiz.savePositionOnUnload()
|
quiz.savePositionOnUnload()
|
||||||
}
|
}
|
||||||
@ -255,7 +289,7 @@ watch(remainingSeconds, async (value) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<section class="question-panel" @touchstart.passive="handleTouchStart" @touchend.passive="handleTouchEnd">
|
||||||
<Transition :name="slideName" mode="out-in">
|
<Transition :name="slideName" mode="out-in">
|
||||||
<article :key="item.id" class="question-slide">
|
<article :key="item.id" class="question-slide">
|
||||||
@ -332,6 +366,9 @@ watch(remainingSeconds, async (value) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-group footer-group--center">
|
<div class="footer-group footer-group--center">
|
||||||
<ElButton class="footer-icon footer-sheet" :icon="Grid" circle @click="sheetOpen = true" />
|
<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>
|
||||||
<div class="footer-group footer-group--right">
|
<div class="footer-group footer-group--right">
|
||||||
<ElButton class="footer-nav footer-next" type="primary" plain :disabled="isLastQuestion" @click="go(quiz.currentIndex + 1)">
|
<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>
|
||||||
<ElButton
|
<ElButton
|
||||||
|
v-if="!isReadonlyReview"
|
||||||
class="footer-finish"
|
class="footer-finish"
|
||||||
:class="{ 'footer-finish--defer': showConfirmAnswer }"
|
:class="{ 'footer-finish--defer': showConfirmAnswer }"
|
||||||
:icon="Finished"
|
:icon="Finished"
|
||||||
@ -479,7 +517,7 @@ watch(remainingSeconds, async (value) => {
|
|||||||
.option-list :deep(.el-radio__label),
|
.option-list :deep(.el-radio__label),
|
||||||
.option-list :deep(.el-checkbox__label) {
|
.option-list :deep(.el-checkbox__label) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
@ -597,7 +635,8 @@ watch(remainingSeconds, async (value) => {
|
|||||||
|
|
||||||
.footer-nav,
|
.footer-nav,
|
||||||
.footer-primary,
|
.footer-primary,
|
||||||
.footer-finish {
|
.footer-finish,
|
||||||
|
.footer-mastered {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -615,6 +654,10 @@ watch(remainingSeconds, async (value) => {
|
|||||||
width: 96px;
|
width: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-mastered {
|
||||||
|
width: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-primary {
|
.footer-primary {
|
||||||
box-shadow: 0 8px 18px rgba(31, 111, 91, 0.18);
|
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-enter-active,
|
||||||
.slide-prev-leave-active {
|
.slide-prev-leave-active {
|
||||||
transition:
|
transition:
|
||||||
transform 160ms ease,
|
transform var(--quiz-slide-duration, 160ms) ease,
|
||||||
opacity 130ms ease;
|
opacity var(--quiz-slide-opacity-duration, 130ms) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-next-enter-from,
|
.slide-next-enter-from,
|
||||||
@ -648,6 +691,21 @@ watch(remainingSeconds, async (value) => {
|
|||||||
transform: translateX(-42px);
|
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 {
|
.answer-sheet {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
|
||||||
@ -752,6 +810,12 @@ watch(remainingSeconds, async (value) => {
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-mastered {
|
||||||
|
grid-area: finish;
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-finish--defer {
|
.footer-finish--defer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { fetchBankTags, startBankAttempt, startPaperAttempt } from '@/api/quiz'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { Paper, QuestionBank } from '@/types/api'
|
import { fetchBankAttemptHistory, fetchBankTags, startBankAttempt, startPaperAttempt } from '@/api/quiz'
|
||||||
|
import type { Paper, QuestionBank, QuizAttemptHistory } from '@/types/api'
|
||||||
import { fetchResources } from '@/api/quiz'
|
import { fetchResources } from '@/api/quiz'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -10,7 +11,11 @@ const banks = shallowRef<QuestionBank[]>([])
|
|||||||
const papers = shallowRef<Paper[]>([])
|
const papers = shallowRef<Paper[]>([])
|
||||||
const loading = shallowRef(false)
|
const loading = shallowRef(false)
|
||||||
const randomDialogVisible = shallowRef(false)
|
const randomDialogVisible = shallowRef(false)
|
||||||
|
const historyDialogVisible = shallowRef(false)
|
||||||
const randomBank = shallowRef<QuestionBank | null>(null)
|
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 tags = shallowRef<Array<{ id: number; name: string }>>([])
|
||||||
const randomForm = reactive({
|
const randomForm = reactive({
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@ -26,6 +31,7 @@ const typeOptions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const randomTitle = computed(() => randomBank.value ? `${randomBank.value.name} - 随机刷题` : '随机刷题')
|
const randomTitle = computed(() => randomBank.value ? `${randomBank.value.name} - 随机刷题` : '随机刷题')
|
||||||
|
const historyTitle = computed(() => historyBank.value ? `${historyBank.value.name} - 历史回顾` : '历史回顾')
|
||||||
|
|
||||||
async function loadResources() {
|
async function loadResources() {
|
||||||
loading.value = true
|
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) {
|
async function startPaper(paper: Paper) {
|
||||||
const response = await startPaperAttempt(paper.id)
|
const response = await startPaperAttempt(paper.id)
|
||||||
await router.push(`/quiz/${response.data.id}`)
|
await router.push(`/quiz/${response.data.id}`)
|
||||||
@ -91,7 +122,8 @@ onMounted(loadResources)
|
|||||||
<ElButton @click="start(bank, 'memorize')">顺序背题</ElButton>
|
<ElButton @click="start(bank, 'memorize')">顺序背题</ElButton>
|
||||||
<ElButton type="primary" @click="start(bank, 'sequence')">顺序刷题</ElButton>
|
<ElButton type="primary" @click="start(bank, 'sequence')">顺序刷题</ElButton>
|
||||||
<ElButton plain @click="openRandom(bank)">随机刷题</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@ -147,6 +179,22 @@ onMounted(loadResources)
|
|||||||
<ElButton type="primary" @click="submitRandom">开始</ElButton>
|
<ElButton type="primary" @click="submitRandom">开始</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElDialog>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -231,6 +279,31 @@ onMounted(loadResources)
|
|||||||
gap: 8px;
|
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) {
|
.resource-actions :deep(.el-button) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -251,6 +324,15 @@ onMounted(loadResources)
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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) {
|
:global(.random-dialog) {
|
||||||
margin-top: 8vh;
|
margin-top: 8vh;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user