Paginate attempt history and refine wrong-review footer

This commit is contained in:
Boen_Shi 2026-06-29 16:42:15 +08:00
parent 2050406306
commit 4682048dc5
6 changed files with 81 additions and 13 deletions

View File

@ -65,15 +65,14 @@ final class QuizController extends Controller
$user = $this->currentUser($request); $user = $this->currentUser($request);
abort_if(! $access->canAccessBank($user, $bank), 403); abort_if(! $access->canAccessBank($user, $bank), 403);
return ApiResponse::success( return ApiResponse::page(
QuizAttempt::query() QuizAttempt::query()
->where('user_id', $user->id) ->where('user_id', $user->id)
->where('question_bank_id', $bank->id) ->where('question_bank_id', $bank->id)
->whereIn('mode', ['sequence', 'random']) ->whereIn('mode', ['sequence', 'random'])
->where('status', 'submitted') ->where('status', 'submitted')
->latest('submitted_at') ->latest('submitted_at')
->limit(30) ->paginate((int) $request->query('per_page', 10), ['id', 'mode', 'status', 'total_questions', 'correct_count', 'score', 'started_at', 'submitted_at']),
->get(['id', 'mode', 'status', 'total_questions', 'correct_count', 'score', 'started_at', 'submitted_at']),
); );
} }

BIN
frontend/dist (2).zip Normal file

Binary file not shown.

Binary file not shown.

View File

@ -9,8 +9,8 @@ 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) { export function fetchBankAttemptHistory(bankId: number, params?: Record<string, unknown>) {
return apiGet<QuizAttemptHistory[]>(`/api/app/banks/${bankId}/attempts/history`) return apiGet<PageData<QuizAttemptHistory>>(`/api/app/banks/${bankId}/attempts/history`, params)
} }
export function startBankAttempt(bankId: number, payload: Record<string, unknown>) { export function startBankAttempt(bankId: number, payload: Record<string, unknown>) {

View File

@ -357,7 +357,7 @@ watch(remainingSeconds, async (value) => {
</section> </section>
<footer class="quiz-footer"> <footer class="quiz-footer">
<div class="footer-inner"> <div class="footer-inner" :class="{ 'footer-inner--wrong-review': isWrongReview }">
<div class="footer-group footer-group--left"> <div class="footer-group footer-group--left">
<ElButton class="footer-icon footer-home" :icon="House" circle @click="router.push('/quiz')" /> <ElButton class="footer-icon footer-home" :icon="House" circle @click="router.push('/quiz')" />
<ElButton class="footer-nav footer-prev" :icon="ArrowLeft" :disabled="isFirstQuestion" @click="go(quiz.currentIndex - 1)"> <ElButton class="footer-nav footer-prev" :icon="ArrowLeft" :disabled="isFirstQuestion" @click="go(quiz.currentIndex - 1)">
@ -365,7 +365,6 @@ watch(remainingSeconds, async (value) => {
</ElButton> </ElButton>
</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 v-if="isWrongReview" class="footer-mastered" type="warning" plain @click="markCurrentMastered"> <ElButton v-if="isWrongReview" class="footer-mastered" type="warning" plain @click="markCurrentMastered">
已掌握 已掌握
</ElButton> </ElButton>
@ -379,7 +378,7 @@ watch(remainingSeconds, async (value) => {
确认本题 确认本题
</ElButton> </ElButton>
<ElButton <ElButton
v-if="!isReadonlyReview" v-if="!isReadonlyReview && !isWrongReview"
class="footer-finish" class="footer-finish"
:class="{ 'footer-finish--defer': showConfirmAnswer }" :class="{ 'footer-finish--defer': showConfirmAnswer }"
:icon="Finished" :icon="Finished"
@ -388,6 +387,7 @@ watch(remainingSeconds, async (value) => {
> >
{{ quiz.attempt.status === 'submitted' ? '已提交' : '提交' }} {{ quiz.attempt.status === 'submitted' ? '已提交' : '提交' }}
</ElButton> </ElButton>
<ElButton class="footer-icon footer-sheet" :icon="Grid" circle @click="sheetOpen = true" />
</div> </div>
</div> </div>
</footer> </footer>
@ -766,6 +766,13 @@ watch(remainingSeconds, async (value) => {
box-shadow: none; box-shadow: none;
} }
.footer-inner--wrong-review {
grid-template-columns: 42px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) 42px;
grid-template-areas:
"home prev mastered next sheet"
"confirm confirm confirm confirm confirm";
}
.footer-group { .footer-group {
display: contents; display: contents;
} }
@ -804,6 +811,10 @@ watch(remainingSeconds, async (value) => {
padding-inline: 10px; padding-inline: 10px;
} }
.footer-inner--wrong-review .footer-primary {
grid-area: confirm;
}
.footer-finish { .footer-finish {
grid-area: finish; grid-area: finish;
width: 100%; width: 100%;
@ -811,7 +822,7 @@ watch(remainingSeconds, async (value) => {
} }
.footer-mastered { .footer-mastered {
grid-area: finish; grid-area: mastered;
width: 100%; width: 100%;
height: 42px; height: 42px;
} }

View File

@ -3,7 +3,7 @@ import { computed, onMounted, reactive, shallowRef } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { fetchBankAttemptHistory, fetchBankTags, startBankAttempt, startPaperAttempt } from '@/api/quiz' import { fetchBankAttemptHistory, fetchBankTags, startBankAttempt, startPaperAttempt } from '@/api/quiz'
import type { Paper, QuestionBank, QuizAttemptHistory } from '@/types/api' import type { PageMeta, Paper, QuestionBank, QuizAttemptHistory } from '@/types/api'
import { fetchResources } from '@/api/quiz' import { fetchResources } from '@/api/quiz'
const router = useRouter() const router = useRouter()
@ -15,6 +15,9 @@ const historyDialogVisible = shallowRef(false)
const randomBank = shallowRef<QuestionBank | null>(null) const randomBank = shallowRef<QuestionBank | null>(null)
const historyBank = shallowRef<QuestionBank | null>(null) const historyBank = shallowRef<QuestionBank | null>(null)
const historyAttempts = shallowRef<QuizAttemptHistory[]>([]) const historyAttempts = shallowRef<QuizAttemptHistory[]>([])
const historyMeta = shallowRef<PageMeta>({ current_page: 1, per_page: 10, total: 0, last_page: 1 })
const historyPage = shallowRef(1)
const historyPageSize = shallowRef(10)
const historyLoading = shallowRef(false) 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({
@ -76,15 +79,37 @@ async function reviewWrong(bank: QuestionBank) {
async function openHistory(bank: QuestionBank) { async function openHistory(bank: QuestionBank) {
historyBank.value = bank historyBank.value = bank
historyAttempts.value = [] historyAttempts.value = []
historyPage.value = 1
historyDialogVisible.value = true historyDialogVisible.value = true
await loadHistory()
}
async function loadHistory() {
if (!historyBank.value) return
historyLoading.value = true historyLoading.value = true
try { try {
historyAttempts.value = (await fetchBankAttemptHistory(bank.id)).data const response = await fetchBankAttemptHistory(historyBank.value.id, {
page: historyPage.value,
per_page: historyPageSize.value,
})
historyAttempts.value = response.data.items
historyMeta.value = response.data.meta
} finally { } finally {
historyLoading.value = false historyLoading.value = false
} }
} }
async function changeHistoryPage(page: number) {
historyPage.value = page
await loadHistory()
}
async function changeHistoryPageSize(size: number) {
historyPageSize.value = size
historyPage.value = 1
await loadHistory()
}
async function reviewAttempt(attempt: QuizAttemptHistory) { async function reviewAttempt(attempt: QuizAttemptHistory) {
if (attempt.status !== 'submitted') { if (attempt.status !== 'submitted') {
ElMessage.warning('只能回顾已提交的记录') ElMessage.warning('只能回顾已提交的记录')
@ -93,6 +118,16 @@ async function reviewAttempt(attempt: QuizAttemptHistory) {
await router.push(`/quiz/${attempt.id}`) await router.push(`/quiz/${attempt.id}`)
} }
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const pad = (number: number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
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}`)
@ -122,7 +157,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="reviewWrong(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> <ElButton plain @click="openHistory(bank)">历史回顾</ElButton>
</div> </div>
</article> </article>
@ -186,13 +222,24 @@ onMounted(loadResources)
<article v-for="attempt in historyAttempts" :key="attempt.id" class="history-item"> <article v-for="attempt in historyAttempts" :key="attempt.id" class="history-item">
<div> <div>
<strong>{{ attempt.mode === 'random' ? '随机刷题' : '顺序刷题' }}</strong> <strong>{{ attempt.mode === 'random' ? '随机刷题' : '顺序刷题' }}</strong>
<p>{{ attempt.submitted_at || attempt.started_at || '-' }}</p> <p>{{ formatDateTime(attempt.submitted_at || attempt.started_at) }}</p>
</div> </div>
<div class="history-meta"> <div class="history-meta">
<ElTag effect="plain">{{ attempt.correct_count }} / {{ attempt.total_questions }}</ElTag> <ElTag effect="plain">{{ attempt.correct_count }} / {{ attempt.total_questions }}</ElTag>
<ElButton type="primary" plain @click="reviewAttempt(attempt)">回顾</ElButton> <ElButton type="primary" plain @click="reviewAttempt(attempt)">回顾</ElButton>
</div> </div>
</article> </article>
<ElPagination
v-if="historyMeta.total > 0"
class="history-pagination"
layout="total, sizes, prev, pager, next"
:total="historyMeta.total"
:page-size="historyPageSize"
:current-page="historyPage"
:page-sizes="[10, 20, 50]"
@update:current-page="changeHistoryPage"
@update:page-size="changeHistoryPageSize"
/>
</div> </div>
</ElDialog> </ElDialog>
</template> </template>
@ -304,6 +351,11 @@ onMounted(loadResources)
gap: 8px; gap: 8px;
} }
.history-pagination {
margin-top: 14px;
justify-content: flex-end;
}
.resource-actions :deep(.el-button) { .resource-actions :deep(.el-button) {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
@ -333,6 +385,12 @@ onMounted(loadResources)
justify-content: space-between; justify-content: space-between;
} }
.history-pagination {
justify-content: flex-start;
overflow-x: auto;
padding-bottom: 4px;
}
:global(.random-dialog) { :global(.random-dialog) {
margin-top: 8vh; margin-top: 8vh;
} }