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 as formatNumber } from '~/lib/analytics-display.js'
import { ANALYTICS_COLLECTED_SINCE_DEFAULT, getAnalyticsDayKey } from '~/lib/analytics-shared.js'
import {
getAnalyticsTrendDescription,
@@ -204,15 +205,6 @@ const getAdsenseCurrencyCode = (currencyCode) => {
}
}
/**
* 숫자를 천 단위 구분 문자열로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 표시 문자열
*/
const formatNumber = (value) => {
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
}
/**
* AdSense 금액을 통화 문자열로 변환한다.
* @param {number} value - 금액
@@ -317,10 +309,10 @@ const formatTrendValue = (key, value) => {
}
if (key === 'visitors') {
return `${Number(value || 0)}명`
return `${formatNumber(value)}명`
}
return `${Number(value || 0)}회`
return `${formatNumber(value)}회`
}
/**
@@ -376,7 +368,7 @@ const formatDeviceSummary = (row) => {
const visitors = Number(row?.visitors || 0)
const pageViews = Number(row?.pageViews || 0)
return `${visitors}명 / ${pageViews}회 / ${formatDeviceVisitorPercent(visitors)}`
return `${formatNumber(visitors)}명 / ${formatNumber(pageViews)}회 / ${formatDeviceVisitorPercent(visitors)}`
}
/**
@@ -398,7 +390,7 @@ const chartMetrics = computed(() => [
{
key: 'visitors',
title: '방문자수',
label: `${getTrendSummaryValue('visitors')}명`,
label: `${formatNumber(getTrendSummaryValue('visitors'))}명`,
metricLabel: '방문자수'
},
{
@@ -410,7 +402,7 @@ const chartMetrics = computed(() => [
{
key: 'scroll50Reach',
title: '50% 스크롤 도달',
label: `${getTrendSummaryValue('scroll50Reach')}회`,
label: `${formatNumber(getTrendSummaryValue('scroll50Reach'))}회`,
metricLabel: '50% 스크롤 도달'
}
])
@@ -477,70 +469,6 @@ const selectDefaultTrendDetail = () => {
shouldSelectDefaultTrendDetail.value = false
}
/**
* 현재 접속자가 보고 있는 화면명을 반환한다.
* @param {Object} session - 접속 세션
* @returns {string} 화면명
*/
const getSessionViewingTitle = (session) => {
if (session.postTitle) {
return session.postTitle
}
if (session.pageTitle) {
return session.pageTitle
}
if (session.path === '/') {
return '홈'
}
return session.path || '알 수 없음'
}
/**
* 현재 접속자가 열어둔 페이지명을 반환한다.
* @param {Object} page - 접속 페이지
* @returns {string} 페이지명
*/
const getActivePageTitle = (page) => {
if (page.postTitle) {
return page.postTitle
}
if (page.pageTitle) {
return page.pageTitle
}
if (page.path === '/') {
return '홈'
}
return page.path || '알 수 없음'
}
/**
* 접속 페이지의 공개 바로가기 경로를 반환한다.
* @param {Object} page - 접속 페이지
* @returns {string} 공개 경로
*/
const getActivePageHref = (page) => {
if (page.postSlug) {
return `/post/${encodeURIComponent(page.postSlug)}`
}
if (page.pageSlug) {
return `/pages/${encodeURIComponent(page.pageSlug)}`
}
const path = String(page.path || '').trim()
if (path.startsWith('/') && !path.startsWith('/admin')) {
return path
}
return ''
}
/**
* 통계 목록의 날짜를 짧은 문자열로 변환한다.
* @param {string | null | undefined} value - ISO 날짜
@@ -568,7 +496,7 @@ const formatCompactDate = (value) => {
* @returns {string} 스크롤 50/75/100 도달 수
*/
const formatScrollCompact = (row) => {
return `${Number(row.scroll50 || 0)}.${Number(row.scroll75 || 0)}.${Number(row.scroll100 || 0)}`
return `${formatNumber(row.scroll50)}.${formatNumber(row.scroll75)}.${formatNumber(row.scroll100)}`
}
/**
@@ -591,9 +519,9 @@ const formatScrollParts = (row) => {
*/
const formatTopPostMeta = (row) => {
return [
{ key: 'views', label: 'V', value: Number(row.views || 0), colorClass: 'text-[#2563eb]' },
{ key: 'reads', label: 'R', value: Number(row.reads || 0), colorClass: 'text-[#16a34a]' },
{ key: 'monthly', label: 'M', value: Number(row.monthlyViews || 0), colorClass: 'text-[#d97706]' },
{ key: 'views', label: 'V', value: formatNumber(row.views), colorClass: 'text-[#2563eb]' },
{ key: 'reads', label: 'R', value: formatNumber(row.reads), colorClass: 'text-[#16a34a]' },
{ key: 'monthly', label: 'M', value: formatNumber(row.monthlyViews), colorClass: 'text-[#d97706]' },
{ key: 'created', label: 'C', value: formatCompactDate(row.createdAt), colorClass: 'text-muted' },
{ key: 'scroll', label: 'S', value: formatScrollCompact(row), colorClass: 'text-muted', parts: formatScrollParts(row) }
]
@@ -606,9 +534,9 @@ const formatTopPostMeta = (row) => {
*/
const formatTopPageMeta = (row) => {
return [
{ key: 'views', label: 'V', value: Number(row.views || 0), colorClass: 'text-[#2563eb]' },
{ key: 'visitors', label: 'R', value: Number(row.visitors || 0), colorClass: 'text-[#16a34a]' },
{ key: 'monthly', label: 'M', value: Number(row.monthlyViews || 0), colorClass: 'text-[#d97706]' },
{ key: 'views', label: 'V', value: formatNumber(row.views), colorClass: 'text-[#2563eb]' },
{ key: 'visitors', label: 'R', value: formatNumber(row.visitors), colorClass: 'text-[#16a34a]' },
{ key: 'monthly', label: 'M', value: formatNumber(row.monthlyViews), colorClass: 'text-[#d97706]' },
{ key: 'created', label: 'C', value: formatCompactDate(row.createdAt), colorClass: 'text-muted' },
{ key: 'scroll', label: 'S', value: formatScrollCompact(row), colorClass: 'text-muted', parts: formatScrollParts(row) }
]
@@ -660,25 +588,23 @@ watch(trendRows, () => {
<section class="admin-dashboard__summary flex flex-wrap items-center gap-x-8 gap-y-2 border border-line bg-white px-4 py-3 text-sm">
<p class="admin-dashboard__summary-item text-muted">
현재 접속자
<strong class="ml-2 text-lg text-ink">{{ realtime.summary.onlineNow }}</strong>
<strong class="ml-2 text-lg text-ink">{{ formatNumber(realtime.summary.onlineNow) }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
오늘 방문자
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.todayVisitors }}</strong>
<strong class="ml-2 text-lg text-ink">{{ formatNumber(analyticsSummary.todayVisitors) }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
누적 방문자
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.totalVisitors }}</strong>
<strong class="ml-2 text-lg text-ink">{{ formatNumber(analyticsSummary.totalVisitors) }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
게시물 수
<strong class="ml-2 text-lg text-ink">{{ posts.length }}</strong>
<span class="ml-1 text-xs">발행 {{ publishedCount }} · 초안 {{ draftCount }}</span>
<strong class="ml-2 text-lg text-ink">{{ formatNumber(posts.length) }}</strong>
<span class="ml-1 text-xs">발행 {{ formatNumber(publishedCount) }} · 초안 {{ formatNumber(draftCount) }}</span>
</p>
</section>
<AdminCommentList class="admin-dashboard__recent-comments" />
<section class="admin-dashboard__adsense">
<p
v-if="adsenseSummary.configured && !adsenseSummary.ok"
@@ -825,7 +751,7 @@ watch(trendRows, () => {
</p>
</div>
<p class="admin-dashboard__traffic-total text-xs text-muted">
페이지뷰 {{ selectedSummaryPageViews }}회
페이지뷰 {{ formatNumber(selectedSummaryPageViews) }}회
</p>
</div>
@@ -849,7 +775,7 @@ watch(trendRows, () => {
페이지뷰
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.pageViews }}
{{ formatNumber(selectedTrendDetail.summary.pageViews) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
@@ -857,7 +783,7 @@ watch(trendRows, () => {
방문자
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.visitors }}
{{ formatNumber(selectedTrendDetail.summary.visitors) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
@@ -873,7 +799,7 @@ watch(trendRows, () => {
50% 스크롤
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.scroll50Reach }}
{{ formatNumber(selectedTrendDetail.summary.scroll50Reach) }}
</strong>
</article>
</div>
@@ -929,7 +855,7 @@ watch(trendRows, () => {
>
.
</span>
<span :class="part.colorClass">{{ part.value }}</span>
<span :class="part.colorClass">{{ formatNumber(part.value) }}</span>
</template>
</template>
<template v-else>
@@ -997,7 +923,7 @@ watch(trendRows, () => {
>
.
</span>
<span :class="part.colorClass">{{ part.value }}</span>
<span :class="part.colorClass">{{ formatNumber(part.value) }}</span>
</template>
</template>
<template v-else>
@@ -1051,7 +977,7 @@ watch(trendRows, () => {
/>
</div>
<p class="mt-1 text-[11px] text-muted">
조회 {{ row.pageViews }} · 방문자 {{ row.visitors }}
조회 {{ formatNumber(row.pageViews) }} · 방문자 {{ formatNumber(row.visitors) }}
</p>
</li>
</ul>
@@ -1093,7 +1019,7 @@ watch(trendRows, () => {
class="flex items-center justify-between gap-2"
>
<span>{{ row.osName || '-' }}</span>
<span>{{ row.visitors }}명 · {{ formatDeviceVisitorPercent(row.visitors) }}</span>
<span>{{ formatNumber(row.visitors) }}명 · {{ formatDeviceVisitorPercent(row.visitors) }}</span>
</li>
</ul>
</li>
@@ -1110,98 +1036,27 @@ watch(trendRows, () => {
</template>
</section>
<AdminCommentList class="admin-dashboard__recent-comments" />
<section class="admin-dashboard__live border border-line bg-white p-4">
<div class="admin-dashboard__live-header flex items-center justify-between gap-4">
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
현재 접속자
</h2>
<p class="admin-dashboard__live-count text-xs text-muted">
{{ realtime.summary.onlineNow }}명 표시
{{ formatNumber(realtime.sessions.length) }}명 표시 · 전체 {{ formatNumber(realtime.summary.onlineNow) }}명
</p>
</div>
<ul
v-if="realtime.sessions.length"
class="admin-dashboard__live-list mt-4 divide-y divide-line"
>
<li
v-for="session in realtime.sessions"
:key="session.activeKey"
class="admin-dashboard__live-item flex items-center gap-3 py-3"
>
<img
v-if="session.user?.avatarUrl"
:src="session.user.avatarUrl"
:alt="session.user.username"
class="admin-dashboard__live-avatar h-9 w-9 shrink-0 rounded-full object-cover"
>
<div
v-else
class="admin-dashboard__live-avatar admin-dashboard__live-avatar--placeholder flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-paper text-xs font-semibold text-muted"
>
{{ session.isLoggedIn ? (session.user?.username || '?').slice(0, 1) : '?' }}
</div>
<div class="min-w-0 flex-1">
<p class="admin-dashboard__live-name text-sm font-medium text-ink">
{{ session.isLoggedIn ? session.user?.username : '익명 방문자' }}
</p>
<p
v-if="Number(session.pageCount || 0) <= 1"
class="admin-dashboard__live-path group mt-0.5 flex min-w-0 items-center gap-1 text-xs text-muted"
>
<span class="min-w-0 truncate">{{ getSessionViewingTitle(session) }}</span>
<a
v-if="getActivePageHref(session)"
:href="getActivePageHref(session)"
target="_blank"
rel="noopener noreferrer"
class="admin-dashboard__live-link grid h-5 w-5 shrink-0 place-items-center rounded border border-line bg-white text-muted opacity-0 transition hover:border-ink hover:text-ink group-hover:opacity-100 focus:opacity-100"
aria-label="현재 페이지 새 창으로 열기"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 5h5v5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10 14 19 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M19 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</p>
<ul
v-if="Number(session.pageCount || 0) > 1"
class="admin-dashboard__live-pages mt-1 space-y-0.5 text-[11px] leading-snug text-muted"
>
<li
v-for="page in session.pages"
:key="page.key"
class="admin-dashboard__live-page group flex min-w-0 items-center gap-1"
>
<span class="min-w-0 truncate">{{ getActivePageTitle(page) }}</span>
<a
v-if="getActivePageHref(page)"
:href="getActivePageHref(page)"
target="_blank"
rel="noopener noreferrer"
class="admin-dashboard__live-link grid h-5 w-5 shrink-0 place-items-center rounded border border-line bg-white text-muted opacity-0 transition hover:border-ink hover:text-ink group-hover:opacity-100 focus:opacity-100"
aria-label="열린 페이지 새 창으로 열기"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 5h5v5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10 14 19 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M19 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</li>
</ul>
</div>
<div class="admin-dashboard__live-meta shrink-0 text-right text-xs text-muted">
<p class="font-medium text-ink">
접속 유지 {{ formatEngagedDuration(session.durationSeconds) }}
</p>
<p class="mt-1 text-[11px] leading-snug text-muted">
IP {{ session.ipAddress || '-' }}
</p>
<p v-if="Number(session.pageCount || 0) > 1" class="mt-0.5 text-[11px] leading-snug text-muted">
열린 페이지 {{ session.pageCount }}개
</p>
<ul v-if="realtime.sessions.length" class="admin-dashboard__live-list mt-3 divide-y divide-line">
<li v-for="session in realtime.sessions" :key="session.activeKey" class="admin-dashboard__live-item grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] items-center gap-3 py-2 text-xs xl:grid-cols-[minmax(0,1fr)_minmax(0,3fr)_6rem_8rem]">
<div class="admin-dashboard__live-identity flex min-w-0 items-center gap-2">
<img v-if="session.user?.avatarUrl" :src="session.user.avatarUrl" :alt="session.user.username" class="admin-dashboard__live-avatar size-6 shrink-0 rounded-full object-cover">
<span v-else class="admin-dashboard__live-avatar grid size-6 shrink-0 place-items-center rounded-full bg-paper text-muted">{{ session.isLoggedIn ? (session.user?.username || '?').slice(0, 1) : '?' }}</span>
<span class="admin-dashboard__live-name truncate font-medium text-ink" :title="session.isLoggedIn ? session.user?.username : '익명 방문자'">{{ session.isLoggedIn ? session.user?.username : '익명 방문자' }}</span>
</div>
<AdminRealtimePages :session="session" />
<span class="admin-dashboard__live-duration whitespace-nowrap text-right text-muted" title="접속 유지시간">{{ formatEngagedDuration(session.durationSeconds) }}</span>
<span class="admin-dashboard__live-ip hidden truncate text-right text-muted xl:block" :title="session.ipAddress">{{ session.ipAddress || '-' }}</span>
</li>
</ul>
<p