feat(study): 문제 이상 신고(태깅) UI — 퀴즈·상세 플래그 + 신고함 큐 + 허브
백엔드(needs_review/flagged_by 컬럼·PATCH·needs-review 큐 API)는 P1 때 깔렸으나 이를 쓰는 화면이 없어 사실상 미구현 상태였음. 프론트 UI 보강(백엔드 무변경). - 퀴즈 세션·문제 상세에 '이 문제 이상해요' 플래그 버튼(PATCH needs_review toggle, flagged_by='user'). 신고/해제 토스트. - 신규 /study/questions-review 신고함: 전 토픽 횡단 목록 + 사유칩(직접신고/문제수정됨/문제삭제됨) + 문제보기·수정 링크 + 검토완료(해제)·폐기(soft-delete). - 허브에 '문제 신고함' 카드 + count 배지(GET needs-review/count). - 퀴즈 세션 신고 상태는 세션 내 optimistic(결과 payload 에 needs_review 없음, 영속 source=신고함 큐). flagQuestion 은 PATCH 응답 needs_review 반영. 검증: 적대검토(runes·API계약·UX) 통과 — blocker(payload 미포함)는 프론트 init 제거로 해소(study_topics.py 미편집=타 세션 작업 보호). 기존 이모지(repeatBadge/근거)는 본 변경 무관. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,19 @@
|
||||
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat } from 'lucide-svelte';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag } from 'lucide-svelte';
|
||||
|
||||
let cardReviewCount = $state(0);
|
||||
let questionFlagCount = $state(0);
|
||||
onMount(async () => {
|
||||
try {
|
||||
const r = await api('/study-cards/needs-review/count');
|
||||
cardReviewCount = r?.count ?? 0;
|
||||
} catch {}
|
||||
try {
|
||||
const r2 = await api('/study-questions/needs-review/count');
|
||||
questionFlagCount = r2?.count ?? 0;
|
||||
} catch {}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -78,7 +83,21 @@
|
||||
<Repeat size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">암기카드 학습</h2>
|
||||
</div>
|
||||
<p class="text-xs text-dim">검수한 암기카드를 모바일에서 학습. <b>복습(간격반복 1·3·7·14일)</b>으로 자기평가하거나, <b>그냥 공부</b>로 덜 본 카드를 휙휙 훑어봅니다.</p>
|
||||
<p class="text-xs text-dim">검수한 암기카드를 모바일에서 학습. <b>복습(간격반복 1·3·7·14일)</b>으로 자기평가하거나, <b>그냥 공부</b>로 덜 본 카드를 가볍게 훑어봅니다.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/study/questions-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">
|
||||
<Flag size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">문제 신고함</h2>
|
||||
{#if questionFlagCount > 0}
|
||||
<span class="ml-auto rounded-full bg-warning px-2 py-0.5 text-xs font-bold text-white">{questionFlagCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-dim">퀴즈·문제 화면에서 <b>이상하다고 신고한 문제</b>를 모아 확인하고 수정·검토 완료·폐기합니다.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script>
|
||||
/**
|
||||
* /study/questions-review — 문제 신고함 (이상 태깅된 study_questions 검수).
|
||||
*
|
||||
* 퀴즈/문제 화면의 '이 문제 이상해요'(PATCH needs_review=true)로 신고된 문제를 전 토픽 횡단으로 모아
|
||||
* 확인 → 수정 / 검토 완료(신고 해제) / 폐기. 암기카드 검수(/study/cards-review)의 문제 버전.
|
||||
*
|
||||
* backend: GET /study-questions/needs-review (목록) · /needs-review/count (배지)
|
||||
* PATCH /study-questions/{id} {needs_review:false} (검토 완료) · DELETE /study-questions/{id} (폐기, soft)
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { ArrowLeft, Flag, Check, Pencil, Trash2, Eye } 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 items = $state([]); // [{id, study_topic_id, question_text, flagged_at, flagged_by}]
|
||||
|
||||
// flagged_by 사유 → 라벨/색 (서버 상수: user / source_changed / source_deleted)
|
||||
const REASON = {
|
||||
user: { label: '직접 신고', cls: 'border-warning/40 text-warning bg-warning/10' },
|
||||
source_changed: { label: '문제 수정됨', cls: 'border-accent-ring text-accent bg-accent/10' },
|
||||
source_deleted: { label: '문제 삭제됨', cls: 'border-error/40 text-error bg-error/10' },
|
||||
};
|
||||
function reasonOf(by) {
|
||||
return REASON[by] ?? { label: '신고', cls: 'border-default text-dim bg-surface' };
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
items = (await api('/study-questions/needs-review')) ?? [];
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '신고 목록 조회 실패');
|
||||
items = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeLocal(id) {
|
||||
items = items.filter((it) => it.id !== id);
|
||||
}
|
||||
|
||||
async function resolve(it) {
|
||||
try {
|
||||
await api(`/study-questions/${it.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ needs_review: false }),
|
||||
});
|
||||
removeLocal(it.id);
|
||||
addToast('success', '검토 완료 — 신고를 해제했어요');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '처리 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function discard(it) {
|
||||
if (!confirm('이 문제를 폐기할까요? (되돌릴 수 없음)')) return;
|
||||
try {
|
||||
await api(`/study-questions/${it.id}`, { method: 'DELETE' });
|
||||
removeLocal(it.id);
|
||||
addToast('success', '문제를 폐기했어요');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '삭제 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
return new Date(s).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
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 items.length > 0}
|
||||
<span class="rounded-full bg-warning px-2.5 py-0.5 text-xs font-bold text-white">{items.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-sm text-dim">
|
||||
퀴즈나 문제 화면에서 <b class="text-text">이상하다고 신고한 문제</b>가 여기에 모입니다.
|
||||
내용을 확인해 <b class="text-text">수정하거나, 검토 완료(신고 해제)하거나, 폐기</b>하세요.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">{#each Array(4).fill(0) as _, i (i)}<Skeleton class="h-24 w-full" />{/each}</div>
|
||||
{:else if items.length === 0}
|
||||
<EmptyState
|
||||
title="신고된 문제가 없습니다"
|
||||
description="문제나 정답이 이상할 때 퀴즈·문제 화면의 '이 문제 이상해요'로 신고하면 여기에 쌓입니다."
|
||||
icon={Flag}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-2.5">
|
||||
{#each items as it (it.id)}
|
||||
{@const r = reasonOf(it.flagged_by)}
|
||||
<div class="rounded-card border border-default bg-surface p-3.5">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="rounded-full border px-2 py-0.5 text-[11px] font-semibold {r.cls}">{r.label}</span>
|
||||
{#if it.flagged_at}<span class="text-[11px] text-faint">{fmtDate(it.flagged_at)}</span>{/if}
|
||||
</div>
|
||||
<div class="text-sm leading-relaxed text-text">{it.question_text}</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button href={`/study/topics/${it.study_topic_id}/questions/${it.id}`} variant="secondary" size="sm" icon={Eye}>문제 보기</Button>
|
||||
<Button href={`/study/topics/${it.study_topic_id}/questions/${it.id}/edit`} variant="ghost" size="sm" icon={Pencil}>수정</Button>
|
||||
<Button variant="ghost" size="sm" icon={Check} onclick={() => resolve(it)}>검토 완료</Button>
|
||||
<Button variant="danger" size="sm" icon={Trash2} onclick={() => discard(it)}>폐기</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Edit, Sparkles, AlertCircle, CheckCircle2, XCircle, ListChecks,
|
||||
ArrowLeft, ArrowRight, Edit, Sparkles, AlertCircle, CheckCircle2, XCircle, ListChecks, Flag,
|
||||
} from 'lucide-svelte';
|
||||
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
@@ -237,6 +237,26 @@
|
||||
if (!s) return '';
|
||||
return new Date(s).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
// 이 문제 이상해요 신고 토글 (PATCH needs_review). 신고함(/study/questions-review)에 모임.
|
||||
let flagBusy = $state(false);
|
||||
async function toggleFlag() {
|
||||
if (flagBusy || !q) return;
|
||||
flagBusy = true;
|
||||
const next = !q.needs_review;
|
||||
try {
|
||||
const res = await api(`/study-questions/${qid}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ needs_review: next }),
|
||||
});
|
||||
q = { ...q, needs_review: res.needs_review, flagged_by: res.flagged_by, flagged_at: res.flagged_at };
|
||||
addToast(next ? 'success' : 'info', next ? '이상 문제로 신고했어요 — 검수함에 모았습니다' : '신고를 해제했어요');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '신고 처리 실패');
|
||||
} finally {
|
||||
flagBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>문제 상세 — {topicName || '주제'}</title></svelte:head>
|
||||
@@ -279,7 +299,22 @@
|
||||
{#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 class="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleFlag}
|
||||
disabled={flagBusy}
|
||||
class="flex items-center gap-1.5 text-xs h-7 px-2.5 rounded-md border transition-colors disabled:opacity-50
|
||||
{q.needs_review
|
||||
? 'border-warning bg-warning/10 text-warning'
|
||||
: 'border-default text-dim hover:text-warning hover:border-warning/40'}"
|
||||
title="문제나 정답이 이상하면 신고해 검수함에 모아둡니다"
|
||||
>
|
||||
<Flag size={13} />
|
||||
<span>{q.needs_review ? '신고됨' : '이 문제 이상해요'}</span>
|
||||
</button>
|
||||
<Button href={`/study/topics/${topicId}/questions/${qid}/edit`} size="sm" variant="ghost" icon={Edit}>편집</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import {
|
||||
ArrowLeft, CheckCircle2, XCircle, HelpCircle, Sparkles, BookOpen, AlertCircle, Square, CheckSquare,
|
||||
ArrowLeft, CheckCircle2, XCircle, HelpCircle, Sparkles, BookOpen, AlertCircle, Square, CheckSquare, Flag,
|
||||
} from 'lucide-svelte';
|
||||
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
@@ -36,6 +36,24 @@
|
||||
// PR-12-A: 카드별 round_count 배지 (틀린/모르겠음 헤더에 표시).
|
||||
let relatedCounts = $state({}); // { [qid]: { repeat_round_count, similar_round_count, ... } }
|
||||
|
||||
// 문제 신고(이상 태깅): qid -> true. 검수함(/study/questions-review)에 모임.
|
||||
let flagged = $state({});
|
||||
let flagBusy = $state({});
|
||||
async function flagQuestion(qid) {
|
||||
if (flagBusy[qid]) return;
|
||||
const next = !flagged[qid];
|
||||
flagBusy = { ...flagBusy, [qid]: true };
|
||||
try {
|
||||
const res = await api(`/study-questions/${qid}`, { method: 'PATCH', body: JSON.stringify({ needs_review: next }) });
|
||||
flagged = { ...flagged, [qid]: res?.needs_review ?? next };
|
||||
addToast(next ? 'success' : 'info', next ? '이상 문제로 신고했어요 — 검수함에 모았습니다' : '신고를 해제했어요');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '신고 처리 실패');
|
||||
} finally {
|
||||
flagBusy = { ...flagBusy, [qid]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopic() {
|
||||
try {
|
||||
const t = await api(`/study-topics/${topicId}`);
|
||||
@@ -51,6 +69,8 @@
|
||||
if ((detail.summary.wrong_count ?? 0) > 0) activeTab = 'wrong';
|
||||
else if ((detail.summary.unsure_count ?? 0) > 0) activeTab = 'unsure';
|
||||
else activeTab = 'correct';
|
||||
// 신고 상태의 영속 source 는 신고함 큐(/study/questions-review) — 세션 결과 payload 엔
|
||||
// needs_review 가 없으므로 여기선 세션 내 optimistic 표시만. 새로고침 시 초기화됨.
|
||||
// PR-12-A: 카드별 반복 출제/유사 유형 배지 — 1회 bulk 호출.
|
||||
void loadRelatedCounts();
|
||||
} catch (err) {
|
||||
@@ -562,8 +582,21 @@
|
||||
{@render subjectNoteBlock(it, cardState)}
|
||||
{/if}
|
||||
|
||||
{#if kind !== 'correct'}
|
||||
<div class="flex justify-end pt-2 border-t border-default">
|
||||
<div class="flex items-center justify-between gap-2 pt-2 border-t border-default">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => flagQuestion(it.q.id)}
|
||||
disabled={flagBusy[it.q.id]}
|
||||
class="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors disabled:opacity-50
|
||||
{flagged[it.q.id]
|
||||
? 'border-warning bg-warning/10 text-warning'
|
||||
: 'border-default text-dim hover:text-warning hover:border-warning/40'}"
|
||||
title="문제나 정답이 이상하면 신고해 검수함에 모아둡니다"
|
||||
>
|
||||
<Flag size={13} />
|
||||
<span>{flagged[it.q.id] ? '신고됨' : '이 문제 이상해요'}</span>
|
||||
</button>
|
||||
{#if kind !== 'correct'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleReviewed(it.attempt)}
|
||||
@@ -575,8 +608,8 @@
|
||||
{#if reviewed}<CheckSquare size={14} />{:else}<Square size={14} />{/if}
|
||||
<span>{reviewed ? '학습완료' : '학습완료로 표시'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user