From 036fc84fa66b70a3d7be6a78d6e97f3e114987dc Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Apr 2026 11:40:10 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.3.53=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20featured/custom=20item=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/history.md | 4 + docs/todo.md | 2 +- docs/update.md | 4 + .../src/composables/useAdminCustomItems.js | 203 ++++++++++++ .../src/composables/useAdminFeaturedGames.js | 93 ++++++ frontend/src/views/AdminView.vue | 292 ++++-------------- 6 files changed, 369 insertions(+), 229 deletions(-) create mode 100644 frontend/src/composables/useAdminCustomItems.js create mode 100644 frontend/src/composables/useAdminFeaturedGames.js diff --git a/docs/history.md b/docs/history.md index 73e3dc9..c9e9249 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.3.53 +- 관리자 후속 리팩터링은 남은 큰 액션 묶음인 `상단 고정 게임 정렬`과 `커스텀 아이템 검수`부터 composable로 분리하는 편이 `AdminView.vue` 체감 복잡도를 가장 빨리 낮춘다고 판단했다. +- 이 단계에서도 레이아웃이나 문구보다 로직 책임 경계를 먼저 옮기고, 실제 스타일 파일 분리는 그 다음 단계로 이어가는 편이 안전하다고 정리했다. + ## 2026-04-02 v1.3.52 - 관리자 화면은 본문을 컴포넌트로 나눈 뒤에도 같은 시각 문법을 유지해야 하므로, `scoped`를 유지한 채 각 섹션에 스타일을 복붙하기보다 관리자 범위 공통 스타일로 다시 묶는 편이 더 안전하다고 정리했다. - `템플릿 요청 관리 / 전체 티어표 관리` 내부 모드 값은 URL과 버튼 상태가 어긋나지 않도록 `all` 하나로 통일하는 편이 맞다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index 57c07e0..3b49186 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,7 +2,7 @@ ## 중기 개선 - 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다. -- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리` composable 분리는 시작했으므로, 다음 단계에서는 `아이템 관리`와 `목록 관리`도 같은 기준으로 옮기고 공통 모달 상태를 어느 계층에서 소유할지 정리한다. +- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다. - 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다. - 관리자 공통 스타일은 `adminUiScope` 기준으로 다시 묶었으므로, 다음 단계에서는 각 섹션을 별도 파일로 완전히 분리할 때 스타일도 `admin.css` 또는 섹션별 스타일로 옮길지 결정한다. - 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다. diff --git a/docs/update.md b/docs/update.md index f7b08aa..df8ef4d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 로그 +## 2026-04-02 v1.3.53 +- 관리자 리팩터링 4차로 `목록 관리` 정렬 로직과 `아이템 관리` 모달/삭제/승격 액션을 각각 `useAdminFeaturedGames`, `useAdminCustomItems` composable로 분리해 `AdminView.vue`의 직접 액션 코드를 더 줄임. +- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태 중심으로 더 가까워졌고, 상단 고정 게임 정렬과 커스텀 아이템 처리 흐름은 각 영역 책임에 맞는 파일로 옮겨 유지보수 범위를 좁힘. + ## 2026-04-02 v1.3.52 - 관리자 본문 섹션을 컴포넌트로 나눈 뒤 `AdminView.vue` 스타일이 `scoped`에 묶여 자식 컴포넌트까지 제대로 닿지 않던 문제를 정리하고, 관리자 전용 공통 스타일을 `adminUiScope` 범위로 다시 묶어 각 페이지 CSS가 함께 살아나도록 보강함. - 템플릿 요청 카드의 신규 게임 입력 영역에는 `게임 이름 / 게임 ID` 필드 스타일을 다시 붙여, 요청 카드만 따로 풀린 것처럼 보이던 레이아웃을 복구함. diff --git a/frontend/src/composables/useAdminCustomItems.js b/frontend/src/composables/useAdminCustomItems.js new file mode 100644 index 0000000..d5470ef --- /dev/null +++ b/frontend/src/composables/useAdminCustomItems.js @@ -0,0 +1,203 @@ +import { nextTick } from 'vue' + +export function useAdminCustomItems({ + api, + toast, + customItems, + customItemPage, + customItemLimit, + customItemPageCount, + customItemQuery, + customItemOrphanOnly, + customItemModalOpen, + customItemDeleteModalOpen, + customItemModalHistoryActive, + modalTargetCustomItem, + customItemModalDraftLabel, + customItemModalLabelSaving, + customItemModalTargetGameId, + customItemModalGameQuery, + customItemModalGameSort, + games, + selectedGameId, + refreshCustomItems, + loadGame, + setTab, + selectAdminGame, + resetMessages, + success, + error, +}) { + function submitCustomItemSearch() { + customItemPage.value = 1 + refreshCustomItems() + } + + function toggleCustomItemOrphanOnly() { + customItemPage.value = 1 + refreshCustomItems() + } + + function changeCustomItemLimit(limit) { + customItemLimit.value = limit + customItemPage.value = 1 + refreshCustomItems() + } + + function moveCustomItemPage(direction) { + const nextPage = customItemPage.value + direction + if (nextPage < 1 || nextPage > customItemPageCount.value) return + customItemPage.value = nextPage + refreshCustomItems() + } + + function pushCustomItemModalHistoryState() { + if (typeof window === 'undefined') return + window.history.pushState({ ...(window.history.state || {}), adminCustomItemModal: true }, '', window.location.href) + customItemModalHistoryActive.value = true + } + + function openCustomItemModal(item) { + modalTargetCustomItem.value = item || null + customItemModalDraftLabel.value = item?.label || '' + customItemModalTargetGameId.value = '' + customItemModalGameQuery.value = '' + customItemModalGameSort.value = 'recent' + customItemModalOpen.value = true + pushCustomItemModalHistoryState() + } + + function closeCustomItemModal({ fromPopState = false } = {}) { + customItemModalOpen.value = false + customItemDeleteModalOpen.value = false + modalTargetCustomItem.value = null + customItemModalDraftLabel.value = '' + customItemModalLabelSaving.value = false + customItemModalTargetGameId.value = '' + customItemModalGameQuery.value = '' + customItemModalGameSort.value = 'recent' + + if (fromPopState) { + customItemModalHistoryActive.value = false + return + } + + if (customItemModalHistoryActive.value && typeof window !== 'undefined') { + customItemModalHistoryActive.value = false + window.history.back() + } + } + + function openCustomItemDeleteModal(item) { + if (!item) return + if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) { + error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' + return + } + modalTargetCustomItem.value = item + customItemDeleteModalOpen.value = true + } + + function closeCustomItemDeleteModal() { + customItemDeleteModalOpen.value = false + } + + function jumpToGameAdmin(gameId) { + if (!gameId) return + closeCustomItemModal() + setTab('game-admin') + nextTick(() => { + selectAdminGame(gameId) + }) + } + + async function removeCustomItem(item = modalTargetCustomItem.value) { + resetMessages() + if (!item) return + if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) { + error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.' + return + } + + try { + await api.deleteAdminCustomItem(item.id) + closeCustomItemDeleteModal() + closeCustomItemModal() + await refreshCustomItems() + success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.' + } catch (e) { + error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.' + } + } + + async function removeUnusedCustomItems() { + resetMessages() + const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?') + if (!ok) return + + try { + const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value }) + await refreshCustomItems() + success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.` + } catch (e) { + error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.' + } + } + + async function saveCustomItemModalLabel() { + const item = modalTargetCustomItem.value + const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60) + if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return + + try { + customItemModalLabelSaving.value = true + const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType }) + item.label = data.item?.label || nextLabel + customItemModalDraftLabel.value = item.label + customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry)) + toast.success('아이템 이름을 변경했어요.') + } catch (e) { + error.value = '아이템 이름 변경에 실패했어요.' + } finally { + customItemModalLabelSaving.value = false + } + } + + async function promoteCustomItem(item) { + resetMessages() + if (!customItemModalTargetGameId.value) { + error.value = '추가할 게임을 먼저 선택해주세요.' + return + } + + try { + item.isPromoting = true + await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value }) + const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value + if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame() + closeCustomItemModal() + success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.` + } catch (e) { + error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.' + } finally { + item.isPromoting = false + } + } + + return { + submitCustomItemSearch, + toggleCustomItemOrphanOnly, + changeCustomItemLimit, + moveCustomItemPage, + pushCustomItemModalHistoryState, + openCustomItemModal, + closeCustomItemModal, + openCustomItemDeleteModal, + closeCustomItemDeleteModal, + jumpToGameAdmin, + removeCustomItem, + removeUnusedCustomItems, + saveCustomItemModalLabel, + promoteCustomItem, + } +} diff --git a/frontend/src/composables/useAdminFeaturedGames.js b/frontend/src/composables/useAdminFeaturedGames.js new file mode 100644 index 0000000..905819e --- /dev/null +++ b/frontend/src/composables/useAdminFeaturedGames.js @@ -0,0 +1,93 @@ +import { nextTick } from 'vue' +import Sortable from 'sortablejs' + +export function useAdminFeaturedGames({ + api, + featuredListEl, + featuredSortable, + featuredGameIds, + games, + resetMessages, + success, + error, +}) { + function destroyFeaturedSortable() { + if (featuredSortable.value) { + featuredSortable.value.destroy() + featuredSortable.value = null + } + } + + async function syncFeaturedSortable() { + await nextTick() + destroyFeaturedSortable() + if (!featuredListEl.value) return + + featuredSortable.value = Sortable.create(featuredListEl.value, { + animation: 160, + draggable: '[data-featured-id]', + handle: '[data-featured-handle]', + ghostClass: 'ghost', + chosenClass: 'chosen', + onEnd: (evt) => { + if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return + const nextIds = [...featuredGameIds.value] + const [moved] = nextIds.splice(evt.oldIndex, 1) + nextIds.splice(evt.newIndex, 0, moved) + featuredGameIds.value = nextIds + }, + }) + } + + function addFeaturedGame(gameId) { + resetMessages() + if (!gameId || featuredGameIds.value.includes(gameId)) return + if (featuredGameIds.value.length >= 50) { + error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.' + return + } + featuredGameIds.value = [...featuredGameIds.value, gameId] + syncFeaturedSortable() + } + + function removeFeaturedGame(gameId) { + resetMessages() + featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId) + syncFeaturedSortable() + } + + function moveFeaturedGame(gameId, direction) { + const currentIndex = featuredGameIds.value.indexOf(gameId) + const nextIndex = currentIndex + direction + if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return + const nextIds = [...featuredGameIds.value] + const [moved] = nextIds.splice(currentIndex, 1) + nextIds.splice(nextIndex, 0, moved) + featuredGameIds.value = nextIds + syncFeaturedSortable() + } + + async function saveFeaturedOrder() { + resetMessages() + try { + const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value }) + games.value = data.games || [] + featuredGameIds.value = games.value + .filter((game) => game.displayRank != null) + .sort((a, b) => a.displayRank - b.displayRank) + .map((game) => game.id) + success.value = '홈 화면 게임 순서를 저장했어요.' + } catch (e) { + error.value = '게임 순서 저장에 실패했어요.' + } + } + + return { + destroyFeaturedSortable, + syncFeaturedSortable, + addFeaturedGame, + removeFeaturedGame, + moveFeaturedGame, + saveFeaturedOrder, + } +} diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 1146d6c..d1f6600 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,7 +1,6 @@