Files
sori.studio/pages/admin/posts/[id]/analytics.vue
zenn ef9efd7af5 관리자 통계 대시보드 개선 및 v1.9.6 반영
통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:42:23 +09:00

427 lines
15 KiB
Vue

<script setup>
definePageMeta({
layout: 'admin'
})
const route = useRoute()
const id = computed(() => String(route.params.id || ''))
const selectedPeriod = ref('daily')
const selectedMetric = ref('views')
const analyticsChartPageOffset = ref(0)
const periodOptions = [
{ label: '일간', value: 'daily', days: 14 },
{ label: '주간', value: 'weekly', days: 84 },
{ label: '월간', value: 'monthly', days: 365 }
]
const metricOptions = [
{ label: '조회수', value: 'views' },
{ label: '방문자', value: 'visitors' }
]
const { data: post } = await useFetch(() => `/admin/api/posts/${id.value}`)
if (!post.value) {
throw createError({
statusCode: 404,
statusMessage: '게시물을 찾을 수 없습니다.'
})
}
const analyticsQuery = computed(() => ({
period: selectedPeriod.value,
days: periodOptions.find((option) => option.value === selectedPeriod.value)?.days || 14
}))
const { data: analytics, refresh } = await useFetch(() => `/admin/api/analytics/posts/${id.value}`, {
query: analyticsQuery,
default: () => ({
updatedAt: '',
total: {
views: 0,
visitors: 0,
reads: 0,
avgEngagedSeconds: 0,
scroll50: 0,
scroll75: 0,
scroll100: 0
},
period: {
views: 0,
visitors: 0,
reads: 0,
avgEngagedSeconds: 0,
scroll50: 0,
scroll75: 0,
scroll100: 0
},
trends: [],
sources: [],
devices: [],
keywords: []
})
})
watch(selectedPeriod, () => {
analyticsChartPageOffset.value = 0
refresh()
})
/**
* 숫자를 관리자 통계 표시 형식으로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 표시 문자열
*/
const formatStatNumber = (value) => {
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
}
/**
* 날짜 값을 YYYY.MM.DD 형식으로 표시한다.
* @param {string | null} value - 날짜 값
* @returns {string} 표시 문자열
*/
const formatAdminDate = (value) => {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}.${month}.${day}`
}
/**
* 날짜 값을 YYYY.MM.DD HH:mm 형식으로 표시한다.
* @param {string | null} value - 날짜 값
* @returns {string} 표시 문자열
*/
const formatAdminDateTime = (value) => {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${formatAdminDate(value)} ${hours}:${minutes}`
}
/**
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
* @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}분`
}
/**
* 선택한 집계 단위 표시명을 반환한다.
* @returns {string} 집계 단위 라벨
*/
const getSelectedPeriodLabel = () => {
return periodOptions.find((option) => option.value === selectedPeriod.value)?.label || '일간'
}
/**
* 유입 그룹 표시명을 반환한다.
* @param {string} sourceGroup - 유입 그룹
* @returns {string} 표시명
*/
const getTrafficGroupLabel = (sourceGroup) => {
if (sourceGroup === 'search') {
return '검색'
}
if (sourceGroup === 'sns') {
return 'SNS'
}
if (sourceGroup === 'direct') {
return '직접'
}
return '기타'
}
/**
* 유입 그룹별 조회수를 합산한다.
* @param {string[]} groups - 유입 그룹
* @returns {number} 조회수 합계
*/
const getSourceGroupViews = (groups) => {
return (analytics.value.sources || [])
.filter((source) => groups.includes(source.sourceGroup))
.reduce((sum, source) => sum + Number(source.pageViews || 0), 0)
}
const searchViews = computed(() => getSourceGroupViews(['search']))
const snsViews = computed(() => getSourceGroupViews(['sns']))
const otherViews = computed(() => getSourceGroupViews(['direct', 'other']))
const mobileDevice = computed(() => (analytics.value.devices || []).find((row) => row.deviceType === '모바일'))
const pcDevice = computed(() => (analytics.value.devices || []).find((row) => row.deviceType === 'PC'))
const totalDevicePageViews = computed(() => {
return (analytics.value.devices || []).reduce((sum, row) => sum + Number(row.pageViews || 0), 0)
})
/**
* 디바이스 비율을 반환한다.
* @param {number} value - 조회수
* @returns {string} 비율
*/
const formatDevicePercent = (value) => {
const total = totalDevicePageViews.value
if (!total) {
return '0.0%'
}
return `${((Number(value || 0) / total) * 100).toFixed(1)}%`
}
</script>
<template>
<section class="admin-post-analytics bg-paper p-6">
<div class="admin-post-analytics__updated mb-3 text-right text-sm text-muted">
{{ formatAdminDateTime(analytics.updatedAt) }} 기준
</div>
<article class="admin-post-analytics__panel border border-line bg-white px-8 py-8">
<header class="admin-post-analytics__header grid gap-6 lg:grid-cols-[minmax(0,1fr)_240px] lg:items-start">
<div class="min-w-0">
<NuxtLink class="admin-post-analytics__back mb-6 inline-flex text-sm font-semibold text-muted hover:text-ink" to="/admin/posts">
&lt; 글 목록
</NuxtLink>
<div class="admin-post-analytics__title-row flex min-w-0 flex-wrap items-center gap-2">
<h1 class="admin-post-analytics__title min-w-0 text-3xl font-semibold leading-tight text-black">
{{ post.title || '(제목 없음)' }}
</h1>
<NuxtLink
v-if="post.slug"
:to="`/post/${post.slug}`"
class="admin-post-analytics__public-link text-muted hover:text-ink"
target="_blank"
aria-label="공개 게시물 보기"
>
↗
</NuxtLink>
</div>
<p class="admin-post-analytics__meta mt-3 text-sm font-medium text-muted">
댓글 {{ formatStatNumber(post.commentCount) }}
<span class="mx-1 text-[#c8ced3]">·</span>
작성일 {{ formatAdminDate(post.createdAt) }}
</p>
</div>
<div class="admin-post-analytics__total text-left lg:text-right">
<strong class="block text-4xl font-bold text-[#333]">
{{ formatStatNumber(analytics.total.views) }}
</strong>
<p class="mt-2 text-sm font-semibold text-muted">
누적 조회수
</p>
</div>
</header>
<section class="admin-post-analytics__chart-section mt-16">
<div class="admin-post-analytics__chart-header flex flex-wrap items-center justify-between gap-4">
<h2 class="admin-post-analytics__chart-title text-2xl font-semibold text-black">
{{ getSelectedPeriodLabel() }} 통계
</h2>
<div class="admin-post-analytics__controls flex flex-wrap gap-4">
<div class="admin-post-analytics__segmented inline-flex border border-line">
<button
v-for="metric in metricOptions"
:key="metric.value"
type="button"
class="admin-post-analytics__segment h-10 border-r border-line px-5 text-sm font-semibold last:border-r-0"
:class="selectedMetric === metric.value ? 'bg-[#666] text-white' : 'bg-white text-muted hover:bg-paper hover:text-ink'"
@click="selectedMetric = metric.value"
>
{{ metric.label }}
</button>
</div>
<div class="admin-post-analytics__segmented inline-flex border border-line">
<button
v-for="option in periodOptions"
:key="option.value"
type="button"
class="admin-post-analytics__segment h-10 border-r border-line px-5 text-sm font-semibold last:border-r-0"
:class="selectedPeriod === option.value ? 'bg-[#666] text-white' : 'bg-white text-muted hover:bg-paper hover:text-ink'"
@click="selectedPeriod = option.value"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<AdminAnalyticsTrendChart
class="admin-post-analytics__chart mt-8"
:rows="analytics.trends"
:period="selectedPeriod"
:metric-key="selectedMetric"
:metric-label="selectedMetric === 'views' ? '조회수' : '방문자'"
:format-value="formatStatNumber"
v-model:page-offset="analyticsChartPageOffset"
bar-height-class="h-72"
/>
</section>
<section class="admin-post-analytics__summary mt-20 grid gap-6 border-t border-line pt-8 lg:grid-cols-4">
<article class="admin-post-analytics__summary-card">
<div class="flex items-center justify-between border-b border-[#e5e8eb] pb-3">
<h3 class="text-sm font-bold text-[#666]">
검색
</h3>
<span class="text-sm text-muted">{{ formatStatNumber(searchViews) }}</span>
</div>
<ul class="mt-4 space-y-2 text-sm text-muted">
<li
v-for="row in analytics.sources.filter((source) => source.sourceGroup === 'search')"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex justify-between gap-3"
>
<span>{{ row.sourceName }}</span>
<span>{{ formatStatNumber(row.pageViews) }}</span>
</li>
<li v-if="!analytics.sources.some((source) => source.sourceGroup === 'search')" class="flex justify-between gap-3">
<span>검색 유입</span>
<span>0</span>
</li>
</ul>
</article>
<article class="admin-post-analytics__summary-card">
<div class="flex items-center justify-between border-b border-[#e5e8eb] pb-3">
<h3 class="text-sm font-bold text-[#666]">
SNS
</h3>
<span class="text-sm text-muted">{{ formatStatNumber(snsViews) }}</span>
</div>
<ul class="mt-4 space-y-2 text-sm text-muted">
<li
v-for="row in analytics.sources.filter((source) => source.sourceGroup === 'sns')"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex justify-between gap-3"
>
<span>{{ row.sourceName }}</span>
<span>{{ formatStatNumber(row.pageViews) }}</span>
</li>
<li v-if="!analytics.sources.some((source) => source.sourceGroup === 'sns')" class="flex justify-between gap-3">
<span>SNS 유입</span>
<span>0</span>
</li>
</ul>
</article>
<article class="admin-post-analytics__summary-card">
<div class="flex items-center justify-between border-b border-[#b8c0c8] pb-3">
<h3 class="text-sm font-bold text-[#666]">
기타
</h3>
<span class="text-sm text-muted">{{ formatStatNumber(otherViews) }}</span>
</div>
<ul class="mt-4 space-y-2 text-sm text-muted">
<li
v-for="row in analytics.sources.filter((source) => ['direct', 'other'].includes(source.sourceGroup))"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex justify-between gap-3"
>
<span>{{ getTrafficGroupLabel(row.sourceGroup) }}</span>
<span>{{ formatStatNumber(row.pageViews) }}</span>
</li>
<li v-if="!analytics.sources.some((source) => ['direct', 'other'].includes(source.sourceGroup))" class="flex justify-between gap-3">
<span>기타 유입</span>
<span>0</span>
</li>
</ul>
</article>
<article class="admin-post-analytics__summary-card">
<div class="border-b border-[#b8c0c8] pb-3">
<h3 class="text-sm font-bold text-[#666]">
디바이스
</h3>
</div>
<ul class="mt-4 space-y-2 text-sm text-muted">
<li class="flex justify-between gap-3">
<span>모바일</span>
<span>{{ formatDevicePercent(mobileDevice?.pageViews) }}</span>
</li>
<li class="flex justify-between gap-3">
<span>PC</span>
<span>{{ formatDevicePercent(pcDevice?.pageViews) }}</span>
</li>
</ul>
</article>
</section>
<section class="admin-post-analytics__keywords mt-20">
<h3 class="text-sm font-bold text-[#666]">
유입 키워드
</h3>
<ul
v-if="analytics.keywords.length"
class="mt-4 grid gap-2 md:grid-cols-2"
>
<li
v-for="row in analytics.keywords"
:key="`${row.sourceName}-${row.keyword}`"
class="flex items-center justify-between gap-3 border border-line px-3 py-2 text-sm text-muted"
>
<span class="min-w-0 truncate font-semibold text-ink">{{ row.keyword }}</span>
<span class="shrink-0">{{ row.sourceName }} · {{ formatStatNumber(row.pageViews) }}</span>
</li>
</ul>
<p
v-else
class="mt-4 text-sm text-muted"
>
검색 유입 키워드가 없습니다.
</p>
</section>
<section class="admin-post-analytics__engagement mt-12 grid gap-3 sm:grid-cols-4">
<article class="border border-line bg-paper p-4">
<p class="text-xs font-semibold uppercase text-muted">읽음</p>
<strong class="mt-1 block text-xl text-ink">{{ formatStatNumber(analytics.period.reads) }}</strong>
</article>
<article class="border border-line bg-paper p-4">
<p class="text-xs font-semibold uppercase text-muted">평균 체류</p>
<strong class="mt-1 block text-xl text-ink">{{ formatEngagedDuration(analytics.period.avgEngagedSeconds) }}</strong>
</article>
<article class="border border-line bg-paper p-4">
<p class="text-xs font-semibold uppercase text-muted">50% 스크롤</p>
<strong class="mt-1 block text-xl text-ink">{{ formatStatNumber(analytics.period.scroll50) }}</strong>
</article>
<article class="border border-line bg-paper p-4">
<p class="text-xs font-semibold uppercase text-muted">75/100%</p>
<strong class="mt-1 block text-xl text-ink">{{ formatStatNumber(analytics.period.scroll75) }} / {{ formatStatNumber(analytics.period.scroll100) }}</strong>
</article>
</section>
</article>
</section>
</template>