Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa114a170e | |||
| 4f300e7dbc | |||
| 717e3b97f0 |
@@ -2004,6 +2004,11 @@ async function updateTemplateRequestStatus({ id, status }) {
|
||||
return findTemplateRequestById(id)
|
||||
}
|
||||
|
||||
async function updateTemplateRequestTargetGame({ id, targetGameId }) {
|
||||
await query('UPDATE template_requests SET target_game_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id])
|
||||
return findTemplateRequestById(id)
|
||||
}
|
||||
|
||||
async function deleteTierList(id) {
|
||||
await query('DELETE FROM tierlists WHERE id = ?', [id])
|
||||
}
|
||||
@@ -2184,4 +2189,5 @@ module.exports = {
|
||||
findTemplateRequestById,
|
||||
listAdminTemplateRequests,
|
||||
updateTemplateRequestStatus,
|
||||
updateTemplateRequestTargetGame,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
findUserById,
|
||||
findGameById,
|
||||
findGameItemById,
|
||||
listGameItems,
|
||||
findImageAssetById,
|
||||
createGame,
|
||||
listGames,
|
||||
@@ -33,6 +34,7 @@ const {
|
||||
listAdminTemplateRequests,
|
||||
findTemplateRequestById,
|
||||
updateTemplateRequestStatus,
|
||||
updateTemplateRequestTargetGame,
|
||||
adminUpdateUser,
|
||||
adminUpdateUserPassword,
|
||||
adminDeleteUser,
|
||||
@@ -65,6 +67,17 @@ function buildItemLabelFromFilename(file) {
|
||||
return normalized || 'item'
|
||||
}
|
||||
|
||||
function buildItemLabelFromSrc(src) {
|
||||
const raw = typeof src === 'string' ? src : ''
|
||||
const base = path.basename(raw.split('?')[0] || '', path.extname(raw.split('?')[0] || ''))
|
||||
const normalized = base
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 60)
|
||||
return normalized || 'item'
|
||||
}
|
||||
|
||||
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
||||
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
||||
|
||||
@@ -399,9 +412,25 @@ async function promoteLibraryItemToGameItem({ item, gameId }) {
|
||||
}
|
||||
|
||||
async function copyUploadIntoGameAsset(src) {
|
||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
|
||||
if (src.startsWith('/uploads/assets/')) return src
|
||||
return src
|
||||
if (typeof src !== 'string') return ''
|
||||
const raw = src.trim()
|
||||
if (!raw) return ''
|
||||
|
||||
if (raw.startsWith('/uploads/')) {
|
||||
if (raw.startsWith('/uploads/assets/')) return raw
|
||||
return raw
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(raw)
|
||||
if (url.pathname.startsWith('/uploads/')) {
|
||||
return url.pathname
|
||||
}
|
||||
} catch (error) {
|
||||
return raw
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
function uniqueTierListPoolItems(tierList) {
|
||||
@@ -435,10 +464,17 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
|
||||
}
|
||||
|
||||
async function promoteSnapshotItemsToGame({ items, gameId }) {
|
||||
const existingItems = await listGameItems(gameId)
|
||||
const existingSrcs = new Set(
|
||||
existingItems
|
||||
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
|
||||
.filter(Boolean)
|
||||
)
|
||||
const createdItems = []
|
||||
|
||||
for (const item of items || []) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
id: nanoid(),
|
||||
@@ -447,19 +483,36 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
|
||||
label: item.label,
|
||||
})
|
||||
)
|
||||
existingSrcs.add(copiedSrc)
|
||||
}
|
||||
|
||||
return createdItems
|
||||
}
|
||||
|
||||
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}) {
|
||||
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}, itemSrcs = []) {
|
||||
const requestedIds = new Set((itemIds || []).filter(Boolean))
|
||||
const requestedSrcs = new Set((itemSrcs || []).filter((src) => typeof src === 'string' && src.trim()).map((src) => src.trim()))
|
||||
const items = Array.isArray(templateRequest?.items) ? templateRequest.items : []
|
||||
const filtered = requestedIds.size ? items.filter((item) => item?.id && requestedIds.has(item.id)) : items
|
||||
return filtered.map((item) => ({
|
||||
...item,
|
||||
label: typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim() ? itemLabels[item.id].trim().slice(0, 60) : item.label,
|
||||
}))
|
||||
const filtered =
|
||||
requestedIds.size || requestedSrcs.size
|
||||
? items.filter((item) => (item?.id && requestedIds.has(item.id)) || (typeof item?.src === 'string' && requestedSrcs.has(item.src.trim())))
|
||||
: items
|
||||
return filtered
|
||||
.filter((item) => typeof item?.src === 'string' && item.src.trim())
|
||||
.map((item) => {
|
||||
const draftLabel =
|
||||
typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim()
|
||||
? itemLabels[item.id].trim().slice(0, 60)
|
||||
: typeof item?.label === 'string' && item.label.trim()
|
||||
? item.label.trim().slice(0, 60)
|
||||
: buildItemLabelFromSrc(item.src)
|
||||
|
||||
return {
|
||||
...item,
|
||||
src: item.src.trim(),
|
||||
label: draftLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
||||
@@ -658,11 +711,36 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||
if (templateRequest.type !== 'create') return res.status(409).json({ error: 'create_request_required' })
|
||||
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
|
||||
return res.status(409).json({ error: 'request_already_handled' })
|
||||
}
|
||||
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
|
||||
const request = await updateTemplateRequestTargetGame({
|
||||
id: templateRequest.id,
|
||||
targetGameId: game.id,
|
||||
})
|
||||
res.json({ request })
|
||||
})
|
||||
|
||||
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
itemLabels: z.record(z.string().min(1).max(60)).optional().default({}),
|
||||
itemSrcs: z.array(z.string().min(1)).optional().default([]),
|
||||
itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -676,10 +754,32 @@ router.post('/template-requests/:requestId/promote-items', requireAdmin, async (
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
|
||||
const items = await promoteSnapshotItemsToGame({
|
||||
items: pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels),
|
||||
gameId: game.id,
|
||||
})
|
||||
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
|
||||
if (!promotableItems.length) {
|
||||
return res.status(400).json({ error: 'no_items_selected' })
|
||||
}
|
||||
|
||||
let items = []
|
||||
try {
|
||||
items = await promoteSnapshotItemsToGame({
|
||||
items: promotableItems,
|
||||
gameId: game.id,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[admin] template request promote-items failed', {
|
||||
requestId: templateRequest.id,
|
||||
gameId: game.id,
|
||||
itemCount: promotableItems.length,
|
||||
message: error?.message || 'unknown_error',
|
||||
code: error?.code || '',
|
||||
stack: error?.stack || '',
|
||||
})
|
||||
return res.status(500).json({
|
||||
error: 'promote_items_failed',
|
||||
detail: error?.message || 'unknown_error',
|
||||
code: error?.code || '',
|
||||
})
|
||||
}
|
||||
|
||||
const request =
|
||||
templateRequest.status === 'reviewing'
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-02 v1.3.59
|
||||
- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다.
|
||||
- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다.
|
||||
- 신규 템플릿 요청 카드는 생성 여부가 관리자의 머릿속 상태가 아니라 UI 메타로 드러나야 하므로, `연결된 게임 있음/없음`과 `이미 반영 n개`를 카드와 작업 패널 양쪽에서 함께 보여주는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.55
|
||||
- 관리자 요청/업로드 배지는 문구만 다르면 빠르게 구분하기 어려우므로, 같은 `pill` 구조를 유지하되 색으로도 역할을 나누는 편이 운영 판단에 더 적합하다고 정리했다.
|
||||
- 신규 템플릿 요청으로 새 게임을 만들 때는 아이템만 가져오고 썸네일이 비어 있으면 식별성이 떨어지므로, 요청 썸네일도 기본값으로 함께 승계하는 편이 맞다고 판단했다.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 중기 개선
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
|
||||
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
|
||||
- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다.
|
||||
- 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-02 v1.3.59
|
||||
- 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함.
|
||||
- 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함.
|
||||
- 신규 템플릿 요청으로 새 게임을 한 번 만들면 해당 요청과 새 게임을 연결해 저장하고, 이후 같은 요청에서 다시 `확인하기`를 눌렀을 때는 새 게임을 또 만들지 않고 기존에 연결된 게임으로 바로 복귀하도록 흐름을 정리함.
|
||||
- 따라서 요청 카드와 게임 관리 작업 패널에서는 `연결된 게임`, `이미 반영 n개` 같은 상태를 함께 보여, 처리 완료 전에도 현재 진행 정도와 재작업 위험을 더 쉽게 구분할 수 있게 함.
|
||||
|
||||
## 2026-04-02 v1.3.55
|
||||
- 관리자 요청 카드 오른쪽 상단의 `신규 템플릿 / 보유 템플릿` 배지는 서로 다른 색상으로 분리해, 카드 타입을 텍스트보다 더 빠르게 구분할 수 있게 조정함.
|
||||
- 게임 관리의 기본 아이템 추가 미리보기에서도 `요청 아이템 / 직접 추가 파일` 배지를 서로 다른 색상으로 구분해, 요청 반영분과 직접 업로드분이 한눈에 섞이지 않도록 정리함.
|
||||
|
||||
@@ -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>
|
||||
@@ -86,6 +106,7 @@ const props = defineProps({
|
||||
<div
|
||||
class="dropZone"
|
||||
:class="{ 'dropZone--active': props.isItemDragOver }"
|
||||
@click="props.openItemFilePicker"
|
||||
@dragenter="props.onItemDragEnter"
|
||||
@dragover="props.onItemDragOver"
|
||||
@dragleave="props.onItemDragLeave"
|
||||
@@ -97,13 +118,9 @@ const props = defineProps({
|
||||
<span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span>
|
||||
</div>
|
||||
<div class="dropZone__actions">
|
||||
<button class="btn btn--ghost btn--small" type="button" @click="props.openItemFilePicker">파일 선택</button>
|
||||
<button class="btn btn--danger btn--small" type="button" :disabled="!props.uploadItemDrafts.length" @click="props.clearItemFiles">선택 비우기</button>
|
||||
<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,20 +162,20 @@ 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">
|
||||
<div class="thumbCard__dragHandle" data-game-item-handle>순서 이동</div>
|
||||
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
|
||||
<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 />
|
||||
<div class="thumbCard__actions">
|
||||
<button
|
||||
class="btn btn--ghost btn--small"
|
||||
data-no-drag
|
||||
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||
@click="props.saveGameItemLabel(item)"
|
||||
>
|
||||
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||
</button>
|
||||
<button class="btn btn--danger btn--small" @click="props.removeGameItem(item.id)">아이템 삭제</button>
|
||||
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeGameItem(item.id)">아이템 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
@@ -50,7 +63,11 @@ export function useAdminGameManager({
|
||||
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
|
||||
animation: 160,
|
||||
draggable: '[data-game-item-id]',
|
||||
handle: '[data-game-item-handle]',
|
||||
forceFallback: true,
|
||||
fallbackOnBody: false,
|
||||
filter: '[data-no-drag]',
|
||||
preventOnFilter: false,
|
||||
fallbackClass: 'thumbCard--dragging',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
@@ -69,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)
|
||||
@@ -81,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) {
|
||||
@@ -145,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
|
||||
@@ -235,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,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;
|
||||
@@ -2628,10 +2688,16 @@ function userAvatarFallback(user) {
|
||||
align-items: start;
|
||||
}
|
||||
.adminUiScope .dropZone {
|
||||
padding: 18px;
|
||||
min-height: 180px;
|
||||
padding: 24px 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed var(--theme-border-strong);
|
||||
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
|
||||
background: var(--theme-pill-bg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background 0.16s ease,
|
||||
@@ -2644,18 +2710,21 @@ function userAvatarFallback(user) {
|
||||
}
|
||||
.adminUiScope .dropZone__title {
|
||||
font-weight: 900;
|
||||
font-size: 16px;
|
||||
}
|
||||
.adminUiScope .dropZone__desc {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.74;
|
||||
line-height: 1.5;
|
||||
max-width: 480px;
|
||||
}
|
||||
.adminUiScope .dropZone__actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.adminUiScope .itemPreviewCard {
|
||||
margin-top: 12px;
|
||||
@@ -2664,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));
|
||||
@@ -2733,24 +2806,12 @@ function userAvatarFallback(user) {
|
||||
background: var(--theme-surface-soft);
|
||||
padding: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.adminUiScope .thumbCard__dragHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.adminUiScope .thumbCard__dragHandle:active {
|
||||
.adminUiScope .thumbCard:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.adminUiScope .thumb {
|
||||
@@ -3873,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