Implement Vue quiz screen and fix route authority
This commit is contained in:
1 parent
aaa051c7f7
commit
392ac642b0
4 files changed
+250
No files matched your search
@@ -0,0 +1,100 @@
|
|||||||
|
import { defineComponent, onMounted, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
type ExamQuestionView,
|
||||||
|
type ExamResult,
|
||||||
|
type OptionKey,
|
||||||
|
type SubjectItem,
|
||||||
|
fetchActiveSubjects,
|
||||||
|
loadQuestions,
|
||||||
|
startExam,
|
||||||
|
submitExam,
|
||||||
|
} from './quiz.service';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'QuizView',
|
||||||
|
setup() {
|
||||||
|
const subjects = ref<SubjectItem[]>([]);
|
||||||
|
const selectedSubjectId = ref<number | null>(null);
|
||||||
|
const attemptId = ref<number | null>(null);
|
||||||
|
const questions = ref<ExamQuestionView[]>([]);
|
||||||
|
const selectedAnswers = ref<Record<number, OptionKey | null>>({});
|
||||||
|
const result = ref<ExamResult | null>(null);
|
||||||
|
const errorMessage = ref<string | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
subjects.value = await fetchActiveSubjects();
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = '과목 목록을 불러오지 못했습니다.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleStartExam = async () => {
|
||||||
|
if (!selectedSubjectId.value) return;
|
||||||
|
|
||||||
|
errorMessage.value = null;
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const started = await startExam(selectedSubjectId.value);
|
||||||
|
attemptId.value = started.attemptId;
|
||||||
|
|
||||||
|
const loadedQuestions = await loadQuestions(started.attemptId);
|
||||||
|
questions.value = loadedQuestions;
|
||||||
|
selectedAnswers.value = {};
|
||||||
|
|
||||||
|
loadedQuestions.forEach(q => {
|
||||||
|
selectedAnswers.value[q.questionId] = null;
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
errorMessage.value = err?.response?.data?.detail || '응시를 시작할 수 없습니다.';
|
||||||
|
attemptId.value = null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitExam = async () => {
|
||||||
|
if (!attemptId.value) return;
|
||||||
|
|
||||||
|
errorMessage.value = null;
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = questions.value.map(q => ({
|
||||||
|
questionId: q.questionId,
|
||||||
|
selectedOption: selectedAnswers.value[q.questionId] ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
result.value = await submitExam(attemptId.value, payload);
|
||||||
|
} catch (err: any) {
|
||||||
|
errorMessage.value = err?.response?.data?.detail || '답안 제출 중 오류가 발생했습니다.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetQuiz = () => {
|
||||||
|
attemptId.value = null;
|
||||||
|
questions.value = [];
|
||||||
|
selectedAnswers.value = {};
|
||||||
|
result.value = null;
|
||||||
|
errorMessage.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
subjects,
|
||||||
|
selectedSubjectId,
|
||||||
|
attemptId,
|
||||||
|
questions,
|
||||||
|
selectedAnswers,
|
||||||
|
result,
|
||||||
|
errorMessage,
|
||||||
|
loading,
|
||||||
|
handleStartExam,
|
||||||
|
handleSubmitExam,
|
||||||
|
resetQuiz,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export type OptionKey = 'A' | 'B' | 'C' | 'D';
|
||||||
|
|
||||||
|
export interface SubjectItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoiceView {
|
||||||
|
optionKey: OptionKey;
|
||||||
|
content: string;
|
||||||
|
displayOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamQuestionView {
|
||||||
|
questionId: number;
|
||||||
|
prompt: string;
|
||||||
|
choices: ChoiceView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitAnswer {
|
||||||
|
questionId: number;
|
||||||
|
selectedOption: OptionKey | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnswerResult {
|
||||||
|
questionId: number;
|
||||||
|
questionText: string;
|
||||||
|
selectedOption: OptionKey | null;
|
||||||
|
selectedContent: string | null;
|
||||||
|
correct: boolean;
|
||||||
|
awardedPoints: number;
|
||||||
|
explanation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamResult {
|
||||||
|
attemptId: number;
|
||||||
|
totalScore: number;
|
||||||
|
submittedAt: string;
|
||||||
|
answers: AnswerResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchActiveSubjects(): Promise<SubjectItem[]> {
|
||||||
|
const response = await axios.get<SubjectItem[]>('/api/subjects?page=0&size=100');
|
||||||
|
return response.data.filter(s => s.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startExam(subjectId: number): Promise<{ attemptId: number; startedAt: string }> {
|
||||||
|
const response = await axios.post('/api/exam-attempts/start', { subjectId });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadQuestions(attemptId: number): Promise<ExamQuestionView[]> {
|
||||||
|
const response = await axios.get<ExamQuestionView[]>(`/api/exam-attempts/${attemptId}/questions`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitExam(attemptId: number, answers: SubmitAnswer[]): Promise<ExamResult> {
|
||||||
|
const response = await axios.post<ExamResult>(`/api/exam-attempts/${attemptId}/submit`, { answers });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>온라인 문제 풀이</h2>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger mt-3">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1단계: 과목 선택 -->
|
||||||
|
<div v-if="!attemptId" class="card p-4 mt-3">
|
||||||
|
<h4>응시할 과목을 선택하세요</h4>
|
||||||
|
|
||||||
|
<div class="form-group mt-2">
|
||||||
|
<select v-model="selectedSubjectId" class="form-control">
|
||||||
|
<option :value="null" disabled>-- 과목 선택 --</option>
|
||||||
|
<option v-for="subject in subjects" :key="subject.id" :value="subject.id">
|
||||||
|
{{ subject.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary mt-3" :disabled="!selectedSubjectId || loading" @click="handleStartExam">응시 시작</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2단계: 문제 풀이 -->
|
||||||
|
<div v-if="attemptId && !result" class="mt-3">
|
||||||
|
<div v-for="(q, index) in questions" :key="q.questionId" class="card p-3 mb-3">
|
||||||
|
<h5>문제 {{ index + 1 }}. {{ q.prompt }}</h5>
|
||||||
|
|
||||||
|
<div v-for="choice in q.choices" :key="choice.optionKey" class="form-check mt-2">
|
||||||
|
<input
|
||||||
|
:id="'q-' + q.questionId + '-' + choice.optionKey"
|
||||||
|
v-model="selectedAnswers[q.questionId]"
|
||||||
|
class="form-check-input"
|
||||||
|
type="radio"
|
||||||
|
:name="'question-' + q.questionId"
|
||||||
|
:value="choice.optionKey"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="form-check-label" :for="'q-' + q.questionId + '-' + choice.optionKey">
|
||||||
|
<strong>{{ choice.optionKey }}.</strong>
|
||||||
|
{{ choice.content }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-success" :disabled="loading" @click="handleSubmitExam">답안 제출 및 채점</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3단계: 채점 결과 -->
|
||||||
|
<div v-if="result" class="card p-4 mt-3">
|
||||||
|
<h3>채점 결과: 총점 {{ result.totalScore }} / {{ result.answers.length }}점</h3>
|
||||||
|
|
||||||
|
<p class="text-muted">제출 시각: {{ result.submittedAt }}</p>
|
||||||
|
|
||||||
|
<div v-for="(ans, idx) in result.answers" :key="ans.questionId" class="border-top pt-3 mt-3">
|
||||||
|
<h5>
|
||||||
|
문제 {{ idx + 1 }}. {{ ans.questionText }}
|
||||||
|
|
||||||
|
<span :class="ans.correct ? 'badge bg-success' : 'badge bg-danger'">
|
||||||
|
{{ ans.correct ? '정답 (+1점)' : '오답 (0점)' }}
|
||||||
|
</span>
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<p class="mb-1">
|
||||||
|
내 선택:
|
||||||
|
{{ ans.selectedOption ? `${ans.selectedOption} (${ans.selectedContent})` : '무응답' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="ans.explanation" class="text-info mb-0"><strong>해설:</strong> {{ ans.explanation }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary mt-4" @click="resetQuiz">다른 과목 응시하기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./quiz.component.ts"></script>
|
||||||
@@ -7,6 +7,8 @@ import admin from '@/router/admin';
|
|||||||
import entities from '@/router/entities';
|
import entities from '@/router/entities';
|
||||||
import pages from '@/router/pages';
|
import pages from '@/router/pages';
|
||||||
|
|
||||||
|
const QuizView = () => import('@/quiz/quiz.vue');
|
||||||
|
|
||||||
export const createRouter = () =>
|
export const createRouter = () =>
|
||||||
createVueRouter({
|
createVueRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -28,6 +30,12 @@ export const createRouter = () =>
|
|||||||
component: Error,
|
component: Error,
|
||||||
meta: { error404: true },
|
meta: { error404: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/quiz',
|
||||||
|
name: 'QuizView',
|
||||||
|
component: QuizView,
|
||||||
|
meta: { authorities: ['ROLE_USER'] },
|
||||||
|
},
|
||||||
...account,
|
...account,
|
||||||
...admin,
|
...admin,
|
||||||
entities,
|
entities,
|
||||||
|
|||||||
Reference in new issue
Block a user