From a7cfb97131ac7bfba26c87a0f7717e4984421500 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Apr 2026 15:28:52 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.3.70=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?=ED=95=84=ED=84=B0=EC=99=80=20=EA=B4=80=EB=A6=AC=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 47 ++++-- backend/src/routes/admin.js | 32 ++++ docs/history.md | 4 + docs/todo.md | 1 + docs/update.md | 5 + .../admin/AdminTierlistsSection.vue | 8 +- frontend/src/lib/api.js | 10 +- frontend/src/views/AdminView.vue | 156 +++++++++++++++++- 8 files changed, 246 insertions(+), 17 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index c6fe31f..03ee6da 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1951,22 +1951,32 @@ function getAutoThumbnailSrc(groups = [], pool = []) { return fallbackItem?.src || '' } -async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) { +async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit = 50, currentUserId = '' } = {}) { const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedPage = Math.max(Number(page) || 1, 1) const hasQuery = !!(queryText || '').trim() + const hasGameId = !!(gameId || '').trim() const search = `%${(queryText || '').trim()}%` - const whereClause = hasQuery - ? ` - WHERE - t.title LIKE ? - OR g.name LIKE ? - OR g.id LIKE ? - OR u.email LIKE ? - OR u.nickname LIKE ? - ` - : '' - const params = hasQuery ? [search, search, search, search, search] : [] + 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( ` @@ -2288,6 +2298,18 @@ async function deleteTierList(id) { await query('DELETE FROM tierlists WHERE id = ?', [id]) } +async function updateAdminTierListMeta({ id, title, description = '', isPublic }) { + await query( + ` + UPDATE tierlists + SET title = ?, description = ?, is_public = ?, updated_at = ? + WHERE id = ? + `, + [title, description || '', isPublic ? 1 : 0, now(), id] + ) + return findTierListById(id) +} + async function findCustomItemsByIds(ids) { if (!ids.length) return [] const placeholders = ids.map(() => '?').join(', ') @@ -2454,6 +2476,7 @@ module.exports = { listAdminTierLists, summarizeAdminTierLists, findTierListById, + updateAdminTierListMeta, favoriteTierList, unfavoriteTierList, favoriteGame, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index df3d295..49dc018 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -22,6 +22,7 @@ const { updateImageAssetLabel, deleteGameItem, deleteGame, + deleteTierList, updateGameDisplayOrder, listCustomItems, findCustomItemById, @@ -33,6 +34,7 @@ const { listAdminTierLists, summarizeAdminTierLists, findTierListById, + updateAdminTierListMeta, listAdminTemplateRequests, findTemplateRequestById, updateTemplateRequestStatus, @@ -296,6 +298,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => { router.get('/tierlists', requireAdmin, async (req, res) => { const schema = z.object({ q: z.string().trim().max(120).optional().default(''), + gameId: z.string().trim().max(120).optional().default(''), page: z.coerce.number().int().min(1).optional().default(1), limit: z.coerce.number().int().min(1).max(200).optional().default(50), }) @@ -304,6 +307,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => { const result = await listAdminTierLists({ queryText: parsed.data.q, + gameId: parsed.data.gameId, page: parsed.data.page, limit: parsed.data.limit, currentUserId: req.session?.userId || '', @@ -693,6 +697,34 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async ( res.json(result) }) +router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => { + const schema = z.object({ + title: z.string().trim().min(1).max(120), + description: z.string().max(500).optional().default(''), + isPublic: z.boolean(), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const tierList = await findTierListById(req.params.tierListId) + if (!tierList) return res.status(404).json({ error: 'not_found' }) + + const updated = await updateAdminTierListMeta({ + id: tierList.id, + title: parsed.data.title, + description: parsed.data.description || '', + isPublic: parsed.data.isPublic, + }) + res.json({ tierList: updated }) +}) + +router.delete('/tierlists/:tierListId', requireAdmin, async (req, res) => { + const tierList = await findTierListById(req.params.tierListId) + if (!tierList) return res.status(404).json({ error: 'not_found' }) + await deleteTierList(tierList.id) + res.json({ ok: true }) +}) + router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => { const templateRequest = await findTemplateRequestById(req.params.requestId) if (!templateRequest) return res.status(404).json({ error: 'not_found' }) diff --git a/docs/history.md b/docs/history.md index 7e06e17..3f8a23b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.3.70 +- 관리자 티어표 목록은 페이지 단위 열람만으로는 운영 개입이 부족하므로, 게임별 필터와 카드 단위 관리 액션을 함께 붙여 실제 검수 도구로 쓰는 편이 맞다고 정리했다. +- 공개 여부는 문장 속 메타보다 배지로 따로 떼어 보여주는 편이 다른 관리자 카드들과 문법이 맞고, 공개/비공개 전환도 같은 관리 모달 안에서 바로 처리하는 쪽이 운영 흐름상 자연스럽다고 판단했다. + ## 2026-04-02 v1.3.69 - 관리자 아이템 라이브러리의 참조 수는 저장 구조 설명에는 도움이 되지만 실제 운영에서는 대부분 의미가 약하므로, 카드와 모달에서 계속 전면에 두기보다 다시 숨기고 필요한 경우 내부 데이터로만 남기는 편이 맞다고 정리했다. - 관리자 상단 요약 수치는 `활성/대기` 같은 상태 문구보다 실제 운영 판단에 바로 쓰이는 `공개/비공개 개수`가 더 중요하므로, 게임 관리와 티어표 관리 모두 공개 상태 집계를 먼저 보여주는 편이 낫다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index 8714f92..28c304e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,7 @@ # 할 일 및 이슈 ## 단기 확인 +- 관리자 `전체 티어표 관리`의 게임 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다. - 관리자 게임 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다. - 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다. - 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다. diff --git a/docs/update.md b/docs/update.md index b773386..e1ec78d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.3.70 +- 관리자 `전체 티어표 관리`는 이제 게임별 필터를 지원해, 특정 게임에서 만들어진 티어표만 따로 골라 보며 공개/비공개 분포를 확인할 수 있게 함. +- 전체 티어표 카드는 공개 여부를 텍스트 대신 다른 관리자 화면과 같은 배지 형식으로 표시하고, 카드의 `관리` 액션에서 제목·설명 수정, 공개/비공개 전환, 삭제를 바로 처리할 수 있게 보강함. +- 이를 위해 관리자 전용 티어표 수정/삭제 API와 게임 기준/검색 기준 공개 집계 로직을 함께 추가해, 관리자 화면에서 비공개 개입과 운영성 검수가 한 흐름으로 이어지게 정리함. + ## 2026-04-02 v1.3.69 - 관리자 아이템 라이브러리는 참조 수/공유 기록 UI가 실제 운영 판단에 비해 노이즈가 커 보여 카드 수치, 상세 모달의 같은 이미지 참조 섹션, 삭제 확인 문구의 참조 강조를 걷어내고 다시 항목 자체 관리 흐름 위주로 정리함. - 관리자 게임 관리 상단 요약은 더 이상 `선택 상태`처럼 추상적인 문구를 보여주지 않고, 선택된 게임 기준으로 만들어진 티어표의 `전체 / 공개 / 비공개` 개수를 바로 보여주도록 바꿈. diff --git a/frontend/src/components/admin/AdminTierlistsSection.vue b/frontend/src/components/admin/AdminTierlistsSection.vue index 02831ce..f618c5e 100644 --- a/frontend/src/components/admin/AdminTierlistsSection.vue +++ b/frontend/src/components/admin/AdminTierlistsSection.vue @@ -22,6 +22,7 @@ const props = defineProps({ adminTierListPageCount: { type: Number, required: true }, adminTierListTotal: { type: Number, required: true }, adminTierListStats: { type: Object, required: true }, + openAdminTierListManageModal: { type: Function, required: true }, moveAdminTierListPage: { type: Function, required: true }, }) @@ -151,13 +152,14 @@ const props = defineProps({
{{ tierList.title }}
{{ tierList.description }}
- {{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }} · {{ props.tierListVisibilityLabel(tierList) }} + {{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }}
{{ props.fmt(tierList.updatedAt) }}
+ {{ props.tierListVisibilityLabel(tierList) }} 전체 아이템 {{ tierList.itemCount }}개 추가 아이템 {{ tierList.extraItemCount }}개
@@ -177,6 +179,10 @@ const props = defineProps({ + +
+ +
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 13c09e7..e83caca 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -45,10 +45,16 @@ export const api = { request( `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}` ), - listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) => - request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), + listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) => + request( + `/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}` + ), getAdminTierListStats: ({ q = '', gameId = '' } = {}) => request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`), + updateAdminTierList: (tierListId, payload) => + request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }), + deleteAdminTierList: (tierListId) => + request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }), 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 3e0e3f0..f8e50bd 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -49,6 +49,7 @@ const customItemModalGameSort = ref('recent') const adminTierLists = ref([]) const adminTierListQuery = ref('') +const adminTierListGameId = ref('') const adminTierListPage = ref(1) const adminTierListLimit = ref(50) const adminTierListTotal = ref(0) @@ -64,6 +65,7 @@ const importModalNewGameId = ref('') const importModalNewGameName = ref('') const previewModalOpen = ref(false) const previewTierList = ref(null) +const adminTierListManageModalOpen = ref(false) const activeTemplateRequest = ref(null) const userEditModalOpen = ref(false) const userPasswordModalOpen = ref(false) @@ -81,6 +83,12 @@ const modalUserDraftIsAdmin = ref(false) const modalTargetCustomItem = ref(null) const customItemModalDraftLabel = ref('') const customItemModalLabelSaving = ref(false) +const modalTargetAdminTierList = ref(null) +const adminTierListDraftTitle = ref('') +const adminTierListDraftDescription = ref('') +const adminTierListDraftIsPublic = ref(false) +const adminTierListSaving = ref(false) +const adminTierListDeleting = ref(false) const users = ref([]) const userQuery = ref('') @@ -288,6 +296,7 @@ const isAnyModalOpen = computed( importModalOpen.value || customItemModalOpen.value || customItemDeleteModalOpen.value || + adminTierListManageModalOpen.value || imageResetModalOpen.value || previewModalOpen.value ) @@ -422,6 +431,8 @@ watch( if (name === 'adminTierlists') { const nextMode = route.query.mode === 'all' ? 'all' : 'requests' if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode + const nextTierListGameId = typeof route.query.gameId === 'string' ? route.query.gameId : '' + if (adminTierListGameId.value !== nextTierListGameId) adminTierListGameId.value = nextTierListGameId } }, { immediate: true } @@ -447,7 +458,18 @@ watch( () => tierlistsMode.value, (mode) => { if (route.name !== 'adminTierlists') return - syncAdminRouteQuery({ mode: mode === 'all' ? 'all' : undefined }) + syncAdminRouteQuery({ + mode: mode === 'all' ? 'all' : undefined, + gameId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined, + }) + } +) + +watch( + () => adminTierListGameId.value, + (gameId) => { + if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return + syncAdminRouteQuery({ gameId: gameId || undefined }) } ) @@ -810,6 +832,7 @@ async function refreshAdminTierLists() { try { const data = await api.listAdminTierLists({ q: adminTierListQuery.value, + gameId: adminTierListGameId.value, page: adminTierListPage.value, limit: adminTierListLimit.value, }) @@ -826,7 +849,7 @@ async function refreshAdminTierLists() { async function refreshAdminTierListStats() { if (!auth.user?.isAdmin) return try { - const data = await api.getAdminTierListStats({ q: adminTierListQuery.value }) + const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, gameId: adminTierListGameId.value }) adminTierListStats.value = { total: data.total || 0, publicCount: data.publicCount || 0, @@ -1274,6 +1297,12 @@ function submitAdminTierListSearch() { refreshAdminTierLists() } +function setAdminTierListGameId(gameId) { + adminTierListGameId.value = gameId || '' + adminTierListPage.value = 1 + refreshAdminTierLists() +} + function changeAdminTierListLimit(limit) { adminTierListLimit.value = limit adminTierListPage.value = 1 @@ -1325,6 +1354,81 @@ function tierListVisibilityLabel(tierList) { return tierList.isPublic ? '공개' : '비공개' } +function openAdminTierListManageModal(tierList) { + if (!tierList) return + modalTargetAdminTierList.value = tierList + adminTierListDraftTitle.value = tierList.title || '' + adminTierListDraftDescription.value = tierList.description || '' + adminTierListDraftIsPublic.value = !!tierList.isPublic + adminTierListManageModalOpen.value = true +} + +function closeAdminTierListManageModal() { + adminTierListManageModalOpen.value = false + modalTargetAdminTierList.value = null + adminTierListDraftTitle.value = '' + adminTierListDraftDescription.value = '' + adminTierListDraftIsPublic.value = false + adminTierListSaving.value = false + adminTierListDeleting.value = false +} + +async function saveAdminTierListMeta() { + if (!modalTargetAdminTierList.value?.id || adminTierListSaving.value) return + const nextTitle = adminTierListDraftTitle.value.trim() + if (!nextTitle) { + error.value = '티어표 제목을 입력해주세요.' + return + } + + resetMessages() + adminTierListSaving.value = true + try { + const data = await api.updateAdminTierList(modalTargetAdminTierList.value.id, { + title: nextTitle, + description: adminTierListDraftDescription.value.trim(), + isPublic: !!adminTierListDraftIsPublic.value, + }) + const updated = data.tierList + adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList)) + if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated } + modalTargetAdminTierList.value = updated + await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')]) + success.value = '티어표 정보를 수정했어요.' + closeAdminTierListManageModal() + } catch (e) { + error.value = '티어표 정보 수정에 실패했어요.' + } finally { + adminTierListSaving.value = false + } +} + +async function deleteAdminTierListEntry() { + if (!modalTargetAdminTierList.value?.id || adminTierListDeleting.value) return + const ok = window.confirm(`"${modalTargetAdminTierList.value.title}" 티어표를 삭제할까요? 이 작업은 되돌릴 수 없어요.`) + if (!ok) return + + resetMessages() + adminTierListDeleting.value = true + try { + await api.deleteAdminTierList(modalTargetAdminTierList.value.id) + adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id) + adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1) + if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null + await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')]) + success.value = '티어표를 삭제했어요.' + closeAdminTierListManageModal() + if (!adminTierLists.value.length && adminTierListPage.value > 1) { + adminTierListPage.value -= 1 + await refreshAdminTierLists() + } + } catch (e) { + error.value = '티어표 삭제에 실패했어요.' + } finally { + adminTierListDeleting.value = false + } +} + function openAdminTierList(tierList) { previewTierList.value = tierList previewModalOpen.value = true @@ -1634,6 +1738,7 @@ function userAvatarFallback(user) { :admin-tier-list-page-count="adminTierListPageCount" :admin-tier-list-total="adminTierListTotal" :admin-tier-list-stats="adminTierListStats" + :open-admin-tier-list-manage-modal="openAdminTierListManageModal" :move-admin-tier-list-page="moveAdminTierListPage" /> @@ -1921,6 +2026,39 @@ function userAvatarFallback(user) { +
+ +
+