Files
sori.studio/pages/admin/index.vue
zenn abb77dbb4d v1.3.4: 통계 확장(체류·스크롤·실시간 접속자)
- 031 마이그레이션: 체류·스크롤 집계, analytics_active_sessions
- heartbeat API, 관리자 realtime API, 클라이언트 heartbeat
- 대시보드: 현재 접속자 목록(로그인 닉네임·아바타), 참여 지표
2026-05-20 12:26:39 +09:00

292 lines
10 KiB
Vue

<script setup>
definePageMeta({
layout: 'admin'
})
const { data: posts } = await useFetch('/admin/api/posts', {
default: () => []
})
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
default: () => ({
todayVisitors: 0,
visitorsLast7Days: 0,
pageViewsLast30Days: 0,
onlineNow: 0,
loggedInNow: 0,
avgEngagedSeconds: 0,
scroll50Reach: 0
})
})
const { data: topPosts } = await useFetch('/admin/api/analytics/posts', {
query: { days: 30, limit: 5 },
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>
<section class="admin-dashboard">
<div class="admin-dashboard__header border-b border-line bg-paper p-6">
<p class="admin-dashboard__eyebrow text-xs font-semibold uppercase text-muted">
Admin
</p>
<h1 class="admin-dashboard__title mt-2 text-3xl font-semibold">
대시보드
</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 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">
오늘 방문
</p>
<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">
평균 체류
</p>
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
{{ 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">
50% 스크롤
</p>
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
{{ 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">
게시물
</p>
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
{{ posts.length }}
</strong>
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
발행 {{ publishedCount }} · 초안 {{ draftCount }}
</p>
</article>
</section>
<section class="admin-dashboard__top-posts border border-line bg-white p-4">
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
인기 게시물 (30)
</h2>
<ul
v-if="topPosts.length"
class="admin-dashboard__top-posts-list mt-4 space-y-3 text-sm"
>
<li
v-for="(item, index) in topPosts"
:key="item.id"
class="admin-dashboard__top-posts-item flex items-start justify-between gap-4 border-b border-line pb-3 last:border-b-0 last:pb-0"
>
<div class="min-w-0 flex-1">
<p class="admin-dashboard__top-posts-rank text-xs font-semibold uppercase text-muted">
#{{ index + 1 }}
</p>
<NuxtLink
:to="`/post/${item.slug}`"
class="admin-dashboard__top-posts-title mt-1 block truncate font-medium text-ink hover:underline"
target="_blank"
>
{{ item.title }}
</NuxtLink>
</div>
<dl class="admin-dashboard__top-posts-stats shrink-0 text-right text-xs text-muted">
<div>
<dt class="inline">
조회
</dt>
<dd class="inline font-semibold text-ink">
{{ item.views }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
읽음
</dt>
<dd class="inline font-semibold text-ink">
{{ 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>
<p
v-else
class="admin-dashboard__top-posts-empty mt-4 text-sm text-muted"
>
아직 집계된 게시물 조회 데이터가 없습니다.
</p>
</section>
</div>
</section>
</template>