- 031 마이그레이션: 체류·스크롤 집계, analytics_active_sessions - heartbeat API, 관리자 realtime API, 클라이언트 heartbeat - 대시보드: 현재 접속자 목록(로그인 닉네임·아바타), 참여 지표
292 lines
10 KiB
Vue
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>
|