릴리스: v1.3.59 관리자 템플릿 요청 중복 방지 및 신규 템플릿 연결 흐름 정리

This commit is contained in:
2026-04-02 12:46:59 +09:00
parent 4f300e7dbc
commit aa114a170e
11 changed files with 303 additions and 32 deletions

View File

@@ -5,6 +5,7 @@ const props = defineProps({
activeTemplateRequest: { type: Object, default: null },
templateRequestSourceUrl: { type: Function, required: true },
stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true },
openGameCreateModal: { type: Function, required: true },
isGameLoading: { type: Boolean, required: true },
hasSelectedGame: { type: Boolean, required: true },
@@ -29,6 +30,10 @@ const props = defineProps({
removeGameItem: { type: Function, required: true },
selectedGameId: { type: String, default: '' },
})
function setGameItemListElement(el) {
props.gameItemListRef(el)
}
</script>
<template>
@@ -38,12 +43,22 @@ const props = defineProps({
<div class="panel__title">진행 중인 요청 작업</div>
<div class="requestWorkspace__title">{{ props.activeTemplateRequest.sourceTierListTitle || '템플릿 요청' }}</div>
<div class="hint hint--tight">
{{ props.activeTemplateRequest.type === 'create' ? '새 게임을 만든 뒤 필요한 아이템만 골라 저장하세요.' : '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.' }}
{{
props.activeTemplateRequest.type === 'create'
? (props.activeTemplateRequest.targetGameId
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
: '새 게임을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
}}
</div>
</div>
<div class="requestWorkspace__stats">
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 게임 요청' : '기존 게임 업데이트' }}</span>
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}</span>
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}</span>
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId)" class="pill pill--soft">
연결된 게임 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
</span>
</div>
</div>
<div class="requestWorkspace__actions">
@@ -56,7 +71,12 @@ const props = defineProps({
>
요청 티어표 보기
</a>
<button v-if="props.activeTemplateRequest.type === 'create'" class="btn btn--ghost btn--small" type="button" @click="props.openGameCreateModal">
<button
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId"
class="btn btn--ghost btn--small"
type="button"
@click="props.openGameCreateModal"
>
게임 만들기
</button>
</div>
@@ -101,9 +121,6 @@ const props = defineProps({
<button class="btn btn--ghost btn--small" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
</div>
</div>
<button class="btn" :disabled="!props.canAddItem" @click="props.uploadItem">
아이템 {{ props.uploadItemDrafts.length || 0 }} 추가
</button>
</div>
<div class="itemPreviewCard">
<div v-if="props.uploadItemDrafts.length" class="itemDraftList">
@@ -128,9 +145,9 @@ const props = defineProps({
</div>
</div>
<div v-else class="itemPreviewEmpty">등록한 기본 아이템 미리보기가 여기에 표시됩니다.</div>
<div class="thumbLabel thumbLabel--preview">
{{ props.uploadItemDrafts.length ? `추가 예정 아이템 ${props.uploadItemDrafts.length}` : '아직 선택된 파일이 없어요.' }}
</div>
<button class="btn itemPreviewCard__submit" :disabled="!props.canAddItem" @click="props.uploadItem">
{{ props.uploadItemDrafts.length ? `아이템 ${props.uploadItemDrafts.length} 추가` : '아이템 추가' }}
</button>
</div>
</div>
</section>
@@ -145,7 +162,7 @@ const props = defineProps({
<button class="btn btn--primary btn--small" :disabled="!props.hasGameItemOrderChanges" @click="props.saveGameItemOrder">순서 저장</button>
</div>
<div v-if="!props.selectedGame?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="props.gameItemListRef" class="thumbGrid">
<div v-else :ref="setGameItemListElement" class="thumbGrid">
<div v-for="item in props.selectedGame.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />

View File

@@ -81,6 +81,9 @@ const props = defineProps({
<div class="tierAdminCard__stats">
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}</span>
<span v-if="request.type === 'create' && (request.targetGameName || request.targetGameId)" class="pill pill--soft">
연결됨 · {{ request.targetGameName || request.targetGameId }}
</span>
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
</div>
@@ -105,7 +108,13 @@ const props = defineProps({
</div>
<div class="templateRequestCard__actions">
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
{{ request.isHandling ? '이동중...' : '확인하기' }}
{{
request.isHandling
? '이동중...'
: request.type === 'create' && (request.targetGameName || request.targetGameId)
? '연결된 게임 열기'
: '확인하기'
}}
</button>
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
</div>

View File

@@ -30,6 +30,19 @@ export function useAdminGameManager({
success,
error,
}) {
function normalizeDraftSrc(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
if (!raw) return ''
if (raw.startsWith('/uploads/')) return raw
try {
const url = new URL(raw)
return url.pathname || raw
} catch (e) {
return raw
}
}
function requestItemFilename(item = {}) {
const src = typeof item.src === 'string' ? item.src : ''
return src.split('/').pop() || item.file?.name || 'item'
@@ -51,9 +64,10 @@ export function useAdminGameManager({
animation: 160,
draggable: '[data-game-item-id]',
forceFallback: true,
fallbackOnBody: true,
fallbackOnBody: false,
filter: '[data-no-drag]',
preventOnFilter: false,
fallbackClass: 'thumbCard--dragging',
ghostClass: 'ghost',
chosenClass: 'chosen',
onEnd: (evt) => {
@@ -72,6 +86,7 @@ export function useAdminGameManager({
function mergeRequestItemsIntoDrafts(request) {
const requestId = request?.id
if (!requestId) return
const existingGameSrcs = new Set((selectedGame.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
const nextRequestDrafts = (request.items || [])
.filter((item) => item?.src)
@@ -84,6 +99,7 @@ export function useAdminGameManager({
sourceName: requestItemFilename(item),
src: item.src,
}))
.filter((draft) => !existingGameSrcs.has(normalizeDraftSrc(draft.src)))
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
if (nextRequestDrafts.length) {
@@ -148,6 +164,24 @@ export function useAdminGameManager({
if (!res.ok) throw new Error('failed')
const data = await res.json()
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, {
gameId: data.game.id,
})
activeTemplateRequest.value = {
...activeTemplateRequest.value,
targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(),
}
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
if (requestIndex >= 0) {
templateRequests.value.splice(requestIndex, 1, {
...templateRequests.value[requestIndex],
targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(),
})
}
}
await refreshGames()
selectedGameId.value = data.game.id
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
@@ -238,15 +272,16 @@ export function useAdminGameManager({
const requestIds = [...new Set(requestDrafts.map((entry) => entry.requestId).filter(Boolean))]
for (const requestId of requestIds) {
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
await api.promoteAdminTemplateRequestItems(requestId, {
const result = await api.promoteAdminTemplateRequestItems(requestId, {
gameId: selectedGameId.value,
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
itemLabels: draftsForRequest.reduce((acc, entry) => {
if (entry.itemId) acc[entry.itemId] = entry.label.trim()
return acc
}, {}),
})
uploadCount += draftsForRequest.length
uploadCount += Array.isArray(result?.items) ? result.items.length : 0
}
}
@@ -254,7 +289,21 @@ export function useAdminGameManager({
await loadGame()
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
} catch (e) {
error.value = '아이템 추가 실패(관리자 권한/파일 크기 확인)'
const apiError = e?.data?.error || ''
if (apiError === 'no_items_selected') {
error.value = '추가할 요청 아이템이 없어요.'
return
}
if (apiError === 'promote_items_failed') {
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
error.value = `요청 아이템을 게임 기본 아이템으로 옮기지 못했어요.${detail}`
return
}
if (apiError === 'game_not_found') {
error.value = '선택한 게임을 찾지 못했어요.'
return
}
error.value = '아이템 추가에 실패했어요.'
}
}

View File

@@ -25,6 +25,7 @@ export function useAdminTemplateRequests({
sourceGameId: request.sourceGameId || '',
sourceTierListTitle: request.sourceTierListTitle || '',
targetGameId: request.targetGameId || '',
targetGameName: request.targetGameName || '',
requesterName: request.requesterName || '',
}
}
@@ -53,9 +54,14 @@ export function useAdminTemplateRequests({
setTab('game-admin')
if (request.type === 'create') {
openGameCreateModal()
newGameId.value = (request.draftGameId || '').trim()
newGameName.value = (request.draftGameName || '').trim()
const linkedGameId = request.targetGameId || ''
if (linkedGameId) {
await selectAdminGame(linkedGameId)
} else {
openGameCreateModal()
newGameId.value = (request.draftGameId || '').trim()
newGameName.value = (request.draftGameName || '').trim()
}
mergeRequestItemsIntoDrafts(request)
} else {
const nextGameId = request.targetGameId || request.sourceGameId || ''

View File

@@ -65,6 +65,8 @@ export const api = {
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
startAdminTemplateRequestReview: (requestId) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
linkAdminTemplateRequestGame: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-game`, { method: 'POST', body: payload }),
promoteAdminTemplateRequestItems: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
completeAdminTemplateRequest: (requestId) =>

View File

@@ -110,6 +110,7 @@ const featuredListEl = ref(null)
const featuredSortable = ref(null)
const gameItemListEl = ref(null)
const gameItemSortable = ref(null)
let gameItemSortableSyncTimer = null
const savedGameItemOrderIds = ref([])
const userAvatarInputs = ref({})
const isGameLoading = ref(false)
@@ -124,14 +125,49 @@ function setItemFileInputRef(el) {
itemFileInput.value = el
}
function scheduleGameItemSortableSync() {
if (gameItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer)
gameItemSortableSyncTimer = null
}
if (!gameItemListEl.value || !selectedGame.value?.items?.length) return
gameItemSortableSyncTimer = setTimeout(() => {
gameItemSortableSyncTimer = null
syncGameItemSortable()
}, 0)
}
function setGameItemListRef(el) {
gameItemListEl.value = el
if (!el) return
scheduleGameItemSortableSync()
}
function normalizeAdminSrc(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
if (!raw) return ''
if (raw.startsWith('/uploads/')) return raw
try {
const url = new URL(raw)
return url.pathname || raw
} catch (e) {
return raw
}
}
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedGameId.value)
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
const appliedRequestItemCount = computed(() => {
if (!activeTemplateRequest.value?.id || !selectedGame.value?.items?.length) return 0
const sourceRequest = templateRequests.value.find((request) => request.id === activeTemplateRequest.value.id)
if (!sourceRequest?.items?.length) return 0
const gameSrcs = new Set((selectedGame.value.items || []).map((item) => normalizeAdminSrc(item?.src)).filter(Boolean))
return sourceRequest.items.filter((item) => gameSrcs.has(normalizeAdminSrc(item?.src))).length
})
const hasGameItemOrderChanges = computed(() => {
const currentIds = (selectedGame.value?.items || []).map((item) => item.id)
return currentIds.join('|') !== savedGameItemOrderIds.value.join('|')
@@ -316,6 +352,10 @@ onUnmounted(() => {
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
clearPreviewUrl('item')
clearPreviewUrl('thumb')
if (gameItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer)
gameItemSortableSyncTimer = null
}
destroyFeaturedSortable()
destroyGameItemSortable()
})
@@ -438,6 +478,14 @@ watch(
}
)
watch(
() => [selectedGame.value?.game?.id || '', selectedGame.value?.items?.length || 0, !!gameItemListEl.value],
([gameId, itemCount, hasListEl]) => {
if (!gameId || !itemCount || !hasListEl) return
scheduleGameItemSortableSync()
}
)
watch(
() => isAnyModalOpen.value,
@@ -746,6 +794,7 @@ const {
const {
destroyGameItemSortable,
syncGameItemSortable,
mergeRequestItemsIntoDrafts,
removeUploadDraft,
loadGame,
@@ -1265,7 +1314,13 @@ function templateRequestTypeLabel(request) {
}
function templateRequestTargetLabel(request) {
return request.type === 'create' ? '새 게임 템플릿 생성' : request.targetGameName || request.targetGameId || request.sourceGameName
if (request.type === 'create') {
if (request.targetGameName || request.targetGameId) {
return `연결된 게임 · ${request.targetGameName || request.targetGameId}`
}
return '연결된 게임 없음'
}
return request.targetGameName || request.targetGameId || request.sourceGameName
}
const displayThumbnailUrl = computed(() => {
@@ -1334,6 +1389,7 @@ function userAvatarFallback(user) {
:active-template-request="activeTemplateRequest"
:template-request-source-url="templateRequestSourceUrl"
:staged-request-draft-count="stagedRequestDraftCount"
:applied-request-item-count="appliedRequestItemCount"
:open-game-create-modal="openGameCreateModal"
:is-game-loading="isGameLoading"
:has-selected-game="hasSelectedGame"
@@ -2521,6 +2577,10 @@ function userAvatarFallback(user) {
.adminUiScope .chosen {
outline: 2px solid rgba(96, 165, 250, 0.45);
}
.adminUiScope .thumbCard--dragging {
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.34);
opacity: 0.96;
}
.adminUiScope .btn:disabled {
cursor: not-allowed;
opacity: 0.45;
@@ -2673,6 +2733,10 @@ function userAvatarFallback(user) {
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-surface-soft);
}
.adminUiScope .itemPreviewCard__submit {
margin-top: 12px;
width: 100%;
}
.adminUiScope .itemPreviewGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2744,6 +2808,8 @@ function userAvatarFallback(user) {
min-width: 0;
cursor: grab;
user-select: none;
-webkit-user-drag: none;
touch-action: none;
}
.adminUiScope .thumbCard:active {
cursor: grabbing;
@@ -3868,6 +3934,9 @@ function userAvatarFallback(user) {
.adminUiScope .itemPreviewCard {
max-width: none;
}
.adminUiScope .itemDraftList {
grid-template-columns: 1fr;
}
.adminUiScope .userCard__identity {
width: 100%;
}