릴리스: v1.3.59 관리자 템플릿 요청 중복 방지 및 신규 템플릿 연결 흐름 정리
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = '아이템 추가에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user