feat: 코딩 문제 20문항 — 브라우저 자동채점 + 멘토 코드 리뷰
읽고 아는 것과 짤 수 있는 것을 나눠 확인하는 세 번째 축(과제·퀴즈에 이어). - 문제 20개: JS 12(자동채점) + Java 8(멘토 리뷰), 난이도 EASY 8/NORMAL 8/HARD 4 - JS 채점은 Web Worker에서 실행 — 서버 샌드박스 없이 무한루프(2초 타임아웃)· DOM 접근을 막는다. 테스트 48건은 모범답안으로 전수 검증함 - Java는 실행하지 않음(격리 비용·보안). 코드를 멘토가 읽고 피드백. 서버가 자바 제출의 클라이언트 채점값을 버리므로 위조 불가 - 멘토 화면: 코드 원문·테스트별 통과/실패·학생별 진도·피드백 작성 - (문제, 학생)당 1행 유지 + attempts 증가. 에디터는 무의존성(textarea 기반) 검증: 백엔드 컴파일·프론트 빌드·시드 20건 적재·API 전 흐름(제출/재제출/ 권한 403/빈코드 400)·브라우저 실사용(오답 3/4 → 정답 4/4 → 멘토 조회)
This commit is contained in:
parent
e53b318ddf
commit
5a82ecb291
@ -4,11 +4,13 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.awesomedev.mirim.domain.Assignment;
|
||||
import dev.awesomedev.mirim.domain.ChecklistItem;
|
||||
import dev.awesomedev.mirim.domain.CodingProblem;
|
||||
import dev.awesomedev.mirim.domain.Document;
|
||||
import dev.awesomedev.mirim.domain.QuizQuestion;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.repository.AssignmentRepository;
|
||||
import dev.awesomedev.mirim.repository.ChecklistItemRepository;
|
||||
import dev.awesomedev.mirim.repository.CodingProblemRepository;
|
||||
import dev.awesomedev.mirim.repository.DocumentRepository;
|
||||
import dev.awesomedev.mirim.repository.QuizQuestionRepository;
|
||||
import dev.awesomedev.mirim.repository.UserRepository;
|
||||
@ -43,6 +45,7 @@ public class SeedDataLoader implements ApplicationRunner {
|
||||
private final ChecklistItemRepository checklistItemRepository;
|
||||
private final AssignmentRepository assignmentRepository;
|
||||
private final QuizQuestionRepository quizQuestionRepository;
|
||||
private final CodingProblemRepository codingProblemRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@ -51,6 +54,7 @@ public class SeedDataLoader implements ApplicationRunner {
|
||||
ChecklistItemRepository checklistItemRepository,
|
||||
AssignmentRepository assignmentRepository,
|
||||
QuizQuestionRepository quizQuestionRepository,
|
||||
CodingProblemRepository codingProblemRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
ObjectMapper objectMapper) {
|
||||
this.userRepository = userRepository;
|
||||
@ -58,6 +62,7 @@ public class SeedDataLoader implements ApplicationRunner {
|
||||
this.checklistItemRepository = checklistItemRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.quizQuestionRepository = quizQuestionRepository;
|
||||
this.codingProblemRepository = codingProblemRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
@ -69,6 +74,7 @@ public class SeedDataLoader implements ApplicationRunner {
|
||||
seedChecklist();
|
||||
seedAssignments();
|
||||
seedQuizzes();
|
||||
seedCodingProblems();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -158,6 +164,39 @@ public class SeedDataLoader implements ApplicationRunner {
|
||||
log.info("퀴즈 시드 {}건을 저장했습니다.", seeds.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 코딩 문제 시드.
|
||||
* 학습 포인트: 테스트케이스는 JSON 배열 그대로 문자열로 넣는다(CodingProblem.testsJson).
|
||||
* 시드 파일에서는 사람이 읽기 좋은 객체로 두고, 저장할 때 다시 문자열로 직렬화한다 —
|
||||
* "파일에서 편한 모양"과 "DB에 담는 모양"이 달라도 되며, 그 변환이 여기서 일어난다.
|
||||
*/
|
||||
private void seedCodingProblems() {
|
||||
if (codingProblemRepository.count() > 0) {
|
||||
return;
|
||||
}
|
||||
List<CodingProblemSeed> seeds = readSeedFile("seed/coding-problems.json", new TypeReference<>() {});
|
||||
if (seeds == null) {
|
||||
return;
|
||||
}
|
||||
int sortOrder = 0;
|
||||
for (CodingProblemSeed seed : seeds) {
|
||||
String testsJson = null;
|
||||
if (seed.tests() != null) {
|
||||
try {
|
||||
testsJson = objectMapper.writeValueAsString(seed.tests());
|
||||
} catch (Exception e) {
|
||||
log.error("테스트케이스 직렬화 실패 — 건너뜁니다: {}", seed.title(), e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
codingProblemRepository.save(new CodingProblem(
|
||||
seed.language(), seed.title(), seed.difficulty(), seed.topic(),
|
||||
seed.description(), seed.starterCode(), seed.functionName(),
|
||||
testsJson, sortOrder++));
|
||||
}
|
||||
log.info("코딩 문제 시드 {}건을 저장했습니다.", seeds.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* classpath에서 시드 JSON 파일을 읽는다.
|
||||
* 학습 포인트: 파일이 없어도 앱이 죽지 않고 경고 로그만 남기고 넘어간다.
|
||||
@ -197,4 +236,14 @@ public class SeedDataLoader implements ApplicationRunner {
|
||||
String option1, String option2, String option3, String option4,
|
||||
Integer answerIndex, String explanation) {
|
||||
}
|
||||
|
||||
/** coding-problems.json 한 건 (tests는 JAVA 문제면 null) */
|
||||
record CodingProblemSeed(String language, String title, String difficulty, String topic,
|
||||
String description, String starterCode, String functionName,
|
||||
List<TestCaseSeed> tests) {
|
||||
}
|
||||
|
||||
/** 테스트케이스 한 건 — args를 함수에 넣었을 때 expected가 나와야 한다 */
|
||||
record TestCaseSeed(String name, List<Object> args, Object expected) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
package dev.awesomedev.mirim.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 코딩 문제 하나를 담는 JPA 엔티티다.
|
||||
* 퀴즈(4지선다)가 "아는지"를 묻는다면, 코딩 문제는 "짤 수 있는지"를 묻는다.
|
||||
*
|
||||
* 언어(language)에 따라 채점 방식이 다르다:
|
||||
* - JS : 브라우저(Web Worker)가 테스트케이스를 돌려 즉시 통과/실패를 가린다.
|
||||
* - JAVA : 실행하지 않는다. 코드를 제출하면 멘토가 직접 읽고 피드백한다.
|
||||
*
|
||||
* 학습 포인트: 왜 자바는 서버에서 실행하지 않을까?
|
||||
* 남이 보낸 코드를 서버에서 그대로 돌리는 건 "내 집 열쇠를 모르는 사람에게 주는 것"과 같다.
|
||||
* 무한루프로 CPU를 잡아먹거나(System.exit로 서버를 끄거나) 파일을 뒤질 수 있다.
|
||||
* 안전하게 하려면 Docker 같은 격리된 방을 매 제출마다 만들어야 하는데, 비용과 복잡도가 크다.
|
||||
* 그래서 우리 규모에서는 "자바는 사람(멘토)이 읽는다"가 가장 합리적인 선택이다.
|
||||
* — 기술적으로 가능한 것과 지금 규모에 맞는 것은 다르다.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "coding_problem")
|
||||
public class CodingProblem {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 문제 언어: JS | JAVA */
|
||||
@Column(nullable = false, length = 8)
|
||||
private String language;
|
||||
|
||||
/** 문제 제목 */
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
/** 난이도: EASY | NORMAL | HARD */
|
||||
@Column(nullable = false, length = 8)
|
||||
private String difficulty;
|
||||
|
||||
/** 관련 학습 주제(코스 slug 느낌의 분류 — 예: javascript, java-basics) */
|
||||
@Column(name = "topic", nullable = false, length = 60)
|
||||
private String topic;
|
||||
|
||||
/** 문제 설명 (마크다운 아님 — 줄바꿈만 살려서 그대로 보여준다) */
|
||||
@Column(nullable = false, columnDefinition = "text")
|
||||
private String description;
|
||||
|
||||
/** 학생 화면에 처음 채워져 있는 뼈대 코드 */
|
||||
@Column(name = "starter_code", nullable = false, columnDefinition = "text")
|
||||
private String starterCode;
|
||||
|
||||
/**
|
||||
* 학생이 구현해야 하는 함수 이름 (JS 전용 — 채점기가 이 이름으로 함수를 찾아 호출한다).
|
||||
* JAVA 문제는 실행하지 않으므로 null.
|
||||
*/
|
||||
@Column(name = "function_name", length = 60)
|
||||
private String functionName;
|
||||
|
||||
/**
|
||||
* 테스트케이스 묶음을 JSON 문자열로 통째로 담는다 (JS 전용, JAVA는 null).
|
||||
* 형식: [{"name":"설명","args":[1,2],"expected":3}, ...]
|
||||
*
|
||||
* 학습 포인트: 왜 테스트케이스용 테이블을 따로 만들지 않고 JSON 한 덩어리로 넣을까?
|
||||
* 테스트케이스는 (1) 항상 문제와 함께 통째로 읽고, (2) 따로 검색하거나 조인할 일이 없다.
|
||||
* 이럴 땐 테이블을 쪼개는 것보다 JSON으로 두는 편이 단순하다.
|
||||
* 반대로 "테스트케이스별 통과율 통계"처럼 개별 조회가 필요해지면 그때 테이블로 승격하면 된다.
|
||||
*/
|
||||
@Column(name = "tests_json", columnDefinition = "text")
|
||||
private String testsJson;
|
||||
|
||||
/** 목록에서의 정렬 순서 */
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
private Integer sortOrder;
|
||||
|
||||
protected CodingProblem() {
|
||||
}
|
||||
|
||||
public CodingProblem(String language, String title, String difficulty, String topic,
|
||||
String description, String starterCode, String functionName,
|
||||
String testsJson, Integer sortOrder) {
|
||||
this.language = language;
|
||||
this.title = title;
|
||||
this.difficulty = difficulty;
|
||||
this.topic = topic;
|
||||
this.description = description;
|
||||
this.starterCode = starterCode;
|
||||
this.functionName = functionName;
|
||||
this.testsJson = testsJson;
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
/** 브라우저에서 자동채점이 가능한 문제인가 (= JS 문제인가) */
|
||||
public boolean isAutoGraded() {
|
||||
return "JS".equals(language);
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getLanguage() {
|
||||
return language;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getDifficulty() {
|
||||
return difficulty;
|
||||
}
|
||||
|
||||
public String getTopic() {
|
||||
return topic;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public String getStarterCode() {
|
||||
return starterCode;
|
||||
}
|
||||
|
||||
public String getFunctionName() {
|
||||
return functionName;
|
||||
}
|
||||
|
||||
public String getTestsJson() {
|
||||
return testsJson;
|
||||
}
|
||||
|
||||
public Integer getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
package dev.awesomedev.mirim.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 학생이 코딩 문제에 대해 제출한 코드 한 건을 담는 JPA 엔티티다.
|
||||
* 같은 문제를 다시 풀면 새 행을 만들지 않고 기존 행을 갱신한다(Submission과 같은 방식).
|
||||
*
|
||||
* 학습 포인트: 왜 (학생, 문제)당 한 행만 둘까?
|
||||
* 매 제출을 다 쌓으면 "이 학생이 이 문제를 풀었나?"를 볼 때마다 최신 행을 골라내야 하고,
|
||||
* 멘토 화면도 같은 문제가 여러 번 나와 지저분해진다. 지금 필요한 건 "현재 상태"이지 이력이 아니다.
|
||||
* 훗날 "몇 번 만에 통과했나"가 궁금해지면 시도 횟수(attempts)만 세면 되고,
|
||||
* 코드 이력 전체가 필요해지면 그때 별도 이력 테이블을 만들면 된다.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "coding_submission",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"problem_id", "user_id"}))
|
||||
public class CodingSubmission {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 어떤 문제에 대한 제출인지. DB에는 problem_id 외래키 컬럼이 된다. */
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "problem_id")
|
||||
private CodingProblem problem;
|
||||
|
||||
/** 제출한 학생. DB에는 user_id 외래키 컬럼이 된다. */
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
/** 학생이 작성한 코드 원문 — 멘토가 그대로 읽는다 */
|
||||
@Column(nullable = false, columnDefinition = "text")
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 상태: SUBMITTED(제출됨) | PASSED(자동채점 전부 통과) | REVIEWED(멘토 피드백 완료)
|
||||
* JAVA 문제는 자동채점이 없으므로 SUBMITTED → REVIEWED로만 움직인다.
|
||||
*/
|
||||
@Column(nullable = false, length = 12)
|
||||
private String status;
|
||||
|
||||
/** 통과한 테스트 수 (JAVA 문제는 null) */
|
||||
@Column(name = "passed_count")
|
||||
private Integer passedCount;
|
||||
|
||||
/** 전체 테스트 수 (JAVA 문제는 null) */
|
||||
@Column(name = "total_count")
|
||||
private Integer totalCount;
|
||||
|
||||
/**
|
||||
* 테스트별 채점 결과를 JSON 문자열로 담는다 (JS 전용).
|
||||
* 형식: [{"name":"1+2=3","passed":true,"expected":"3","actual":"3"}, ...]
|
||||
* 멘토가 "어디서 틀렸는지"를 볼 수 있게 하기 위한 것이다.
|
||||
*/
|
||||
@Column(name = "results_json", columnDefinition = "text")
|
||||
private String resultsJson;
|
||||
|
||||
/** 시도 횟수 — 제출할 때마다 1씩 는다 */
|
||||
@Column(nullable = false)
|
||||
private Integer attempts;
|
||||
|
||||
/** 제출(또는 재제출) 시각 */
|
||||
@Column(name = "submitted_at", nullable = false)
|
||||
private Instant submittedAt;
|
||||
|
||||
/** 멘토의 피드백. 아직 리뷰 전이면 null. */
|
||||
@Column(columnDefinition = "text")
|
||||
private String feedback;
|
||||
|
||||
protected CodingSubmission() {
|
||||
}
|
||||
|
||||
public CodingSubmission(CodingProblem problem, User user, String code,
|
||||
Integer passedCount, Integer totalCount, String resultsJson) {
|
||||
this.problem = problem;
|
||||
this.user = user;
|
||||
this.attempts = 0;
|
||||
submit(code, passedCount, totalCount, resultsJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제출(또는 재제출): 코드와 채점 결과를 갱신하고 시도 횟수를 올린다.
|
||||
*
|
||||
* 학습 포인트: 상태를 바깥에서 set으로 바꾸지 않고 이 메서드 안에서만 정한다.
|
||||
* "전부 통과했으면 PASSED"라는 규칙이 엔티티 안에 한 번만 쓰이므로,
|
||||
* 규칙이 바뀌어도 고칠 곳이 한 곳뿐이다. (피드백은 이전 것을 지운다 — 코드가 바뀌었으니까.)
|
||||
*/
|
||||
public void submit(String code, Integer passedCount, Integer totalCount, String resultsJson) {
|
||||
this.code = code;
|
||||
this.passedCount = passedCount;
|
||||
this.totalCount = totalCount;
|
||||
this.resultsJson = resultsJson;
|
||||
this.attempts = this.attempts + 1;
|
||||
this.submittedAt = Instant.now();
|
||||
this.feedback = null;
|
||||
boolean allPassed = totalCount != null && totalCount > 0 && totalCount.equals(passedCount);
|
||||
this.status = allPassed ? "PASSED" : "SUBMITTED";
|
||||
}
|
||||
|
||||
/** 멘토 피드백 등록: 피드백을 저장하고 상태를 REVIEWED로 바꾼다. */
|
||||
public void review(String feedback) {
|
||||
this.feedback = feedback;
|
||||
this.status = "REVIEWED";
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public CodingProblem getProblem() {
|
||||
return problem;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public Integer getPassedCount() {
|
||||
return passedCount;
|
||||
}
|
||||
|
||||
public Integer getTotalCount() {
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
public String getResultsJson() {
|
||||
return resultsJson;
|
||||
}
|
||||
|
||||
public Integer getAttempts() {
|
||||
return attempts;
|
||||
}
|
||||
|
||||
public Instant getSubmittedAt() {
|
||||
return submittedAt;
|
||||
}
|
||||
|
||||
public String getFeedback() {
|
||||
return feedback;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package dev.awesomedev.mirim.repository;
|
||||
|
||||
import dev.awesomedev.mirim.domain.CodingProblem;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일: coding_problem 테이블의 DB 접근 창구.
|
||||
*/
|
||||
public interface CodingProblemRepository extends JpaRepository<CodingProblem, Long> {
|
||||
|
||||
/** 문제 목록 (정렬 순서대로) */
|
||||
List<CodingProblem> findAllByOrderBySortOrderAsc();
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package dev.awesomedev.mirim.repository;
|
||||
|
||||
import dev.awesomedev.mirim.domain.CodingProblem;
|
||||
import dev.awesomedev.mirim.domain.CodingSubmission;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일: coding_submission 테이블의 DB 접근 창구.
|
||||
*/
|
||||
public interface CodingSubmissionRepository extends JpaRepository<CodingSubmission, Long> {
|
||||
|
||||
/**
|
||||
* 이 학생이 이 문제에 낸 제출물 (있으면).
|
||||
* 학습 포인트: (문제, 학생)당 한 행만 두기로 했으므로 List가 아니라 Optional이다.
|
||||
* 엔티티의 uniqueConstraint와 이 반환 타입이 같은 약속을 서로 다른 층에서 표현한다.
|
||||
*/
|
||||
Optional<CodingSubmission> findByProblemAndUser(CodingProblem problem, User user);
|
||||
|
||||
/** 이 학생의 모든 제출물 */
|
||||
List<CodingSubmission> findByUser(User user);
|
||||
|
||||
/** 전체 제출물 — 멘토 화면용 (최신 제출 먼저) */
|
||||
List<CodingSubmission> findAllByOrderBySubmittedAtDesc();
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import dev.awesomedev.mirim.domain.CodingProblem;
|
||||
import dev.awesomedev.mirim.domain.CodingSubmission;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.repository.CodingProblemRepository;
|
||||
import dev.awesomedev.mirim.repository.CodingSubmissionRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 코딩 문제의 비즈니스 로직 — 문제 조회, 제출 저장, 진도 요약, 멘토 피드백.
|
||||
*/
|
||||
@Service
|
||||
public class CodingService {
|
||||
|
||||
private final CodingProblemRepository codingProblemRepository;
|
||||
private final CodingSubmissionRepository codingSubmissionRepository;
|
||||
|
||||
public CodingService(CodingProblemRepository codingProblemRepository,
|
||||
CodingSubmissionRepository codingSubmissionRepository) {
|
||||
this.codingProblemRepository = codingProblemRepository;
|
||||
this.codingSubmissionRepository = codingSubmissionRepository;
|
||||
}
|
||||
|
||||
/** 전체 문제 목록 (정렬 순서대로) */
|
||||
@Transactional(readOnly = true)
|
||||
public List<CodingProblem> allProblems() {
|
||||
return codingProblemRepository.findAllByOrderBySortOrderAsc();
|
||||
}
|
||||
|
||||
/** 문제 한 건 — 없으면 404 */
|
||||
@Transactional(readOnly = true)
|
||||
public CodingProblem problem(Long id) {
|
||||
return codingProblemRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "문제를 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
/** 이 학생의 제출물을 문제 id로 찾아보기 쉽게 Map으로 */
|
||||
@Transactional(readOnly = true)
|
||||
public Map<Long, CodingSubmission> mySubmissionsByProblemId(User user) {
|
||||
return codingSubmissionRepository.findByUser(user).stream()
|
||||
.collect(Collectors.toMap(s -> s.getProblem().getId(), Function.identity()));
|
||||
}
|
||||
|
||||
/** 이 학생이 이 문제에 낸 제출물 (없을 수 있음) */
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<CodingSubmission> mySubmission(User user, Long problemId) {
|
||||
return codingSubmissionRepository.findByProblemAndUser(problem(problemId), user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드를 제출한다. 이미 낸 적이 있으면 그 행을 갱신한다(새 행을 만들지 않는다).
|
||||
*
|
||||
* 학습 포인트: 채점 결과(passedCount/totalCount)를 브라우저가 계산해서 보내온다.
|
||||
* 즉 마음먹으면 "다 통과했다"고 거짓말할 수 있다 — 개발자도구로 요청을 조작하면 된다.
|
||||
* 그런데도 이렇게 만든 이유는, 이 값의 용도가 "성적"이 아니라 "즉시 피드백"이기 때문이다.
|
||||
* 진짜 판정은 멘토가 코드 원문을 읽고 내린다(그래서 code를 반드시 함께 저장한다).
|
||||
* — 대회 채점기라면 절대 이렇게 하면 안 된다. QuizQuestion을 보면 정답은 서버에만 두었다.
|
||||
* "무엇을 지켜야 하는가"에 따라 설계가 달라진다는 걸 두 기능을 비교하며 보자.
|
||||
*/
|
||||
@Transactional
|
||||
public CodingSubmission submit(User user, Long problemId, String code,
|
||||
Integer passedCount, Integer totalCount, String resultsJson) {
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "코드가 비어 있습니다.");
|
||||
}
|
||||
CodingProblem problem = problem(problemId);
|
||||
|
||||
// 자바 문제는 자동채점이 없다 — 브라우저가 뭘 보내오든 채점 값은 버린다.
|
||||
Integer passed = problem.isAutoGraded() ? passedCount : null;
|
||||
Integer total = problem.isAutoGraded() ? totalCount : null;
|
||||
String results = problem.isAutoGraded() ? resultsJson : null;
|
||||
|
||||
return codingSubmissionRepository.findByProblemAndUser(problem, user)
|
||||
.map(existing -> {
|
||||
existing.submit(code, passed, total, results);
|
||||
return existing;
|
||||
})
|
||||
.orElseGet(() -> codingSubmissionRepository.save(
|
||||
new CodingSubmission(problem, user, code, passed, total, results)));
|
||||
}
|
||||
|
||||
/** 멘토 피드백 등록 — 없으면 404 */
|
||||
@Transactional
|
||||
public CodingSubmission giveFeedback(Long submissionId, String feedback) {
|
||||
CodingSubmission submission = codingSubmissionRepository.findById(submissionId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "제출물을 찾을 수 없습니다."));
|
||||
submission.review(feedback);
|
||||
return submission;
|
||||
}
|
||||
|
||||
/** 전체 제출물 — 멘토 화면용 (최신순) */
|
||||
@Transactional(readOnly = true)
|
||||
public List<CodingSubmission> allSubmissions() {
|
||||
return codingSubmissionRepository.findAllByOrderBySubmittedAtDesc();
|
||||
}
|
||||
|
||||
/** 학생 한 명의 진도 요약: 전체 문제 수, 제출 수, 통과 수 */
|
||||
public record ProgressSummary(int total, int submitted, int passed) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 학생의 진도 요약.
|
||||
* 학습 포인트: 문제 총수는 count() 한 번으로 얻고, 제출은 이 학생 것만 가져온다.
|
||||
* 학생이 4~5명이라 이 정도면 충분히 빠르다(MentorController의 N+1 주석과 같은 판단이다).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ProgressSummary progressOf(User user) {
|
||||
int total = (int) codingProblemRepository.count();
|
||||
List<CodingSubmission> mine = codingSubmissionRepository.findByUser(user);
|
||||
int passed = (int) mine.stream().filter(s -> "PASSED".equals(s.getStatus())).count();
|
||||
return new ProgressSummary(total, mine.size(), passed);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
package dev.awesomedev.mirim.web;
|
||||
|
||||
import dev.awesomedev.mirim.domain.CodingProblem;
|
||||
import dev.awesomedev.mirim.domain.CodingSubmission;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.service.AuthService;
|
||||
import dev.awesomedev.mirim.service.CodingService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 학생용 코딩 문제 API — 문제 목록, 문제 상세(+내 제출물), 코드 제출, 내 진도.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/coding")
|
||||
public class CodingController {
|
||||
|
||||
private final CodingService codingService;
|
||||
private final AuthService authService;
|
||||
|
||||
public CodingController(CodingService codingService, AuthService authService) {
|
||||
this.codingService = codingService;
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록용 요약 — 문제 설명·테스트케이스는 빼고 필요한 것만 담는다.
|
||||
* 학습 포인트: 목록 20건마다 긴 description을 실어 보내면 응답이 쓸데없이 커진다.
|
||||
* "화면이 필요로 하는 만큼만 준다"가 DTO를 따로 두는 이유다.
|
||||
*/
|
||||
public record ProblemSummary(Long id, String language, String title, String difficulty,
|
||||
String topic, String status, Integer passedCount, Integer totalCount) {
|
||||
}
|
||||
|
||||
/** GET /api/coding/problems — 문제 목록 + 내 풀이 상태 */
|
||||
@GetMapping("/problems")
|
||||
public List<ProblemSummary> problems(Authentication authentication) {
|
||||
User user = authService.requireUser(authentication);
|
||||
Map<Long, CodingSubmission> mine = codingService.mySubmissionsByProblemId(user);
|
||||
return codingService.allProblems().stream()
|
||||
.map(problem -> {
|
||||
CodingSubmission submission = mine.get(problem.getId());
|
||||
return new ProblemSummary(
|
||||
problem.getId(), problem.getLanguage(), problem.getTitle(),
|
||||
problem.getDifficulty(), problem.getTopic(),
|
||||
submission == null ? "TODO" : submission.getStatus(),
|
||||
submission == null ? null : submission.getPassedCount(),
|
||||
submission == null ? null : submission.getTotalCount());
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 문제 상세 — 테스트케이스(testsJson)는 브라우저가 채점해야 하므로 함께 내려준다 */
|
||||
public record ProblemDetail(Long id, String language, String title, String difficulty,
|
||||
String topic, String description, String starterCode,
|
||||
String functionName, String testsJson,
|
||||
String myCode, String status, Integer passedCount,
|
||||
Integer totalCount, String resultsJson, Integer attempts,
|
||||
String feedback) {
|
||||
}
|
||||
|
||||
/** GET /api/coding/problems/{id} — 문제 상세 + 내가 마지막에 낸 코드/결과 */
|
||||
@GetMapping("/problems/{id}")
|
||||
public ProblemDetail problem(@PathVariable Long id, Authentication authentication) {
|
||||
User user = authService.requireUser(authentication);
|
||||
CodingProblem problem = codingService.problem(id);
|
||||
return codingService.mySubmission(user, id)
|
||||
.map(s -> new ProblemDetail(problem.getId(), problem.getLanguage(), problem.getTitle(),
|
||||
problem.getDifficulty(), problem.getTopic(), problem.getDescription(),
|
||||
problem.getStarterCode(), problem.getFunctionName(), problem.getTestsJson(),
|
||||
s.getCode(), s.getStatus(), s.getPassedCount(), s.getTotalCount(),
|
||||
s.getResultsJson(), s.getAttempts(), s.getFeedback()))
|
||||
.orElseGet(() -> new ProblemDetail(problem.getId(), problem.getLanguage(), problem.getTitle(),
|
||||
problem.getDifficulty(), problem.getTopic(), problem.getDescription(),
|
||||
problem.getStarterCode(), problem.getFunctionName(), problem.getTestsJson(),
|
||||
null, "TODO", null, null, null, 0, null));
|
||||
}
|
||||
|
||||
/** 제출 요청 — 코드와, 브라우저가 매긴 채점 결과(JS 문제일 때) */
|
||||
public record SubmitRequest(String code, Integer passedCount, Integer totalCount, String resultsJson) {
|
||||
}
|
||||
|
||||
/** 제출 응답 */
|
||||
public record SubmitResponse(Long id, String status, Integer passedCount, Integer totalCount,
|
||||
Integer attempts) {
|
||||
}
|
||||
|
||||
/** POST /api/coding/problems/{id}/submit — 코드 제출(재제출이면 기존 행 갱신) */
|
||||
@PostMapping("/problems/{id}/submit")
|
||||
public SubmitResponse submit(@PathVariable Long id,
|
||||
@RequestBody SubmitRequest request,
|
||||
Authentication authentication) {
|
||||
User user = authService.requireUser(authentication);
|
||||
CodingSubmission submission = codingService.submit(user, id, request.code(),
|
||||
request.passedCount(), request.totalCount(), request.resultsJson());
|
||||
return new SubmitResponse(submission.getId(), submission.getStatus(),
|
||||
submission.getPassedCount(), submission.getTotalCount(), submission.getAttempts());
|
||||
}
|
||||
|
||||
/** GET /api/coding/progress/me — 내 진도(전체/제출/통과) */
|
||||
@GetMapping("/progress/me")
|
||||
public CodingService.ProgressSummary myProgress(Authentication authentication) {
|
||||
User user = authService.requireUser(authentication);
|
||||
return codingService.progressOf(user);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
package dev.awesomedev.mirim.web;
|
||||
|
||||
import dev.awesomedev.mirim.domain.CodingSubmission;
|
||||
import dev.awesomedev.mirim.domain.Submission;
|
||||
import dev.awesomedev.mirim.repository.UserRepository;
|
||||
import dev.awesomedev.mirim.service.CodingService;
|
||||
import dev.awesomedev.mirim.service.CourseProgressService;
|
||||
import dev.awesomedev.mirim.service.ProgressService;
|
||||
import dev.awesomedev.mirim.service.QuizService;
|
||||
@ -32,17 +34,20 @@ public class MentorController {
|
||||
private final CourseProgressService courseProgressService;
|
||||
private final ProgressService progressService;
|
||||
private final QuizService quizService;
|
||||
private final CodingService codingService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public MentorController(SubmissionService submissionService,
|
||||
CourseProgressService courseProgressService,
|
||||
ProgressService progressService,
|
||||
QuizService quizService,
|
||||
CodingService codingService,
|
||||
UserRepository userRepository) {
|
||||
this.submissionService = submissionService;
|
||||
this.courseProgressService = courseProgressService;
|
||||
this.progressService = progressService;
|
||||
this.quizService = quizService;
|
||||
this.codingService = codingService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@ -114,4 +119,60 @@ public class MentorController {
|
||||
Submission submission = submissionService.giveFeedback(id, feedbackRequest.feedback());
|
||||
return MentorSubmissionResponse.from(submission);
|
||||
}
|
||||
|
||||
// --- 코딩 문제 ---
|
||||
|
||||
/**
|
||||
* 코딩 제출물 한 건 (멘토 화면용).
|
||||
* 학생이 짠 코드 원문(code)과 테스트별 채점 결과(resultsJson)를 그대로 담는다 —
|
||||
* 멘토가 "무엇을 어떻게 짰고, 어디서 틀렸는지"를 한 화면에서 보게 하기 위해서다.
|
||||
*/
|
||||
public record MentorCodingSubmissionResponse(Long id, Long problemId, String problemTitle,
|
||||
String language, String difficulty,
|
||||
Long userId, String studentName, String track,
|
||||
String code, String status,
|
||||
Integer passedCount, Integer totalCount,
|
||||
String resultsJson, Integer attempts,
|
||||
String submittedAt, String feedback) {
|
||||
static MentorCodingSubmissionResponse from(CodingSubmission s) {
|
||||
return new MentorCodingSubmissionResponse(
|
||||
s.getId(), s.getProblem().getId(), s.getProblem().getTitle(),
|
||||
s.getProblem().getLanguage(), s.getProblem().getDifficulty(),
|
||||
s.getUser().getId(), s.getUser().getName(), s.getUser().getTrack(),
|
||||
s.getCode(), s.getStatus(), s.getPassedCount(), s.getTotalCount(),
|
||||
s.getResultsJson(), s.getAttempts(),
|
||||
s.getSubmittedAt().toString(), s.getFeedback());
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/mentor/coding-submissions — 전체 코딩 제출물 (최신순) */
|
||||
@GetMapping("/coding-submissions")
|
||||
public List<MentorCodingSubmissionResponse> codingSubmissions() {
|
||||
return codingService.allSubmissions().stream()
|
||||
.map(MentorCodingSubmissionResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 학생 한 명의 코딩 진도 */
|
||||
public record StudentCodingProgress(Long userId, String name, String track,
|
||||
CodingService.ProgressSummary progress) {
|
||||
}
|
||||
|
||||
/** GET /api/mentor/coding-progress — 학생별 코딩 진도(전체/제출/통과) */
|
||||
@GetMapping("/coding-progress")
|
||||
public List<StudentCodingProgress> codingProgress() {
|
||||
return userRepository.findByRoleOrderByNameAsc("STUDENT").stream()
|
||||
.map(student -> new StudentCodingProgress(
|
||||
student.getId(), student.getName(), student.getTrack(),
|
||||
codingService.progressOf(student)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** POST /api/mentor/coding-submissions/{id}/feedback — 코드에 피드백 등록 */
|
||||
@PostMapping("/coding-submissions/{id}/feedback")
|
||||
public MentorCodingSubmissionResponse giveCodingFeedback(@PathVariable Long id,
|
||||
@RequestBody FeedbackRequest feedbackRequest) {
|
||||
return MentorCodingSubmissionResponse.from(
|
||||
codingService.giveFeedback(id, feedbackRequest.feedback()));
|
||||
}
|
||||
}
|
||||
|
||||
262
backend/src/main/resources/seed/coding-problems.json
Normal file
262
backend/src/main/resources/seed/coding-problems.json
Normal file
@ -0,0 +1,262 @@
|
||||
[
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "두 수의 합",
|
||||
"difficulty": "EASY",
|
||||
"topic": "javascript",
|
||||
"description": "정수 두 개 a, b를 받아 그 합을 돌려주는 함수를 완성하세요.\n\n예) solution(1, 2) → 3\n\n힌트: return 문으로 값을 돌려줍니다. return이 없으면 undefined가 나갑니다.",
|
||||
"starterCode": "function solution(a, b) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "1 + 2 = 3", "args": [1, 2], "expected": 3 },
|
||||
{ "name": "0 + 0 = 0", "args": [0, 0], "expected": 0 },
|
||||
{ "name": "음수도 된다 (-5 + 3)", "args": [-5, 3], "expected": -2 },
|
||||
{ "name": "큰 수 (10000 + 23456)", "args": [10000, 23456], "expected": 33456 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "짝수인지 판별하기",
|
||||
"difficulty": "EASY",
|
||||
"topic": "javascript",
|
||||
"description": "정수 n이 짝수면 true, 홀수면 false를 돌려주세요.\n\n힌트: 나머지 연산자 %를 쓰면 2로 나눈 나머지를 알 수 있습니다. 0은 짝수입니다.",
|
||||
"starterCode": "function solution(n) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "4는 짝수", "args": [4], "expected": true },
|
||||
{ "name": "7은 홀수", "args": [7], "expected": false },
|
||||
{ "name": "0은 짝수", "args": [0], "expected": true },
|
||||
{ "name": "-3은 홀수", "args": [-3], "expected": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "문자열 뒤집기",
|
||||
"difficulty": "EASY",
|
||||
"topic": "javascript",
|
||||
"description": "문자열 s를 거꾸로 뒤집어 돌려주세요.\n\n예) solution('hello') → 'olleh'\n\n힌트: 문자열을 split('')으로 배열로 만들고 reverse() 후 join('')으로 다시 붙일 수 있습니다.",
|
||||
"starterCode": "function solution(s) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "'hello' → 'olleh'", "args": ["hello"], "expected": "olleh" },
|
||||
{ "name": "한 글자는 그대로", "args": ["a"], "expected": "a" },
|
||||
{ "name": "빈 문자열", "args": [""], "expected": "" },
|
||||
{ "name": "한글도 된다", "args": ["가나다"], "expected": "다나가" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "배열에서 가장 큰 수 찾기",
|
||||
"difficulty": "EASY",
|
||||
"topic": "javascript",
|
||||
"description": "숫자 배열 numbers에서 가장 큰 값을 돌려주세요. 배열은 항상 1개 이상입니다.\n\n힌트: Math.max(...numbers)처럼 펼침 연산자(...)를 쓰거나, 반복문으로 직접 비교할 수 있습니다.",
|
||||
"starterCode": "function solution(numbers) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "[1, 5, 3] → 5", "args": [[1, 5, 3]], "expected": 5 },
|
||||
{ "name": "원소 하나", "args": [[42]], "expected": 42 },
|
||||
{ "name": "전부 음수", "args": [[-1, -9, -4]], "expected": -1 },
|
||||
{ "name": "중복이 있어도", "args": [[7, 7, 2]], "expected": 7 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "1부터 n까지 더하기",
|
||||
"difficulty": "EASY",
|
||||
"topic": "javascript",
|
||||
"description": "1부터 n까지의 합을 돌려주세요. n은 1 이상입니다.\n\n예) solution(5) → 15 (1+2+3+4+5)\n\n힌트: for문으로 더해도 되고, 공식 n*(n+1)/2를 써도 됩니다.",
|
||||
"starterCode": "function solution(n) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "n=5 → 15", "args": [5], "expected": 15 },
|
||||
{ "name": "n=1 → 1", "args": [1], "expected": 1 },
|
||||
{ "name": "n=100 → 5050", "args": [100], "expected": 5050 },
|
||||
{ "name": "n=10 → 55", "args": [10], "expected": 55 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "모음 개수 세기",
|
||||
"difficulty": "NORMAL",
|
||||
"topic": "javascript",
|
||||
"description": "영어 소문자 문자열 s에서 모음(a, e, i, o, u)이 몇 개인지 세어 돌려주세요.\n\n예) solution('hello') → 2\n\n힌트: 문자열도 for...of로 한 글자씩 돌 수 있습니다. includes()가 유용합니다.",
|
||||
"starterCode": "function solution(s) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "'hello' → 2", "args": ["hello"], "expected": 2 },
|
||||
{ "name": "모음 없음", "args": ["xyz"], "expected": 0 },
|
||||
{ "name": "전부 모음", "args": ["aeiou"], "expected": 5 },
|
||||
{ "name": "빈 문자열", "args": [""], "expected": 0 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "배열에서 짝수만 골라내기",
|
||||
"difficulty": "NORMAL",
|
||||
"topic": "javascript",
|
||||
"description": "숫자 배열에서 짝수만 순서를 유지한 채 새 배열로 돌려주세요.\n\n예) solution([1,2,3,4]) → [2, 4]\n\n힌트: filter()는 조건을 만족하는 원소만 모아 새 배열을 만듭니다. 원본은 바뀌지 않습니다.",
|
||||
"starterCode": "function solution(numbers) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "[1,2,3,4] → [2,4]", "args": [[1, 2, 3, 4]], "expected": [2, 4] },
|
||||
{ "name": "짝수 없음", "args": [[1, 3, 5]], "expected": [] },
|
||||
{ "name": "빈 배열", "args": [[]], "expected": [] },
|
||||
{ "name": "0도 짝수", "args": [[0, 1]], "expected": [0] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "FizzBuzz",
|
||||
"difficulty": "NORMAL",
|
||||
"topic": "javascript",
|
||||
"description": "1부터 n까지를 배열로 돌려주되,\n3의 배수는 'Fizz', 5의 배수는 'Buzz', 3과 5의 공배수는 'FizzBuzz'로 바꿔 넣으세요.\n그 외는 숫자 그대로(문자열 아님) 넣습니다.\n\n예) solution(5) → [1, 2, 'Fizz', 4, 'Buzz']\n\n힌트: 15의 배수를 가장 먼저 검사해야 합니다. 순서를 바꾸면 'FizzBuzz'가 절대 나오지 않습니다.",
|
||||
"starterCode": "function solution(n) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "n=5", "args": [5], "expected": [1, 2, "Fizz", 4, "Buzz"] },
|
||||
{ "name": "n=15 (마지막이 FizzBuzz)", "args": [15], "expected": [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz"] },
|
||||
{ "name": "n=1", "args": [1], "expected": [1] },
|
||||
{ "name": "n=3", "args": [3], "expected": [1, 2, "Fizz"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "중복 제거하기",
|
||||
"difficulty": "NORMAL",
|
||||
"topic": "javascript",
|
||||
"description": "배열에서 중복된 값을 없애고, 처음 나온 순서를 유지한 새 배열을 돌려주세요.\n\n예) solution([1,2,2,3,1]) → [1, 2, 3]\n\n힌트: Set은 중복을 자동으로 없애 줍니다. [...new Set(arr)]로 배열로 되돌릴 수 있습니다.",
|
||||
"starterCode": "function solution(numbers) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "[1,2,2,3,1] → [1,2,3]", "args": [[1, 2, 2, 3, 1]], "expected": [1, 2, 3] },
|
||||
{ "name": "중복 없음", "args": [[1, 2, 3]], "expected": [1, 2, 3] },
|
||||
{ "name": "전부 같은 값", "args": [[5, 5, 5]], "expected": [5] },
|
||||
{ "name": "빈 배열", "args": [[]], "expected": [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "회문(팰린드롬) 판별",
|
||||
"difficulty": "NORMAL",
|
||||
"topic": "javascript",
|
||||
"description": "문자열이 앞뒤 어느 쪽에서 읽어도 같으면 true, 아니면 false를 돌려주세요.\n대소문자는 구분하지 않고, 공백은 무시합니다.\n\n예) solution('level') → true, solution('a b a') → true\n\n힌트: 먼저 소문자로 바꾸고 공백을 없앤 다음, 뒤집은 것과 비교하세요. replaceAll(' ', '')이 쓸 만합니다.",
|
||||
"starterCode": "function solution(s) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "'level' → true", "args": ["level"], "expected": true },
|
||||
{ "name": "'hello' → false", "args": ["hello"], "expected": false },
|
||||
{ "name": "대소문자 무시 'Level'", "args": ["Level"], "expected": true },
|
||||
{ "name": "공백 무시 'a b a'", "args": ["a b a"], "expected": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "단어 개수 세기 (객체 다루기)",
|
||||
"difficulty": "HARD",
|
||||
"topic": "javascript",
|
||||
"description": "공백으로 구분된 문자열에서 각 단어가 몇 번 나오는지 세어 객체로 돌려주세요.\n\n예) solution('a b a') → { a: 2, b: 1 }\n빈 문자열이면 빈 객체 {} 를 돌려주세요.\n\n힌트: split(' ')으로 나누고, 객체에 obj[word] = (obj[word] || 0) + 1 패턴을 쓰면 됩니다.\n(왜 || 0 이 필요할까? 처음 보는 단어면 obj[word]가 undefined이고, undefined + 1은 NaN이기 때문입니다.)",
|
||||
"starterCode": "function solution(sentence) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "'a b a'", "args": ["a b a"], "expected": { "a": 2, "b": 1 } },
|
||||
{ "name": "단어 하나", "args": ["hello"], "expected": { "hello": 1 } },
|
||||
{ "name": "빈 문자열 → {}", "args": [""], "expected": {} },
|
||||
{ "name": "전부 다른 단어", "args": ["x y z"], "expected": { "x": 1, "y": 1, "z": 1 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JS",
|
||||
"title": "정렬해서 두 번째로 큰 수 찾기",
|
||||
"difficulty": "HARD",
|
||||
"topic": "javascript",
|
||||
"description": "배열에서 (중복을 제외하고) 두 번째로 큰 수를 돌려주세요.\n두 번째로 큰 수가 없으면 null을 돌려주세요.\n\n예) solution([3,1,3,2]) → 2 (중복 제외하면 3,2,1 이므로 두 번째는 2)\n\n힌트: 중복을 먼저 없애고 내림차순 정렬하세요.\n주의! sort()는 기본이 '문자열' 정렬이라 [10, 9]를 [10, 9]가 아니라 [10, 9]... 헷갈립니다.\n숫자는 반드시 sort((a, b) => b - a)처럼 비교 함수를 줘야 합니다.",
|
||||
"starterCode": "function solution(numbers) {\n // 여기에 코드를 작성하세요\n}",
|
||||
"functionName": "solution",
|
||||
"tests": [
|
||||
{ "name": "[3,1,3,2] → 2", "args": [[3, 1, 3, 2]], "expected": 2 },
|
||||
{ "name": "전부 같으면 null", "args": [[5, 5, 5]], "expected": null },
|
||||
{ "name": "두 자리 수 주의 [10, 9]", "args": [[10, 9]], "expected": 9 },
|
||||
{ "name": "원소 하나면 null", "args": [[1]], "expected": null }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "JAVA",
|
||||
"title": "두 수 중 큰 값 돌려주기",
|
||||
"difficulty": "EASY",
|
||||
"topic": "java-basics",
|
||||
"description": "정수 두 개 중 더 큰 값을 돌려주는 메서드를 완성하세요. 같으면 그 값을 돌려줍니다.\n\n이 문제는 자동채점이 없습니다. 코드를 제출하면 멘토가 직접 읽고 피드백합니다.\n제출 전 IntelliJ에서 직접 실행해 확인해 보세요.\n\n힌트: if문으로 풀어도 되고 Math.max를 써도 됩니다. 둘 다 해보고 어느 쪽이 읽기 좋은지 생각해 보세요.",
|
||||
"starterCode": "public class Solution {\n\n public int solution(int a, int b) {\n // 여기에 코드를 작성하세요\n return 0;\n }\n}",
|
||||
"functionName": null,
|
||||
"tests": null
|
||||
},
|
||||
{
|
||||
"language": "JAVA",
|
||||
"title": "배열의 합 구하기",
|
||||
"difficulty": "EASY",
|
||||
"topic": "java-basics",
|
||||
"description": "int 배열의 모든 원소를 더한 값을 돌려주세요. 빈 배열이면 0입니다.\n\n이 문제는 자동채점이 없습니다 — 멘토가 코드를 읽고 피드백합니다.\n\n힌트: 향상된 for문(for (int n : numbers))을 쓰면 인덱스 실수를 줄일 수 있습니다.",
|
||||
"starterCode": "public class Solution {\n\n public int solution(int[] numbers) {\n // 여기에 코드를 작성하세요\n return 0;\n }\n}",
|
||||
"functionName": null,
|
||||
"tests": null
|
||||
},
|
||||
{
|
||||
"language": "JAVA",
|
||||
"title": "문자열이 비었는지 안전하게 검사하기",
|
||||
"difficulty": "EASY",
|
||||
"topic": "java-basics",
|
||||
"description": "문자열이 null이거나, 비어 있거나, 공백뿐이면 true를 돌려주세요. 그 외에는 false입니다.\n\n예) null → true, \"\" → true, \" \" → true, \" a \" → false\n\n힌트: null 검사를 가장 먼저 해야 합니다. 순서를 바꾸면 NullPointerException이 납니다 —\n왜 그런지 설명할 수 있게 되는 것이 이 문제의 진짜 목표입니다. isBlank()를 찾아보세요.",
|
||||
"starterCode": "public class Solution {\n\n public boolean solution(String s) {\n // 여기에 코드를 작성하세요\n return false;\n }\n}",
|
||||
"functionName": null,
|
||||
"tests": null
|
||||
},
|
||||
{
|
||||
"language": "JAVA",
|
||||
"title": "1부터 n까지 짝수만 더하기",
|
||||
"difficulty": "NORMAL",
|
||||
"topic": "java-basics",
|
||||
"description": "1부터 n까지의 정수 중 짝수만 더한 값을 돌려주세요.\n\n예) solution(10) → 30 (2+4+6+8+10)\n\n힌트: for문의 증가식을 i += 2로 두면 짝수만 밟을 수 있습니다.\nif로 걸러내는 방식과 비교해 보고, 어느 쪽이 더 좋은지 근거를 들어 설명해 보세요.",
|
||||
"starterCode": "public class Solution {\n\n public int solution(int n) {\n // 여기에 코드를 작성하세요\n return 0;\n }\n}",
|
||||
"functionName": null,
|
||||
"tests": null
|
||||
},
|
||||
{
|
||||
"language": "JAVA",
|
||||
"title": "문자열 뒤집기 (StringBuilder)",
|
||||
"difficulty": "NORMAL",
|
||||
"topic": "java-basics",
|
||||
"description": "문자열을 거꾸로 뒤집어 돌려주세요.\n\n힌트: StringBuilder에는 reverse()가 있습니다.\n생각해 볼 것 — String을 +로 계속 이어 붙이며 뒤집으면 왜 느릴까요?\n(자바의 String은 한 번 만들면 바뀌지 않는다 = 불변. 이어 붙일 때마다 새 객체가 생깁니다.)\n제출할 때 이 이유를 주석으로 적어 주세요.",
|
||||
"starterCode": "public class Solution {\n\n public String solution(String s) {\n // 여기에 코드를 작성하세요\n return \"\";\n }\n}",
|
||||
"functionName": null,
|
||||
"tests": null
|
||||
},
|
||||
{
|
||||
"language": "JAVA",
|
||||
"title": "배열에서 최댓값 찾기",
|
||||
"difficulty": "NORMAL",
|
||||
"topic": "java-basics",
|
||||
"description": "int 배열에서 가장 큰 값을 돌려주세요. 배열은 항상 1개 이상입니다.\n\n힌트: 최댓값의 시작값을 0으로 두면 안 됩니다 — 전부 음수인 배열에서 틀립니다.\n첫 원소(numbers[0])로 시작하세요. 이런 실수를 '경계 조건 버그'라고 부릅니다.",
|
||||
"starterCode": "public class Solution {\n\n public int solution(int[] numbers) {\n // 여기에 코드를 작성하세요\n return 0;\n }\n}",
|
||||
"functionName": null,
|
||||
"tests": null
|
||||
},
|
||||
{
|
||||
"language": "JAVA",
|
||||
"title": "단어 개수 세기 (Map 다루기)",
|
||||
"difficulty": "HARD",
|
||||
"topic": "java-basics",
|
||||
"description": "공백으로 구분된 문장에서 각 단어의 등장 횟수를 Map<String, Integer>로 돌려주세요.\n\n예) \"a b a\" → {a=2, b=1}\n\n힌트: split(\" \")으로 나눈 뒤 Map에 넣습니다.\nmap.getOrDefault(word, 0) + 1 패턴을 찾아보세요 — merge()로도 됩니다.\n빈 문자열일 때 split이 무엇을 돌려주는지 직접 찍어 보고 처리하세요.",
|
||||
"starterCode": "import java.util.HashMap;\nimport java.util.Map;\n\npublic class Solution {\n\n public Map<String, Integer> solution(String sentence) {\n // 여기에 코드를 작성하세요\n return new HashMap<>();\n }\n}",
|
||||
"functionName": null,
|
||||
"tests": null
|
||||
},
|
||||
{
|
||||
"language": "JAVA",
|
||||
"title": "학생 목록을 점수순으로 정렬하기",
|
||||
"difficulty": "HARD",
|
||||
"topic": "java-basics",
|
||||
"description": "Student(name, score) 목록을 점수가 높은 순으로 정렬해 돌려주세요.\n점수가 같으면 이름 오름차순으로 정렬합니다.\n\n힌트: list.sort(Comparator.comparingInt(Student::getScore).reversed().thenComparing(Student::getName))\n메서드 참조(::)와 Comparator 조합을 익히는 문제입니다.\n한 줄로 쓰기 전에, 익명 클래스로 먼저 써 보고 무엇이 줄어드는지 비교해 보세요.",
|
||||
"starterCode": "import java.util.Comparator;\nimport java.util.List;\n\npublic class Solution {\n\n public static class Student {\n private final String name;\n private final int score;\n\n public Student(String name, int score) {\n this.name = name;\n this.score = score;\n }\n\n public String getName() { return name; }\n public int getScore() { return score; }\n }\n\n public List<Student> solution(List<Student> students) {\n // 여기에 코드를 작성하세요\n return students;\n }\n}",
|
||||
"functionName": null,
|
||||
"tests": null
|
||||
}
|
||||
]
|
||||
@ -18,6 +18,8 @@ import DocViewerPage from './pages/DocViewerPage';
|
||||
import AssignmentsPage from './pages/AssignmentsPage';
|
||||
import SetupGuidePage from './pages/SetupGuidePage';
|
||||
import QuizPage from './pages/QuizPage';
|
||||
import CodingPage from './pages/CodingPage';
|
||||
import CodingProblemPage from './pages/CodingProblemPage';
|
||||
import MentorPage from './pages/MentorPage';
|
||||
|
||||
// 학습 포인트: "로그인 필요" 가드 컴포넌트.
|
||||
@ -71,6 +73,9 @@ function Layout() {
|
||||
<NavLink to="/assignments" className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
과제
|
||||
</NavLink>
|
||||
<NavLink to="/coding" className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
코딩 문제
|
||||
</NavLink>
|
||||
{user?.role === 'MENTOR' && (
|
||||
<NavLink to="/mentor" className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
멘토 리뷰
|
||||
@ -130,6 +135,9 @@ export default function App() {
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/docs/:slug" element={<DocViewerPage />} />
|
||||
<Route path="/assignments" element={<AssignmentsPage />} />
|
||||
{/* 코딩 문제: 목록 + 문제별 풀이 화면 (:id는 문제 번호) */}
|
||||
<Route path="/coding" element={<CodingPage />} />
|
||||
<Route path="/coding/:id" element={<CodingProblemPage />} />
|
||||
{/* 멘토 전용 영역 */}
|
||||
<Route element={<RequireMentor />}>
|
||||
<Route path="/mentor" element={<MentorPage />} />
|
||||
|
||||
75
frontend/src/components/CodeEditor.jsx
Normal file
75
frontend/src/components/CodeEditor.jsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 코드를 입력하는 에디터다. 겉보기엔 줄번호가 붙은 코드 창이지만,
|
||||
* 속은 그냥 <textarea> 하나다.
|
||||
*
|
||||
* 학습 포인트: 왜 Monaco(VS Code 엔진)나 CodeMirror 같은 라이브러리를 안 썼을까?
|
||||
* 그 라이브러리들은 수 MB짜리다. 우리가 필요한 건 "고정폭 글꼴 + 탭키 + 줄번호"가 전부인데,
|
||||
* 그걸 위해 앱 로딩을 몇 초 늦추는 건 손해다.
|
||||
* 라이브러리를 고를 때는 "이게 해 주는 일" 말고 "내가 진짜 필요한 일"을 먼저 적어 보자.
|
||||
* 나중에 자동완성·문법 하이라이팅이 정말 필요해지면 그때 바꾸면 된다.
|
||||
*/
|
||||
export default function CodeEditor({ value, onChange, readOnly = false, minRows = 12 }) {
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
// 줄번호는 줄 수만큼 만들어 준다. 최소 minRows줄은 보이게 해서 창이 쪼그라들지 않게 한다.
|
||||
const lineCount = Math.max(value.split('\n').length, minRows);
|
||||
const lineNumbers = Array.from({ length: lineCount }, (_, i) => i + 1);
|
||||
|
||||
/**
|
||||
* Tab 키 처리.
|
||||
* 학습 포인트: textarea는 원래 Tab을 누르면 "다음 입력칸으로 이동"한다(브라우저 기본 동작).
|
||||
* 코드 창에서는 들여쓰기가 돼야 하므로 preventDefault()로 기본 동작을 막고
|
||||
* 우리가 직접 공백 2칸을 끼워 넣는다.
|
||||
*/
|
||||
function handleKeyDown(event) {
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const textarea = event.target;
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const next = value.slice(0, selectionStart) + ' ' + value.slice(selectionEnd);
|
||||
onChange(next);
|
||||
// 값이 바뀐 뒤에 커서를 옮겨야 하므로 다음 렌더 시점으로 미룬다.
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = selectionEnd + 2;
|
||||
textarea.selectionEnd = selectionEnd + 2;
|
||||
});
|
||||
}
|
||||
|
||||
// 왼쪽 줄번호와 오른쪽 코드가 같이 스크롤되게 맞춘다.
|
||||
function handleScroll(event) {
|
||||
const gutter = event.target.previousSibling;
|
||||
if (gutter) {
|
||||
gutter.scrollTop = event.target.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-editor">
|
||||
<div className="code-editor__gutter" aria-hidden="true">
|
||||
{lineNumbers.map((n) => (
|
||||
<div key={n}>{n}</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="code-editor__area"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleScroll}
|
||||
readOnly={readOnly}
|
||||
spellCheck={false}
|
||||
// 학습 포인트: 브라우저의 자동 대문자/맞춤법 교정이 코드를 망가뜨리므로 전부 끈다.
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
rows={lineCount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
260
frontend/src/components/MentorCodingSection.jsx
Normal file
260
frontend/src/components/MentorCodingSection.jsx
Normal file
@ -0,0 +1,260 @@
|
||||
// 이 파일이 하는 일: 멘토 리뷰 화면의 "코딩 문제" 부분 — 학생별 진도표와,
|
||||
// 제출된 코드를 펼쳐 읽고 피드백을 남기는 목록.
|
||||
//
|
||||
// 학습 포인트: MentorPage가 이미 400줄이라 여기에 다 넣으면 읽기 어려워진다.
|
||||
// "화면의 한 덩어리"를 파일로 떼면, 이 기능만 고칠 때 이 파일만 열면 된다.
|
||||
// 대신 데이터 로딩은 이 컴포넌트가 스스로 한다 — 부모가 몰라도 되는 일이기 때문이다.
|
||||
import { useEffect, useState } from 'react';
|
||||
import client from '../api/client';
|
||||
|
||||
const STATUS_LABEL = {
|
||||
SUBMITTED: '제출됨',
|
||||
PASSED: '통과',
|
||||
REVIEWED: '피드백 완료',
|
||||
};
|
||||
|
||||
/** 학생별 코딩 진도표 (전체 20문제 중 몇 개 제출/통과) */
|
||||
function CodingProgressTable({ rows }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflowX: 'auto' }}>
|
||||
<table className="table table-static" style={{ minWidth: 480 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>학생</th>
|
||||
<th>트랙</th>
|
||||
<th style={{ textAlign: 'center' }}>제출</th>
|
||||
<th style={{ textAlign: 'center' }}>통과</th>
|
||||
<th style={{ textAlign: 'center' }}>전체</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.userId}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.track}</td>
|
||||
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{row.progress.submitted}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
// 통과가 하나도 없으면 옅게 — 눈에 띄어야 할 건 "막힌 학생"이다
|
||||
color: row.progress.passed === 0 ? 'var(--muted, #888)' : undefined,
|
||||
fontWeight: row.progress.passed > 0 ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{row.progress.passed}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{row.progress.total}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MentorCodingSection() {
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [progress, setProgress] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [feedbackDraft, setFeedbackDraft] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
function load() {
|
||||
// MentorPage와 같은 이유로 allSettled — 한쪽이 실패해도 다른 쪽은 보여 준다.
|
||||
return Promise.allSettled([
|
||||
client.get('/mentor/coding-submissions'),
|
||||
client.get('/mentor/coding-progress'),
|
||||
]).then(([subsRes, progRes]) => {
|
||||
if (subsRes.status === 'fulfilled') setSubmissions(subsRes.value.data);
|
||||
if (progRes.status === 'fulfilled') setProgress(progRes.value.data);
|
||||
setLoadError([subsRes, progRes].some((r) => r.status === 'rejected'));
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const selected = submissions.find((s) => s.id === selectedId) || null;
|
||||
|
||||
function selectRow(submission) {
|
||||
if (selectedId === submission.id) {
|
||||
setSelectedId(null);
|
||||
return;
|
||||
}
|
||||
setSelectedId(submission.id);
|
||||
setFeedbackDraft(submission.feedback || '');
|
||||
setError('');
|
||||
}
|
||||
|
||||
async function saveFeedback(e) {
|
||||
e.preventDefault();
|
||||
if (!feedbackDraft.trim()) {
|
||||
setError('피드백 내용을 적어 주세요.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await client.post(`/mentor/coding-submissions/${selectedId}/feedback`, {
|
||||
feedback: feedbackDraft,
|
||||
});
|
||||
await load();
|
||||
} catch {
|
||||
setError('피드백 저장 중 문제가 생겼어요. 다시 시도해 주세요.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 저장된 채점 결과(JSON 문자열)를 화면용 배열로. 깨져 있으면 그냥 안 보여 준다.
|
||||
let selectedResults = null;
|
||||
if (selected?.resultsJson) {
|
||||
try {
|
||||
selectedResults = JSON.parse(selected.resultsJson);
|
||||
} catch {
|
||||
selectedResults = null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: 24 }}>
|
||||
코딩 문제 진도
|
||||
</h2>
|
||||
{loadError && (
|
||||
<p className="error-text" style={{ marginBottom: 14 }}>
|
||||
코딩 데이터를 일부 불러오지 못했어요.
|
||||
</p>
|
||||
)}
|
||||
{progress.length === 0 ? (
|
||||
<p className="empty">아직 학생 계정이 없어요.</p>
|
||||
) : (
|
||||
<CodingProgressTable rows={progress} />
|
||||
)}
|
||||
|
||||
<h2 className="section-title" style={{ marginTop: 24 }}>
|
||||
코딩 제출물
|
||||
</h2>
|
||||
{submissions.length === 0 ? (
|
||||
<p className="empty">아직 제출된 코드가 없어요.</p>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflowX: 'auto' }}>
|
||||
<table className="table" style={{ minWidth: 640 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>학생</th>
|
||||
<th>문제</th>
|
||||
<th>언어</th>
|
||||
<th style={{ textAlign: 'center' }}>채점</th>
|
||||
<th style={{ textAlign: 'center' }}>시도</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{submissions.map((s) => (
|
||||
<tr
|
||||
key={s.id}
|
||||
onClick={() => selectRow(s)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
className={selectedId === s.id ? 'is-selected' : ''}
|
||||
>
|
||||
<td>{s.studentName}</td>
|
||||
<td>{s.problemTitle}</td>
|
||||
<td>
|
||||
<span className={`lang-badge lang-badge--${s.language.toLowerCase()}`}>
|
||||
{s.language === 'JS' ? 'JavaScript' : 'Java'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{/* 자바는 자동채점이 없으므로 점수 칸이 비어 있는 게 정상이다 */}
|
||||
{s.totalCount == null ? '—' : `${s.passedCount}/${s.totalCount}`}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{s.attempts}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-badge status-badge--${s.status.toLowerCase()}`}>
|
||||
{STATUS_LABEL[s.status] ?? s.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택한 제출물 상세 — 코드 원문 + 채점 결과 + 피드백 */}
|
||||
{selected && (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h3>
|
||||
{selected.studentName} — {selected.problemTitle}
|
||||
</h3>
|
||||
|
||||
<h4>제출한 코드</h4>
|
||||
{/* 학습 포인트: 코드는 <pre>로 감싸야 들여쓰기와 줄바꿈이 그대로 보인다.
|
||||
<p>에 넣으면 공백이 다 뭉개져서 코드 리뷰가 불가능해진다. */}
|
||||
<pre className="code-view">
|
||||
<code>{selected.code}</code>
|
||||
</pre>
|
||||
|
||||
{selectedResults && (
|
||||
<>
|
||||
<h4>채점 결과</h4>
|
||||
<ul className="test-results">
|
||||
{selectedResults.map((r, i) => (
|
||||
<li key={i} className={r.passed ? 'test--pass' : 'test--fail'}>
|
||||
<span className="test__mark">{r.passed ? '통과' : '실패'}</span>
|
||||
<span className="test__name">{r.name}</span>
|
||||
{!r.passed && (
|
||||
<span className="test__detail">
|
||||
기대: <code>{r.expected}</code> / 실제: <code>{r.actual}</code>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{selected.language === 'JAVA' && (
|
||||
<p className="empty" style={{ marginTop: 8 }}>
|
||||
자바 문제는 자동채점이 없습니다 — 코드를 직접 읽고 피드백해 주세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={saveFeedback} style={{ marginTop: 12 }}>
|
||||
<div className="field">
|
||||
<label>피드백</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={feedbackDraft}
|
||||
onChange={(e) => setFeedbackDraft(e.target.value)}
|
||||
placeholder="잘한 점과 다음에 시도해 볼 점을 구체적으로 적어 주세요."
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? '저장 중...' : '피드백 저장'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={() => setSelectedId(null)}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
111
frontend/src/lib/runJs.js
Normal file
111
frontend/src/lib/runJs.js
Normal file
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 학생이 작성한 JavaScript 코드를 "안전하게" 실행하고 테스트케이스로 채점한다.
|
||||
*
|
||||
* 핵심 아이디어 — Web Worker.
|
||||
* 학습 포인트: 브라우저에서 남의 코드를 그냥 eval()로 돌리면 두 가지가 위험하다.
|
||||
* 1) while(true) 같은 무한루프가 들어오면 화면이 통째로 얼어붙는다(멈출 방법이 없다).
|
||||
* 2) 그 코드가 document를 만져 화면을 부수거나 쿠키를 훔쳐볼 수 있다.
|
||||
* Web Worker는 "다른 스레드에서 도는 별도의 방"이다.
|
||||
* 1) 오래 걸리면 terminate()로 방을 통째로 없애 버릴 수 있다 → 무한루프 방어
|
||||
* 2) Worker 안에는 document도 window도 없다 → 화면을 만질 수 없다
|
||||
* 그래서 서버에 Docker 샌드박스를 짓지 않고도, 브라우저가 이미 가진 격리 장치를 빌려 쓴다.
|
||||
*/
|
||||
|
||||
/** 코드 한 번 실행에 허용할 시간(ms). 넘으면 무한루프로 보고 Worker를 없앤다. */
|
||||
const TIMEOUT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Worker 안에서 돌 코드.
|
||||
* 학습 포인트: Worker는 별도 파일이 필요한데, 파일을 따로 두는 대신
|
||||
* 문자열을 Blob으로 만들어 URL처럼 쓴다(createObjectURL).
|
||||
* "파일 하나 늘리지 않고 Worker 쓰기" 관용구다.
|
||||
*/
|
||||
const workerSource = `
|
||||
self.onmessage = function (event) {
|
||||
var code = event.data.code;
|
||||
var functionName = event.data.functionName;
|
||||
var tests = event.data.tests;
|
||||
var results = [];
|
||||
|
||||
// 학생 코드를 평가해서 함수를 꺼낸다.
|
||||
var fn;
|
||||
try {
|
||||
// (0, eval) 형태로 부르면 전역 스코프에서 평가된다.
|
||||
var factory = new Function(code + '\\nreturn typeof ' + functionName + ' === "function" ? ' + functionName + ' : null;');
|
||||
fn = factory();
|
||||
} catch (e) {
|
||||
self.postMessage({ error: '코드에 문법 오류가 있습니다: ' + e.message });
|
||||
return;
|
||||
}
|
||||
if (!fn) {
|
||||
self.postMessage({ error: '"' + functionName + '" 함수를 찾을 수 없습니다. 함수 이름을 바꾸지 마세요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < tests.length; i++) {
|
||||
var test = tests[i];
|
||||
var actual;
|
||||
var errorMessage = null;
|
||||
try {
|
||||
// 인자를 복사해서 넘긴다 — 학생 코드가 배열을 바꿔도 다음 테스트에 영향이 없도록.
|
||||
var args = JSON.parse(JSON.stringify(test.args));
|
||||
actual = fn.apply(null, args);
|
||||
} catch (e) {
|
||||
errorMessage = e.message;
|
||||
}
|
||||
|
||||
var expectedText = JSON.stringify(test.expected);
|
||||
var actualText = errorMessage ? null : JSON.stringify(actual);
|
||||
// 학습 포인트: 배열/객체는 ===로 비교하면 "내용이 같아도" false다(주소를 비교하니까).
|
||||
// JSON 문자열로 바꿔 비교하면 내용 비교가 된다. 키 순서까지 같아야 하는 한계는 있지만
|
||||
// 우리 문제 범위에서는 충분하다.
|
||||
var passed = !errorMessage && actualText === expectedText;
|
||||
|
||||
results.push({
|
||||
name: test.name,
|
||||
passed: passed,
|
||||
expected: expectedText,
|
||||
actual: errorMessage ? ('에러: ' + errorMessage) : (actualText === undefined ? 'undefined' : actualText)
|
||||
});
|
||||
}
|
||||
|
||||
self.postMessage({ results: results });
|
||||
};
|
||||
`;
|
||||
|
||||
/**
|
||||
* 학생 코드를 테스트케이스로 채점한다.
|
||||
*
|
||||
* @param {string} code 학생이 작성한 코드
|
||||
* @param {string} functionName 호출할 함수 이름
|
||||
* @param {Array} tests [{name, args, expected}]
|
||||
* @returns {Promise<{results?: Array, error?: string}>}
|
||||
*/
|
||||
export function runJs(code, functionName, tests) {
|
||||
return new Promise((resolve) => {
|
||||
const blob = new Blob([workerSource], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const worker = new Worker(url);
|
||||
|
||||
// 끝났든 시간이 지났든, 뒷정리는 한 곳에서 한다.
|
||||
let settled = false;
|
||||
function finish(value) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
worker.terminate();
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish({ error: `실행이 ${TIMEOUT_MS / 1000}초를 넘겼습니다. 무한루프가 아닌지 확인해 보세요.` });
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
worker.onmessage = (event) => finish(event.data);
|
||||
worker.onerror = (event) => finish({ error: '실행 중 오류: ' + event.message });
|
||||
|
||||
worker.postMessage({ code, functionName, tests });
|
||||
});
|
||||
}
|
||||
116
frontend/src/pages/CodingPage.jsx
Normal file
116
frontend/src/pages/CodingPage.jsx
Normal file
@ -0,0 +1,116 @@
|
||||
// 이 파일이 하는 일: 코딩 문제 목록 페이지 — 20문제를 언어·난이도와 함께 보여주고,
|
||||
// 내가 각 문제를 어디까지 했는지(안 품 / 제출함 / 통과 / 피드백 받음)를 표시한다.
|
||||
//
|
||||
// 학습 포인트: 목록 API(/coding/problems)는 문제 설명이나 테스트케이스를 주지 않는다.
|
||||
// 그건 문제를 실제로 열 때(/coding/problems/{id}) 받는다.
|
||||
// "목록에 필요한 것"과 "상세에 필요한 것"을 나누면 목록이 가벼워진다.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import client from '../api/client';
|
||||
|
||||
/** 상태값을 사람이 읽는 말로 바꾼다 */
|
||||
const STATUS_LABEL = {
|
||||
TODO: '안 풂',
|
||||
SUBMITTED: '제출함',
|
||||
PASSED: '통과',
|
||||
REVIEWED: '피드백 받음',
|
||||
};
|
||||
|
||||
const DIFFICULTY_LABEL = { EASY: '쉬움', NORMAL: '보통', HARD: '어려움' };
|
||||
|
||||
export default function CodingPage() {
|
||||
const [problems, setProblems] = useState([]);
|
||||
const [progress, setProgress] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// 학습 포인트: 두 API를 동시에 부른다. 순서대로 await하면 기다리는 시간이 두 배가 된다.
|
||||
Promise.all([client.get('/coding/problems'), client.get('/coding/progress/me')])
|
||||
.then(([problemsRes, progressRes]) => {
|
||||
if (cancelled) return;
|
||||
setProblems(problemsRes.data);
|
||||
setProgress(progressRes.data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('문제를 불러오지 못했어요.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <p className="empty">불러오는 중…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="hero compact">
|
||||
<p className="eyebrow">코딩 문제</p>
|
||||
<h1>직접 짜 보기</h1>
|
||||
<p>
|
||||
읽어서 아는 것과 짤 수 있는 것은 다릅니다. 20문제를 풀며 손에 익혀 보세요.
|
||||
<br />
|
||||
자바스크립트 문제는 <strong>브라우저에서 바로 채점</strong>되고, 자바 문제는{' '}
|
||||
<strong>멘토가 코드를 읽고 피드백</strong>합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{progress && (
|
||||
<div className="chip-row">
|
||||
<div className="stat">
|
||||
<div className="stat-num">{progress.total}</div>
|
||||
<div className="stat-label">전체 문제</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-num">{progress.submitted}</div>
|
||||
<div className="stat-label">제출함</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-num">{progress.passed}</div>
|
||||
<div className="stat-label">통과</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
|
||||
<div className="coding-list">
|
||||
{problems.map((problem, index) => (
|
||||
<Link key={problem.id} to={`/coding/${problem.id}`} className="card coding-item">
|
||||
<div className="coding-item__main">
|
||||
<span className="coding-item__no">{String(index + 1).padStart(2, '0')}</span>
|
||||
<div>
|
||||
<div className="coding-item__title">{problem.title}</div>
|
||||
<div className="coding-item__meta">
|
||||
<span className={`lang-badge lang-badge--${problem.language.toLowerCase()}`}>
|
||||
{problem.language === 'JS' ? 'JavaScript' : 'Java'}
|
||||
</span>
|
||||
<span className="coding-item__difficulty">
|
||||
{DIFFICULTY_LABEL[problem.difficulty] ?? problem.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="coding-item__status">
|
||||
<span className={`status-badge status-badge--${problem.status.toLowerCase()}`}>
|
||||
{STATUS_LABEL[problem.status] ?? problem.status}
|
||||
</span>
|
||||
{/* 자동채점 문제만 통과/전체 점수가 있다 */}
|
||||
{problem.totalCount != null && (
|
||||
<span className="coding-item__score">
|
||||
{problem.passedCount}/{problem.totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
frontend/src/pages/CodingProblemPage.jsx
Normal file
233
frontend/src/pages/CodingProblemPage.jsx
Normal file
@ -0,0 +1,233 @@
|
||||
// 이 파일이 하는 일: 코딩 문제 풀이 화면 — 문제를 읽고, 코드를 짜고, 실행해 보고, 제출한다.
|
||||
//
|
||||
// 언어별로 흐름이 다르다:
|
||||
// - JS : [실행] 누르면 브라우저(Web Worker)가 테스트를 돌려 즉시 통과/실패를 보여준다.
|
||||
// [제출]하면 코드 + 채점 결과가 서버에 저장되고 멘토가 본다.
|
||||
// - JAVA : 실행 버튼이 없다. 코드를 제출하면 멘토가 읽고 피드백한다.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import client from '../api/client';
|
||||
import CodeEditor from '../components/CodeEditor';
|
||||
import { runJs } from '../lib/runJs';
|
||||
|
||||
const DIFFICULTY_LABEL = { EASY: '쉬움', NORMAL: '보통', HARD: '어려움' };
|
||||
|
||||
export default function CodingProblemPage() {
|
||||
const { id } = useParams();
|
||||
|
||||
const [problem, setProblem] = useState(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [results, setResults] = useState(null); // 실행 결과 [{name, passed, expected, actual}]
|
||||
const [runError, setRunError] = useState(''); // 문법 오류·타임아웃 등
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
client
|
||||
.get(`/coding/problems/${id}`)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const data = res.data;
|
||||
setProblem(data);
|
||||
// 전에 제출한 코드가 있으면 그걸 이어서, 없으면 뼈대 코드로 시작한다.
|
||||
setCode(data.myCode ?? data.starterCode);
|
||||
// 전에 낸 채점 결과가 있으면 복원해서 보여준다.
|
||||
if (data.resultsJson) {
|
||||
try {
|
||||
setResults(JSON.parse(data.resultsJson));
|
||||
} catch {
|
||||
// 저장된 결과가 깨져 있어도 화면은 떠야 한다 — 무시하고 넘어간다.
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('문제를 불러오지 못했어요.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const isJs = problem?.language === 'JS';
|
||||
const tests = problem?.testsJson ? JSON.parse(problem.testsJson) : [];
|
||||
|
||||
/** [실행] — 브라우저에서 테스트를 돌린다 (서버에 저장하지 않는다) */
|
||||
async function handleRun() {
|
||||
setRunning(true);
|
||||
setRunError('');
|
||||
setMessage('');
|
||||
const outcome = await runJs(code, problem.functionName, tests);
|
||||
if (outcome.error) {
|
||||
setRunError(outcome.error);
|
||||
setResults(null);
|
||||
} else {
|
||||
setResults(outcome.results);
|
||||
}
|
||||
setRunning(false);
|
||||
}
|
||||
|
||||
/** [제출] — JS면 먼저 채점하고, 코드와 결과를 함께 서버에 저장한다 */
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
try {
|
||||
let payload = { code, passedCount: null, totalCount: null, resultsJson: null };
|
||||
|
||||
if (isJs) {
|
||||
const outcome = await runJs(code, problem.functionName, tests);
|
||||
if (outcome.error) {
|
||||
// 문법 오류가 있으면 제출을 막는다 — 멘토에게 안 돌아가는 코드를 보내지 않도록.
|
||||
setRunError(outcome.error);
|
||||
setResults(null);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setResults(outcome.results);
|
||||
const passed = outcome.results.filter((r) => r.passed).length;
|
||||
payload = {
|
||||
code,
|
||||
passedCount: passed,
|
||||
totalCount: outcome.results.length,
|
||||
resultsJson: JSON.stringify(outcome.results),
|
||||
};
|
||||
}
|
||||
|
||||
const res = await client.post(`/coding/problems/${id}/submit`, payload);
|
||||
setProblem((prev) => ({ ...prev, ...res.data, feedback: null }));
|
||||
setMessage(
|
||||
res.data.status === 'PASSED'
|
||||
? '통과! 제출했습니다. 멘토가 코드를 확인합니다.'
|
||||
: '제출했습니다. 멘토가 코드를 읽고 피드백을 남길 거예요.'
|
||||
);
|
||||
} catch {
|
||||
setError('제출하지 못했어요. 잠시 뒤 다시 시도해 주세요.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="empty">불러오는 중…</p>;
|
||||
}
|
||||
if (!problem) {
|
||||
return <p className="error-text">{error || '문제를 찾을 수 없습니다.'}</p>;
|
||||
}
|
||||
|
||||
const passedCount = results ? results.filter((r) => r.passed).length : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
<Link to="/coding">← 코딩 문제 목록</Link>
|
||||
</p>
|
||||
|
||||
<section className="hero compact">
|
||||
<div className="chip-row">
|
||||
<span className={`lang-badge lang-badge--${problem.language.toLowerCase()}`}>
|
||||
{isJs ? 'JavaScript' : 'Java'}
|
||||
</span>
|
||||
<span className="coding-item__difficulty">
|
||||
{DIFFICULTY_LABEL[problem.difficulty] ?? problem.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<h1>{problem.title}</h1>
|
||||
</section>
|
||||
|
||||
<div className="card">
|
||||
{/* 학습 포인트: white-space: pre-wrap 이면 \n(줄바꿈)이 화면에서도 줄바꿈이 된다.
|
||||
그냥 넣으면 HTML은 줄바꿈을 공백 하나로 취급해 문단이 다 붙어 버린다. */}
|
||||
<p className="coding-description">{problem.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>코드 작성</h2>
|
||||
<CodeEditor value={code} onChange={setCode} />
|
||||
|
||||
<div className="coding-actions">
|
||||
{isJs && (
|
||||
<button type="button" className="btn" onClick={handleRun} disabled={running || submitting}>
|
||||
{running ? '실행 중…' : '실행해 보기'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || running}
|
||||
>
|
||||
{submitting ? '제출 중…' : '제출하기'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setCode(problem.starterCode);
|
||||
setResults(null);
|
||||
setRunError('');
|
||||
}}
|
||||
disabled={submitting || running}
|
||||
>
|
||||
처음으로 되돌리기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{runError && <p className="error-text">{runError}</p>}
|
||||
{message && <p className="success-text">{message}</p>}
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* JS 문제: 테스트별 채점 결과 */}
|
||||
{isJs && results && (
|
||||
<div className="card">
|
||||
<h2>
|
||||
채점 결과{' '}
|
||||
<span className={passedCount === results.length ? 'ok' : 'ng'}>
|
||||
{passedCount}/{results.length} 통과
|
||||
</span>
|
||||
</h2>
|
||||
<ul className="test-results">
|
||||
{results.map((result, i) => (
|
||||
<li key={i} className={result.passed ? 'test--pass' : 'test--fail'}>
|
||||
<span className="test__mark">{result.passed ? '통과' : '실패'}</span>
|
||||
<span className="test__name">{result.name}</span>
|
||||
{!result.passed && (
|
||||
<span className="test__detail">
|
||||
기대: <code>{result.expected}</code> / 실제: <code>{result.actual}</code>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자바 문제: 자동채점이 없다는 걸 분명히 알려 준다 */}
|
||||
{!isJs && (
|
||||
<div className="card">
|
||||
<h2>채점 안내</h2>
|
||||
<p>
|
||||
자바 문제는 자동으로 실행하지 않습니다. IntelliJ에서 직접 돌려 확인한 뒤 제출하면,
|
||||
멘토가 코드를 읽고 피드백을 남깁니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 멘토 피드백 */}
|
||||
{problem.feedback && (
|
||||
<div className="card feedback-card">
|
||||
<h2>멘토 피드백</h2>
|
||||
<p className="coding-description">{problem.feedback}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
// 학생들의 전체 제출물을 테이블로 확인해 피드백을 남긴다.
|
||||
import { useEffect, useState } from 'react';
|
||||
import client from '../api/client';
|
||||
import MentorCodingSection from '../components/MentorCodingSection';
|
||||
import Card from '../components/Card';
|
||||
import Badge from '../components/Badge';
|
||||
import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog';
|
||||
@ -299,6 +300,9 @@ export default function MentorPage() {
|
||||
<QuizResultsTable quizResults={quizResults} />
|
||||
)}
|
||||
|
||||
{/* ── 코딩 문제 (진도표 + 제출된 코드 리뷰) ── */}
|
||||
<MentorCodingSection />
|
||||
|
||||
{/* ── 제출물 리뷰 ── */}
|
||||
<h2 className="section-title" style={{ marginTop: 24 }}>제출물</h2>
|
||||
{submissions.length === 0 ? (
|
||||
|
||||
@ -879,3 +879,244 @@ inline-code, .icode {
|
||||
.fold[open] summary {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ── 코딩 문제 ──────────────────────────────────────────
|
||||
학습 포인트: 색을 직접 쓰지 않고 :root의 변수(--ink, --line …)만 쓴다.
|
||||
그래야 다크 모드에서 자동으로 같이 바뀐다 — 파일 맨 위 :root 정의를 보자. */
|
||||
|
||||
/* 성공 메시지 (에러의 반대쪽 짝) */
|
||||
.success-text {
|
||||
color: var(--teal);
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* 선택된 표의 행 */
|
||||
.is-selected {
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
/* 언어 배지 */
|
||||
.lang-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.lang-badge--js {
|
||||
background: color-mix(in srgb, var(--amber) 12%, transparent);
|
||||
color: var(--amber);
|
||||
}
|
||||
.lang-badge--java {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 풀이 상태 배지 */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
.status-badge--passed {
|
||||
background: color-mix(in srgb, var(--teal) 14%, transparent);
|
||||
color: var(--teal);
|
||||
border-color: transparent;
|
||||
}
|
||||
.status-badge--submitted {
|
||||
background: color-mix(in srgb, var(--amber) 14%, transparent);
|
||||
color: var(--amber);
|
||||
border-color: transparent;
|
||||
}
|
||||
.status-badge--reviewed {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* 문제 목록 */
|
||||
.coding-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.coding-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.coding-item:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.coding-item__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.coding-item__no {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 700;
|
||||
}
|
||||
.coding-item__title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.coding-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.coding-item__difficulty {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.coding-item__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.coding-item__score {
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 문제 설명·피드백 — \n 줄바꿈을 그대로 살린다 */
|
||||
.coding-description {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.coding-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feedback-card {
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
/* 코드 에디터 — 줄번호(왼쪽) + textarea(오른쪽) */
|
||||
.code-editor {
|
||||
display: flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--code-bg);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.code-editor__gutter {
|
||||
padding: 12px 8px 12px 12px;
|
||||
text-align: right;
|
||||
color: var(--ink-faint);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--line);
|
||||
min-width: 42px;
|
||||
}
|
||||
.code-editor__area {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
/* 학습 포인트: 탭 문자를 2칸으로 보이게 한다(에디터 설정과 화면을 맞추기 위해) */
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 멘토가 읽는 코드 — 읽기 전용 표시 */
|
||||
.code-view {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
/* 테스트 채점 결과 */
|
||||
.test-results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.test-results li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
}
|
||||
.test--pass {
|
||||
background: color-mix(in srgb, var(--teal) 8%, transparent);
|
||||
}
|
||||
.test--fail {
|
||||
background: color-mix(in srgb, var(--rose) 8%, transparent);
|
||||
}
|
||||
.test__mark {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.test--pass .test__mark {
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
}
|
||||
.test--fail .test__mark {
|
||||
background: var(--rose);
|
||||
color: #fff;
|
||||
}
|
||||
.test__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.test__detail {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.test__detail code {
|
||||
background: var(--code-bg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 채점 요약 색 */
|
||||
.ok {
|
||||
color: var(--teal);
|
||||
}
|
||||
.ng {
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user