Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f042861fcd | ||
|
|
392ac642b0 | ||
|
|
aaa051c7f7 | ||
|
|
b539a9a99a | ||
|
|
1db02c4e15 | ||
|
|
c03a7e6362 |
No files matched your search
@@ -1,17 +1,33 @@
|
||||
package com.example.questionbank.service.quiz;
|
||||
|
||||
import com.example.questionbank.domain.Choice;
|
||||
import com.example.questionbank.domain.ExamAnswer;
|
||||
import com.example.questionbank.domain.ExamAttempt;
|
||||
import com.example.questionbank.domain.Question;
|
||||
import com.example.questionbank.domain.Subject;
|
||||
import com.example.questionbank.domain.User;
|
||||
import com.example.questionbank.domain.enumeration.AttemptStatus;
|
||||
import com.example.questionbank.domain.enumeration.OptionKey;
|
||||
import com.example.questionbank.repository.ExamAnswerRepository;
|
||||
import com.example.questionbank.repository.ExamAttemptRepository;
|
||||
import com.example.questionbank.repository.QuestionRepository;
|
||||
import com.example.questionbank.repository.SubjectRepository;
|
||||
import com.example.questionbank.repository.UserRepository;
|
||||
import com.example.questionbank.web.rest.dto.AnswerResult;
|
||||
import com.example.questionbank.web.rest.dto.ChoiceView;
|
||||
import com.example.questionbank.web.rest.dto.ExamAttemptStartResult;
|
||||
import com.example.questionbank.web.rest.dto.ExamAttemptSummary;
|
||||
import com.example.questionbank.web.rest.dto.ExamQuestionView;
|
||||
import com.example.questionbank.web.rest.dto.ExamResult;
|
||||
import com.example.questionbank.web.rest.dto.SubmitAnswerRequest;
|
||||
import com.example.questionbank.web.rest.dto.SubmitExamRequest;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -21,22 +37,29 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
public class QuizAttemptService {
|
||||
|
||||
private final ExamAttemptRepository attemptRepository;
|
||||
private final ExamAnswerRepository answerRepository;
|
||||
private final SubjectRepository subjectRepository;
|
||||
private final QuestionRepository questionRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ExamScoringService scoringService;
|
||||
|
||||
public QuizAttemptService(
|
||||
ExamAttemptRepository attemptRepository,
|
||||
ExamAnswerRepository answerRepository,
|
||||
SubjectRepository subjectRepository,
|
||||
QuestionRepository questionRepository,
|
||||
UserRepository userRepository
|
||||
UserRepository userRepository,
|
||||
ExamScoringService scoringService
|
||||
) {
|
||||
this.attemptRepository = attemptRepository;
|
||||
this.answerRepository = answerRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.questionRepository = questionRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.scoringService = scoringService;
|
||||
}
|
||||
|
||||
// 1. 시험 응시 시작
|
||||
@Transactional
|
||||
public ExamAttemptStartResult start(Long subjectId, String login) {
|
||||
Subject subject = subjectRepository
|
||||
@@ -65,6 +88,198 @@ public class QuizAttemptService {
|
||||
attempt.setScore(0);
|
||||
|
||||
ExamAttempt saved = attemptRepository.save(attempt);
|
||||
|
||||
return new ExamAttemptStartResult(saved.getId(), saved.getStartedAt());
|
||||
}
|
||||
|
||||
// 2. 시험 문제 조회 (정답 숨김)
|
||||
@Transactional(readOnly = true)
|
||||
public List<ExamQuestionView> getQuestions(Long attemptId, String login) {
|
||||
ExamAttempt attempt = getOwnedAttempt(attemptId, login);
|
||||
|
||||
if (attempt.getStatus() != AttemptStatus.IN_PROGRESS) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "진행 중인 응시만 문제를 조회할 수 있습니다.");
|
||||
}
|
||||
|
||||
return questionRepository
|
||||
.findAllBySubjectIdAndActiveTrue(attempt.getSubject().getId())
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(Question::getId))
|
||||
.map(question ->
|
||||
new ExamQuestionView(
|
||||
question.getId(),
|
||||
question.getPrompt(),
|
||||
question
|
||||
.getChoices()
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(Choice::getDisplayOrder))
|
||||
.map(choice -> new ChoiceView(choice.getOptionKey(), choice.getContent(), choice.getDisplayOrder()))
|
||||
.toList()
|
||||
)
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 3. 답안 제출 및 자동 채점
|
||||
@Transactional
|
||||
public ExamResult submit(Long attemptId, String login, SubmitExamRequest request) {
|
||||
ExamAttempt attempt = getOwnedAttempt(attemptId, login);
|
||||
|
||||
if (attempt.getStatus() != AttemptStatus.IN_PROGRESS) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 제출된 응시입니다.");
|
||||
}
|
||||
|
||||
Set<Long> seenQuestionIds = new HashSet<>();
|
||||
|
||||
Set<Long> requiredQuestionIds = questionRepository
|
||||
.findAllBySubjectIdAndActiveTrue(attempt.getSubject().getId())
|
||||
.stream()
|
||||
.map(Question::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<AnswerResult> results = new ArrayList<>();
|
||||
int totalScore = 0;
|
||||
|
||||
for (SubmitAnswerRequest answerRequest : request.answers()) {
|
||||
if (!seenQuestionIds.add(answerRequest.questionId())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "중복 제출된 문항이 있습니다.");
|
||||
}
|
||||
|
||||
Question question = questionRepository
|
||||
.findById(answerRequest.questionId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "문항을 찾을 수 없습니다."));
|
||||
|
||||
if (!Boolean.TRUE.equals(question.getActive()) || !question.getSubject().getId().equals(attempt.getSubject().getId())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "응시할 수 없는 문항입니다.");
|
||||
}
|
||||
|
||||
List<Choice> choices = new ArrayList<>(question.getChoices());
|
||||
|
||||
List<Choice> correctChoices = choices
|
||||
.stream()
|
||||
.filter(choice -> Boolean.TRUE.equals(choice.getCorrect()))
|
||||
.toList();
|
||||
|
||||
Set<OptionKey> optionKeys = choices.stream().map(Choice::getOptionKey).collect(Collectors.toSet());
|
||||
|
||||
if (choices.size() != 4 || optionKeys.size() != 4 || correctChoices.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "문항의 선택지 구성이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
OptionKey selectedOption = answerRequest.selectedOption();
|
||||
|
||||
Choice selectedChoice =
|
||||
selectedOption == null
|
||||
? null
|
||||
: choices
|
||||
.stream()
|
||||
.filter(choice -> choice.getOptionKey() == selectedOption)
|
||||
.findFirst()
|
||||
.orElseThrow(() ->
|
||||
new ResponseStatusException(HttpStatus.BAD_REQUEST, "해당 문항에 존재하지 않는 선택지입니다.")
|
||||
);
|
||||
|
||||
ExamScoringService.ScoringResult score = scoringService.score(correctChoices.getFirst().getOptionKey(), selectedOption);
|
||||
|
||||
ExamAnswer answer = new ExamAnswer();
|
||||
answer.setAttempt(attempt);
|
||||
answer.setQuestion(question);
|
||||
answer.setQuestionTextSnapshot(question.getPrompt());
|
||||
answer.setExplanationSnapshot(question.getExplanation());
|
||||
answer.setSelectedOption(selectedOption);
|
||||
answer.setSelectedContentSnapshot(selectedChoice == null ? null : selectedChoice.getContent());
|
||||
answer.setCorrect(score.correct());
|
||||
answer.setAwardedPoints(score.awardedPoints());
|
||||
|
||||
answerRepository.save(answer);
|
||||
|
||||
totalScore += score.awardedPoints();
|
||||
|
||||
results.add(
|
||||
new AnswerResult(
|
||||
question.getId(),
|
||||
question.getPrompt(),
|
||||
selectedOption,
|
||||
selectedChoice == null ? null : selectedChoice.getContent(),
|
||||
score.correct(),
|
||||
score.awardedPoints(),
|
||||
question.getExplanation()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!seenQuestionIds.equals(requiredQuestionIds)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "과목의 모든 활성 문항을 빠짐없이 제출해야 합니다.");
|
||||
}
|
||||
|
||||
Instant submittedAt = Instant.now();
|
||||
attempt.setScore(totalScore);
|
||||
attempt.setSubmittedAt(submittedAt);
|
||||
attempt.setStatus(AttemptStatus.SUBMITTED);
|
||||
|
||||
return new ExamResult(attempt.getId(), totalScore, submittedAt, results);
|
||||
}
|
||||
|
||||
// 4. 제출 완료된 시험 결과 조회
|
||||
@Transactional(readOnly = true)
|
||||
public ExamResult getResult(Long attemptId, String login) {
|
||||
ExamAttempt attempt = getOwnedAttempt(attemptId, login);
|
||||
|
||||
if (attempt.getStatus() != AttemptStatus.SUBMITTED) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "아직 제출되지 않은 응시입니다.");
|
||||
}
|
||||
|
||||
List<AnswerResult> results = attempt
|
||||
.getAnswers()
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(a -> a.getQuestion().getId()))
|
||||
.map(answer ->
|
||||
new AnswerResult(
|
||||
answer.getQuestion().getId(),
|
||||
answer.getQuestionTextSnapshot(),
|
||||
answer.getSelectedOption(),
|
||||
answer.getSelectedContentSnapshot(),
|
||||
Boolean.TRUE.equals(answer.getCorrect()),
|
||||
answer.getAwardedPoints() == null ? 0 : answer.getAwardedPoints(),
|
||||
answer.getExplanationSnapshot()
|
||||
)
|
||||
)
|
||||
.toList();
|
||||
|
||||
return new ExamResult(attempt.getId(), attempt.getScore() == null ? 0 : attempt.getScore(), attempt.getSubmittedAt(), results);
|
||||
}
|
||||
|
||||
// 5. 로그인한 사용자의 응시 이력 조회
|
||||
@Transactional(readOnly = true)
|
||||
public List<ExamAttemptSummary> getMyAttempts(String login) {
|
||||
return attemptRepository
|
||||
.findAllByUserLogin(login)
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(ExamAttempt::getStartedAt, Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.map(attempt ->
|
||||
new ExamAttemptSummary(
|
||||
attempt.getId(),
|
||||
attempt.getSubject().getId(),
|
||||
attempt.getSubject().getName(),
|
||||
attempt.getStartedAt(),
|
||||
attempt.getSubmittedAt(),
|
||||
attempt.getStatus(),
|
||||
attempt.getScore()
|
||||
)
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 6. 응시 기록 소유자 확인
|
||||
private ExamAttempt getOwnedAttempt(Long attemptId, String login) {
|
||||
ExamAttempt attempt = attemptRepository
|
||||
.findById(attemptId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "응시를 찾을 수 없습니다."));
|
||||
|
||||
if (!attempt.getUser().getLogin().equals(login)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "응시를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return attempt;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ package com.example.questionbank.web.rest;
|
||||
import com.example.questionbank.repository.ExamAttemptRepository;
|
||||
import com.example.questionbank.service.ExamAttemptService;
|
||||
import com.example.questionbank.service.dto.ExamAttemptDTO;
|
||||
import com.example.questionbank.service.quiz.QuizAttemptService;
|
||||
import com.example.questionbank.web.rest.dto.ExamAttemptStartResult;
|
||||
import com.example.questionbank.web.rest.dto.ExamAttemptSummary;
|
||||
import com.example.questionbank.web.rest.dto.ExamQuestionView;
|
||||
import com.example.questionbank.web.rest.dto.ExamResult;
|
||||
import com.example.questionbank.web.rest.dto.StartExamRequest;
|
||||
import com.example.questionbank.web.rest.dto.SubmitExamRequest;
|
||||
import com.example.questionbank.web.rest.errors.BadRequestAlertException;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
@@ -17,7 +24,9 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
import tech.jhipster.web.util.HeaderUtil;
|
||||
@@ -31,6 +40,8 @@ import tech.jhipster.web.util.ResponseUtil;
|
||||
@RequestMapping("/api/exam-attempts")
|
||||
public class ExamAttemptResource {
|
||||
|
||||
private final QuizAttemptService quizAttemptService;
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ExamAttemptResource.class);
|
||||
|
||||
private static final String ENTITY_NAME = "examAttempt";
|
||||
@@ -42,9 +53,14 @@ public class ExamAttemptResource {
|
||||
|
||||
private final ExamAttemptRepository examAttemptRepository;
|
||||
|
||||
public ExamAttemptResource(ExamAttemptService examAttemptService, ExamAttemptRepository examAttemptRepository) {
|
||||
public ExamAttemptResource(
|
||||
ExamAttemptService examAttemptService,
|
||||
ExamAttemptRepository examAttemptRepository,
|
||||
QuizAttemptService quizAttemptService
|
||||
) {
|
||||
this.examAttemptService = examAttemptService;
|
||||
this.examAttemptRepository = examAttemptRepository;
|
||||
this.quizAttemptService = quizAttemptService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,4 +201,43 @@ public class ExamAttemptResource {
|
||||
.headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, id.toString()))
|
||||
.build();
|
||||
}
|
||||
|
||||
// 1. 시험 응시 시작
|
||||
@PostMapping("/start")
|
||||
public ResponseEntity<ExamAttemptStartResult> startExam(@Valid @RequestBody StartExamRequest request, Authentication authentication) {
|
||||
ExamAttemptStartResult result = quizAttemptService.start(request.subjectId(), authentication.getName());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(result);
|
||||
}
|
||||
|
||||
// 2. 시험 문제 조회
|
||||
@GetMapping("/{attemptId}/questions")
|
||||
public ResponseEntity<List<ExamQuestionView>> getExamQuestions(
|
||||
@PathVariable("attemptId") Long attemptId,
|
||||
Authentication authentication
|
||||
) {
|
||||
return ResponseEntity.ok(quizAttemptService.getQuestions(attemptId, authentication.getName()));
|
||||
}
|
||||
|
||||
// 3. 답안 제출 및 자동 채점
|
||||
@PostMapping("/{attemptId}/submit")
|
||||
public ResponseEntity<ExamResult> submitExam(
|
||||
@PathVariable("attemptId") Long attemptId,
|
||||
@Valid @RequestBody SubmitExamRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
return ResponseEntity.ok(quizAttemptService.submit(attemptId, authentication.getName(), request));
|
||||
}
|
||||
|
||||
// 4. 제출된 시험 결과 조회
|
||||
@GetMapping("/{attemptId}/result")
|
||||
public ResponseEntity<ExamResult> getExamResult(@PathVariable("attemptId") Long attemptId, Authentication authentication) {
|
||||
return ResponseEntity.ok(quizAttemptService.getResult(attemptId, authentication.getName()));
|
||||
}
|
||||
|
||||
// 5. 내 응시 이력 조회
|
||||
@GetMapping("/my")
|
||||
public ResponseEntity<List<ExamAttemptSummary>> getMyExamAttempts(Authentication authentication) {
|
||||
return ResponseEntity.ok(quizAttemptService.getMyAttempts(authentication.getName()));
|
||||
}
|
||||
}
|
||||
@@ -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 pages from '@/router/pages';
|
||||
|
||||
const QuizView = () => import('@/quiz/quiz.vue');
|
||||
|
||||
export const createRouter = () =>
|
||||
createVueRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -28,6 +30,12 @@ export const createRouter = () =>
|
||||
component: Error,
|
||||
meta: { error404: true },
|
||||
},
|
||||
{
|
||||
path: '/quiz',
|
||||
name: 'QuizView',
|
||||
component: QuizView,
|
||||
meta: { authorities: ['ROLE_USER'] },
|
||||
},
|
||||
...account,
|
||||
...admin,
|
||||
entities,
|
||||
|
||||
Reference in new issue
Block a user