Files
sori.studio/pages/admin/index.vue
zenn c43873ce5f v1.3.5: 관리자 로그인·대시보드 차트·통계 보관 정리
운영 HTTP에서 관리자 세션이 유지되지 않던 문제를 쿠키 공통화로 수정하고, 통계 클라이언트 분리·조회 오류·기간별 차트를 보강했다. 방문자 해시는 32일 초과분만 정리하고 일별 집계는 누적 보관한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 13:54:38 +09:00

417 lines
14 KiB
Vue

<script setup>
definePageMeta({
layout: 'admin'
})
const { data: posts } = await useFetch('/admin/api/posts', {
default: () => []
})
const analyticsRangeOptions = [
{ label: '7일', days: 7 },
{ label: '30일', days: 30 },
{ label: '3개월', days: 90 },
{ label: '6개월', days: 180 },
{ label: '12개월', days: 365 }
]
const selectedAnalyticsDays = ref(30)
const analyticsQuery = computed(() => ({
days: selectedAnalyticsDays.value
}))
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
query: analyticsQuery,
default: () => ({
todayVisitors: 0,
visitorsLast7Days: 0,
pageViewsLast30Days: 0,
onlineNow: 0,
loggedInNow: 0,
avgEngagedSeconds: 0,
scroll50Reach: 0,
trends: []
})
})
const topPostsQuery = computed(() => ({
days: selectedAnalyticsDays.value,
limit: 5
}))
const { data: topPosts, refresh: refreshTopPosts } = await useFetch('/admin/api/analytics/posts', {
query: topPostsQuery,
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)
const analyticsRangeLabel = computed(() => {
return analyticsRangeOptions.find((option) => option.days === selectedAnalyticsDays.value)?.label || '30일'
})
const trendRows = computed(() => analyticsSummary.value.trends || [])
const trendStartDay = computed(() => trendRows.value[0]?.day || '')
const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '')
/**
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
* @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 {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
* @returns {number} 합계 또는 평균
*/
const getTrendSummaryValue = (key) => {
const rows = trendRows.value
if (key === 'avgEngagedSeconds') {
const nonZeroRows = rows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
if (!nonZeroRows.length) {
return 0
}
const total = nonZeroRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
return Math.round(total / nonZeroRows.length)
}
return rows.reduce((sum, row) => sum + Number(row[key] || 0), 0)
}
/**
* 추세 값 표시 문자열을 반환한다.
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
* @param {number} value - 값
* @returns {string} 표시 문자열
*/
const formatTrendValue = (key, value) => {
if (key === 'avgEngagedSeconds') {
return formatEngagedDuration(value)
}
return `${Number(value || 0)}`
}
const chartMetrics = computed(() => [
{
key: 'visitors',
title: '방문자수',
label: `${getTrendSummaryValue('visitors')}`
},
{
key: 'avgEngagedSeconds',
title: '평균 체류시간',
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds'))
},
{
key: 'scroll50Reach',
title: '50% 스크롤 도달',
label: `${getTrendSummaryValue('scroll50Reach')}`
}
])
/**
* 차트 막대 높이 비율을 반환한다.
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
* @param {Object} row - 추세 행
* @returns {number} 높이 %
*/
const getTrendBarHeight = (key, row) => {
const rows = trendRows.value
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
if (maxValue <= 0) {
return 3
}
const value = Number(row[key] || 0)
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
}
/**
* 추세 시작·종료 날짜 라벨을 반환한다.
* @param {string} day - YYYY-MM-DD
* @returns {string} 날짜 라벨
*/
const formatTrendDayLabel = (day) => {
if (!day) {
return ''
}
const [, month, date] = day.split('-')
return `${month}.${date}`
}
/**
* 현재 접속자가 보고 있는 화면명을 반환한다.
* @param {Object} session - 접속 세션
* @returns {string} 화면명
*/
const getSessionViewingTitle = (session) => {
if (session.postTitle) {
return session.postTitle
}
if (session.path === '/') {
return '홈'
}
return session.path || '알 수 없음'
}
let refreshTimer = null
onMounted(() => {
refreshTimer = window.setInterval(() => {
refreshSummary()
refreshTopPosts()
refreshRealtime()
}, 30000)
})
onUnmounted(() => {
if (refreshTimer) {
window.clearInterval(refreshTimer)
}
})
watch(selectedAnalyticsDays, () => {
refreshSummary()
refreshTopPosts()
})
</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__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>
</p>
<p class="admin-dashboard__summary-item text-muted">
오늘 접속자
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.todayVisitors }}</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>
</p>
</section>
<section class="admin-dashboard__charts border border-line bg-white p-4">
<div class="admin-dashboard__charts-header flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
통계 추이
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
선택한 기간의 방문자수, 평균 체류시간, 50% 스크롤 도달 추이
</p>
</div>
<div class="admin-dashboard__range flex flex-wrap gap-1">
<button
v-for="option in analyticsRangeOptions"
:key="option.days"
type="button"
class="admin-dashboard__range-button border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
:class="option.days === selectedAnalyticsDays ? 'bg-[#15171a] text-white hover:bg-[#15171a] hover:text-white' : 'bg-white'"
@click="selectedAnalyticsDays = option.days"
>
{{ option.label }}
</button>
</div>
</div>
<div class="admin-dashboard__chart-grid mt-4 grid gap-4 lg:grid-cols-3">
<article
v-for="metric in chartMetrics"
:key="metric.key"
class="admin-dashboard__chart border border-line bg-paper p-4"
>
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold uppercase text-muted">
{{ metric.title }}
</p>
<strong class="text-sm text-ink">
{{ metric.label }}
</strong>
</div>
<div class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line">
<div
v-for="row in trendRows"
:key="`${metric.key}-${row.day}`"
class="admin-dashboard__chart-bar-wrap flex min-w-[3px] flex-1 items-end"
:title="`${row.day} · ${formatTrendValue(metric.key, row[metric.key])}`"
>
<div
class="admin-dashboard__chart-bar w-full bg-[#15171a]/80"
:style="{ height: `${getTrendBarHeight(metric.key, row)}%` }"
/>
</div>
</div>
<div class="mt-2 flex justify-between text-[11px] text-muted">
<span>{{ formatTrendDayLabel(trendStartDay) }}</span>
<span>{{ analyticsRangeLabel }}</span>
<span>{{ formatTrendDayLabel(trendEndDay) }}</span>
</div>
</article>
</div>
</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">
{{ getSessionViewingTitle(session) }}
</p>
</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>
</div>
</li>
</ul>
<p
v-else
class="admin-dashboard__live-empty mt-4 text-sm text-muted"
>
현재 접속 중인 방문자가 없습니다.
</p>
</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">
인기 게시물 ({{ analyticsRangeLabel }})
</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>