릴리스: v0.1.47 템플릿 요청과 관리자 승인 흐름 추가

This commit is contained in:
2026-03-27 11:10:45 +09:00
parent e0eeaa01cd
commit 3b314381a0
11 changed files with 725 additions and 15 deletions

View File

@@ -33,6 +33,7 @@ const adminTierListQuery = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
const templateRequests = ref([])
const importModalOpen = ref(false)
const importModalMode = ref('existing')
const importModalTierList = ref(null)
@@ -74,7 +75,7 @@ const importModalItemCount = computed(() => importModalItems.value.length)
onMounted(async () => {
await auth.refresh()
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers()])
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests()])
await syncFeaturedSortable()
})
@@ -189,6 +190,30 @@ async function refreshAdminTierLists() {
}
}
async function refreshTemplateRequests() {
if (!auth.user?.isAdmin) return
try {
const data = await api.listAdminTemplateRequests()
templateRequests.value = (data.requests || []).map((request) => ({
...request,
draftGameId:
request.type === 'create'
? (request.sourceTierListTitle || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'new-template'
: request.targetGameId || request.sourceGameId || '',
draftGameName:
request.type === 'create'
? `${request.sourceTierListTitle || request.sourceGameName || '새 템플릿'}`
: request.targetGameName || request.sourceGameName || '',
}))
} catch (e) {
error.value = '템플릿 요청 목록을 불러오지 못했어요.'
}
}
async function refreshUsers() {
if (!auth.user?.isAdmin) return
try {
@@ -690,6 +715,59 @@ async function confirmTierListImport() {
}
}
function templateRequestTypeLabel(request) {
return request.type === 'create' ? '템플릿 등록 요청' : '템플릿 업데이트 요청'
}
function templateRequestTargetLabel(request) {
return request.type === 'create' ? '새 게임 템플릿 생성' : request.targetGameName || request.targetGameId || request.sourceGameName
}
async function approveTemplateRequest(request) {
resetMessages()
try {
request.isHandling = true
if (request.type === 'create') {
const nextGameId = (request.draftGameId || '').trim()
const nextGameName = (request.draftGameName || '').trim()
if (!nextGameId || !nextGameName) {
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
return
}
await api.approveAdminTemplateRequest(request.id, {
gameId: nextGameId,
name: nextGameName,
})
await refreshGames()
success.value = `"${nextGameName}" 템플릿 생성을 승인했어요.`
} else {
const data = await api.approveAdminTemplateRequest(request.id)
if (selectedGameId.value === (request.targetGameId || request.sourceGameId)) await loadGame()
success.value = `${data.items?.length || 0}개의 아이템 추가 요청을 승인했어요.`
}
await refreshTemplateRequests()
await refreshAdminTierLists()
} catch (e) {
error.value = request.type === 'create' ? '템플릿 등록 요청 승인에 실패했어요.' : '템플릿 업데이트 요청 승인에 실패했어요.'
} finally {
request.isHandling = false
}
}
async function rejectTemplateRequest(request) {
resetMessages()
try {
request.isHandling = true
await api.rejectAdminTemplateRequest(request.id)
await refreshTemplateRequests()
success.value = '요청을 반려했어요.'
} catch (e) {
error.value = '요청 반려에 실패했어요.'
} finally {
request.isHandling = false
}
}
const displayThumbnailUrl = computed(() => {
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
@@ -1006,6 +1084,53 @@ async function saveFeaturedOrder() {
</template>
<template v-else-if="activeTab === 'tierlists'">
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">사용자 템플릿 요청</div>
<div class="hint hint--tight">freeform 템플릿 등록 요청과 기존 게임 템플릿 업데이트 요청을 여기서 승인하거나 반려할 있어요.</div>
</div>
<button class="btn btn--ghost" @click="refreshTemplateRequests">새로고침</button>
</div>
<div v-if="!templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div>
<div v-else class="templateRequestList">
<article v-for="request in templateRequests" :key="request.id" class="templateRequestCard">
<div class="templateRequestCard__head">
<div>
<div class="templateRequestCard__title">{{ request.sourceTierListTitle }}</div>
<div class="templateRequestCard__meta">
{{ templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ fmt(request.createdAt) }}
</div>
<div class="templateRequestCard__meta">{{ templateRequestTargetLabel(request) }}</div>
</div>
<button class="btn btn--ghost btn--small" @click="openAdminTierList({ id: request.sourceTierListId, gameId: request.sourceGameId })">
원본 보기
</button>
</div>
<div v-if="request.items?.length" class="templateRequestItems">
<div v-for="item in request.items" :key="item.id" class="templateRequestItem">
<img class="templateRequestItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="templateRequestItem__label">{{ item.label }}</div>
</div>
</div>
<div v-if="request.type === 'create'" class="templateRequestCard__form">
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
</div>
<div class="templateRequestCard__actions">
<button class="btn btn--primary" :disabled="request.isHandling" @click="approveTemplateRequest(request)">
{{ request.isHandling ? '처리중...' : '승인' }}
</button>
<button class="btn btn--danger" :disabled="request.isHandling" @click="rejectTemplateRequest(request)">반려</button>
</div>
</article>
</div>
</div>
<div class="panel">
<div class="sectionHeader">
<div>
@@ -1785,6 +1910,70 @@ async function saveFeaturedOrder() {
.roleBadge--admin {
background: rgba(96, 165, 250, 0.18);
}
.templateRequestList {
margin-top: 14px;
display: grid;
gap: 14px;
}
.templateRequestCard {
display: grid;
gap: 14px;
padding: 16px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
}
.templateRequestCard__head {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
}
.templateRequestCard__title {
font-weight: 900;
font-size: 18px;
}
.templateRequestCard__meta {
margin-top: 4px;
font-size: 13px;
opacity: 0.72;
}
.templateRequestItems {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
}
.templateRequestItem {
display: grid;
gap: 8px;
}
.templateRequestItem__thumb {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.templateRequestItem__label {
font-size: 12px;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.templateRequestCard__form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.templateRequestCard__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
}
.tierAdminList {
margin-top: 14px;
display: grid;
@@ -1960,7 +2149,8 @@ async function saveFeaturedOrder() {
.toolbar,
.itemComposer,
.tierAdminCard,
.userStats {
.userStats,
.templateRequestCard__form {
grid-template-columns: 1fr;
}
.toolbar--secondary {