From badf2509678354b35099daeb105bca0fe2e04169 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Apr 2026 15:21:03 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.3.69=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8B=B0=EC=96=B4=ED=91=9C=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=EC=99=80=20=EC=95=84=EC=9D=B4=ED=85=9C=20UI?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 45 ++++++ backend/src/routes/admin.js | 16 +++ docs/history.md | 5 + docs/todo.md | 1 + docs/update.md | 5 + .../components/admin/AdminItemsSection.vue | 4 - .../admin/AdminTierlistsSection.vue | 6 + frontend/src/lib/api.js | 2 + frontend/src/views/AdminView.vue | 128 ++++++++---------- 9 files changed, 140 insertions(+), 72 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 482aba2..c6fe31f 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -2026,6 +2026,50 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren } } +async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) { + const hasQuery = !!(queryText || '').trim() + const hasGameId = !!(gameId || '').trim() + const search = `%${(queryText || '').trim()}%` + const whereParts = [] + const params = [] + + if (hasGameId) { + whereParts.push('t.game_id = ?') + params.push((gameId || '').trim()) + } + + if (hasQuery) { + whereParts.push(`( + t.title LIKE ? + OR g.name LIKE ? + OR g.id LIKE ? + OR u.email LIKE ? + OR u.nickname LIKE ? + )`) + params.push(search, search, search, search, search) + } + + const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '' + const rows = await query( + ` + SELECT t.is_public + FROM tierlists t + INNER JOIN users u ON u.id = t.author_id + INNER JOIN games g ON g.id = t.game_id + ${whereClause} + `, + params + ) + + const total = rows.length + const publicCount = rows.filter((row) => Number(row.is_public) === 1).length + return { + total, + publicCount, + privateCount: Math.max(0, total - publicCount), + } +} + async function findTierListById(id, currentUserId = '') { const rows = await query( ` @@ -2408,6 +2452,7 @@ module.exports = { listFavoriteTierLists, listUserTierLists, listAdminTierLists, + summarizeAdminTierLists, findTierListById, favoriteTierList, unfavoriteTierList, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index ae0eeb7..df3d295 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -31,6 +31,7 @@ const { listUsers, findPrimaryAdminUser, listAdminTierLists, + summarizeAdminTierLists, findTierListById, listAdminTemplateRequests, findTemplateRequestById, @@ -310,6 +311,21 @@ router.get('/tierlists', requireAdmin, async (req, res) => { res.json(result) }) +router.get('/tierlists/stats', requireAdmin, async (req, res) => { + const schema = z.object({ + q: z.string().trim().max(120).optional().default(''), + gameId: z.string().trim().max(120).optional().default(''), + }) + const parsed = schema.safeParse(req.query) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const result = await summarizeAdminTierLists({ + queryText: parsed.data.q, + gameId: parsed.data.gameId, + }) + res.json(result) +}) + router.get('/template-requests', requireAdmin, async (req, res) => { const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] }) res.json({ requests }) diff --git a/docs/history.md b/docs/history.md index c1f0df2..7e06e17 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-02 v1.3.69 +- 관리자 아이템 라이브러리의 참조 수는 저장 구조 설명에는 도움이 되지만 실제 운영에서는 대부분 의미가 약하므로, 카드와 모달에서 계속 전면에 두기보다 다시 숨기고 필요한 경우 내부 데이터로만 남기는 편이 맞다고 정리했다. +- 관리자 상단 요약 수치는 `활성/대기` 같은 상태 문구보다 실제 운영 판단에 바로 쓰이는 `공개/비공개 개수`가 더 중요하므로, 게임 관리와 티어표 관리 모두 공개 상태 집계를 먼저 보여주는 편이 낫다고 판단했다. +- 공개/비공개 수치는 현재 페이지 일부를 세면 오해가 생기기 쉬우므로, 검색/게임 기준 전체 결과를 집계하는 별도 관리자 API로 계산하는 편이 맞다고 정리했다. + ## 2026-04-02 v1.3.68 - 관리자 아이템 상세는 새 모달을 겹쳐 올리는 방식보다 기존 모달 안에서 `왼쪽 선택 대상 / 오른쪽 작업과 참조 정보` 역할만 분명히 나누는 편이 더 안정적이라고 정리했다. - 같은 이미지를 두 위치에서 반복 노출하면 “모달이 두 개 겹친 것처럼” 느껴질 수 있으므로, 선택 썸네일은 한 곳에만 두고 양쪽 패널은 각자 스크롤되는 구조로 정리하는 편이 맞다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index 419c0a3..8714f92 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,7 @@ # 할 일 및 이슈 ## 단기 확인 +- 관리자 게임 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다. - 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다. - 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다. - 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다. diff --git a/docs/update.md b/docs/update.md index d00b077..b773386 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.3.69 +- 관리자 아이템 라이브러리는 참조 수/공유 기록 UI가 실제 운영 판단에 비해 노이즈가 커 보여 카드 수치, 상세 모달의 같은 이미지 참조 섹션, 삭제 확인 문구의 참조 강조를 걷어내고 다시 항목 자체 관리 흐름 위주로 정리함. +- 관리자 게임 관리 상단 요약은 더 이상 `선택 상태`처럼 추상적인 문구를 보여주지 않고, 선택된 게임 기준으로 만들어진 티어표의 `전체 / 공개 / 비공개` 개수를 바로 보여주도록 바꿈. +- 전체 티어표 관리 상단에도 검색 결과 기준 `전체 / 공개 / 비공개` 수치를 함께 노출하고, 이를 위해 관리자 티어표 집계 API를 별도로 추가해 페이지 단위가 아니라 실제 전체 결과 기준 숫자를 안정적으로 표시함. + ## 2026-04-02 v1.3.68 - 관리자 아이템 상세 모달은 같은 이미지를 왼쪽 선택 카드와 오른쪽 본문에서 두 번 보여주던 중복 미리보기를 제거해, 한 모달 안에서 정보가 겹쳐 보이던 문제를 정리함. - 왼쪽 게임 선택 패널과 오른쪽 상세 정보 패널은 각각 독립 스크롤이 되도록 바꾸고, 스크롤바도 다시 보이게 조정해 긴 목록이나 긴 참조 정보가 있어도 레이아웃이 깨지지 않고 탐색할 수 있게 함. diff --git a/frontend/src/components/admin/AdminItemsSection.vue b/frontend/src/components/admin/AdminItemsSection.vue index 518081c..ae9ae47 100644 --- a/frontend/src/components/admin/AdminItemsSection.vue +++ b/frontend/src/components/admin/AdminItemsSection.vue @@ -19,10 +19,6 @@ const props = defineProps({ {{ item.sourceLabel }}
{{ item.label }}
-
- 참조 {{ item.sharedReferenceCount || 1 }} - 게임 {{ item.sharedLinkedGameCount || item.linkedGames?.length || 0 }} -
diff --git a/frontend/src/components/admin/AdminTierlistsSection.vue b/frontend/src/components/admin/AdminTierlistsSection.vue index f0be8a2..02831ce 100644 --- a/frontend/src/components/admin/AdminTierlistsSection.vue +++ b/frontend/src/components/admin/AdminTierlistsSection.vue @@ -21,6 +21,7 @@ const props = defineProps({ adminTierListPage: { type: Number, required: true }, adminTierListPageCount: { type: Number, required: true }, adminTierListTotal: { type: Number, required: true }, + adminTierListStats: { type: Object, required: true }, moveAdminTierListPage: { type: Function, required: true }, }) @@ -128,6 +129,11 @@ const props = defineProps({
전체 티어표 관리
+
+ 전체 {{ props.adminTierListStats.total || 0 }}개 + 공개 {{ props.adminTierListStats.publicCount || 0 }}개 + 비공개 {{ props.adminTierListStats.privateCount || 0 }}개 +
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 2b9df9c..13c09e7 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -47,6 +47,8 @@ export const api = { ), listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) => request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), + getAdminTierListStats: ({ q = '', gameId = '' } = {}) => + request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`), listAdminTemplateRequests: () => request('/api/admin/template-requests'), getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => { const query = new URLSearchParams() diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index ba012a7..3e0e3f0 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -52,6 +52,8 @@ const adminTierListQuery = ref('') const adminTierListPage = ref(1) const adminTierListLimit = ref(50) const adminTierListTotal = ref(0) +const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 }) +const selectedGameTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 }) const templateRequests = ref([]) const importModalOpen = ref(false) const importModalMode = ref('existing') @@ -228,7 +230,6 @@ const activeTabDescription = computed(() => { return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.' }) const adminOverviewStats = computed(() => { - const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length const pendingRequests = templateRequests.value.length const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length const adminCount = users.value.filter((user) => user.isAdmin).length @@ -243,7 +244,9 @@ const adminOverviewStats = computed(() => { if (activeTab.value === 'game-admin') { return [ { label: '전체 게임', value: `${games.value.length}` }, - { label: '선택 상태', value: hasSelectedGame.value ? '활성' : '대기' }, + { label: '티어표 전체', value: `${selectedGameTierListStats.value.total || 0}` }, + { label: '공개', value: `${selectedGameTierListStats.value.publicCount || 0}` }, + { label: '비공개', value: `${selectedGameTierListStats.value.privateCount || 0}` }, { label: '기본 아이템', value: `${selectedGame.value?.items?.length || 0}` }, ] } @@ -263,8 +266,9 @@ const adminOverviewStats = computed(() => { { label: '업데이트 요청', value: `${templateRequests.value.filter((request) => request.type === 'update').length}` }, ] : [ - { label: '검색 결과', value: `${adminTierListTotal.value}` }, - { label: '공개 티어표', value: `${publishedTierLists}` }, + { label: '검색 결과', value: `${adminTierListStats.value.total || 0}` }, + { label: '공개', value: `${adminTierListStats.value.publicCount || 0}` }, + { label: '비공개', value: `${adminTierListStats.value.privateCount || 0}` }, { label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` }, ] } @@ -431,6 +435,14 @@ watch( } ) +watch( + () => selectedGame.value?.game?.id || '', + async (gameId) => { + await refreshSelectedGameTierListStats(gameId) + }, + { immediate: true } +) + watch( () => tierlistsMode.value, (mode) => { @@ -559,17 +571,13 @@ function formatImageJobStatus(status) { function customItemDeleteImpactText(item) { if (!item) return '' - const sharedCount = Number(item.sharedReferenceCount || 1) - if (item.sourceType === 'template') { - const base = item.isAssetLibraryItem + return item.isAssetLibraryItem ? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.` : `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 게임의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.` - return sharedCount > 1 ? `${base} 현재 같은 이미지 참조 ${sharedCount}건 중 이 항목만 다룹니다.` : base } - const base = `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.` - return sharedCount > 1 ? `${base} 현재 같은 이미지 참조 ${sharedCount}건 중 이 항목만 다룹니다.` : base + return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.` } const imageDiagnosticsCards = computed(() => { @@ -587,7 +595,6 @@ const imageDiagnosticsCards = computed(() => { const visibleLinkedGames = computed(() => (modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform') ) -const visibleSharedEntries = computed(() => modalTargetCustomItem.value?.sharedEntries || []) const filteredCustomItemModalGames = computed(() => { const query = customItemModalGameQuery.value.trim().toLowerCase() const linkedIds = new Set(visibleLinkedGames.value.map((game) => game.id)) @@ -810,11 +817,44 @@ async function refreshAdminTierLists() { adminTierListTotal.value = data.total || 0 adminTierListPage.value = data.page || 1 adminTierListLimit.value = data.limit || adminTierListLimit.value + await refreshAdminTierListStats() } catch (e) { error.value = '관리자 티어표 목록을 불러오지 못했어요.' } } +async function refreshAdminTierListStats() { + if (!auth.user?.isAdmin) return + try { + const data = await api.getAdminTierListStats({ q: adminTierListQuery.value }) + adminTierListStats.value = { + total: data.total || 0, + publicCount: data.publicCount || 0, + privateCount: data.privateCount || 0, + } + } catch (e) { + adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 } + } +} + +async function refreshSelectedGameTierListStats(gameId = '') { + if (!auth.user?.isAdmin || !gameId) { + selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 } + return + } + + try { + const data = await api.getAdminTierListStats({ gameId }) + selectedGameTierListStats.value = { + total: data.total || 0, + publicCount: data.publicCount || 0, + privateCount: data.privateCount || 0, + } + } catch (e) { + selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 } + } +} + async function refreshTemplateRequests() { if (!auth.user?.isAdmin) return try { @@ -1261,12 +1301,6 @@ function buildModalItemFromTierListItem(item, tierList) { sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템', ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList), linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [], - sharedReferenceCount: matchedItem?.sharedReferenceCount || 1, - sharedUserReferenceCount: matchedItem?.sharedUserReferenceCount || 0, - sharedTemplateReferenceCount: matchedItem?.sharedTemplateReferenceCount || 0, - sharedAssetReferenceCount: matchedItem?.sharedAssetReferenceCount || 0, - sharedLinkedGameCount: matchedItem?.sharedLinkedGameCount || 0, - sharedEntries: Array.isArray(matchedItem?.sharedEntries) ? matchedItem.sharedEntries : [], usageCount: matchedItem?.usageCount || 0, canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false, isPromoting: false, @@ -1599,6 +1633,7 @@ function userAvatarFallback(user) { :admin-tier-list-page="adminTierListPage" :admin-tier-list-page-count="adminTierListPageCount" :admin-tier-list-total="adminTierListTotal" + :admin-tier-list-stats="adminTierListStats" :move-admin-tier-list-page="moveAdminTierListPage" /> @@ -1796,10 +1831,8 @@ function userAvatarFallback(user) {
{{ modalTargetCustomItem.label }}
- {{ modalTargetCustomItem.sharedReferenceCount || 1 }}개 참조 - 사용자 {{ modalTargetCustomItem.sharedUserReferenceCount }} - 템플릿 {{ modalTargetCustomItem.sharedTemplateReferenceCount }} - 보관 {{ modalTargetCustomItem.sharedAssetReferenceCount }} + {{ modalTargetCustomItem.sourceLabel }} + {{ modalTargetCustomItem.ownerName }}
@@ -1863,25 +1896,6 @@ function userAvatarFallback(user) {
아직 템플릿에 연결된 게임이 없어요.
- -
- 같은 이미지 참조 -
-
총 참조{{ modalTargetCustomItem.sharedReferenceCount || 1 }}건
-
사용자 업로드{{ modalTargetCustomItem.sharedUserReferenceCount || 0 }}건
-
템플릿 항목{{ modalTargetCustomItem.sharedTemplateReferenceCount || 0 }}건
-
보관 자산{{ modalTargetCustomItem.sharedAssetReferenceCount || 0 }}건
-
-
-
- 같은 이미지 기록 -
-
-
{{ entry.label }}
- -
-
-
같은 이미지를 가리키는 다른 기록이 없어요.
이미지 다운로드 @@ -3195,15 +3209,6 @@ function userAvatarFallback(user) { line-height: 1.3; color: var(--theme-text); } -.adminUiScope .customItemCard__stats { - display: flex; - gap: 8px; - flex-wrap: wrap; -} -.adminUiScope .customItemCard__stat { - font-size: 11px; - color: var(--theme-text-soft); -} .adminUiScope .customItemModal { display: grid; grid-template-columns: 340px minmax(0, 1fr); @@ -3359,25 +3364,6 @@ function userAvatarFallback(user) { border: 1px solid var(--theme-border); background: var(--theme-surface-soft); } -.adminUiScope .customItemModal__entryList { - display: grid; - gap: 8px; -} -.adminUiScope .customItemModal__entry { - padding: 10px 12px; - border-radius: 14px; - border: 1px solid var(--theme-border); - background: var(--theme-surface-soft); -} -.adminUiScope .customItemModal__entryTitle { - font-size: 13px; - font-weight: 800; -} -.adminUiScope .customItemModal__entryMeta { - margin-top: 4px; - font-size: 11px; - color: var(--theme-text-soft); -} .adminUiScope .customItemModal__close { justify-self: end; border: 0; @@ -4010,6 +3996,12 @@ function userAvatarFallback(user) { gap: 8px; flex-wrap: wrap; } +.adminUiScope .tierAdminHeaderStats { + margin-top: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} .adminUiScope .pill { display: inline-flex; align-items: center;