Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f042861fcd | ||
|
|
392ac642b0 | ||
|
|
aaa051c7f7 | ||
|
|
b539a9a99a |
No files matched your search
@@ -16,6 +16,7 @@ import com.example.questionbank.repository.UserRepository;
|
|||||||
import com.example.questionbank.web.rest.dto.AnswerResult;
|
import com.example.questionbank.web.rest.dto.AnswerResult;
|
||||||
import com.example.questionbank.web.rest.dto.ChoiceView;
|
import com.example.questionbank.web.rest.dto.ChoiceView;
|
||||||
import com.example.questionbank.web.rest.dto.ExamAttemptStartResult;
|
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.ExamQuestionView;
|
||||||
import com.example.questionbank.web.rest.dto.ExamResult;
|
import com.example.questionbank.web.rest.dto.ExamResult;
|
||||||
import com.example.questionbank.web.rest.dto.SubmitAnswerRequest;
|
import com.example.questionbank.web.rest.dto.SubmitAnswerRequest;
|
||||||
@@ -140,22 +141,18 @@ public class QuizAttemptService {
|
|||||||
int totalScore = 0;
|
int totalScore = 0;
|
||||||
|
|
||||||
for (SubmitAnswerRequest answerRequest : request.answers()) {
|
for (SubmitAnswerRequest answerRequest : request.answers()) {
|
||||||
// 동일한 문제를 중복 제출했는지 확인
|
|
||||||
if (!seenQuestionIds.add(answerRequest.questionId())) {
|
if (!seenQuestionIds.add(answerRequest.questionId())) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "중복 제출된 문항이 있습니다.");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "중복 제출된 문항이 있습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 제출한 문제 조회
|
|
||||||
Question question = questionRepository
|
Question question = questionRepository
|
||||||
.findById(answerRequest.questionId())
|
.findById(answerRequest.questionId())
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "문항을 찾을 수 없습니다."));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "문항을 찾을 수 없습니다."));
|
||||||
|
|
||||||
// 현재 과목에 속한 활성 문제인지 확인
|
|
||||||
if (!Boolean.TRUE.equals(question.getActive()) || !question.getSubject().getId().equals(attempt.getSubject().getId())) {
|
if (!Boolean.TRUE.equals(question.getActive()) || !question.getSubject().getId().equals(attempt.getSubject().getId())) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "응시할 수 없는 문항입니다.");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "응시할 수 없는 문항입니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 보기 4개와 정답 1개가 올바르게 등록됐는지 확인
|
|
||||||
List<Choice> choices = new ArrayList<>(question.getChoices());
|
List<Choice> choices = new ArrayList<>(question.getChoices());
|
||||||
|
|
||||||
List<Choice> correctChoices = choices
|
List<Choice> correctChoices = choices
|
||||||
@@ -169,7 +166,6 @@ public class QuizAttemptService {
|
|||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "문항의 선택지 구성이 올바르지 않습니다.");
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "문항의 선택지 구성이 올바르지 않습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자가 선택한 보기 찾기
|
|
||||||
OptionKey selectedOption = answerRequest.selectedOption();
|
OptionKey selectedOption = answerRequest.selectedOption();
|
||||||
|
|
||||||
Choice selectedChoice =
|
Choice selectedChoice =
|
||||||
@@ -183,10 +179,8 @@ public class QuizAttemptService {
|
|||||||
new ResponseStatusException(HttpStatus.BAD_REQUEST, "해당 문항에 존재하지 않는 선택지입니다.")
|
new ResponseStatusException(HttpStatus.BAD_REQUEST, "해당 문항에 존재하지 않는 선택지입니다.")
|
||||||
);
|
);
|
||||||
|
|
||||||
// 앞에서 만든 ExamScoringService로 채점
|
|
||||||
ExamScoringService.ScoringResult score = scoringService.score(correctChoices.getFirst().getOptionKey(), selectedOption);
|
ExamScoringService.ScoringResult score = scoringService.score(correctChoices.getFirst().getOptionKey(), selectedOption);
|
||||||
|
|
||||||
// 문제·해설·선택 답안의 스냅샷 저장
|
|
||||||
ExamAnswer answer = new ExamAnswer();
|
ExamAnswer answer = new ExamAnswer();
|
||||||
answer.setAttempt(attempt);
|
answer.setAttempt(attempt);
|
||||||
answer.setQuestion(question);
|
answer.setQuestion(question);
|
||||||
@@ -199,10 +193,8 @@ public class QuizAttemptService {
|
|||||||
|
|
||||||
answerRepository.save(answer);
|
answerRepository.save(answer);
|
||||||
|
|
||||||
// 총점 계산
|
|
||||||
totalScore += score.awardedPoints();
|
totalScore += score.awardedPoints();
|
||||||
|
|
||||||
// 응시자에게 반환할 채점 결과 만들기
|
|
||||||
results.add(
|
results.add(
|
||||||
new AnswerResult(
|
new AnswerResult(
|
||||||
question.getId(),
|
question.getId(),
|
||||||
@@ -216,14 +208,11 @@ public class QuizAttemptService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 활성 문제를 제출했는지 확인
|
|
||||||
if (!seenQuestionIds.equals(requiredQuestionIds)) {
|
if (!seenQuestionIds.equals(requiredQuestionIds)) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "과목의 모든 활성 문항을 빠짐없이 제출해야 합니다.");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "과목의 모든 활성 문항을 빠짐없이 제출해야 합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 시험 제출 완료 처리
|
|
||||||
Instant submittedAt = Instant.now();
|
Instant submittedAt = Instant.now();
|
||||||
|
|
||||||
attempt.setScore(totalScore);
|
attempt.setScore(totalScore);
|
||||||
attempt.setSubmittedAt(submittedAt);
|
attempt.setSubmittedAt(submittedAt);
|
||||||
attempt.setStatus(AttemptStatus.SUBMITTED);
|
attempt.setStatus(AttemptStatus.SUBMITTED);
|
||||||
@@ -231,7 +220,57 @@ public class QuizAttemptService {
|
|||||||
return new ExamResult(attempt.getId(), totalScore, submittedAt, results);
|
return new ExamResult(attempt.getId(), totalScore, submittedAt, results);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 응시 기록 소유자 확인
|
// 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) {
|
private ExamAttempt getOwnedAttempt(Long attemptId, String login) {
|
||||||
ExamAttempt attempt = attemptRepository
|
ExamAttempt attempt = attemptRepository
|
||||||
.findById(attemptId)
|
.findById(attemptId)
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ package com.example.questionbank.web.rest;
|
|||||||
import com.example.questionbank.repository.ExamAttemptRepository;
|
import com.example.questionbank.repository.ExamAttemptRepository;
|
||||||
import com.example.questionbank.service.ExamAttemptService;
|
import com.example.questionbank.service.ExamAttemptService;
|
||||||
import com.example.questionbank.service.dto.ExamAttemptDTO;
|
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 com.example.questionbank.web.rest.errors.BadRequestAlertException;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotNull;
|
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.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||||
import tech.jhipster.web.util.HeaderUtil;
|
import tech.jhipster.web.util.HeaderUtil;
|
||||||
@@ -31,6 +40,8 @@ import tech.jhipster.web.util.ResponseUtil;
|
|||||||
@RequestMapping("/api/exam-attempts")
|
@RequestMapping("/api/exam-attempts")
|
||||||
public class ExamAttemptResource {
|
public class ExamAttemptResource {
|
||||||
|
|
||||||
|
private final QuizAttemptService quizAttemptService;
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ExamAttemptResource.class);
|
private static final Logger LOG = LoggerFactory.getLogger(ExamAttemptResource.class);
|
||||||
|
|
||||||
private static final String ENTITY_NAME = "examAttempt";
|
private static final String ENTITY_NAME = "examAttempt";
|
||||||
@@ -42,9 +53,14 @@ public class ExamAttemptResource {
|
|||||||
|
|
||||||
private final ExamAttemptRepository examAttemptRepository;
|
private final ExamAttemptRepository examAttemptRepository;
|
||||||
|
|
||||||
public ExamAttemptResource(ExamAttemptService examAttemptService, ExamAttemptRepository examAttemptRepository) {
|
public ExamAttemptResource(
|
||||||
|
ExamAttemptService examAttemptService,
|
||||||
|
ExamAttemptRepository examAttemptRepository,
|
||||||
|
QuizAttemptService quizAttemptService
|
||||||
|
) {
|
||||||
this.examAttemptService = examAttemptService;
|
this.examAttemptService = examAttemptService;
|
||||||
this.examAttemptRepository = examAttemptRepository;
|
this.examAttemptRepository = examAttemptRepository;
|
||||||
|
this.quizAttemptService = quizAttemptService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,4 +201,43 @@ public class ExamAttemptResource {
|
|||||||
.headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, id.toString()))
|
.headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, id.toString()))
|
||||||
.build();
|
.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 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