v1.12.25 대시보드 숫자 표기 및 접속자 목록 간소화

This commit is contained in:
2026-10-21 17:17:57 +09:00
parent 93dc6c04bd
commit 9e2fec6d6c
14 changed files with 349 additions and 195 deletions

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
import {
ANALYTICS_SINGLE_BAR_COLORS,
formatAnalyticsChartHeaderLabel,
@@ -33,7 +34,7 @@ const props = defineProps({
},
formatValue: {
type: Function,
default: (value) => String(value ?? 0)
default: (value) => formatAnalyticsCount(value)
},
formatDelta: {
type: Function,
@@ -188,7 +189,7 @@ const formatDeltaLabel = (delta) => {
}
const absolute = Math.abs(delta)
return Number.isFinite(absolute) ? String(absolute) : '0'
return formatAnalyticsCount(absolute)
}
/**
@@ -309,11 +310,11 @@ const getRowDelta = (row) => {
v-if="trafficStack && getStackSegments(row).length > 1"
class="admin-analytics-trend-chart__tooltip-breakdown mt-1 text-[10px] leading-relaxed text-muted"
>
<span class="text-[#e5484d]">검색 {{ row.trafficSearch || 0 }}</span>
<span class="text-[#e5484d]">검색 {{ formatAnalyticsCount(row.trafficSearch) }}</span>
·
<span class="text-[#12a594]">SNS {{ row.trafficSns || 0 }}</span>
<span class="text-[#12a594]">SNS {{ formatAnalyticsCount(row.trafficSns) }}</span>
·
<span class="text-[#8b8d98]">기타 {{ row.trafficOther || 0 }}</span>
<span class="text-[#8b8d98]">기타 {{ formatAnalyticsCount(row.trafficOther) }}</span>
</p>
<p class="admin-analytics-trend-chart__tooltip-period mt-1 text-[10px] leading-none text-muted">
{{ getAnalyticsTrendDetailLabel(period, row, todayKey) }}

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
const { data, error, refresh } = await useFetch('/admin/api/comments', {
query: { pageSize: 5 },
default: () => ({ items: [], pagination: { total: 0 } })
@@ -19,7 +20,7 @@ const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
<template>
<section class="admin-comment-list border border-line bg-white px-4 py-3">
<h2 class="admin-comment-list__heading mb-1 text-xs font-semibold text-muted">최근 댓글 <span class="font-normal">· 전체 {{ data?.pagination.total || 0 }}개</span></h2>
<h2 class="admin-comment-list__heading mb-1 text-xs font-semibold text-muted">최근 댓글 <span class="font-normal">· 전체 {{ formatAnalyticsCount(data?.pagination.total) }}개</span></h2>
<p v-if="error" class="admin-comment-list__error text-xs text-red-700" role="alert">
댓글을 불러오지 못했습니다. <button type="button" class="underline" @click="refresh()">다시 시도</button>
</p>

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
const props = defineProps({ memberId: { type: String, required: true } })
const searchInput = ref('')
const filters = ref({ q: '', type: 'all', page: 1 })
@@ -41,7 +42,7 @@ const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
<template>
<section class="admin-member-activity border border-line bg-white p-4" :aria-busy="status === 'pending'">
<div class="admin-member-activity__toolbar flex flex-wrap items-center justify-between gap-3">
<h2 class="admin-member-activity__heading text-sm font-semibold">작성한 글 <span class="text-xs font-normal text-muted">{{ data?.pagination.total || 0 }}개 · 최신순</span></h2>
<h2 class="admin-member-activity__heading text-sm font-semibold">작성한 글 <span class="text-xs font-normal text-muted">{{ formatAnalyticsCount(data?.pagination.total) }}개 · 최신순</span></h2>
<form class="admin-member-activity__search flex w-full flex-wrap items-center gap-2 sm:w-auto" role="search" @submit.prevent="applySearch">
<select class="admin-member-activity__type h-10 rounded border border-line bg-white px-3 text-sm" aria-label="작성 종류" :value="filters.type" @change="changeType">
<option value="all">전체</option>

View File

@@ -0,0 +1,145 @@
<script setup>
import { formatAnalyticsCount, getActivePageHref, getActivePageTitle, getRealtimePages } from '~/lib/analytics-display.js'
const props = defineProps({ session: { type: Object, required: true } })
const pages = computed(() => getRealtimePages(props.session))
const triggerRef = ref(null)
const panelRef = ref(null)
const isOpen = ref(false)
const isPinned = ref(false)
const popoverStyle = ref({})
const popoverId = useId()
let closeTimer = null
/**
* 열린 페이지 목록이 화면 안에 표시되도록 위치를 계산한다.
* @returns {void}
*/
const updatePosition = () => {
if (!triggerRef.value || !isOpen.value) return
const rect = triggerRef.value.getBoundingClientRect()
const width = Math.min(480, window.innerWidth - 24)
const maxHeight = Math.min(320, window.innerHeight - 24)
const height = Math.min(panelRef.value?.parentElement?.offsetHeight || maxHeight, maxHeight)
const top = rect.bottom + height + 8 <= window.innerHeight ? rect.bottom + 4 : Math.max(12, rect.top - height - 4)
popoverStyle.value = {
left: `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`,
top: `${top}px`, width: `${width}px`, maxHeight: `${maxHeight}px`
}
}
/**
* 목록 닫기 예약을 취소한다.
* @returns {void}
*/
const cancelClose = () => { clearTimeout(closeTimer) }
/**
* 열린 페이지 목록을 표시한다.
* @returns {Promise<void>} 위치 반영
*/
const openPages = async () => {
cancelClose()
isOpen.value = true
await nextTick()
updatePosition()
}
/**
* 고정하지 않은 목록을 포인터 이동 후 닫는다.
* @returns {void}
*/
const scheduleClose = () => {
cancelClose()
closeTimer = setTimeout(() => {
if (!isPinned.value && !panelRef.value?.contains(document.activeElement) && document.activeElement !== triggerRef.value) isOpen.value = false
}, 150)
}
/**
* 클릭이나 Enter로 목록 고정을 전환한다.
* @returns {void}
*/
const togglePinned = () => {
isPinned.value = !isPinned.value
if (isPinned.value) openPages()
else isOpen.value = false
}
/**
* 목록을 닫고 필요하면 트리거로 포커스를 돌린다.
* @param {boolean} [restoreFocus] - 포커스 복원 여부
* @returns {void}
*/
const closePages = (restoreFocus = false) => {
isPinned.value = false
if (restoreFocus) triggerRef.value?.focus()
isOpen.value = false
}
/**
* 목록 바깥 클릭을 처리한다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const handleOutside = (event) => {
if (!triggerRef.value?.contains(event.target) && !panelRef.value?.contains(event.target)) closePages()
}
/**
* Escape로 페이지 목록을 닫는다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleKeydown = (event) => {
if (!isOpen.value) return
if (event.key === 'Escape') {
event.preventDefault()
closePages(true)
}
if (event.key === 'ArrowDown' && document.activeElement === triggerRef.value) {
event.preventDefault()
panelRef.value?.querySelector('a, button')?.focus()
}
}
onMounted(() => {
document.addEventListener('pointerdown', handleOutside)
document.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
})
onBeforeUnmount(() => {
cancelClose()
document.removeEventListener('pointerdown', handleOutside)
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
})
</script>
<template>
<div class="admin-realtime-pages min-w-0">
<button ref="triggerRef" type="button" class="admin-realtime-pages__trigger flex w-full min-w-0 items-center gap-1 text-left text-xs text-muted hover:text-ink" :aria-expanded="isOpen" :aria-controls="popoverId" aria-label="열린 페이지 목록" @mouseenter="openPages" @mouseleave="scheduleClose" @focus="openPages" @blur="scheduleClose" @click="togglePinned">
<span class="admin-realtime-pages__latest min-w-0 truncate">{{ getActivePageTitle(pages[0]) }}</span>
<span v-if="pages.length > 1" class="admin-realtime-pages__extra shrink-0 font-medium">외 {{ formatAnalyticsCount(pages.length - 1) }}개</span>
</button>
<Teleport to="body">
<AdminPopoverSurface v-if="isOpen" :id="popoverId" root-class="admin-realtime-pages__popover admin-layout--light-controls fixed z-[80] overflow-y-auto rounded-lg border-line p-3 text-xs text-[#15171a]" :surface-style="popoverStyle" @mouseenter="cancelClose" @mouseleave="scheduleClose" @focusin="cancelClose" @focusout="scheduleClose">
<div ref="panelRef">
<div class="mb-2 flex items-center justify-between gap-3">
<p class="font-semibold">열린 페이지 {{ formatAnalyticsCount(pages.length) }}개 · 최근 확인순</p>
<button type="button" class="shrink-0 text-muted hover:text-ink" aria-label="열린 페이지 목록 닫기" @click="closePages(true)">닫기</button>
</div>
<p class="mb-2 text-muted">IP {{ session.ipAddress || '-' }}</p>
<ul class="admin-realtime-pages__list divide-y divide-line">
<li v-for="(page, index) in pages" :key="page.key || index" class="py-2">
<a v-if="getActivePageHref(page)" :href="getActivePageHref(page)" target="_blank" rel="noopener noreferrer" class="admin-realtime-pages__link block break-words hover:underline">{{ getActivePageTitle(page) }} <span aria-hidden="true">↗</span><span class="sr-only"> 새 창으로 열기</span></a>
<span v-else class="break-words">{{ getActivePageTitle(page) }}</span>
</li>
</ul>
</div>
</AdminPopoverSurface>
</Teleport>
</div>
</template>