릴리스: v0.1.42 관리자 티어표 관리 추가

This commit is contained in:
2026-03-26 19:02:46 +09:00
parent b9aa714501
commit 3bd9751621
10 changed files with 625 additions and 8 deletions

View File

@@ -39,8 +39,14 @@ export const api = {
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
),
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
promoteAdminCustomItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
promoteAdminTierListItems: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
listAdminUsers: () => request('/api/admin/users'),
updateAdminUser: (userId, payload) =>
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),

View File

@@ -1,10 +1,12 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
const isAdmin = computed(() => !!auth.user?.isAdmin)
@@ -24,6 +26,13 @@ const customItemTotal = ref(0)
const customItemOrphanOnly = ref(false)
const customItemTargetGameId = ref('')
const adminTierLists = ref([])
const adminTierListQuery = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
const adminTierListTargetGameId = ref('')
const users = ref([])
const error = ref('')
@@ -46,6 +55,7 @@ const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
const canAddItem = computed(() => uploadFiles.value.length > 0 && !!selectedGameId.value)
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
const featuredGames = computed(() =>
featuredGameIds.value
.map((gameId) => games.value.find((game) => game.id === gameId))
@@ -55,7 +65,7 @@ const availableGamesForFeatured = computed(() => games.value.filter((game) => !f
onMounted(async () => {
await auth.refresh()
await Promise.all([refreshGames(), refreshCustomItems(), refreshUsers()])
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers()])
await syncFeaturedSortable()
})
@@ -76,6 +86,9 @@ function setTab(tab) {
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
customItemTargetGameId.value = games.value[0].id
}
if (tab === 'tierlists' && !adminTierListTargetGameId.value && games.value.length) {
adminTierListTargetGameId.value = games.value[0].id
}
}
async function refreshGames() {
@@ -85,6 +98,9 @@ async function refreshGames() {
if (!customItemTargetGameId.value && games.value.length) {
customItemTargetGameId.value = games.value[0].id
}
if (!adminTierListTargetGameId.value && games.value.length) {
adminTierListTargetGameId.value = games.value[0].id
}
featuredGameIds.value = games.value
.filter((game) => game.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank)
@@ -141,6 +157,30 @@ async function refreshCustomItems() {
}
}
async function refreshAdminTierLists() {
if (!auth.user?.isAdmin) return
try {
const data = await api.listAdminTierLists({
q: adminTierListQuery.value,
page: adminTierListPage.value,
limit: adminTierListLimit.value,
})
adminTierLists.value = (data.tierLists || []).map((tierList) => ({
...tierList,
templateGameId: tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy`,
templateGameName:
tierList.gameId === 'freeform'
? `${tierList.title} 템플릿`
: `${tierList.gameName || tierList.gameId} 확장 템플릿`,
}))
adminTierListTotal.value = data.total || 0
adminTierListPage.value = data.page || 1
adminTierListLimit.value = data.limit || adminTierListLimit.value
} catch (e) {
error.value = '관리자 티어표 목록을 불러오지 못했어요.'
}
}
async function refreshUsers() {
if (!auth.user?.isAdmin) return
try {
@@ -478,6 +518,24 @@ function changeCustomItemLimit(limit) {
refreshCustomItems()
}
function submitAdminTierListSearch() {
adminTierListPage.value = 1
refreshAdminTierLists()
}
function changeAdminTierListLimit(limit) {
adminTierListLimit.value = limit
adminTierListPage.value = 1
refreshAdminTierLists()
}
function moveAdminTierListPage(direction) {
const nextPage = adminTierListPage.value + direction
if (nextPage < 1 || nextPage > adminTierListPageCount.value) return
adminTierListPage.value = nextPage
refreshAdminTierLists()
}
function moveCustomItemPage(direction) {
const nextPage = customItemPage.value + direction
if (nextPage < 1 || nextPage > customItemPageCount.value) return
@@ -538,6 +596,95 @@ async function promoteCustomItem(item) {
}
}
function tierListThumbUrl(tierList) {
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
function tierListAuthorDisplayName(tierList) {
return tierList.authorName || '알 수 없음'
}
function tierListVisibilityLabel(tierList) {
return tierList.isPublic ? '공개' : '비공개'
}
function openAdminTierList(tierList) {
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
}
async function promoteTierListExtraItem(tierList, item) {
resetMessages()
if (!adminTierListTargetGameId.value) {
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
return
}
try {
item.isPromoting = true
const data = await api.promoteAdminTierListItems(tierList.id, {
gameId: adminTierListTargetGameId.value,
itemIds: [item.id],
})
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
success.value = `"${item.label}" 아이템을 기본 템플릿으로 추가했어요. (${data.items?.length || 0}개 반영)`
} catch (e) {
error.value = '티어표 추가 아이템을 기본 템플릿으로 가져오지 못했어요.'
} finally {
item.isPromoting = false
}
}
async function promoteAllTierListExtraItems(tierList) {
resetMessages()
if (!adminTierListTargetGameId.value) {
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
return
}
if (!tierList.extraItems?.length) {
error.value = '가져올 추가 아이템이 없어요.'
return
}
try {
tierList.isPromotingAll = true
const data = await api.promoteAdminTierListItems(tierList.id, {
gameId: adminTierListTargetGameId.value,
itemIds: tierList.extraItems.map((item) => item.id),
})
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
success.value = `${data.items?.length || 0}개의 추가 아이템을 기본 템플릿으로 가져왔어요.`
} catch (e) {
error.value = '추가 아이템 일괄 가져오기에 실패했어요.'
} finally {
tierList.isPromotingAll = false
}
}
async function createTemplateFromTierList(tierList) {
resetMessages()
const nextGameId = (tierList.templateGameId || '').trim()
const nextName = (tierList.templateGameName || '').trim()
if (!nextGameId || !nextName) {
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
return
}
try {
tierList.isCreatingTemplate = true
const data = await api.createAdminGameTemplateFromTierList(tierList.id, {
gameId: nextGameId,
name: nextName,
})
await refreshGames()
success.value = `"${data.game?.name || nextName}" 게임 템플릿을 생성했어요.`
} catch (e) {
error.value = '커스텀 티어표를 새 게임 템플릿으로 만들지 못했어요.'
} finally {
tierList.isCreatingTemplate = false
}
}
const displayThumbnailUrl = computed(() => {
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
@@ -610,6 +757,7 @@ async function saveFeaturedOrder() {
<div class="tabs">
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
</div>
@@ -843,6 +991,111 @@ async function saveFeaturedOrder() {
</div>
</template>
<template v-else-if="activeTab === 'tierlists'">
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">전체 티어표 관리</div>
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 게임 템플릿으로 만들 있어요.</div>
</div>
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
</div>
<div class="toolbar">
<input
v-model="adminTierListQuery"
class="input toolbar__search"
placeholder="제목, 작성자, 게임 이름 검색"
@keydown.enter.prevent="submitAdminTierListSearch"
/>
<button class="btn btn--ghost toolbar__button" @click="submitAdminTierListSearch">검색</button>
<select :value="adminTierListLimit" class="select toolbar__select" @change="changeAdminTierListLimit(Number($event.target.value))">
<option :value="50">50개씩 보기</option>
<option :value="200">200개씩 보기</option>
</select>
</div>
<div class="toolbar toolbar--secondary">
<select v-model="adminTierListTargetGameId" class="select toolbar__select">
<option value="">추가 아이템을 넣을 게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
</div>
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
<div v-else class="tierAdminList">
<article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard">
<div class="tierAdminCard__preview" @click="openAdminTierList(tierList)">
<img v-if="tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="tierListThumbUrl(tierList)" :alt="tierList.title" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</div>
<div class="tierAdminCard__body">
<div class="tierAdminCard__head">
<div>
<div class="tierAdminCard__title">{{ tierList.title }}</div>
<div class="tierAdminCard__meta">
{{ tierList.gameName || tierList.gameId }} · {{ tierListAuthorDisplayName(tierList) }} · {{ tierListVisibilityLabel(tierList) }}
</div>
<div class="tierAdminCard__meta">{{ fmt(tierList.updatedAt) }}</div>
</div>
<button class="btn btn--ghost btn--small" @click="openAdminTierList(tierList)">완성본 보기</button>
</div>
<div class="tierAdminCard__stats">
<span class="pill">전체 아이템 {{ tierList.itemCount }}</span>
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}</span>
</div>
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
<div class="tierAdminItemList">
<article v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem">
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="tierAdminItem__body">
<div class="tierAdminItem__title">{{ item.label }}</div>
<div class="tierAdminItem__meta">{{ item.origin === 'custom' ? '사용자 추가 아이템' : '기본 아이템' }}</div>
</div>
<button
class="btn btn--ghost btn--small"
:disabled="!adminTierListTargetGameId || item.isPromoting"
@click="promoteTierListExtraItem(tierList, item)"
>
{{ item.isPromoting ? '추가중...' : '이 아이템 추가' }}
</button>
</article>
</div>
<button
class="btn btn--ghost btn--small"
:disabled="!adminTierListTargetGameId || tierList.isPromotingAll"
@click="promoteAllTierListExtraItems(tierList)"
>
{{ tierList.isPromotingAll ? '가져오는 중...' : '추가 아이템 전체 가져오기' }}
</button>
</div>
<div v-if="tierList.gameId === 'freeform'" class="tierAdminSection">
<div class="tierAdminSection__title">커스텀 티어표를 게임 템플릿으로 만들기</div>
<div class="tierAdminTemplateForm">
<input v-model="tierList.templateGameId" class="input" placeholder="새 게임 ID" />
<input v-model="tierList.templateGameName" class="input" placeholder="새 게임 이름" />
<button class="btn btn--primary" :disabled="tierList.isCreatingTemplate" @click="createTemplateFromTierList(tierList)">
{{ tierList.isCreatingTemplate ? '생성중...' : ' 게임 템플릿 만들기' }}
</button>
</div>
</div>
</div>
</article>
</div>
<div class="pager">
<button class="btn btn--ghost" :disabled="adminTierListPage <= 1" @click="moveAdminTierListPage(-1)">이전</button>
<div class="pager__info">{{ adminTierListPage }} / {{ adminTierListPageCount }} 페이지 · {{ adminTierListTotal }}</div>
<button class="btn btn--ghost" :disabled="adminTierListPage >= adminTierListPageCount" @click="moveAdminTierListPage(1)">다음</button>
</div>
</div>
</template>
<template v-else>
<div class="panel">
<div class="sectionHeader">
@@ -1445,6 +1698,123 @@ async function saveFeaturedOrder() {
.roleBadge--admin {
background: rgba(96, 165, 250, 0.18);
}
.tierAdminList {
margin-top: 14px;
display: grid;
gap: 14px;
}
.tierAdminCard {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
padding: 14px;
}
.tierAdminCard__preview {
cursor: pointer;
}
.tierAdminCard__thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
}
.tierAdminCard__thumb--empty {
background: linear-gradient(135deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03));
}
.tierAdminCard__body {
min-width: 0;
display: grid;
gap: 14px;
}
.tierAdminCard__head {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: flex-start;
}
.tierAdminCard__title {
font-size: 18px;
font-weight: 900;
}
.tierAdminCard__meta {
margin-top: 4px;
opacity: 0.74;
font-size: 13px;
word-break: break-word;
}
.tierAdminCard__stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
font-size: 12px;
font-weight: 800;
}
.pill--accent {
border-color: rgba(251, 191, 36, 0.32);
background: rgba(251, 191, 36, 0.12);
color: rgba(253, 230, 138, 0.96);
}
.tierAdminSection {
display: grid;
gap: 10px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.14);
}
.tierAdminSection__title {
font-weight: 800;
}
.tierAdminItemList {
display: grid;
gap: 10px;
}
.tierAdminItem {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.tierAdminItem__thumb {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 12px;
}
.tierAdminItem__body {
min-width: 0;
}
.tierAdminItem__title {
font-weight: 800;
word-break: break-word;
}
.tierAdminItem__meta {
margin-top: 4px;
opacity: 0.7;
font-size: 12px;
}
.tierAdminTemplateForm {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 10px;
}
.checkRow {
margin-top: 12px;
display: inline-flex;
@@ -1459,7 +1829,9 @@ async function saveFeaturedOrder() {
.featuredOrderPanel,
.section--topGrid,
.toolbar,
.itemComposer {
.itemComposer,
.tierAdminCard,
.tierAdminTemplateForm {
grid-template-columns: 1fr;
}
.toolbar--secondary {
@@ -1475,6 +1847,10 @@ async function saveFeaturedOrder() {
.userList {
grid-template-columns: 1fr;
}
.tierAdminCard__head,
.tierAdminItem {
grid-template-columns: 1fr;
}
.customItemCard {
align-items: stretch;
}