릴리스: v0.1.42 관리자 티어표 관리 추가
This commit is contained in:
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user