From 0e2a430a6c01294682e64a14b20eaa2d0cb630d7 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 28 Apr 2026 08:14:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(study):=20=ED=86=B5=ED=95=A9=EB=B7=B0=20?= =?UTF-8?q?=EC=9E=90=EB=A3=8C=20=EC=84=B9=EC=85=98=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=ED=8A=B8=EB=A6=AC=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=ED=95=91=20+=20=EC=A0=91=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가스기사처럼 한 워크스페이스에 273건 자료가 묶이면 평면 리스트로 쭉 나열 되어 통합뷰가 무너졌음. /study/topics/[id] 자료 섹션을 자료실 카테고리 경로 기반 트리로 그룹핑하고 노드별 접기/펼치기 도입. 기본값 모두 접힘. 백엔드: StudyTopicDocumentSummary 에 library_paths(`@library/` 태그 에서 prefix 제거) 필드 추가. 그룹핑은 첫 path 만 사용 (단순화). 프론트: documents 를 path segment 별로 트리 빌드 → snippet 재귀 렌더링. 헤더에 "자료 N개 · 카테고리 K개 · [모두 펼치기/접기]" 컨트롤. 분류 없는 자료는 "분류 없음" 그룹으로 별도. 자료 0건 path 는 자동 누락. 필기/문제 섹션은 분류축이 달라(certification/subject vs subject) 동일 트리 못 쓰므로 본 PR 범위 밖. 후속에서 패턴 일관성 검토. --- app/api/study_topics.py | 19 +- .../src/routes/study/topics/[id]/+page.svelte | 181 +++++++++++++++--- 2 files changed, 177 insertions(+), 23 deletions(-) diff --git a/app/api/study_topics.py b/app/api/study_topics.py index 6477b97..f994655 100644 --- a/app/api/study_topics.py +++ b/app/api/study_topics.py @@ -102,7 +102,13 @@ class StudyTopicSessionSummary(BaseModel): class StudyTopicDocumentSummary(BaseModel): - """상세 뷰의 자료 카드 페이로드.""" + """상세 뷰의 자료 카드 페이로드. + + library_paths: 통합뷰 자료 섹션의 카테고리 트리 그룹핑용. user_tags 의 + `@library/` 태그에서 prefix 제거한 path 들. 한 자료가 여러 카테고리에 + 속할 수 있으나(다중 분류), 트리 그룹핑 시 첫 번째 path 만 사용. 빈 리스트면 + "분류 없음" 그룹. + """ id: int title: str | None @@ -113,6 +119,7 @@ class StudyTopicDocumentSummary(BaseModel): importance: str | None sort_order: int linked_at: datetime + library_paths: list[str] = [] class StudyTopicQuestionSummary(BaseModel): @@ -429,6 +436,15 @@ async def get_study_topic( ) ).all() + def _extract_library_paths(tags: list | None) -> list[str]: + if not tags: + return [] + out: list[str] = [] + for t in tags: + if isinstance(t, str) and t.startswith(LIBRARY_PREFIX): + out.append(t[len(LIBRARY_PREFIX):]) + return out + documents_payload = [ StudyTopicDocumentSummary( id=d.id, @@ -440,6 +456,7 @@ async def get_study_topic( importance=d.importance, sort_order=link.sort_order, linked_at=link.created_at, + library_paths=_extract_library_paths(d.user_tags), ) for d, link in doc_rows ] diff --git a/frontend/src/routes/study/topics/[id]/+page.svelte b/frontend/src/routes/study/topics/[id]/+page.svelte index a47a298..8de71f8 100644 --- a/frontend/src/routes/study/topics/[id]/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/+page.svelte @@ -43,6 +43,80 @@ let docPage = $state(1); let docTotal = $state(0); + // 통합뷰 자료 섹션 트리 그룹핑 (자료실 @library/ 기준) + let docTreeExpanded = $state({}); // {fullPath: bool} + + /** + * documents 를 첫 library_path 기준으로 트리 빌드. + * 한 자료가 여러 카테고리에 속해도 그룹핑은 첫 path 만 사용 (단순화). + */ + function buildDocTree(documents) { + const root = { name: '', path: '', children: new Map(), documents: [] }; + const unclassified = []; + let pathSet = new Set(); + + for (const d of documents) { + const paths = d.library_paths ?? []; + if (paths.length === 0) { + unclassified.push(d); + continue; + } + const path = paths[0]; + pathSet.add(path); + const segments = path.split('/'); + let node = root; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const childPath = segments.slice(0, i + 1).join('/'); + if (!node.children.has(seg)) { + node.children.set(seg, { + name: seg, + path: childPath, + children: new Map(), + documents: [], + }); + } + node = node.children.get(seg); + } + node.documents.push(d); + } + return { root, unclassified, leafPathCount: pathSet.size + (unclassified.length > 0 ? 1 : 0) }; + } + + /** 노드 + 하위 모든 자료 수 합산 */ + function nodeTotalCount(node) { + let n = node.documents.length; + for (const child of node.children.values()) n += nodeTotalCount(child); + return n; + } + + /** 트리 내 모든 fullPath 수집 (expand all 용) */ + function collectAllPaths(node, out) { + for (const child of node.children.values()) { + out.push(child.path); + collectAllPaths(child, out); + } + } + + let docTree = $derived(buildDocTree(detail?.sections?.documents ?? [])); + + function expandAllDocs() { + const paths = []; + collectAllPaths(docTree.root, paths); + if (docTree.unclassified.length > 0) paths.push('__unclassified__'); + const next = {}; + for (const p of paths) next[p] = true; + docTreeExpanded = next; + } + + function collapseAllDocs() { + docTreeExpanded = {}; + } + + function toggleDocPath(path) { + docTreeExpanded = { ...docTreeExpanded, [path]: !docTreeExpanded[path] }; + } + async function load() { loading = true; try { @@ -326,7 +400,7 @@
-
+

자료 {detail.sections.documents.length} @@ -338,30 +412,93 @@ 이 주제에 연결된 자료가 없습니다. "자료 추가" 로 자료실 자료를 묶어보세요.

{:else} -
- {#each detail.sections.documents as d (d.id)} -
-
-
- {d.title || `(제목 없음 · #${d.id})`} -
-
- {d.file_format} - {#if d.category}· {d.category}{/if} - {#if d.ai_domain}· {d.ai_domain}{/if} - {#if d.importance && d.importance !== 'medium'}· {d.importance}{/if} - {fmtDate(d.linked_at)} -
+ +
+ + 자료 {detail.sections.documents.length}개 + · 카테고리 {docTree.leafPathCount}개 + +
+ + · + +
+
+ + {#snippet docCard(d)} +
+
+
{d.title || `(제목 없음 · #${d.id})`}
+
+ {d.file_format} + {#if d.ai_domain}· {d.ai_domain}{/if} + {#if d.importance && d.importance !== 'medium'}· {d.importance}{/if} + {fmtDate(d.linked_at)}
- -
+ + +
+ {/snippet} + + {#snippet treeNode(node, depth)} + {@const isExpanded = docTreeExpanded[node.path] === true} + {@const totalUnder = nodeTotalCount(node)} +
+ + {#if isExpanded} +
+ {#each node.documents as d (d.id)} + {@render docCard(d)} + {/each} +
+ {#each [...node.children.values()] as child (child.path)} + {@render treeNode(child, depth + 1)} + {/each} + {/if} +
+ {/snippet} + +
+ {#each [...docTree.root.children.values()] as child (child.path)} + {@render treeNode(child, 0)} {/each} + + {#if docTree.unclassified.length > 0} + {@const isExp = docTreeExpanded['__unclassified__'] === true} + + {#if isExp} +
+ {#each docTree.unclassified as d (d.id)} + {@render docCard(d)} + {/each} +
+ {/if} + {/if}
{/if}