관리자 이미지 최적화 대시보드 추가
This commit is contained in:
@@ -44,6 +44,9 @@ export const api = {
|
||||
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
||||
getAdminImageAssetStats: () => request('/api/admin/image-assets/stats'),
|
||||
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
|
||||
@@ -57,6 +57,9 @@ const modalRoleNextAdmin = ref(false)
|
||||
const modalTargetCustomItem = ref(null)
|
||||
|
||||
const users = ref([])
|
||||
const imageStats = ref(null)
|
||||
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
|
||||
const imageRecentJobs = ref([])
|
||||
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
@@ -166,7 +169,7 @@ const adminOverviewStats = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests()])
|
||||
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()])
|
||||
await syncFeaturedSortable()
|
||||
})
|
||||
|
||||
@@ -226,6 +229,41 @@ function resetMessages() {
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0)
|
||||
if (!size) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let current = size
|
||||
let unitIndex = 0
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const imageDiagnosticsCards = computed(() => {
|
||||
const stats = imageStats.value
|
||||
if (!stats) return []
|
||||
return [
|
||||
{ label: '총 자산', value: `${stats.assetCount || 0}` },
|
||||
{ label: '현재 용량', value: formatBytes(stats.totalByteSize) },
|
||||
{ label: '절감 용량', value: formatBytes(stats.savedByteSize) },
|
||||
{ label: '절감률', value: `${Math.round((stats.savingsRatio || 0) * 100)}%` },
|
||||
]
|
||||
})
|
||||
|
||||
async function refreshImageDiagnostics() {
|
||||
try {
|
||||
const data = await api.getAdminImageAssetStats()
|
||||
imageStats.value = data.stats || null
|
||||
imageQueue.value = data.queue || { concurrency: 1, activeCount: 0, pendingCount: 0 }
|
||||
imageRecentJobs.value = data.recentJobs || []
|
||||
} catch (e) {
|
||||
error.value = '이미지 최적화 현황을 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
function setTab(tab) {
|
||||
resetMessages()
|
||||
activeTab.value = tab
|
||||
@@ -1842,6 +1880,46 @@ async function saveFeaturedOrder() {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Image Optimization</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshImageDiagnostics">현황 새로고침</button>
|
||||
</div>
|
||||
<div v-if="imageDiagnosticsCards.length" class="adminSidebar__stats adminSidebar__stats--grid">
|
||||
<article v-for="stat in imageDiagnosticsCards" :key="stat.label" class="sidebarStat">
|
||||
<span class="sidebarStat__label">{{ stat.label }}</span>
|
||||
<strong class="sidebarStat__value">{{ stat.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">큐 상태</span>
|
||||
<strong class="sidebarStat__value">{{ imageQueue.activeCount }} 실행 / {{ imageQueue.pendingCount }} 대기</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">누적 작업</span>
|
||||
<strong class="sidebarStat__value">{{ imageStats?.completedCount || 0 }} 완료 · {{ imageStats?.failedCount || 0 }} 실패</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">중복 재사용</span>
|
||||
<strong class="sidebarStat__value">{{ imageStats?.reusedCount || 0 }}건</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adminSidebar__group">
|
||||
<div class="section__title">최근 최적화 작업</div>
|
||||
<div v-if="!imageRecentJobs.length" class="hint hint--tight">아직 기록된 최적화 작업이 없어요.</div>
|
||||
<div v-else class="imageJobList">
|
||||
<article v-for="job in imageRecentJobs" :key="job.id" class="imageJobRow">
|
||||
<div class="imageJobRow__head">
|
||||
<strong>{{ job.sourceCategory || 'asset' }}</strong>
|
||||
<span class="imageJobRow__status">{{ job.status }}</span>
|
||||
</div>
|
||||
<div class="hint hint--tight">{{ formatBytes(job.originalByteSize) }} → {{ formatBytes(job.optimizedByteSize) }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -1932,6 +2010,38 @@ async function saveFeaturedOrder() {
|
||||
rgba(13, 13, 13, 0.94);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.adminSidebar__stats--grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.imageJobList {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.imageJobRow {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.imageJobRow__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.imageJobRow__status {
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.adminSidebar__label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
|
||||
Reference in New Issue
Block a user