feat(study): 문제 회차별 그룹 + 읽기전용 보기 페이지 (PR-11)
- 통합뷰 문제 섹션: 평면 리스트 → 회차별 아코디언 (디폴트 모두 접힘)
- 회차 정렬: "YYYY년 N회" 파싱 → year desc / round desc (localeCompare 단독 회귀 차단)
- 회차 행 라벨: "총 시도 N건 · 마지막 결과: 정답 K / 오답 M" (누적/마지막 혼동 회피)
- 회차 미지정 그룹은 노란 톤 + 안내, 표시 문자열은 UI 전용 (원본 NULL 분리)
- 본문 / [편집] 링크 구조 분리로 이벤트 버블링 충돌 차단
- /study/topics/{tid}/questions/{qid} 신규 — KaTeX 마크다운 렌더 + 정답 표시 +
AI 해설 5상태 (idle/loading/success/stale/error) + 비슷한 문제 + prev/next
- prev/next URL 직접 접근 — 단건 fetch + 같은 회차 목록 fetch 자체 처리
- page_size=200 만땅 + total>200 시 토스트 안내 (조용히 자르지 않음)
- 사용자 입력 해설/이미지 없으면 섹션 숨김, exam_round NULL 이면 prev/next 비활성
- StudyTopicQuestionSummary 에 exam_question_number 추가 (회차 안 정렬 키)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,7 @@ class StudyTopicQuestionSummary(BaseModel):
|
||||
scope: str | None
|
||||
exam_name: str | None
|
||||
exam_round: str | None
|
||||
exam_question_number: int | None # PR-11: 회차별 그룹 안 정렬 키
|
||||
is_active: bool
|
||||
attempt_count: int
|
||||
last_correct: bool | None
|
||||
@@ -629,6 +630,7 @@ async def get_study_topic(
|
||||
scope=q.scope,
|
||||
exam_name=q.exam_name,
|
||||
exam_round=q.exam_round,
|
||||
exam_question_number=q.exam_question_number,
|
||||
is_active=q.is_active,
|
||||
attempt_count=q_attempt_count.get(q.id, 0),
|
||||
last_correct=q_last_correct.get(q.id),
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
// PR-10: 문제풀이 세션 (진행 중 + 최근 완료)
|
||||
let quizSessions = $state({ active: null, recent_done: [] });
|
||||
|
||||
// PR-11: 문제 섹션 회차별 그룹 expand/collapse 상태. key = exam_round 표시 문자열.
|
||||
let roundsExpanded = $state({});
|
||||
|
||||
// 자료 추가 모달
|
||||
let docModalOpen = $state(false);
|
||||
let docSearch = $state('');
|
||||
@@ -338,6 +341,75 @@
|
||||
function fmtDate(s) {
|
||||
return new Date(s).toLocaleDateString('ko-KR', { dateStyle: 'medium' });
|
||||
}
|
||||
|
||||
// PR-11: 문제 회차별 그룹.
|
||||
// - "(회차 미지정)"은 UI group key 전용 — q.exam_round 원본 값(NULL/문자열)과 절대 섞지 않음.
|
||||
// - 정렬은 "YYYY년 N회" 파싱해서 year desc → round desc. localeCompare 단독 사용 금지 (2024년 10회 vs 2024년 2회 회귀).
|
||||
const UNCAT_ROUND_KEY = '(회차 미지정)';
|
||||
|
||||
function parseRoundKey(round) {
|
||||
if (typeof round !== 'string') return { year: -1, round: -1 };
|
||||
const m = round.match(/(\d{4}).*?(\d+)/);
|
||||
if (!m) return { year: -1, round: -1 };
|
||||
return { year: Number(m[1]), round: Number(m[2]) };
|
||||
}
|
||||
|
||||
/** [['2024년 1회', [q,...]], ..., ['(회차 미지정)', [...]]] desc 정렬, 미지정은 맨 아래. */
|
||||
let questionsByRound = $derived.by(() => {
|
||||
const groups = new Map();
|
||||
for (const q of detail?.sections?.questions ?? []) {
|
||||
const key = q.exam_round || UNCAT_ROUND_KEY;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(q);
|
||||
}
|
||||
// 그룹 내부 정렬: exam_question_number asc, 미설정이면 created_at desc fallback.
|
||||
for (const arr of groups.values()) {
|
||||
arr.sort((a, b) => {
|
||||
const an = a.exam_question_number;
|
||||
const bn = b.exam_question_number;
|
||||
if (an != null && bn != null) return an - bn;
|
||||
if (an != null) return -1;
|
||||
if (bn != null) return 1;
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => {
|
||||
if (a === UNCAT_ROUND_KEY) return 1;
|
||||
if (b === UNCAT_ROUND_KEY) return -1;
|
||||
const pa = parseRoundKey(a);
|
||||
const pb = parseRoundKey(b);
|
||||
if (pb.year !== pa.year) return pb.year - pa.year;
|
||||
if (pb.round !== pa.round) return pb.round - pa.round;
|
||||
return b.localeCompare(a);
|
||||
});
|
||||
});
|
||||
|
||||
/** 회차 행 라벨용 합산 — 누적 시도 / "마지막 결과: 정답/오답" (last_correct 기반). */
|
||||
function roundAggregate(items) {
|
||||
let attempts = 0;
|
||||
let lastOk = 0;
|
||||
let lastNg = 0;
|
||||
for (const q of items) {
|
||||
attempts += q.attempt_count ?? 0;
|
||||
if (q.last_correct === true) lastOk += 1;
|
||||
else if (q.last_correct === false) lastNg += 1;
|
||||
}
|
||||
return { attempts, lastOk, lastNg };
|
||||
}
|
||||
|
||||
function toggleRound(key) {
|
||||
roundsExpanded = { ...roundsExpanded, [key]: !roundsExpanded[key] };
|
||||
}
|
||||
|
||||
function expandAllRounds() {
|
||||
const next = {};
|
||||
for (const [k] of questionsByRound) next[k] = true;
|
||||
roundsExpanded = next;
|
||||
}
|
||||
|
||||
function collapseAllRounds() {
|
||||
roundsExpanded = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -547,14 +619,23 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- 문제 (PR-2) -->
|
||||
<!-- 문제 (PR-2 → PR-11: 회차별 그룹 + 접기) -->
|
||||
<section class="mb-5">
|
||||
<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||
<h2 class="text-sm font-semibold text-text flex items-center gap-2">
|
||||
<HelpCircle size={14} class="text-accent" /> 문제
|
||||
<span class="text-[10px] text-dim">{detail.sections.questions?.length ?? 0}</span>
|
||||
<span class="text-[10px] text-dim">
|
||||
{detail.sections.questions?.length ?? 0}개
|
||||
{#if questionsByRound.length > 0}· 회차 {questionsByRound.length}개{/if}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
{#if questionsByRound.length > 0}
|
||||
<button type="button" onclick={expandAllRounds} class="text-[11px] text-accent hover:underline px-1">모두 펼치기</button>
|
||||
<span class="text-faint text-[11px]">·</span>
|
||||
<button type="button" onclick={collapseAllRounds} class="text-[11px] text-dim hover:text-text px-1">모두 접기</button>
|
||||
<span class="w-1"></span>
|
||||
{/if}
|
||||
<Button href={`/study/topics/${topicId}/exam-rounds`} size="sm" variant="ghost" icon={ListChecks}>회차 보기</Button>
|
||||
<Button href={`/study/topics/${topicId}/questions/new`} size="sm" variant="ghost" icon={Plus}>새 문제</Button>
|
||||
{#if (detail.sections.questions?.length ?? 0) > 0}
|
||||
@@ -622,29 +703,84 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each detail.sections.questions as q (q.id)}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg border border-default bg-surface hover:bg-surface/80">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm text-text line-clamp-2">{q.question_text}</div>
|
||||
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
|
||||
{#if q.subject}<span>{q.subject}</span>{/if}
|
||||
{#if q.scope}<span>· {q.scope}</span>{/if}
|
||||
{#if q.exam_round}<span>· {q.exam_round}</span>{/if}
|
||||
{#if q.attempt_count > 0}
|
||||
<span class="ml-auto flex items-center gap-1">
|
||||
{#if q.last_correct === true}
|
||||
<CheckCircle2 size={11} class="text-success" />
|
||||
{:else if q.last_correct === false}
|
||||
<XCircle size={11} class="text-error" />
|
||||
{/if}
|
||||
<span>{q.attempt_count}회</span>
|
||||
</span>
|
||||
<!-- PR-11: 회차별 아코디언 (디폴트 모두 접힘) -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each questionsByRound as [roundKey, items] (roundKey)}
|
||||
{@const isExpanded = roundsExpanded[roundKey] === true}
|
||||
{@const isUncategorized = roundKey === UNCAT_ROUND_KEY}
|
||||
{@const agg = roundAggregate(items)}
|
||||
<div class="rounded-lg border {isUncategorized ? 'border-warning/30 bg-warning/5' : 'border-default bg-surface'}">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleRound(roundKey)}
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-bg/30 transition-colors rounded-lg"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown size={14} class="text-dim shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight size={14} class="text-dim shrink-0" />
|
||||
{/if}
|
||||
<span class="text-sm text-text font-medium truncate {isUncategorized ? 'italic' : ''}">{roundKey}</span>
|
||||
<span class="ml-auto text-[11px] text-dim flex items-center gap-2 flex-wrap">
|
||||
<span>{items.length}문제</span>
|
||||
{#if agg.attempts > 0}
|
||||
<span>· 총 시도 {agg.attempts}건</span>
|
||||
{#if agg.lastOk + agg.lastNg > 0}
|
||||
<span>· 마지막 결과:</span>
|
||||
<span class="text-success">정답 {agg.lastOk}</span>
|
||||
<span class="text-dim">/</span>
|
||||
<span class="text-error">오답 {agg.lastNg}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !q.is_active}<span class="text-warning">· 비활성</span>{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
{#if isUncategorized}
|
||||
<div class="px-3 pb-2 text-[11px] text-warning/80 italic">
|
||||
회차를 지정하면 회차 카드 페이지에서 진행률 추적이 가능합니다.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="px-2 pb-2 flex flex-col gap-1.5">
|
||||
{#each items as q (q.id)}
|
||||
<!-- 본문 링크와 [편집] 링크는 형제 구조로 분리해 이벤트 버블링 충돌 회피. -->
|
||||
<div class="flex items-stretch gap-1 rounded border border-default bg-surface hover:border-accent/40 transition-colors overflow-hidden">
|
||||
<a
|
||||
href={`/study/topics/${topicId}/questions/${q.id}`}
|
||||
title="상세 보기"
|
||||
class="flex-1 min-w-0 p-3 hover:bg-accent/5 transition-colors"
|
||||
>
|
||||
<div class="text-sm text-text line-clamp-2">
|
||||
{#if q.exam_question_number}<span class="text-dim mr-1.5">{q.exam_question_number}.</span>{/if}{q.question_text}
|
||||
</div>
|
||||
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
|
||||
{#if q.subject}<span>{q.subject}</span>{/if}
|
||||
{#if q.scope}<span>· {q.scope}</span>{/if}
|
||||
{#if q.attempt_count > 0}
|
||||
<span class="ml-auto flex items-center gap-1">
|
||||
{#if q.last_correct === true}
|
||||
<CheckCircle2 size={11} class="text-success" />
|
||||
{:else if q.last_correct === false}
|
||||
<XCircle size={11} class="text-error" />
|
||||
{/if}
|
||||
<span>{q.attempt_count}회</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if !q.is_active}<span class="text-warning">· 비활성</span>{/if}
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={`/study/topics/${topicId}/questions/${q.id}/edit`}
|
||||
title="편집"
|
||||
class="flex items-center px-3 text-dim hover:text-accent hover:bg-accent/10 transition-colors border-l border-default"
|
||||
aria-label="편집"
|
||||
>
|
||||
<Edit size={14} />
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<Button href={`/study/topics/${topicId}/questions/${q.id}/edit`} size="sm" variant="ghost" icon={Edit}>편집</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
<script>
|
||||
/**
|
||||
* /study/topics/[id]/questions/[qid] — 문제 상세 (PR-11 신규).
|
||||
*
|
||||
* 읽기전용. 정답을 미리 보여주는 "검토/학습 자료" 페이지.
|
||||
* 풀이 페이지가 아니므로 "문제 상세" 라벨 사용.
|
||||
*
|
||||
* URL 직접 접근 가능 — 통합뷰 detail 의존 없이 자체 fetch:
|
||||
* 1) GET /study-questions/{qid} → 본문/보기/정답/해설/이미지/통계
|
||||
* 2) 응답의 study_topic_id + exam_round 확인
|
||||
* 3) GET /study-topics/{tid}/questions?page_size=200 → 같은 회차 prev/next
|
||||
* (200건 만땅이면 토스트 — 조용히 자르지 않음)
|
||||
*
|
||||
* AI 해설 5상태: idle / loading / success / stale / error.
|
||||
* regenerate=true 옵션으로 강제 재생성 (PR-3 검증된 동작).
|
||||
*
|
||||
* 이미지/사용자 해설 없으면 섹션 숨김.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Edit, Sparkles, GitCompare, AlertCircle, CheckCircle2, XCircle, ListChecks,
|
||||
} from 'lucide-svelte';
|
||||
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import ImgAuth from '$lib/components/ImgAuth.svelte';
|
||||
|
||||
let topicId = $derived(Number($page.params.id));
|
||||
let qid = $derived(Number($page.params.qid));
|
||||
let topicName = $state('');
|
||||
|
||||
let q = $state(null); // GET /study-questions/{qid} 응답
|
||||
let loading = $state(true);
|
||||
|
||||
// 같은 회차 prev/next — exam_round 있을 때만.
|
||||
let roundSiblings = $state([]); // [{id, exam_question_number, created_at}, ...] 정렬됨
|
||||
|
||||
// AI 해설 상태머신: 'idle' | 'loading' | 'success' | 'stale' | 'error'
|
||||
let aiState = $state('idle');
|
||||
let aiData = $state(null); // AIExplanationResponse
|
||||
let aiError = $state(null);
|
||||
|
||||
// 비슷한 문제 (PR-5)
|
||||
let simOpen = $state(false);
|
||||
let simLoading = $state(false);
|
||||
let simItems = $state([]);
|
||||
let simSourceStatus = $state('none');
|
||||
|
||||
async function loadTopic() {
|
||||
try {
|
||||
const t = await api(`/study-topics/${topicId}`);
|
||||
topicName = t?.topic?.name ?? '';
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 같은 회차 prev/next 후보 fetch. exam_round 가 truthy 일 때만. */
|
||||
async function loadRoundSiblings() {
|
||||
roundSiblings = [];
|
||||
if (!q?.exam_round) return; // 원본 값 truthy 체크 (표시 문자열과 분리)
|
||||
try {
|
||||
const res = await api(`/study-topics/${topicId}/questions?page_size=200`);
|
||||
const items = res.items ?? [];
|
||||
// 백엔드 max=200. 200건 만땅 + total>200 이면 회차 안 일부가 누락될 수 있음 → 안내.
|
||||
if (items.length === 200 && (res.total ?? 0) > 200) {
|
||||
addToast('info', '이 토픽에 200문제 초과 — 회차 prev/next 가 일부 누락될 수 있습니다');
|
||||
}
|
||||
const siblings = items.filter((it) => it.exam_round === q.exam_round);
|
||||
siblings.sort((a, b) => {
|
||||
const an = a.exam_question_number;
|
||||
const bn = b.exam_question_number;
|
||||
if (an != null && bn != null) return an - bn;
|
||||
if (an != null) return -1;
|
||||
if (bn != null) return 1;
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
});
|
||||
roundSiblings = siblings;
|
||||
} catch {
|
||||
roundSiblings = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
q = await api(`/study-questions/${qid}`);
|
||||
// 페이지 진입 시 AI 해설 상태 초기화 — q.ai_explanation_status 가 ready/stale 이면 1회 prefetch (캐시 hit).
|
||||
// 사용자가 [해설 보기] 누르기 전엔 본문 표시 안 함, 다만 상태는 미리 알 수 있음.
|
||||
aiState = 'idle';
|
||||
aiData = null;
|
||||
aiError = null;
|
||||
// simExplOpen reset
|
||||
simOpen = false; simItems = []; simSourceStatus = 'none';
|
||||
await loadRoundSiblings();
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '문제 로드 실패');
|
||||
q = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadTopic();
|
||||
await load();
|
||||
});
|
||||
|
||||
// qid 가 prev/next 로 바뀌면 다시 로드
|
||||
$effect(() => {
|
||||
void qid;
|
||||
if (Number.isFinite(qid)) {
|
||||
// onMount 첫 호출과 중복 방지: q?.id 와 qid 가 다를 때만.
|
||||
if (q && q.id !== qid) load();
|
||||
}
|
||||
});
|
||||
|
||||
/** AI 해설 호출. regenerate 옵션 명시. */
|
||||
async function loadAi(regenerate = false) {
|
||||
aiState = 'loading';
|
||||
aiError = null;
|
||||
try {
|
||||
const res = await api(`/study-questions/${qid}/ai-explanation`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ regenerate }),
|
||||
});
|
||||
aiData = res;
|
||||
if (res.ai_explanation_status === 'failed') {
|
||||
aiState = 'error';
|
||||
aiError = '풀이 생성 실패. 다시 시도해주세요.';
|
||||
} else if (res.is_stale) {
|
||||
aiState = 'stale';
|
||||
} else {
|
||||
aiState = 'success';
|
||||
}
|
||||
} catch (err) {
|
||||
aiState = 'error';
|
||||
aiError = err?.status === 409
|
||||
? '다른 호출이 풀이를 생성 중입니다. 잠시 후 다시 시도해주세요.'
|
||||
: (err?.detail || 'AI 해설을 불러오지 못했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSimilar() {
|
||||
if (simOpen) { simOpen = false; return; }
|
||||
simOpen = true;
|
||||
if (simItems.length > 0) return;
|
||||
simLoading = true;
|
||||
try {
|
||||
const res = await api(`/study-questions/${qid}/similar?limit=5&topic_only=true`);
|
||||
simItems = res.items ?? [];
|
||||
simSourceStatus = res.source_status ?? 'none';
|
||||
} catch {
|
||||
simItems = [];
|
||||
} finally {
|
||||
simLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 보기 4지선다 derived (마크업에서 {@const} 위치 제약 회피).
|
||||
let choices = $derived(q ? [
|
||||
{ number: 1, text: q.choice_1 },
|
||||
{ number: 2, text: q.choice_2 },
|
||||
{ number: 3, text: q.choice_3 },
|
||||
{ number: 4, text: q.choice_4 },
|
||||
] : []);
|
||||
|
||||
// prev/next 계산. exam_round truthy 일 때만 활성.
|
||||
let currentSiblingIdx = $derived(roundSiblings.findIndex((s) => s.id === qid));
|
||||
let prevSibling = $derived(currentSiblingIdx > 0 ? roundSiblings[currentSiblingIdx - 1] : null);
|
||||
let nextSibling = $derived(
|
||||
currentSiblingIdx >= 0 && currentSiblingIdx < roundSiblings.length - 1
|
||||
? roundSiblings[currentSiblingIdx + 1]
|
||||
: null
|
||||
);
|
||||
|
||||
function goSibling(s) {
|
||||
if (!s) return;
|
||||
goto(`/study/topics/${topicId}/questions/${s.id}`);
|
||||
}
|
||||
|
||||
function fmtDateTime(s) {
|
||||
if (!s) return '';
|
||||
return new Date(s).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>문제 상세 — {topicName || '주제'}</title></svelte:head>
|
||||
|
||||
<div class="p-4 md:p-6 max-w-3xl mx-auto">
|
||||
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
|
||||
<a href="/study" class="text-dim hover:text-text">공부</a>
|
||||
<span class="text-faint">/</span>
|
||||
<a href="/study/topics" class="text-dim hover:text-text">주제</a>
|
||||
<span class="text-faint">/</span>
|
||||
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
|
||||
<span class="text-faint">/</span>
|
||||
<span class="text-text font-medium">문제 #{qid}</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<Skeleton h="h-32" rounded="lg" />
|
||||
{:else if !q}
|
||||
<EmptyState title="문제를 찾을 수 없습니다" description="삭제되었거나 권한이 없는 문제입니다." />
|
||||
<div class="mt-3">
|
||||
<Button href={`/study/topics/${topicId}`} icon={ArrowLeft}>주제로</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Card>
|
||||
{#snippet children()}
|
||||
<div class="p-5 flex flex-col gap-4">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap pb-3 border-b border-default">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-base font-semibold text-text">문제 상세</h1>
|
||||
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
|
||||
{#if q.subject}<span>{q.subject}</span>{/if}
|
||||
{#if q.scope}<span>· {q.scope}</span>{/if}
|
||||
{#if q.exam_round}
|
||||
<span>·
|
||||
<a href={`/study/topics/${topicId}/exam-rounds`} class="hover:text-accent">{q.exam_round}</a>
|
||||
</span>
|
||||
{/if}
|
||||
{#if q.exam_question_number}<span>· {q.exam_question_number}번</span>{/if}
|
||||
{#if !q.is_active}<span class="text-warning">· 비활성</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Button href={`/study/topics/${topicId}/questions/${qid}/edit`} size="sm" variant="ghost" icon={Edit}>편집</Button>
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
<div class="text-base text-text leading-relaxed math-area">
|
||||
{@html renderMathMarkdown(q.question_text)}
|
||||
</div>
|
||||
|
||||
<!-- 첨부 이미지 (있을 때만) -->
|
||||
{#if q.images?.length > 0}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{#each q.images as img (img.id)}
|
||||
<ImgAuth src={img.served_url} class="w-full max-h-72 object-contain rounded border border-default bg-bg/30" />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 4지선다 + 정답 표시 -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each choices as ch (ch.number)}
|
||||
{@const isCorrect = q.correct_choice === ch.number}
|
||||
<div class="p-2.5 rounded border flex items-start gap-3 text-sm
|
||||
{isCorrect ? 'border-success bg-success/10 text-text' : 'border-default bg-surface text-dim'}"
|
||||
>
|
||||
<span class="font-semibold w-5 shrink-0">{ch.number}</span>
|
||||
<span class="flex-1 math-area overflow-x-auto">{@html renderMathMarkdownInline(ch.text)}</span>
|
||||
{#if isCorrect}<CheckCircle2 size={14} class="text-success shrink-0" />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 사용자 입력 해설 (있을 때만) -->
|
||||
{#if q.explanation}
|
||||
<div class="rounded border border-default bg-bg/30 p-3 text-sm text-text math-area">
|
||||
<div class="text-[10px] text-dim mb-1.5 font-medium">사용자 입력 해설</div>
|
||||
<div>{@html renderMathMarkdown(q.explanation)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- AI 해설 (PR-3) — 5상태 명시 -->
|
||||
<div class="rounded border border-default bg-bg/30 p-3 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Sparkles size={14} class="text-accent" />
|
||||
<span class="text-xs font-semibold text-text">AI 해설</span>
|
||||
{#if aiState === 'stale'}
|
||||
<span class="text-[10px] text-warning border border-warning/40 rounded px-1.5 py-0.5">stale</span>
|
||||
{:else if aiState === 'success' && aiData?.from_cache}
|
||||
<span class="text-[10px] text-dim">캐시</span>
|
||||
{/if}
|
||||
<span class="ml-auto flex items-center gap-1.5">
|
||||
{#if aiState === 'idle'}
|
||||
<Button size="sm" variant="ghost" onclick={() => loadAi(false)}>해설 보기</Button>
|
||||
{:else if aiState === 'loading'}
|
||||
<Button size="sm" variant="ghost" loading={true}>생성 중…</Button>
|
||||
{:else if aiState === 'stale'}
|
||||
<Button size="sm" onclick={() => loadAi(true)}>다시 생성</Button>
|
||||
{:else if aiState === 'error'}
|
||||
<Button size="sm" onclick={() => loadAi(true)}>다시 시도</Button>
|
||||
{:else}
|
||||
<Button size="sm" variant="ghost" onclick={() => loadAi(true)}>다시 생성</Button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if aiState === 'loading'}
|
||||
<div class="text-[11px] text-dim">해설을 생성하는 중입니다… (캐시 hit 시 1초, miss 시 최대 30초)</div>
|
||||
{:else if aiState === 'stale'}
|
||||
<div class="text-[11px] text-warning bg-warning/5 border border-warning/30 rounded px-2 py-1.5 flex items-start gap-1.5">
|
||||
<AlertCircle size={12} class="mt-0.5 shrink-0" />
|
||||
<span>이 풀이는 정답·문제가 수정된 후의 이전 풀이입니다. "다시 생성" 으로 새로 만들 수 있습니다.</span>
|
||||
</div>
|
||||
{:else if aiState === 'error'}
|
||||
<div class="text-[11px] text-error">{aiError || 'AI 해설을 불러오지 못했습니다. 다시 시도해 주세요.'}</div>
|
||||
{/if}
|
||||
|
||||
{#if (aiState === 'success' || aiState === 'stale') && aiData?.ai_explanation}
|
||||
<div class="text-xs text-text leading-relaxed prose prose-sm prose-invert max-w-none math-area">
|
||||
{@html renderMathMarkdown(aiData.ai_explanation)}
|
||||
</div>
|
||||
{#if aiData.evidence?.length}
|
||||
<details class="text-[10px] text-dim">
|
||||
<summary class="cursor-pointer hover:text-text">참고 근거 {aiData.evidence.length}건</summary>
|
||||
<ul class="mt-1 flex flex-col gap-1">
|
||||
{#each aiData.evidence as ev}
|
||||
<li>
|
||||
<span class="text-text">{ev.source_type === 'document' ? '📄' : '❓'} {ev.title}</span>
|
||||
<div class="pl-4 truncate">{ev.snippet}</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 누적 통계 -->
|
||||
<div class="text-[11px] text-dim border-t border-default pt-3">
|
||||
누적 시도 {q.stats?.attempt_count ?? 0}회 ·
|
||||
정답 {q.stats?.correct_count ?? 0} ·
|
||||
오답 {q.stats?.wrong_count ?? 0}
|
||||
</div>
|
||||
|
||||
<!-- 비슷한 문제 (PR-5) -->
|
||||
<div class="rounded border border-default bg-bg/30 p-3 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<GitCompare size={14} class="text-accent" />
|
||||
<span class="text-xs font-semibold text-text">비슷한 문제</span>
|
||||
{#if simItems.length > 0}<span class="text-[10px] text-dim">{simItems.length}건</span>{/if}
|
||||
<span class="ml-auto">
|
||||
<Button size="sm" variant="ghost" onclick={toggleSimilar} loading={simLoading}>
|
||||
{simOpen ? '접기' : '비슷한 문제 보기'}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
{#if simOpen}
|
||||
{#if simSourceStatus !== 'ready'}
|
||||
<div class="text-[11px] text-dim">
|
||||
{#if simSourceStatus === 'pending'}임베딩 생성 중입니다. 잠시 후 다시.
|
||||
{:else if simSourceStatus === 'failed'}임베딩 생성 실패. 다음 cron 자동 재시도.
|
||||
{:else if simSourceStatus === 'stale'}본문 변경 후 임베딩 재계산 대기 중.
|
||||
{:else}임베딩 미생성. 약 1분 안에 cron 처리.
|
||||
{/if}
|
||||
</div>
|
||||
{:else if simItems.length === 0}
|
||||
<div class="text-[11px] text-dim">이 주제에 비슷한 문제가 없습니다.</div>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
{#each simItems as it (it.id)}
|
||||
<li>
|
||||
<a
|
||||
href={`/study/topics/${it.study_topic_id}/questions/${it.id}`}
|
||||
class="flex items-center gap-3 p-2 rounded border border-default bg-surface hover:border-accent transition-colors"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-text truncate">{it.question_text}</div>
|
||||
<div class="text-[10px] text-dim mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
{#if it.subject}<span>{it.subject}</span>{/if}
|
||||
{#if it.scope}<span>· {it.scope}</span>{/if}
|
||||
{#if it.exam_round}<span>· {it.exam_round}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-accent font-medium shrink-0">{Math.round(it.similarity * 100)}%</span>
|
||||
<ArrowRight size={11} class="text-dim shrink-0" />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 푸터 네비게이션 -->
|
||||
<div class="flex items-center justify-between gap-2 pt-3 border-t border-default flex-wrap">
|
||||
<Button href={`/study/topics/${topicId}`} variant="ghost" size="sm" icon={ArrowLeft}>주제로</Button>
|
||||
{#if q.exam_round}
|
||||
<Button href={`/study/topics/${topicId}/exam-rounds`} variant="ghost" size="sm" icon={ListChecks}>회차 카드</Button>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={ArrowLeft}
|
||||
disabled={!prevSibling}
|
||||
onclick={() => goSibling(prevSibling)}
|
||||
>이전</Button>
|
||||
{#if q.exam_round && roundSiblings.length > 0}
|
||||
<span class="text-[11px] text-dim">
|
||||
{currentSiblingIdx + 1} / {roundSiblings.length}
|
||||
</span>
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
icon={ArrowRight}
|
||||
iconPosition="right"
|
||||
disabled={!nextSibling}
|
||||
onclick={() => goSibling(nextSibling)}
|
||||
>다음</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user