릴리스: v1.3.70 관리자 티어표 필터와 관리 액션 보강

This commit is contained in:
2026-04-02 15:28:52 +09:00
parent badf250967
commit a7cfb97131
8 changed files with 246 additions and 17 deletions

View File

@@ -22,6 +22,7 @@ const props = defineProps({
adminTierListPageCount: { type: Number, required: true },
adminTierListTotal: { type: Number, required: true },
adminTierListStats: { type: Object, required: true },
openAdminTierListManageModal: { type: Function, required: true },
moveAdminTierListPage: { type: Function, required: true },
})
</script>
@@ -151,13 +152,14 @@ const props = defineProps({
<div class="tierAdminCard__title">{{ tierList.title }}</div>
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
<div class="tierAdminCard__meta">
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }} · {{ props.tierListVisibilityLabel(tierList) }}
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }}
</div>
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
</div>
</div>
<div class="tierAdminCard__stats">
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
<span class="pill">전체 아이템 {{ tierList.itemCount }}</span>
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}</span>
</div>
@@ -177,6 +179,10 @@ const props = defineProps({
</button>
</div>
</div>
<div class="tierAdminSection__actions">
<button class="btn btn--ghost btn--small" @click="props.openAdminTierListManageModal(tierList)">관리</button>
</div>
</div>
</article>
</div>

View File

@@ -45,10 +45,16 @@ export const api = {
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
),
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) =>
request(
`/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
),
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
updateAdminTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
deleteAdminTierList: (tierListId) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }),
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
const query = new URLSearchParams()

View File

@@ -49,6 +49,7 @@ const customItemModalGameSort = ref('recent')
const adminTierLists = ref([])
const adminTierListQuery = ref('')
const adminTierListGameId = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
@@ -64,6 +65,7 @@ const importModalNewGameId = ref('')
const importModalNewGameName = ref('')
const previewModalOpen = ref(false)
const previewTierList = ref(null)
const adminTierListManageModalOpen = ref(false)
const activeTemplateRequest = ref(null)
const userEditModalOpen = ref(false)
const userPasswordModalOpen = ref(false)
@@ -81,6 +83,12 @@ const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const customItemModalDraftLabel = ref('')
const customItemModalLabelSaving = ref(false)
const modalTargetAdminTierList = ref(null)
const adminTierListDraftTitle = ref('')
const adminTierListDraftDescription = ref('')
const adminTierListDraftIsPublic = ref(false)
const adminTierListSaving = ref(false)
const adminTierListDeleting = ref(false)
const users = ref([])
const userQuery = ref('')
@@ -288,6 +296,7 @@ const isAnyModalOpen = computed(
importModalOpen.value ||
customItemModalOpen.value ||
customItemDeleteModalOpen.value ||
adminTierListManageModalOpen.value ||
imageResetModalOpen.value ||
previewModalOpen.value
)
@@ -422,6 +431,8 @@ watch(
if (name === 'adminTierlists') {
const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
const nextTierListGameId = typeof route.query.gameId === 'string' ? route.query.gameId : ''
if (adminTierListGameId.value !== nextTierListGameId) adminTierListGameId.value = nextTierListGameId
}
},
{ immediate: true }
@@ -447,7 +458,18 @@ watch(
() => tierlistsMode.value,
(mode) => {
if (route.name !== 'adminTierlists') return
syncAdminRouteQuery({ mode: mode === 'all' ? 'all' : undefined })
syncAdminRouteQuery({
mode: mode === 'all' ? 'all' : undefined,
gameId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined,
})
}
)
watch(
() => adminTierListGameId.value,
(gameId) => {
if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return
syncAdminRouteQuery({ gameId: gameId || undefined })
}
)
@@ -810,6 +832,7 @@ async function refreshAdminTierLists() {
try {
const data = await api.listAdminTierLists({
q: adminTierListQuery.value,
gameId: adminTierListGameId.value,
page: adminTierListPage.value,
limit: adminTierListLimit.value,
})
@@ -826,7 +849,7 @@ async function refreshAdminTierLists() {
async function refreshAdminTierListStats() {
if (!auth.user?.isAdmin) return
try {
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value })
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, gameId: adminTierListGameId.value })
adminTierListStats.value = {
total: data.total || 0,
publicCount: data.publicCount || 0,
@@ -1274,6 +1297,12 @@ function submitAdminTierListSearch() {
refreshAdminTierLists()
}
function setAdminTierListGameId(gameId) {
adminTierListGameId.value = gameId || ''
adminTierListPage.value = 1
refreshAdminTierLists()
}
function changeAdminTierListLimit(limit) {
adminTierListLimit.value = limit
adminTierListPage.value = 1
@@ -1325,6 +1354,81 @@ function tierListVisibilityLabel(tierList) {
return tierList.isPublic ? '공개' : '비공개'
}
function openAdminTierListManageModal(tierList) {
if (!tierList) return
modalTargetAdminTierList.value = tierList
adminTierListDraftTitle.value = tierList.title || ''
adminTierListDraftDescription.value = tierList.description || ''
adminTierListDraftIsPublic.value = !!tierList.isPublic
adminTierListManageModalOpen.value = true
}
function closeAdminTierListManageModal() {
adminTierListManageModalOpen.value = false
modalTargetAdminTierList.value = null
adminTierListDraftTitle.value = ''
adminTierListDraftDescription.value = ''
adminTierListDraftIsPublic.value = false
adminTierListSaving.value = false
adminTierListDeleting.value = false
}
async function saveAdminTierListMeta() {
if (!modalTargetAdminTierList.value?.id || adminTierListSaving.value) return
const nextTitle = adminTierListDraftTitle.value.trim()
if (!nextTitle) {
error.value = '티어표 제목을 입력해주세요.'
return
}
resetMessages()
adminTierListSaving.value = true
try {
const data = await api.updateAdminTierList(modalTargetAdminTierList.value.id, {
title: nextTitle,
description: adminTierListDraftDescription.value.trim(),
isPublic: !!adminTierListDraftIsPublic.value,
})
const updated = data.tierList
adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList))
if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated }
modalTargetAdminTierList.value = updated
await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')])
success.value = '티어표 정보를 수정했어요.'
closeAdminTierListManageModal()
} catch (e) {
error.value = '티어표 정보 수정에 실패했어요.'
} finally {
adminTierListSaving.value = false
}
}
async function deleteAdminTierListEntry() {
if (!modalTargetAdminTierList.value?.id || adminTierListDeleting.value) return
const ok = window.confirm(`"${modalTargetAdminTierList.value.title}" 티어표를 삭제할까요? 이 작업은 되돌릴 수 없어요.`)
if (!ok) return
resetMessages()
adminTierListDeleting.value = true
try {
await api.deleteAdminTierList(modalTargetAdminTierList.value.id)
adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id)
adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1)
if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null
await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')])
success.value = '티어표를 삭제했어요.'
closeAdminTierListManageModal()
if (!adminTierLists.value.length && adminTierListPage.value > 1) {
adminTierListPage.value -= 1
await refreshAdminTierLists()
}
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
} finally {
adminTierListDeleting.value = false
}
}
function openAdminTierList(tierList) {
previewTierList.value = tierList
previewModalOpen.value = true
@@ -1634,6 +1738,7 @@ function userAvatarFallback(user) {
:admin-tier-list-page-count="adminTierListPageCount"
:admin-tier-list-total="adminTierListTotal"
:admin-tier-list-stats="adminTierListStats"
:open-admin-tier-list-manage-modal="openAdminTierListManageModal"
:move-admin-tier-list-page="moveAdminTierListPage"
/>
@@ -1921,6 +2026,39 @@ function userAvatarFallback(user) {
</div>
</div>
<div v-if="adminTierListManageModalOpen" class="modalOverlay" @click.self="closeAdminTierListManageModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">티어표 관리</div>
<div class="modalCard__desc">
{{ modalTargetAdminTierList ? `${modalTargetAdminTierList.gameName || modalTargetAdminTierList.gameId} · ${tierListAuthorDisplayName(modalTargetAdminTierList)}` : '' }}
</div>
<div class="modalCard__form">
<label class="field">
<span class="field__label">제목</span>
<input v-model="adminTierListDraftTitle" class="field__input" maxlength="120" placeholder="티어표 제목" />
</label>
<label class="field">
<span class="field__label">설명</span>
<textarea v-model="adminTierListDraftDescription" class="field__input field__input--textarea" rows="4" maxlength="500" placeholder="설명 수정"></textarea>
</label>
<label class="toggleSwitch">
<input v-model="adminTierListDraftIsPublic" type="checkbox" />
<span class="toggleSwitch__label">{{ adminTierListDraftIsPublic ? '공개 상태' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeAdminTierListManageModal">취소</button>
<button class="btn btn--danger" :disabled="adminTierListDeleting" @click="deleteAdminTierListEntry">
{{ adminTierListDeleting ? '삭제중...' : '삭제' }}
</button>
<button class="btn btn--primary" :disabled="adminTierListSaving || !adminTierListDraftTitle.trim()" @click="saveAdminTierListMeta">
{{ adminTierListSaving ? '저장중...' : '저장' }}
</button>
</div>
</div>
</div>
<div v-if="imageResetModalOpen" class="modalOverlay" @click.self="closeImageResetModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">최적화 기록 비우기</div>
@@ -2110,6 +2248,10 @@ function userAvatarFallback(user) {
@keydown.enter.prevent="submitAdminTierListSearch"
/>
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
<select :value="adminTierListGameId" class="select" @change="setAdminTierListGameId($event.target.value)">
<option value="">모든 게임</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
<option :value="50">50개씩 보기</option>
<option :value="200">200개씩 보기</option>
@@ -4038,6 +4180,16 @@ function userAvatarFallback(user) {
background: rgba(251, 191, 36, 0.12);
color: rgba(253, 230, 138, 0.96);
}
.adminUiScope .pill--public {
border-color: rgba(52, 211, 153, 0.34);
background: rgba(52, 211, 153, 0.14);
color: rgba(209, 250, 229, 0.98);
}
.adminUiScope .pill--private {
border-color: rgba(251, 191, 36, 0.32);
background: rgba(251, 191, 36, 0.12);
color: rgba(253, 230, 138, 0.96);
}
.adminUiScope .pill--link {
color: var(--theme-text);
cursor: pointer;