merge(study): 암기카드 검수 UI
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"""study_cards API — 암기카드 검수 (공부 암기노트 Phase 1 검수 UI).
|
||||
|
||||
needs_review=true 카드를 '출처 문제별 그룹'으로 보고 채택(approve)/수정(edit)/폐기(delete).
|
||||
별 라우터(prefix=/api/study-cards)라 /api/study-questions/{id} 와 경로 충돌 없음.
|
||||
정적 경로(/needs-review/count, /approve-batch)는 /{card_id} 보다 먼저 정의.
|
||||
|
||||
결정(2026-06-07):
|
||||
- 수정(cue/fact/cloze 편집) 시 dedup_hash 재계산 + needs_review=false(사용자 확정본). flagged 클리어.
|
||||
- 전체 일괄승인 버튼 없음 — approve-batch 는 source_question_id 단위(그 문제의 카드만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.study_memo_card import StudyMemoCard, StudyMemoCardEvidence
|
||||
from models.study_question import StudyQuestion
|
||||
from models.user import User
|
||||
from services.study.card_normalize import compute_dedup_hash
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CardEvidence(BaseModel):
|
||||
source_type: str
|
||||
source_id: int | None = None
|
||||
snippet: str | None = None
|
||||
|
||||
|
||||
class CardItem(BaseModel):
|
||||
id: int
|
||||
format: str
|
||||
cue: str
|
||||
fact: str
|
||||
cloze_text: str | None = None
|
||||
needs_review: bool
|
||||
flagged_by: str | None = None
|
||||
evidence: list[CardEvidence] = []
|
||||
|
||||
|
||||
class CardQuestionGroup(BaseModel):
|
||||
source_question_id: int | None = None
|
||||
question_text: str | None = None
|
||||
correct_choice: int | None = None
|
||||
cards: list[CardItem] = []
|
||||
|
||||
|
||||
class CardUpdate(BaseModel):
|
||||
needs_review: bool | None = None
|
||||
cue: str | None = None
|
||||
fact: str | None = None
|
||||
cloze_text: str | None = None
|
||||
|
||||
|
||||
class ApproveBatch(BaseModel):
|
||||
source_question_id: int
|
||||
|
||||
|
||||
def _verify_card(card: StudyMemoCard | None, user: User) -> StudyMemoCard:
|
||||
if card is None or card.user_id != user.id or card.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="카드를 찾을 수 없습니다")
|
||||
return card
|
||||
|
||||
|
||||
@router.get("/needs-review/count")
|
||||
async def count_needs_review_cards(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""검수 대기 카드 수 (배지용)."""
|
||||
n = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(StudyMemoCard)
|
||||
.where(
|
||||
StudyMemoCard.user_id == user.id,
|
||||
StudyMemoCard.deleted_at.is_(None),
|
||||
StudyMemoCard.needs_review,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
return {"count": n}
|
||||
|
||||
|
||||
@router.get("", response_model=list[CardQuestionGroup])
|
||||
async def list_cards(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
needs_review: Annotated[bool, Query()] = True,
|
||||
format: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=2000)] = 600,
|
||||
):
|
||||
"""카드 목록 — 출처 문제별 그룹. 기본 needs_review=true 검수 큐."""
|
||||
conds = [StudyMemoCard.user_id == user.id, StudyMemoCard.deleted_at.is_(None)]
|
||||
if needs_review:
|
||||
conds.append(StudyMemoCard.needs_review)
|
||||
if format in ("qa", "cloze"):
|
||||
conds.append(StudyMemoCard.format == format)
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(StudyMemoCard)
|
||||
.where(*conds)
|
||||
.order_by(StudyMemoCard.source_question_id.asc().nulls_last(), StudyMemoCard.id.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# evidence 일괄 조회
|
||||
card_ids = [c.id for c in rows]
|
||||
ev_rows = (
|
||||
await session.execute(
|
||||
select(StudyMemoCardEvidence).where(StudyMemoCardEvidence.card_id.in_(card_ids))
|
||||
)
|
||||
).scalars().all()
|
||||
ev_by_card: dict[int, list[CardEvidence]] = {}
|
||||
for e in ev_rows:
|
||||
ev_by_card.setdefault(e.card_id, []).append(
|
||||
CardEvidence(source_type=e.source_type, source_id=e.source_id, snippet=e.snippet)
|
||||
)
|
||||
|
||||
# 출처 문제 메타 일괄 조회
|
||||
qids = sorted({c.source_question_id for c in rows if c.source_question_id is not None})
|
||||
q_meta: dict[int, tuple[str, int]] = {}
|
||||
if qids:
|
||||
q_rows = (
|
||||
await session.execute(
|
||||
select(StudyQuestion.id, StudyQuestion.question_text, StudyQuestion.correct_choice)
|
||||
.where(StudyQuestion.id.in_(qids))
|
||||
)
|
||||
).all()
|
||||
q_meta = {r.id: (r.question_text, r.correct_choice) for r in q_rows}
|
||||
|
||||
# 그룹핑 (출제순서=rows 순서 유지)
|
||||
groups: dict[int | None, CardQuestionGroup] = {}
|
||||
order: list[int | None] = []
|
||||
for c in rows:
|
||||
key = c.source_question_id
|
||||
if key not in groups:
|
||||
qt, cc = q_meta.get(key, (None, None)) if key is not None else (None, None)
|
||||
groups[key] = CardQuestionGroup(source_question_id=key, question_text=qt, correct_choice=cc, cards=[])
|
||||
order.append(key)
|
||||
groups[key].cards.append(
|
||||
CardItem(
|
||||
id=c.id, format=c.format, cue=c.cue, fact=c.fact, cloze_text=c.cloze_text,
|
||||
needs_review=c.needs_review, flagged_by=c.flagged_by,
|
||||
evidence=ev_by_card.get(c.id, []),
|
||||
)
|
||||
)
|
||||
return [groups[k] for k in order]
|
||||
|
||||
|
||||
@router.post("/approve-batch")
|
||||
async def approve_batch(
|
||||
body: ApproveBatch,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""한 출처 문제의 검수 대기 카드를 일괄 승인(needs_review=false). 전체 일괄승인은 없음."""
|
||||
result = await session.execute(
|
||||
update(StudyMemoCard)
|
||||
.where(
|
||||
StudyMemoCard.user_id == user.id,
|
||||
StudyMemoCard.source_question_id == body.source_question_id,
|
||||
StudyMemoCard.deleted_at.is_(None),
|
||||
StudyMemoCard.needs_review,
|
||||
)
|
||||
.values(needs_review=False, flagged_by=None, flagged_at=None)
|
||||
)
|
||||
await session.commit()
|
||||
return {"approved": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.patch("/{card_id}", response_model=CardItem)
|
||||
async def update_card(
|
||||
card_id: int,
|
||||
body: CardUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""승인(needs_review=false) 또는 수정(cue/fact/cloze). 내용 수정 시 dedup_hash 재계산 + 검수완료."""
|
||||
card = await session.get(StudyMemoCard, card_id)
|
||||
card = _verify_card(card, user)
|
||||
fields_set = body.model_fields_set
|
||||
|
||||
content_changed = False
|
||||
for fname in {"cue", "fact", "cloze_text"} & fields_set:
|
||||
setattr(card, fname, getattr(body, fname))
|
||||
content_changed = True
|
||||
|
||||
if content_changed:
|
||||
# 정답 토큰(fact) 기준 dedup_hash 재계산 + 사용자 확정본 → 검수 완료.
|
||||
card.dedup_hash = compute_dedup_hash(card.source_question_id, card.format, card.fact)
|
||||
card.needs_review = False
|
||||
card.flagged_by = None
|
||||
card.flagged_at = None
|
||||
elif "needs_review" in fields_set:
|
||||
card.needs_review = bool(body.needs_review)
|
||||
if card.needs_review:
|
||||
card.flagged_by = "user"
|
||||
card.flagged_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
card.flagged_by = None
|
||||
card.flagged_at = None
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=409, detail="같은 정답의 중복 카드가 이미 있습니다")
|
||||
|
||||
return CardItem(
|
||||
id=card.id, format=card.format, cue=card.cue, fact=card.fact, cloze_text=card.cloze_text,
|
||||
needs_review=card.needs_review, flagged_by=card.flagged_by, evidence=[],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{card_id}", status_code=204)
|
||||
async def delete_card(
|
||||
card_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""저품질 카드 soft-delete. partial unique(WHERE deleted_at IS NULL)가 자연 정합."""
|
||||
card = await session.get(StudyMemoCard, card_id)
|
||||
card = _verify_card(card, user)
|
||||
card.deleted_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
@@ -28,6 +28,7 @@ from api.study_questions import router as study_questions_router
|
||||
from api.study_sessions import router as study_sessions_router
|
||||
from api.study_topics import router as study_topics_router
|
||||
from api.study_reminders import router as study_reminders_router
|
||||
from api.study_cards import router as study_cards_router
|
||||
from api.video import router as video_router
|
||||
from core.config import settings
|
||||
from core.database import async_session, engine, init_db
|
||||
@@ -167,6 +168,7 @@ app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study
|
||||
# study_questions: 라우터 안에서 /study-topics/{id}/questions 와 /study-questions/{id} 두 줄기를 모두 정의하므로 prefix=/api 로 등록
|
||||
app.include_router(study_questions_router, prefix="/api", tags=["study-questions"])
|
||||
app.include_router(study_reminders_router, prefix="/api/study-reminders", tags=["study-reminders"])
|
||||
app.include_router(study_cards_router, prefix="/api/study-cards", tags=["study-cards"])
|
||||
# Phase 1: 학습 진행 상태 (review-complete + review-queue). prefix=/api/study-topics 안에 정의됨.
|
||||
app.include_router(study_question_progress_router, prefix="/api", tags=["study-progress"])
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<script>
|
||||
// /study — 학습 hub.
|
||||
// 자료 학습 (자료실 자료 + 회독 추적) / 필사 세션 (Apple Pencil) / Phase 2~ 퀴즈/SRS.
|
||||
// 학습 워크스페이스(주제) — 필기·자료를 묶어 보는 1차 컨테이너.
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban } from 'lucide-svelte';
|
||||
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers } from 'lucide-svelte';
|
||||
|
||||
let cardReviewCount = $state(0);
|
||||
onMount(async () => {
|
||||
try {
|
||||
const r = await api('/study-cards/needs-review/count');
|
||||
cardReviewCount = r?.count ?? 0;
|
||||
} catch {}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-4 md:p-6 max-w-5xl mx-auto">
|
||||
@@ -10,7 +19,7 @@
|
||||
<h1 class="text-xl font-semibold text-text flex items-center gap-2">
|
||||
<GraduationCap size={22} /> 공부
|
||||
</h1>
|
||||
<p class="text-sm text-dim mt-1">학습 자료 회독 / 손글씨 필사 세션 / (예정) 퀴즈·복습.</p>
|
||||
<p class="text-sm text-dim mt-1">주제별 퀴즈·복습(SRS)·통계 / 학습 자료 회독 / 손글씨 필사 세션.</p>
|
||||
</header>
|
||||
|
||||
<a
|
||||
@@ -46,14 +55,27 @@
|
||||
</div>
|
||||
<p class="text-xs text-dim">iPad + Apple Pencil 로 자격증 교재 / 어학 한자·단어를 손으로 필사. 세션 단위 stroke 보존.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/study/cards-review"
|
||||
class="block p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Layers size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">암기카드 검수</h2>
|
||||
{#if cardReviewCount > 0}
|
||||
<span class="ml-auto rounded-full bg-accent px-2 py-0.5 text-xs font-bold text-white">{cardReviewCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-dim">푼 문제에서 AI가 추출한 암기카드(cloze 빈칸 / qa)를 확인하고 승인·수정·폐기. 승인된 카드만 학습에 쓰입니다.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 rounded-lg border border-dashed border-default/60 text-xs text-dim">
|
||||
<div class="font-medium text-dim mb-1">예정 (Phase 2~)</div>
|
||||
<div class="font-medium text-dim mb-1">예정</div>
|
||||
<ul class="list-disc list-inside space-y-0.5">
|
||||
<li>모바일 암기노트 / 카드 복습</li>
|
||||
<li>AI 자료 기반 퀴즈 출제 + 정답률 분야별 통계</li>
|
||||
<li>SRS (1·3·7·14일 복습 일정)</li>
|
||||
<li>검수한 암기카드로 복습 (카드 SRS)</li>
|
||||
<li>모바일 암기카드 복습 + 공부 알람</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<script>
|
||||
/**
|
||||
* /study/cards-review — 암기카드 검수 (공부 암기노트 Phase 1).
|
||||
*
|
||||
* needs_review=true 카드를 '출처 문제별 그룹'으로 보고 채택(승인)/수정/폐기.
|
||||
* backend: GET /study-cards?needs_review=true (그룹) · /study-cards/needs-review/count
|
||||
* PATCH /study-cards/{id}(승인/수정) · DELETE /study-cards/{id} · POST /approve-batch
|
||||
* 전체 일괄승인 없음 — 문제 단위 승인만(품질 관찰 우선).
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import {
|
||||
ArrowLeft, Check, Pencil, Trash2, X, CheckCheck, FileText,
|
||||
} from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
let groups = $state([]); // [{ source_question_id, question_text, correct_choice, cards: [...] }]
|
||||
let total = $state(0);
|
||||
let fmtFilter = $state(''); // '' | 'cloze' | 'qa'
|
||||
let editing = $state(null); // card id 편집 중
|
||||
let draft = $state({ cue: '', fact: '', cloze_text: '' });
|
||||
|
||||
let shownGroups = $derived(
|
||||
fmtFilter
|
||||
? groups
|
||||
.map((g) => ({ ...g, cards: g.cards.filter((c) => c.format === fmtFilter) }))
|
||||
.filter((g) => g.cards.length > 0)
|
||||
: groups,
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const q = fmtFilter ? `&format=${fmtFilter}` : '';
|
||||
groups = (await api(`/study-cards?needs_review=true${q}`)) ?? [];
|
||||
const c = await api('/study-cards/needs-review/count');
|
||||
total = c?.count ?? 0;
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '카드 조회 실패');
|
||||
groups = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 상태에서 카드 제거 (승인/삭제 후 큐에서 사라짐)
|
||||
function removeCard(qid, cardId) {
|
||||
groups = groups
|
||||
.map((g) =>
|
||||
g.source_question_id === qid
|
||||
? { ...g, cards: g.cards.filter((c) => c.id !== cardId) }
|
||||
: g,
|
||||
)
|
||||
.filter((g) => g.cards.length > 0);
|
||||
total = Math.max(0, total - 1);
|
||||
}
|
||||
|
||||
function removeGroup(qid, n) {
|
||||
groups = groups.filter((g) => g.source_question_id !== qid);
|
||||
total = Math.max(0, total - n);
|
||||
}
|
||||
|
||||
async function approve(qid, cardId) {
|
||||
try {
|
||||
await api(`/study-cards/${cardId}`, { method: 'PATCH', body: JSON.stringify({ needs_review: false }) });
|
||||
removeCard(qid, cardId);
|
||||
addToast('success', '승인됨');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '승인 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(qid, cardId) {
|
||||
if (!confirm('이 카드를 폐기할까요? (되돌릴 수 없음)')) return;
|
||||
try {
|
||||
await api(`/study-cards/${cardId}`, { method: 'DELETE' });
|
||||
removeCard(qid, cardId);
|
||||
addToast('success', '폐기됨');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '삭제 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function approveGroup(qid, n) {
|
||||
try {
|
||||
const r = await api('/study-cards/approve-batch', {
|
||||
method: 'POST', body: JSON.stringify({ source_question_id: qid }),
|
||||
});
|
||||
removeGroup(qid, n);
|
||||
addToast('success', `${r?.approved ?? n}장 승인됨`);
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '일괄 승인 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(card) {
|
||||
editing = card.id;
|
||||
draft = { cue: card.cue, fact: card.fact, cloze_text: card.cloze_text ?? '' };
|
||||
}
|
||||
function cancelEdit() { editing = null; }
|
||||
|
||||
async function saveEdit(qid, cardId) {
|
||||
try {
|
||||
const body = { cue: draft.cue, fact: draft.fact, cloze_text: draft.cloze_text || null };
|
||||
await api(`/study-cards/${cardId}`, { method: 'PATCH', body: JSON.stringify(body) });
|
||||
editing = null;
|
||||
// 수정 = 검수 완료 → 큐에서 제거
|
||||
removeCard(qid, cardId);
|
||||
addToast('success', '수정 후 승인됨');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '수정 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(f) {
|
||||
if (fmtFilter === f) return;
|
||||
fmtFilter = f;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>암기카드 검수</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-6">
|
||||
<div class="mb-5 flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" icon={ArrowLeft} onclick={() => goto('/study')}>공부</Button>
|
||||
<h1 class="text-xl font-bold text-text">암기카드 검수</h1>
|
||||
{#if total > 0}
|
||||
<span class="rounded-full bg-accent px-2.5 py-0.5 text-xs font-bold text-white">대기 {total}</span>
|
||||
{/if}
|
||||
<div class="ml-auto flex gap-1.5">
|
||||
<button class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === '' ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}" onclick={() => setFilter('')}>전체</button>
|
||||
<button class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === 'cloze' ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}" onclick={() => setFilter('cloze')}>cloze</button>
|
||||
<button class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === 'qa' ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}" onclick={() => setFilter('qa')}>qa</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-sm text-dim">
|
||||
AI가 추출한 암기카드를 확인하고 <b class="text-text">승인 / 수정 / 폐기</b>합니다. 승인된 카드만 학습에 쓰입니다.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">{#each Array(4).fill(0) as _, i (i)}<Skeleton class="h-28 w-full" />{/each}</div>
|
||||
{:else if shownGroups.length === 0}
|
||||
<EmptyState title="검수할 카드가 없습니다" description="새 문제를 풀면 AI가 암기카드를 추출해 여기에 쌓입니다." icon={CheckCheck} />
|
||||
{:else}
|
||||
<div class="space-y-5">
|
||||
{#each shownGroups as g (g.source_question_id)}
|
||||
<div class="rounded-card border border-default bg-bg/40 p-3">
|
||||
<!-- 출처 문제 -->
|
||||
<div class="mb-3 flex items-start gap-2 rounded-lg border border-default bg-surface px-3 py-2">
|
||||
<FileText size={15} class="mt-0.5 shrink-0 text-faint" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-bold uppercase tracking-wide text-faint">출처 문제</div>
|
||||
<div class="text-sm text-text">{g.question_text}</div>
|
||||
{#if g.correct_choice}<div class="mt-0.5 text-xs text-accent">사용자 정답: {g.correct_choice}번</div>{/if}
|
||||
</div>
|
||||
{#if g.cards.length > 1}
|
||||
<Button variant="secondary" size="sm" icon={CheckCheck} onclick={() => approveGroup(g.source_question_id, g.cards.length)}>{g.cards.length}장 승인</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 카드들 -->
|
||||
<div class="space-y-2.5">
|
||||
{#each g.cards as c (c.id)}
|
||||
<div class="rounded-lg border border-default bg-surface p-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-[10px] font-bold text-white {c.format === 'cloze' ? 'bg-accent' : 'bg-domain-engineering'}">{c.format}</span>
|
||||
{#if c.flagged_by === 'source_changed' || c.flagged_by === 'source_deleted'}
|
||||
<span class="text-[11px] text-warning">{c.flagged_by === 'source_changed' ? '문제 수정됨' : '문제 삭제됨'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editing === c.id}
|
||||
<!-- 편집 모드 -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-[11px] font-semibold text-dim">앞 (단서/질문)
|
||||
<textarea bind:value={draft.cue} rows="2" class="mt-1 w-full rounded-md border border-default bg-bg px-2 py-1.5 text-sm text-text"></textarea>
|
||||
</label>
|
||||
<label class="block text-[11px] font-semibold text-dim">정답 (fact)
|
||||
<input bind:value={draft.fact} class="mt-1 w-full rounded-md border border-default bg-bg px-2 py-1.5 text-sm text-text" />
|
||||
</label>
|
||||
{#if c.format === 'cloze'}
|
||||
<label class="block text-[11px] font-semibold text-dim">빈칸 문장 (cloze, [____] 포함)
|
||||
<textarea bind:value={draft.cloze_text} rows="2" class="mt-1 w-full rounded-md border border-default bg-bg px-2 py-1.5 text-sm text-text"></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="primary" size="sm" icon={Check} onclick={() => saveEdit(g.source_question_id, c.id)}>저장 후 승인</Button>
|
||||
<Button variant="ghost" size="sm" icon={X} onclick={cancelEdit}>취소</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 보기 모드 -->
|
||||
<div class="rounded-md border border-default bg-surface-active px-3 py-2 text-sm">
|
||||
<div class="text-[10px] font-bold uppercase tracking-wide text-faint">앞</div>{c.cue}
|
||||
</div>
|
||||
<div class="mt-1.5 rounded-md border border-accent-ring bg-bg px-3 py-2 text-sm">
|
||||
{#if c.format === 'cloze' && c.cloze_text}
|
||||
{c.cloze_text}
|
||||
<div class="mt-1 text-xs text-accent">정답: <b>{c.fact}</b></div>
|
||||
{:else}
|
||||
<b class="text-accent">{c.fact}</b>
|
||||
{/if}
|
||||
</div>
|
||||
{#if c.evidence?.length}
|
||||
<div class="mt-2 text-[11px] text-dim">근거: {c.evidence[0].snippet}</div>
|
||||
{:else}
|
||||
<div class="mt-2 text-[11px] text-faint">근거: 확정 풀이(비정량 개념)</div>
|
||||
{/if}
|
||||
<div class="mt-2.5 flex gap-2">
|
||||
<Button variant="primary" size="sm" icon={Check} onclick={() => approve(g.source_question_id, c.id)}>승인</Button>
|
||||
<Button variant="ghost" size="sm" icon={Pencil} onclick={() => startEdit(c)}>수정</Button>
|
||||
<Button variant="danger" size="sm" icon={Trash2} onclick={() => remove(g.source_question_id, c.id)}>삭제</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user