v1.3.4: 통계 확장(체류·스크롤·실시간 접속자)
- 031 마이그레이션: 체류·스크롤 집계, analytics_active_sessions - heartbeat API, 관리자 realtime API, 클라이언트 heartbeat - 대시보드: 현재 접속자 목록(로그인 닉네임·아바타), 참여 지표
This commit is contained in:
@@ -7,11 +7,15 @@ const { data: posts } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: analyticsSummary } = await useFetch('/admin/api/analytics/summary', {
|
||||
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
|
||||
default: () => ({
|
||||
todayVisitors: 0,
|
||||
visitorsLast7Days: 0,
|
||||
pageViewsLast30Days: 0
|
||||
pageViewsLast30Days: 0,
|
||||
onlineNow: 0,
|
||||
loggedInNow: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50Reach: 0
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,8 +24,72 @@ const { data: topPosts } = await useFetch('/admin/api/analytics/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/analytics/realtime', {
|
||||
query: { limit: 20 },
|
||||
default: () => ({
|
||||
summary: {
|
||||
onlineNow: 0,
|
||||
loggedInNow: 0,
|
||||
anonymousNow: 0
|
||||
},
|
||||
sessions: []
|
||||
})
|
||||
})
|
||||
|
||||
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
|
||||
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
|
||||
|
||||
/**
|
||||
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
|
||||
* @param {number} seconds - 초
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatEngagedDuration = (seconds) => {
|
||||
const value = Number(seconds) || 0
|
||||
if (value < 60) {
|
||||
return `${value}초`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(value / 60)
|
||||
const remainSeconds = value % 60
|
||||
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 활동 시각을 상대 표시로 변환한다.
|
||||
* @param {string|null} iso - ISO 시각
|
||||
* @returns {string} 상대 시각
|
||||
*/
|
||||
const formatLastSeen = (iso) => {
|
||||
if (!iso) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - new Date(iso).getTime()
|
||||
const diffSec = Math.max(Math.floor(diffMs / 1000), 0)
|
||||
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec}초 전`
|
||||
}
|
||||
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
return `${diffMin}분 전`
|
||||
}
|
||||
|
||||
let refreshTimer = null
|
||||
|
||||
onMounted(() => {
|
||||
refreshTimer = window.setInterval(() => {
|
||||
refreshSummary()
|
||||
refreshRealtime()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
window.clearInterval(refreshTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,7 +103,18 @@ const draftCount = computed(() => posts.value.filter((post) => post.status === '
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-dashboard__body space-y-6 bg-paper p-6">
|
||||
<section class="admin-dashboard__analytics grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<section class="admin-dashboard__analytics grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
현재 접속자
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ realtime.summary.onlineNow }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
로그인 {{ realtime.summary.loggedInNow }} · 익명 {{ realtime.summary.anonymousNow }}
|
||||
</p>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
오늘 방문
|
||||
@@ -43,23 +122,89 @@ const draftCount = computed(() => posts.value.filter((post) => post.status === '
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.todayVisitors }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
7일 {{ analyticsSummary.visitorsLast7Days }}
|
||||
</p>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
7일 방문
|
||||
평균 체류
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.visitorsLast7Days }}
|
||||
{{ formatEngagedDuration(analyticsSummary.avgEngagedSeconds) }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
30일 기준
|
||||
</p>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
30일 조회
|
||||
50% 스크롤
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.pageViewsLast30Days }}
|
||||
{{ analyticsSummary.scroll50Reach }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
30일 조회 {{ analyticsSummary.pageViewsLast30Days }}
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<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.sessions.length }}명 표시
|
||||
</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.sessionHash"
|
||||
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 class="admin-dashboard__live-path mt-0.5 truncate text-xs text-muted">
|
||||
{{ session.path }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-dashboard__live-meta shrink-0 text-right text-xs text-muted">
|
||||
<p>{{ formatLastSeen(session.lastSeenAt) }}</p>
|
||||
<p class="mt-1">
|
||||
{{ formatEngagedDuration(session.durationSeconds) }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="admin-dashboard__live-empty mt-4 text-sm text-muted"
|
||||
>
|
||||
현재 접속 중인 방문자가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__posts-meta grid gap-4 md:grid-cols-3">
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
게시물
|
||||
@@ -115,6 +260,22 @@ const draftCount = computed(() => posts.value.filter((post) => post.status === '
|
||||
{{ item.reads }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<dt class="inline">
|
||||
체류
|
||||
</dt>
|
||||
<dd class="inline font-semibold text-ink">
|
||||
{{ formatEngagedDuration(item.avgEngagedSeconds) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<dt class="inline">
|
||||
50/75/100%
|
||||
</dt>
|
||||
<dd class="inline font-semibold text-ink">
|
||||
{{ item.scroll50 }}/{{ item.scroll75 }}/{{ item.scroll100 }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user