릴리스: v1.3.69 관리자 티어표 집계와 아이템 UI 정리

This commit is contained in:
2026-04-02 15:21:03 +09:00
parent a16b1e1025
commit badf250967
9 changed files with 140 additions and 72 deletions

View File

@@ -19,10 +19,6 @@ const props = defineProps({
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
<div class="customItemCard__stats">
<span class="customItemCard__stat">참조 {{ item.sharedReferenceCount || 1 }}</span>
<span class="customItemCard__stat">게임 {{ item.sharedLinkedGameCount || item.linkedGames?.length || 0 }}</span>
</div>
</button>
</div>

View File

@@ -21,6 +21,7 @@ const props = defineProps({
adminTierListPage: { type: Number, required: true },
adminTierListPageCount: { type: Number, required: true },
adminTierListTotal: { type: Number, required: true },
adminTierListStats: { type: Object, required: true },
moveAdminTierListPage: { type: Function, required: true },
})
</script>
@@ -128,6 +129,11 @@ const props = defineProps({
<div class="sectionHeader">
<div>
<div class="panel__title">전체 티어표 관리</div>
<div class="tierAdminHeaderStats">
<span class="pill">전체 {{ props.adminTierListStats.total || 0 }}</span>
<span class="pill pill--soft">공개 {{ props.adminTierListStats.publicCount || 0 }}</span>
<span class="pill pill--soft">비공개 {{ props.adminTierListStats.privateCount || 0 }}</span>
</div>
</div>
</div>

View File

@@ -47,6 +47,8 @@ export const api = {
),
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
const query = new URLSearchParams()

View File

@@ -52,6 +52,8 @@ const adminTierListQuery = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
const selectedGameTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
const templateRequests = ref([])
const importModalOpen = ref(false)
const importModalMode = ref('existing')
@@ -228,7 +230,6 @@ const activeTabDescription = computed(() => {
return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.'
})
const adminOverviewStats = computed(() => {
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
const pendingRequests = templateRequests.value.length
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
const adminCount = users.value.filter((user) => user.isAdmin).length
@@ -243,7 +244,9 @@ const adminOverviewStats = computed(() => {
if (activeTab.value === 'game-admin') {
return [
{ label: '전체 게임', value: `${games.value.length}` },
{ label: '선택 상태', value: hasSelectedGame.value ? '활성' : '대기' },
{ label: '티어표 전체', value: `${selectedGameTierListStats.value.total || 0}` },
{ label: '공개', value: `${selectedGameTierListStats.value.publicCount || 0}` },
{ label: '비공개', value: `${selectedGameTierListStats.value.privateCount || 0}` },
{ label: '기본 아이템', value: `${selectedGame.value?.items?.length || 0}` },
]
}
@@ -263,8 +266,9 @@ const adminOverviewStats = computed(() => {
{ label: '업데이트 요청', value: `${templateRequests.value.filter((request) => request.type === 'update').length}` },
]
: [
{ label: '검색 결과', value: `${adminTierListTotal.value}` },
{ label: '공개 티어표', value: `${publishedTierLists}` },
{ label: '검색 결과', value: `${adminTierListStats.value.total || 0}` },
{ label: '공개', value: `${adminTierListStats.value.publicCount || 0}` },
{ label: '비공개', value: `${adminTierListStats.value.privateCount || 0}` },
{ label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` },
]
}
@@ -431,6 +435,14 @@ watch(
}
)
watch(
() => selectedGame.value?.game?.id || '',
async (gameId) => {
await refreshSelectedGameTierListStats(gameId)
},
{ immediate: true }
)
watch(
() => tierlistsMode.value,
(mode) => {
@@ -559,17 +571,13 @@ function formatImageJobStatus(status) {
function customItemDeleteImpactText(item) {
if (!item) return ''
const sharedCount = Number(item.sharedReferenceCount || 1)
if (item.sourceType === 'template') {
const base = item.isAssetLibraryItem
return item.isAssetLibraryItem
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 게임의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
return sharedCount > 1 ? `${base} 현재 같은 이미지 참조 ${sharedCount}건 중 이 항목만 다룹니다.` : base
}
const base = `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
return sharedCount > 1 ? `${base} 현재 같은 이미지 참조 ${sharedCount}건 중 이 항목만 다룹니다.` : base
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
}
const imageDiagnosticsCards = computed(() => {
@@ -587,7 +595,6 @@ const imageDiagnosticsCards = computed(() => {
const visibleLinkedGames = computed(() =>
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
)
const visibleSharedEntries = computed(() => modalTargetCustomItem.value?.sharedEntries || [])
const filteredCustomItemModalGames = computed(() => {
const query = customItemModalGameQuery.value.trim().toLowerCase()
const linkedIds = new Set(visibleLinkedGames.value.map((game) => game.id))
@@ -810,11 +817,44 @@ async function refreshAdminTierLists() {
adminTierListTotal.value = data.total || 0
adminTierListPage.value = data.page || 1
adminTierListLimit.value = data.limit || adminTierListLimit.value
await refreshAdminTierListStats()
} catch (e) {
error.value = '관리자 티어표 목록을 불러오지 못했어요.'
}
}
async function refreshAdminTierListStats() {
if (!auth.user?.isAdmin) return
try {
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value })
adminTierListStats.value = {
total: data.total || 0,
publicCount: data.publicCount || 0,
privateCount: data.privateCount || 0,
}
} catch (e) {
adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
}
}
async function refreshSelectedGameTierListStats(gameId = '') {
if (!auth.user?.isAdmin || !gameId) {
selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
return
}
try {
const data = await api.getAdminTierListStats({ gameId })
selectedGameTierListStats.value = {
total: data.total || 0,
publicCount: data.publicCount || 0,
privateCount: data.privateCount || 0,
}
} catch (e) {
selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
}
}
async function refreshTemplateRequests() {
if (!auth.user?.isAdmin) return
try {
@@ -1261,12 +1301,6 @@ function buildModalItemFromTierListItem(item, tierList) {
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [],
sharedReferenceCount: matchedItem?.sharedReferenceCount || 1,
sharedUserReferenceCount: matchedItem?.sharedUserReferenceCount || 0,
sharedTemplateReferenceCount: matchedItem?.sharedTemplateReferenceCount || 0,
sharedAssetReferenceCount: matchedItem?.sharedAssetReferenceCount || 0,
sharedLinkedGameCount: matchedItem?.sharedLinkedGameCount || 0,
sharedEntries: Array.isArray(matchedItem?.sharedEntries) ? matchedItem.sharedEntries : [],
usageCount: matchedItem?.usageCount || 0,
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
isPromoting: false,
@@ -1599,6 +1633,7 @@ function userAvatarFallback(user) {
:admin-tier-list-page="adminTierListPage"
:admin-tier-list-page-count="adminTierListPageCount"
:admin-tier-list-total="adminTierListTotal"
:admin-tier-list-stats="adminTierListStats"
:move-admin-tier-list-page="moveAdminTierListPage"
/>
@@ -1796,10 +1831,8 @@ function userAvatarFallback(user) {
<div class="customItemModal__selectedMeta">
<div class="customItemModal__selectedTitle">{{ modalTargetCustomItem.label }}</div>
<div class="customItemModal__selectedChips">
<span class="pill">{{ modalTargetCustomItem.sharedReferenceCount || 1 }} 참조</span>
<span class="pill" v-if="modalTargetCustomItem.sharedUserReferenceCount">사용자 {{ modalTargetCustomItem.sharedUserReferenceCount }}</span>
<span class="pill" v-if="modalTargetCustomItem.sharedTemplateReferenceCount">템플릿 {{ modalTargetCustomItem.sharedTemplateReferenceCount }}</span>
<span class="pill" v-if="modalTargetCustomItem.sharedAssetReferenceCount">보관 {{ modalTargetCustomItem.sharedAssetReferenceCount }}</span>
<span class="pill">{{ modalTargetCustomItem.sourceLabel }}</span>
<span class="pill" v-if="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</span>
</div>
</div>
</div>
@@ -1863,25 +1896,6 @@ function userAvatarFallback(user) {
<button v-for="game in visibleLinkedGames" :key="game.id" type="button" class="pill pill--link" @click="jumpToGameAdmin(game.id)">{{ game.name }}</button>
</div>
<div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label">같은 이미지 참조</span>
<div class="customItemModal__metaList">
<div class="customItemModal__metaRow"><span> 참조</span><strong>{{ modalTargetCustomItem.sharedReferenceCount || 1 }}</strong></div>
<div class="customItemModal__metaRow"><span>사용자 업로드</span><strong>{{ modalTargetCustomItem.sharedUserReferenceCount || 0 }}</strong></div>
<div class="customItemModal__metaRow"><span>템플릿 항목</span><strong>{{ modalTargetCustomItem.sharedTemplateReferenceCount || 0 }}</strong></div>
<div class="customItemModal__metaRow"><span>보관 자산</span><strong>{{ modalTargetCustomItem.sharedAssetReferenceCount || 0 }}</strong></div>
</div>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label">같은 이미지 기록</span>
<div v-if="visibleSharedEntries.length" class="customItemModal__entryList">
<div v-for="entry in visibleSharedEntries" :key="entry.id" class="customItemModal__entry">
<div class="customItemModal__entryTitle">{{ entry.label }}</div>
<div class="customItemModal__entryMeta">{{ entry.sourceLabel }} · {{ entry.ownerName || '알 수 없음' }}</div>
</div>
</div>
<div v-else class="hint hint--tight">같은 이미지를 가리키는 다른 기록이 없어요.</div>
</div>
<div class="customItemModal__actions">
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
@@ -3195,15 +3209,6 @@ function userAvatarFallback(user) {
line-height: 1.3;
color: var(--theme-text);
}
.adminUiScope .customItemCard__stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.adminUiScope .customItemCard__stat {
font-size: 11px;
color: var(--theme-text-soft);
}
.adminUiScope .customItemModal {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
@@ -3359,25 +3364,6 @@ function userAvatarFallback(user) {
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.adminUiScope .customItemModal__entryList {
display: grid;
gap: 8px;
}
.adminUiScope .customItemModal__entry {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.adminUiScope .customItemModal__entryTitle {
font-size: 13px;
font-weight: 800;
}
.adminUiScope .customItemModal__entryMeta {
margin-top: 4px;
font-size: 11px;
color: var(--theme-text-soft);
}
.adminUiScope .customItemModal__close {
justify-self: end;
border: 0;
@@ -4010,6 +3996,12 @@ function userAvatarFallback(user) {
gap: 8px;
flex-wrap: wrap;
}
.adminUiScope .tierAdminHeaderStats {
margin-top: 10px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.adminUiScope .pill {
display: inline-flex;
align-items: center;