관리자 통계 대시보드 개선 및 v1.9.6 반영

통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 13:42:23 +09:00
parent 67ea58ccc2
commit ef9efd7af5
25 changed files with 4038 additions and 620 deletions

View File

@@ -1,4 +1,7 @@
<script setup>
import { ANALYTICS_COLLECTED_SINCE_DEFAULT } from '~/lib/analytics-shared.js'
import { getAnalyticsTrendDescription, getAnalyticsTrendDetailLabel, paginateAnalyticsTrendRows } from '~/lib/analytics-chart-ui.js'
definePageMeta({
layout: 'admin'
})
@@ -8,15 +11,20 @@ const { data: posts } = await useFetch('/admin/api/posts', {
})
const analyticsRangeOptions = [
{ label: '7일', days: 7 },
{ label: '30일', days: 30 },
{ label: '3개월', days: 90 },
{ label: '6개월', days: 180 },
{ label: '12개월', days: 365 }
{ label: '일일', period: 'daily', days: 7 },
{ label: '주간', period: 'weekly', days: 56 },
{ label: '월간', period: 'monthly', days: 365 },
{ label: '년간', period: 'yearly', days: 365 * 8 }
]
const selectedAnalyticsDays = ref(30)
const selectedAnalyticsPeriod = ref('weekly')
const analyticsChartPageOffset = ref(0)
const selectedAnalyticsRange = computed(() => {
return analyticsRangeOptions.find((option) => option.period === selectedAnalyticsPeriod.value) || analyticsRangeOptions[1]
})
const selectedAnalyticsDays = computed(() => selectedAnalyticsRange.value.days)
const analyticsQuery = computed(() => ({
days: selectedAnalyticsDays.value
days: selectedAnalyticsDays.value,
period: selectedAnalyticsPeriod.value
}))
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
@@ -29,6 +37,7 @@ const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/adm
loggedInNow: 0,
avgEngagedSeconds: 0,
scroll50Reach: 0,
collectedSince: ANALYTICS_COLLECTED_SINCE_DEFAULT,
trends: []
})
})
@@ -73,18 +82,55 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/
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일'
return selectedAnalyticsRange.value.label
})
const trendRows = computed(() => analyticsSummary.value.trends || [])
const trendStartDay = computed(() => trendRows.value[0]?.day || '')
const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '')
const analyticsTrendDescription = computed(() => {
return getAnalyticsTrendDescription(
selectedAnalyticsPeriod.value,
analyticsSummary.value?.collectedSince || ANALYTICS_COLLECTED_SINCE_DEFAULT
)
})
const selectedTrendDetail = ref(null)
const selectedTrendDetailLabel = ref('')
const isTrendDetailOpen = ref(false)
const trafficSources = computed(() => trafficSummary.value.sources || [])
const trafficDevices = computed(() => trafficSummary.value.devices || [])
const trafficKeywords = computed(() => trafficSummary.value.keywords || [])
const isTrendDetailLoading = ref(false)
const trendDetailError = ref('')
const totalTrafficPageViews = computed(() => {
return trafficSources.value.reduce((sum, row) => sum + Number(row.pageViews || 0), 0)
})
const chartPagination = computed(() => {
return paginateAnalyticsTrendRows(trendRows.value, selectedAnalyticsPeriod.value, analyticsChartPageOffset.value)
})
/**
* 통계 차트 이전 기간으로 이동한다.
* @returns {void}
*/
const goAnalyticsChartPrevPage = () => {
if (!chartPagination.value.canGoPrev) {
return
}
analyticsChartPageOffset.value += chartPagination.value.pageSize
}
/**
* 통계 차트 다음 기간으로 이동한다.
* @returns {void}
*/
const goAnalyticsChartNextPage = () => {
if (!chartPagination.value.canGoNext) {
return
}
analyticsChartPageOffset.value = Math.max(analyticsChartPageOffset.value - chartPagination.value.pageSize, 0)
}
/**
* 관리자 날짜를 YYYY.MM.DD로 표시한다.
* @param {string | null} value - 날짜 값
@@ -205,127 +251,86 @@ const formatTrafficPercent = (value) => {
return `${Math.round((Number(value || 0) / total) * 100)}%`
}
/**
* 추세 막대 툴팁 문구를 반환한다.
* @param {{ key: 'visitors' | 'avgEngagedSeconds' | 'scroll50Reach', title: string }} metric - 지표 정보
* @param {Object} row - 추세 행
* @returns {string} 툴팁 문구
*/
const getTrendTooltipLabel = (metric, row) => {
const dayLabel = row.label || formatTrendDayLabel(row.day)
return `${dayLabel} · ${metric.title} ${formatTrendValue(metric.key, row[metric.key])}`
}
const chartMetrics = computed(() => [
{
key: 'visitors',
title: '방문자수',
label: `${getTrendSummaryValue('visitors')}명`
label: `${getTrendSummaryValue('visitors')}명`,
metricLabel: '방문자수'
},
{
key: 'avgEngagedSeconds',
title: '평균 체류시간',
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds'))
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds')),
metricLabel: '평균 체류시간'
},
{
key: 'scroll50Reach',
title: '50% 스크롤 도달',
label: `${getTrendSummaryValue('scroll50Reach')}회`
label: `${getTrendSummaryValue('scroll50Reach')}회`,
metricLabel: '50% 스크롤 도달'
}
])
/**
* 차트 막대 높이 비율을 반환한다.
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
* 체류시간 증감 표시 문자열을 반환한다.
* @param {number} delta - 증감 초
* @returns {string} 표시 문자열
*/
const formatEngagedDurationDelta = (delta) => {
const value = Math.abs(Number(delta) || 0)
if (value < 60) {
return `${value}초`
}
const minutes = Math.floor(value / 60)
const remainSeconds = value % 60
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
}
/**
* 추세 날짜 상세 모달을 연다.
* @param {Object} row - 추세 행
* @returns {number} 높이 %
* @returns {Promise<void>}
*/
const getTrendBarHeight = (key, row) => {
const rows = chartTrendRows.value
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
const openTrendDetail = async (row) => {
const startDay = row.startDay || row.day
const endDay = row.endDay || row.day
if (maxValue <= 0) {
return 3
if (!startDay || !endDay) {
return
}
const value = Number(row[key] || 0)
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
}
isTrendDetailOpen.value = true
isTrendDetailLoading.value = true
trendDetailError.value = ''
selectedTrendDetail.value = null
selectedTrendDetailLabel.value = getAnalyticsTrendDetailLabel(selectedAnalyticsPeriod.value, row)
/**
* 추세 시작·종료 날짜 라벨을 반환한다.
* @param {string} day - YYYY-MM-DD
* @returns {string} 날짜 라벨
*/
const formatTrendDayLabel = (day) => {
if (!day) {
return ''
}
const [, month, date] = day.split('-')
return `${month}.${date}`
}
/**
* 선택 기간에 맞는 차트 표시 집계 단위를 반환한다.
* @param {number} days - 선택 기간
* @returns {number} 묶을 일수
*/
const getTrendChartBucketSize = (days) => {
if (days <= 14) {
return 1
}
if (days <= 60) {
return 7
}
if (days <= 180) {
return 14
}
return 30
}
/**
* 차트 표시용 추세 묶음 행을 만든다.
* @param {Array<Object>} rows - 일자별 추세 행
* @param {number} bucketSize - 묶을 일수
* @returns {Array<Object>} 차트 표시 행
*/
const buildTrendChartRows = (rows, bucketSize) => {
if (bucketSize <= 1) {
return rows.map((row) => ({
...row,
label: formatTrendDayLabel(row.day)
}))
}
const buckets = []
for (let index = 0; index < rows.length; index += bucketSize) {
const bucketRows = rows.slice(index, index + bucketSize)
const firstRow = bucketRows[0]
const lastRow = bucketRows[bucketRows.length - 1]
const engagedRows = bucketRows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
const totalEngagedSeconds = engagedRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
const startLabel = formatTrendDayLabel(firstRow?.day)
const endLabel = formatTrendDayLabel(lastRow?.day)
buckets.push({
day: firstRow?.day || '',
label: startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`,
visitors: bucketRows.reduce((sum, row) => sum + Number(row.visitors || 0), 0),
avgEngagedSeconds: engagedRows.length ? Math.round(totalEngagedSeconds / engagedRows.length) : 0,
scroll50Reach: bucketRows.reduce((sum, row) => sum + Number(row.scroll50Reach || 0), 0)
try {
selectedTrendDetail.value = await $fetch('/admin/api/analytics/range-detail', {
query: {
startDay,
endDay,
limit: 5
}
})
} catch {
trendDetailError.value = '날짜별 통계 상세를 불러오지 못했습니다.'
} finally {
isTrendDetailLoading.value = false
}
return buckets
}
const chartTrendRows = computed(() => {
return buildTrendChartRows(trendRows.value, getTrendChartBucketSize(selectedAnalyticsDays.value))
})
/**
* 추세 날짜 상세 모달을 닫는다.
* @returns {void}
*/
const closeTrendDetail = () => {
isTrendDetailOpen.value = false
selectedTrendDetail.value = null
trendDetailError.value = ''
}
/**
* 현재 접속자가 보고 있는 화면명을 반환한다.
@@ -366,7 +371,8 @@ onUnmounted(() => {
}
})
watch(selectedAnalyticsDays, () => {
watch(selectedAnalyticsPeriod, () => {
analyticsChartPageOffset.value = 0
refreshSummary()
refreshTopPosts()
refreshTopPages()
@@ -408,20 +414,38 @@ watch(selectedAnalyticsDays, () => {
통계 추이
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
선택한 기간의 방문자수, 평균 체류시간, 50% 스크롤 도달 추이
{{ analyticsTrendDescription }}
</p>
</div>
<div class="admin-dashboard__range flex flex-wrap gap-1">
<div class="admin-dashboard__range flex flex-wrap items-center gap-1">
<button
v-for="option in analyticsRangeOptions"
:key="option.days"
:key="option.period"
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"
:class="option.period === selectedAnalyticsPeriod ? 'bg-[#15171a] text-white hover:bg-[#15171a] hover:text-white' : 'bg-white'"
@click="selectedAnalyticsPeriod = option.period"
>
{{ option.label }}
</button>
<button
class="admin-dashboard__range-nav grid h-[26px] w-7 place-items-center border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
type="button"
aria-label="이전 기간"
:disabled="!chartPagination.canGoPrev"
@click="goAnalyticsChartPrevPage"
>
‹
</button>
<button
class="admin-dashboard__range-nav grid h-[26px] w-7 place-items-center border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
type="button"
aria-label="다음 기간"
:disabled="!chartPagination.canGoNext"
@click="goAnalyticsChartNextPage"
>
›
</button>
</div>
</div>
@@ -439,40 +463,23 @@ watch(selectedAnalyticsDays, () => {
{{ metric.label }}
</strong>
</div>
<div
class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line"
role="img"
:aria-label="`${metric.title} ${analyticsRangeLabel} 추이`"
>
<div
v-for="row in chartTrendRows"
:key="`${metric.key}-${row.day}`"
class="admin-dashboard__chart-bar-wrap group relative flex h-full min-w-[3px] flex-1 items-end"
tabindex="0"
:aria-label="getTrendTooltipLabel(metric, row)"
>
<div
class="admin-dashboard__chart-tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 whitespace-nowrap rounded bg-[#15171a] px-2 py-1 text-[11px] font-medium text-white shadow-lg group-hover:block group-focus-visible:block"
>
{{ getTrendTooltipLabel(metric, row) }}
</div>
<div
class="admin-dashboard__chart-bar w-full rounded-t-sm bg-[#15171a] transition-colors group-hover:bg-[#2f6feb] group-focus-visible:bg-[#2f6feb]"
:style="{ height: `${getTrendBarHeight(metric.key, row)}%` }"
/>
</div>
</div>
<p
v-if="!chartTrendRows.length"
class="admin-dashboard__chart-empty mt-4 text-center text-xs text-muted"
>
선택한 기간에 표시할 추이 데이터가 없습니다.
</p>
<div class="mt-2 flex justify-between text-[11px] text-muted">
<span>{{ formatTrendDayLabel(trendStartDay) }}</span>
<span>{{ analyticsRangeLabel }}</span>
<span>{{ formatTrendDayLabel(trendEndDay) }}</span>
</div>
<AdminAnalyticsTrendChart
class="mt-4"
:rows="trendRows"
:period="selectedAnalyticsPeriod"
:metric-key="metric.key"
:metric-label="metric.metricLabel"
:format-value="(value) => formatTrendValue(metric.key, value)"
:format-delta="metric.key === 'avgEngagedSeconds' ? formatEngagedDurationDelta : undefined"
:traffic-stack="metric.key === 'visitors'"
v-model:page-offset="analyticsChartPageOffset"
:show-header="false"
:show-navigation="false"
compact
clickable
bar-height-class="h-32"
@bar-click="openTrendDetail"
/>
</article>
</div>
</section>
@@ -823,5 +830,226 @@ watch(selectedAnalyticsDays, () => {
</section>
</div>
</div>
<Teleport to="body">
<div
v-if="isTrendDetailOpen"
class="admin-dashboard__trend-modal fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-10"
role="dialog"
aria-modal="true"
@click.self="closeTrendDetail"
>
<section class="admin-dashboard__trend-modal-panel w-full max-w-4xl rounded-lg border border-line bg-white shadow-xl">
<div class="admin-dashboard__trend-modal-header flex items-start justify-between gap-4 border-b border-line px-5 py-4">
<div>
<p class="text-xs font-semibold uppercase text-muted">
통계 상세
</p>
<h2 class="mt-1 text-lg font-semibold text-ink">
{{ selectedTrendDetailLabel }}
</h2>
</div>
<button
type="button"
class="admin-dashboard__trend-modal-close border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
@click="closeTrendDetail"
>
닫기
</button>
</div>
<div class="admin-dashboard__trend-modal-body space-y-4 p-5">
<p
v-if="isTrendDetailLoading"
class="text-sm text-muted"
>
날짜별 통계를 불러오는 중입니다.
</p>
<p
v-else-if="trendDetailError"
class="text-sm text-red-600"
>
{{ trendDetailError }}
</p>
<template v-else-if="selectedTrendDetail">
<div class="admin-dashboard__trend-modal-summary grid gap-3 sm:grid-cols-4">
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
페이지뷰
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.pageViews }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
방문자
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.visitors }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
평균 체류
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatEngagedDuration(selectedTrendDetail.summary.avgEngagedSeconds) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
50% 스크롤
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.scroll50Reach }}
</strong>
</article>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
유입
</h3>
<ul
v-if="selectedTrendDetail.sources.length"
class="mt-3 space-y-2"
>
<li
v-for="row in selectedTrendDetail.sources"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<span class="font-medium text-ink">{{ getTrafficGroupLabel(row.sourceGroup) }} · {{ row.sourceName }}</span>
<span class="text-right text-muted">조회 {{ row.pageViews }} · 방문자 {{ row.visitors }}</span>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
해당 기간에 집계된 유입 정보가 없습니다.
</p>
</section>
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
디바이스
</h3>
<ul
v-if="selectedTrendDetail.devices.length"
class="mt-3 space-y-2"
>
<li
v-for="row in selectedTrendDetail.devices"
:key="`${row.deviceType}-${row.osName}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<span class="font-medium text-ink">{{ row.deviceType }} · {{ row.osName }}</span>
<span class="text-right text-muted">조회 {{ row.pageViews }} · 방문자 {{ row.visitors }}</span>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
해당 기간에 집계된 디바이스 정보가 없습니다.
</p>
</section>
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
유입 키워드
</h3>
<ul
v-if="selectedTrendDetail.keywords.length"
class="mt-3 space-y-2"
>
<li
v-for="row in selectedTrendDetail.keywords"
:key="`${row.sourceName}-${row.keyword}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<span class="min-w-0 truncate font-medium text-ink">{{ row.keyword }}</span>
<span class="shrink-0 text-right text-muted">{{ row.sourceName }} · {{ row.pageViews }}회</span>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
해당 기간에 수집된 유입 키워드가 없습니다.
</p>
</section>
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
인기 게시물
</h3>
<ul
v-if="selectedTrendDetail.topPosts.length"
class="mt-3 space-y-2"
>
<li
v-for="row in selectedTrendDetail.topPosts"
:key="row.id"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<NuxtLink
:to="`/post/${row.slug}`"
class="min-w-0 truncate font-medium text-ink hover:underline"
target="_blank"
>
{{ row.title }}
</NuxtLink>
<span class="shrink-0 text-right text-muted">조회 {{ row.views }} · 읽음 {{ row.reads }}</span>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
해당 기간에 집계된 게시물 조회가 없습니다.
</p>
</section>
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4 lg:col-span-2">
<h3 class="text-xs font-semibold uppercase text-muted">
인기 페이지
</h3>
<ul
v-if="selectedTrendDetail.topPages.length"
class="mt-3 grid gap-2 md:grid-cols-2"
>
<li
v-for="row in selectedTrendDetail.topPages"
:key="row.id"
class="flex items-center justify-between gap-3 border border-line bg-white px-3 py-2 text-xs"
>
<NuxtLink
:to="`/pages/${row.slug}`"
class="min-w-0 truncate font-medium text-ink hover:underline"
target="_blank"
>
{{ row.title }}
</NuxtLink>
<span class="shrink-0 text-right text-muted">조회 {{ row.views }} · 방문자 {{ row.visitors }}</span>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
해당 기간에 집계된 페이지 조회가 없습니다.
</p>
</section>
</div>
</template>
</div>
</section>
</div>
</Teleport>
</section>
</template>

View File

@@ -5,6 +5,13 @@ definePageMeta({
const deletingId = ref('')
const errorMessage = ref('')
const analyticsModalPage = ref(null)
const analyticsModalData = ref(createDefaultPageAnalytics())
const analyticsModalPeriod = ref('daily')
const analyticsModalMetric = ref('views')
const analyticsModalChartPageOffset = ref(0)
const analyticsModalLoading = ref(false)
const analyticsModalError = ref('')
const { openMenuId, closeMenu } = useAdminRowMenu()
@@ -12,6 +19,112 @@ const { data: pages, refresh } = await useFetch('/admin/api/pages', {
default: () => []
})
const analyticsPeriodOptions = [
{ label: '일간', value: 'daily', days: 14 },
{ label: '주간', value: 'weekly', days: 84 },
{ label: '월간', value: 'monthly', days: 365 }
]
const analyticsMetricOptions = [
{ label: '조회수', value: 'views' },
{ label: '방문자', value: 'visitors' }
]
/**
* 페이지별 통계 기본값을 생성한다.
* @returns {Object} 페이지별 통계 기본값
*/
function createDefaultPageAnalytics() {
return {
updatedAt: '',
total: {
views: 0,
visitors: 0,
avgEngagedSeconds: 0,
scroll50: 0,
scroll75: 0,
scroll100: 0
},
period: {
views: 0,
visitors: 0,
avgEngagedSeconds: 0,
scroll50: 0,
scroll75: 0,
scroll100: 0
},
trends: []
}
}
/** 페이지 통계 모달이 열려 있는지 */
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPage.value))
/**
* 숫자를 관리자 목록 표시 형식으로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 표시 문자열
*/
const formatAdminNumber = (value) => {
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
}
/**
* 날짜 값을 YYYY.MM.DD HH:mm 형식으로 표시한다.
* @param {string | null} value - 날짜 값
* @returns {string} 표시 문자열
*/
const formatAdminDateTimeLabel = (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')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}.${month}.${day} ${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 getAnalyticsPeriodLabel = () => {
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.label || '일간'
}
/**
* 페이지 통계 기간 조회 일수를 반환한다.
* @returns {number} 조회 일수
*/
const getAnalyticsPeriodDays = () => {
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.days || 14
}
/**
* 페이지 상태 표시 문자열 생성
* @param {Object} page - 페이지
@@ -64,6 +177,69 @@ const formatDate = (value) => {
return `${year}.${month}.${day}`
}
/**
* 페이지 통계 모달 데이터를 조회한다.
* @returns {Promise<void>}
*/
const fetchAnalyticsModalData = async () => {
if (!analyticsModalPage.value?.id) {
return
}
analyticsModalLoading.value = true
analyticsModalError.value = ''
try {
analyticsModalData.value = await $fetch(`/admin/api/analytics/pages/${analyticsModalPage.value.id}`, {
query: {
period: analyticsModalPeriod.value,
days: getAnalyticsPeriodDays()
}
})
} catch (error) {
analyticsModalData.value = createDefaultPageAnalytics()
analyticsModalError.value = error?.data?.message || '페이지 통계를 불러오지 못했습니다.'
} finally {
analyticsModalLoading.value = false
}
}
/**
* 페이지 통계 모달을 연다.
* @param {Object} page - 페이지
* @returns {Promise<void>}
*/
const openAnalyticsModal = async (page) => {
closeMenu()
analyticsModalPage.value = page
analyticsModalPeriod.value = 'daily'
analyticsModalMetric.value = 'views'
analyticsModalChartPageOffset.value = 0
analyticsModalData.value = createDefaultPageAnalytics()
await fetchAnalyticsModalData()
}
/**
* 페이지 통계 모달을 닫는다.
* @returns {void}
*/
const closeAnalyticsModal = () => {
analyticsModalPage.value = null
analyticsModalError.value = ''
analyticsModalData.value = createDefaultPageAnalytics()
}
/**
* 통계 모달 집계 단위를 변경한다.
* @param {string} period - 집계 단위
* @returns {Promise<void>}
*/
const selectAnalyticsModalPeriod = async (period) => {
analyticsModalPeriod.value = period
analyticsModalChartPageOffset.value = 0
await fetchAnalyticsModalData()
}
/**
* 고정 페이지 삭제
* @param {Object} page - 삭제할 고정 페이지
@@ -118,6 +294,7 @@ const deletePage = async (page) => {
<tr>
<th class="admin-pages__cell px-4 py-3">제목</th>
<th class="admin-pages__cell px-4 py-3">상태</th>
<th class="admin-pages__cell px-4 py-3">조회수</th>
<th class="admin-pages__cell px-4 py-3">수정일</th>
<th class="admin-pages__cell admin-pages__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
@@ -139,6 +316,18 @@ const deletePage = async (page) => {
{{ getPageStatusLabel(page) }}
</span>
</td>
<td class="admin-pages__cell admin-pages__cell-views px-4 py-4">
<p class="admin-pages__view-count text-[#394047]">
{{ formatAdminNumber(page.totalViews) }}
</p>
<button
class="admin-pages__analytics-link mt-1 inline-flex text-xs text-muted transition-colors hover:text-[#2f6feb]"
type="button"
@click.stop="openAnalyticsModal(page)"
>
통계
</button>
</td>
<td class="admin-pages__cell px-4 py-4">
{{ formatDate(page.updatedAt) }}
</td>
@@ -175,5 +364,164 @@ const deletePage = async (page) => {
<p v-if="pages.length === 0" class="admin-pages__empty mt-6 text-sm text-muted">
아직 작성된 페이지가 없습니다.
</p>
<Teleport to="body">
<div
v-if="isAnalyticsModalOpen"
class="admin-pages__analytics-modal fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-4 py-6"
role="dialog"
aria-modal="true"
aria-labelledby="admin-pages-analytics-modal-title"
@click.self="closeAnalyticsModal"
>
<article class="admin-pages__analytics-modal-panel max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-lg border border-line bg-white shadow-xl">
<header class="admin-pages__analytics-modal-header flex items-start justify-between gap-4 border-b border-line px-5 py-4">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase text-muted">
페이지 통계
</p>
<h2 id="admin-pages-analytics-modal-title" class="mt-1 truncate text-lg font-semibold text-ink">
{{ analyticsModalPage?.title || '(제목 없음)' }}
</h2>
<p class="mt-1 text-xs text-muted">
{{ formatAdminDateTimeLabel(analyticsModalData.updatedAt) }} 기준
</p>
</div>
<button
class="admin-pages__analytics-modal-close border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
type="button"
@click="closeAnalyticsModal"
>
닫기
</button>
</header>
<div class="admin-pages__analytics-modal-body space-y-4 p-5">
<p v-if="analyticsModalLoading" class="text-sm text-muted">
페이지 통계를 불러오는 중입니다.
</p>
<p v-else-if="analyticsModalError" class="text-sm text-red-600">
{{ analyticsModalError }}
</p>
<template v-else>
<div class="admin-pages__analytics-modal-summary grid gap-3 sm:grid-cols-4">
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
누적 조회수
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.total.views) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
방문자
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.period.visitors) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
평균 체류
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatEngagedDuration(analyticsModalData.period.avgEngagedSeconds) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
50% 스크롤
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.period.scroll50) }}
</strong>
</article>
</div>
<section class="admin-pages__analytics-modal-chart border border-line bg-paper p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-xs font-semibold uppercase text-muted">
통계 추이
</h3>
<p class="mt-1 text-xs text-muted">
{{ getAnalyticsPeriodLabel() }} 기준
</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="inline-flex border border-line bg-white">
<button
v-for="metric in analyticsMetricOptions"
:key="metric.value"
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
:class="analyticsModalMetric === metric.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
type="button"
@click="analyticsModalMetric = metric.value"
>
{{ metric.label }}
</button>
</div>
<div class="inline-flex border border-line bg-white">
<button
v-for="option in analyticsPeriodOptions"
:key="option.value"
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
:class="analyticsModalPeriod === option.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
type="button"
@click="selectAnalyticsModalPeriod(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<AdminAnalyticsTrendChart
class="mt-4"
:rows="analyticsModalData.trends"
:period="analyticsModalPeriod"
:metric-key="analyticsModalMetric"
:metric-label="analyticsModalMetric === 'views' ? '조회수' : '방문자'"
:format-value="formatAdminNumber"
v-model:page-offset="analyticsModalChartPageOffset"
:show-header="false"
compact
bar-height-class="h-36"
/>
</section>
<div class="admin-pages__analytics-modal-engagement grid gap-3 sm:grid-cols-3">
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
75% 스크롤
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.period.scroll75) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
100% 스크롤
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.period.scroll100) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
선택 기간 조회수
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.period.views) }}
</strong>
</article>
</div>
</template>
</div>
</article>
</div>
</Teleport>
</section>
</template>

View File

@@ -0,0 +1,426 @@
<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>

View File

@@ -15,6 +15,13 @@ const tagFilter = ref('all')
const featuredFilter = ref('all')
const sortOrder = ref('newest')
const searchQuery = ref('')
const analyticsModalPost = ref(null)
const analyticsModalData = ref(createDefaultPostAnalytics())
const analyticsModalPeriod = ref('daily')
const analyticsModalMetric = ref('views')
const analyticsModalChartPageOffset = ref(0)
const analyticsModalLoading = ref(false)
const analyticsModalError = ref('')
const postMenuTriggerRefs = new Map()
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
@@ -30,6 +37,113 @@ const { data: siteSettings } = await useFetch('/admin/api/settings', {
})
const defaultTagColor = '#15171a'
const analyticsPeriodOptions = [
{ label: '일간', value: 'daily', days: 14 },
{ label: '주간', value: 'weekly', days: 84 },
{ label: '월간', value: 'monthly', days: 365 }
]
const analyticsMetricOptions = [
{ label: '조회수', value: 'views' },
{ label: '방문자', value: 'visitors' }
]
/**
* 게시물별 통계 기본값을 생성한다.
* @returns {Object} 게시물별 통계 기본값
*/
function createDefaultPostAnalytics() {
return {
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: []
}
}
/**
* 숫자를 관리자 목록 표시 형식으로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 표시 문자열
*/
const formatAdminNumber = (value) => {
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
}
/**
* 날짜 값을 YYYY.MM.DD HH:mm 형식으로 표시한다.
* @param {string | null} value - 날짜 값
* @returns {string} 표시 문자열
*/
const formatAdminDateTimeLabel = (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')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}.${month}.${day} ${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 getAnalyticsPeriodLabel = () => {
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.label || '일간'
}
/**
* 게시물 통계 기간 조회 일수를 반환한다.
* @returns {number} 조회 일수
*/
const getAnalyticsPeriodDays = () => {
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.days || 14
}
/**
* 게시물 발행일 라벨을 반환한다.
@@ -248,6 +362,142 @@ const filteredPosts = computed(() => {
})
})
/** 게시물 통계 모달이 열려 있는지 */
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPost.value))
/** 통계 모달 검색 유입 조회수 */
const analyticsModalSearchViews = computed(() => getAnalyticsSourceGroupViews(['search']))
/** 통계 모달 SNS 유입 조회수 */
const analyticsModalSnsViews = computed(() => getAnalyticsSourceGroupViews(['sns']))
/** 통계 모달 기타 유입 조회수 */
const analyticsModalOtherViews = computed(() => getAnalyticsSourceGroupViews(['direct', 'other']))
/** 통계 모달 모바일 디바이스 행 */
const analyticsModalMobileDevice = computed(() => {
return (analyticsModalData.value.devices || []).find((row) => row.deviceType === '모바일')
})
/** 통계 모달 PC 디바이스 행 */
const analyticsModalPcDevice = computed(() => {
return (analyticsModalData.value.devices || []).find((row) => row.deviceType === 'PC')
})
/** 통계 모달 디바이스 총 조회수 */
const analyticsModalTotalDevicePageViews = computed(() => {
return (analyticsModalData.value.devices || []).reduce((sum, row) => sum + Number(row.pageViews || 0), 0)
})
/**
* 유입 그룹 표시명을 반환한다.
* @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 getAnalyticsSourceGroupViews = (groups) => {
return (analyticsModalData.value.sources || [])
.filter((source) => groups.includes(source.sourceGroup))
.reduce((sum, source) => sum + Number(source.pageViews || 0), 0)
}
/**
* 디바이스 조회 비율을 반환한다.
* @param {number} value - 조회수
* @returns {string} 비율
*/
const formatAnalyticsDevicePercent = (value) => {
const total = analyticsModalTotalDevicePageViews.value
if (!total) {
return '0.0%'
}
return `${((Number(value || 0) / total) * 100).toFixed(1)}%`
}
/**
* 게시물 통계 모달 데이터를 조회한다.
* @returns {Promise<void>} 조회 완료
*/
const fetchAnalyticsModalData = async () => {
if (!analyticsModalPost.value?.id) {
return
}
analyticsModalLoading.value = true
analyticsModalError.value = ''
try {
analyticsModalData.value = await $fetch(`/admin/api/analytics/posts/${analyticsModalPost.value.id}`, {
query: {
period: analyticsModalPeriod.value,
days: getAnalyticsPeriodDays()
}
})
} catch (error) {
analyticsModalData.value = createDefaultPostAnalytics()
analyticsModalError.value = error?.data?.message || '게시물 통계를 불러오지 못했습니다.'
} finally {
analyticsModalLoading.value = false
}
}
/**
* 게시물 통계 모달을 연다.
* @param {Object} post - 게시물
* @returns {Promise<void>} 모달 열기 완료
*/
const openAnalyticsModal = async (post) => {
closePostMenu()
analyticsModalPost.value = post
analyticsModalPeriod.value = 'daily'
analyticsModalMetric.value = 'views'
analyticsModalChartPageOffset.value = 0
analyticsModalData.value = createDefaultPostAnalytics()
await fetchAnalyticsModalData()
}
/**
* 게시물 통계 모달을 닫는다.
* @returns {void}
*/
const closeAnalyticsModal = () => {
analyticsModalPost.value = null
analyticsModalError.value = ''
analyticsModalData.value = createDefaultPostAnalytics()
}
/**
* 통계 모달 집계 단위를 변경한다.
* @param {string} period - 집계 단위
* @returns {Promise<void>} 기간 변경 완료
*/
const selectAnalyticsModalPeriod = async (period) => {
analyticsModalPeriod.value = period
analyticsModalChartPageOffset.value = 0
await fetchAnalyticsModalData()
}
/**
* 게시물 수정 API 본문을 구성한다.
* @param {Object} post - 게시물
@@ -351,6 +601,17 @@ const onPostMenuDocumentPointerDown = (event) => {
closePostMenu()
}
/**
* 문서 키 입력을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const onDocumentKeydown = (event) => {
if (event.key === 'Escape' && isAnalyticsModalOpen.value) {
closeAnalyticsModal()
}
}
/**
* 게시물 추천 여부를 토글한다.
* @param {Object} post - 게시물
@@ -403,12 +664,14 @@ const deletePost = async (post) => {
onMounted(() => {
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
document.addEventListener('keydown', onDocumentKeydown)
window.addEventListener('resize', updatePostMenuPosition)
window.addEventListener('scroll', updatePostMenuPosition, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
document.removeEventListener('keydown', onDocumentKeydown)
window.removeEventListener('resize', updatePostMenuPosition)
window.removeEventListener('scroll', updatePostMenuPosition, true)
})
@@ -531,6 +794,7 @@ watch(openPostMenuId, async (postId) => {
<th class="admin-posts__cell px-4 py-3">제목</th>
<th class="admin-posts__cell px-4 py-3">상태</th>
<th class="admin-posts__cell px-4 py-3">태그</th>
<th class="admin-posts__cell px-4 py-3">조회수</th>
<th class="admin-posts__cell px-4 py-3">발행일</th>
<th class="admin-posts__cell admin-posts__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
@@ -596,6 +860,19 @@ watch(openPostMenuId, async (postId) => {
</div>
<span v-else class="admin-posts__tag-empty text-muted">-</span>
</td>
<td class="admin-posts__cell admin-posts__cell-views px-4 py-4">
<p class="admin-posts__view-count text-[#394047]">
{{ formatAdminNumber(post.totalViews) }}
</p>
<button
class="admin-posts__analytics-link mt-1 inline-flex text-xs text-muted transition-colors hover:text-[#2f6feb]"
type="button"
@pointerdown.stop
@click.stop="openAnalyticsModal(post)"
>
통계
</button>
</td>
<td class="admin-posts__cell px-4 py-4">
<p class="admin-posts__publish-date text-[#394047]">
{{ getPublishDateLabel(post) }}
@@ -672,5 +949,239 @@ watch(openPostMenuId, async (postId) => {
<p v-if="filteredPosts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
{{ posts.length === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
</p>
<Teleport to="body">
<div
v-if="isAnalyticsModalOpen"
class="admin-posts__analytics-modal fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-4 py-6"
role="dialog"
aria-modal="true"
aria-labelledby="admin-posts-analytics-modal-title"
@pointerdown.self="closeAnalyticsModal"
>
<article class="admin-posts__analytics-modal-panel max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-lg border border-line bg-white shadow-xl">
<header class="admin-posts__analytics-modal-header flex items-start justify-between gap-4 border-b border-line px-5 py-4">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase text-muted">
게시물 통계
</p>
<h2 id="admin-posts-analytics-modal-title" class="mt-1 truncate text-lg font-semibold text-ink">
{{ analyticsModalPost?.title || '(제목 없음)' }}
</h2>
<p class="mt-1 text-xs text-muted">
{{ formatAdminDateTimeLabel(analyticsModalData.updatedAt) }} 기준
</p>
</div>
<button
class="admin-posts__analytics-modal-close border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
type="button"
@click="closeAnalyticsModal"
>
닫기
</button>
</header>
<div class="admin-posts__analytics-modal-body space-y-4 p-5">
<p v-if="analyticsModalLoading" class="text-sm text-muted">
게시물 통계를 불러오는 중입니다.
</p>
<p v-else-if="analyticsModalError" class="text-sm text-red-600">
{{ analyticsModalError }}
</p>
<template v-else>
<div class="admin-posts__analytics-modal-summary grid gap-3 sm:grid-cols-4">
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
누적 조회수
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.total.views) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
방문자
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.period.visitors) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
평균 체류
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatEngagedDuration(analyticsModalData.period.avgEngagedSeconds) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
50% 스크롤
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.period.scroll50) }}
</strong>
</article>
</div>
<section class="admin-posts__analytics-modal-chart border border-line bg-paper p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-xs font-semibold uppercase text-muted">
통계 추이
</h3>
<p class="mt-1 text-xs text-muted">
{{ getAnalyticsPeriodLabel() }} 기준
</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="inline-flex border border-line bg-white">
<button
v-for="metric in analyticsMetricOptions"
:key="metric.value"
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
:class="analyticsModalMetric === metric.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
type="button"
@click="analyticsModalMetric = metric.value"
>
{{ metric.label }}
</button>
</div>
<div class="inline-flex border border-line bg-white">
<button
v-for="option in analyticsPeriodOptions"
:key="option.value"
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
:class="analyticsModalPeriod === option.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
type="button"
@click="selectAnalyticsModalPeriod(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<AdminAnalyticsTrendChart
class="mt-4"
:rows="analyticsModalData.trends"
:period="analyticsModalPeriod"
:metric-key="analyticsModalMetric"
:metric-label="analyticsModalMetric === 'views' ? '조회수' : '방문자'"
:format-value="formatAdminNumber"
v-model:page-offset="analyticsModalChartPageOffset"
compact
bar-height-class="h-36"
/>
</section>
<div class="grid gap-4 lg:grid-cols-2">
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
검색
</h3>
<p class="mt-1 text-xs text-muted">
조회 {{ formatAdminNumber(analyticsModalSearchViews) }}
</p>
<ul class="mt-3 space-y-2">
<li
v-for="row in analyticsModalData.sources.filter((source) => source.sourceGroup === 'search')"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<span class="font-medium text-ink">{{ row.sourceName }}</span>
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
</li>
</ul>
<p v-if="!analyticsModalData.sources.some((source) => source.sourceGroup === 'search')" class="mt-3 text-xs text-muted">
해당 기간에 집계된 검색 유입이 없습니다.
</p>
</section>
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
SNS
</h3>
<p class="mt-1 text-xs text-muted">
조회 {{ formatAdminNumber(analyticsModalSnsViews) }}
</p>
<ul class="mt-3 space-y-2">
<li
v-for="row in analyticsModalData.sources.filter((source) => source.sourceGroup === 'sns')"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<span class="font-medium text-ink">{{ row.sourceName }}</span>
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
</li>
</ul>
<p v-if="!analyticsModalData.sources.some((source) => source.sourceGroup === 'sns')" class="mt-3 text-xs text-muted">
해당 기간에 집계된 SNS 유입이 없습니다.
</p>
</section>
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
기타
</h3>
<p class="mt-1 text-xs text-muted">
조회 {{ formatAdminNumber(analyticsModalOtherViews) }}
</p>
<ul class="mt-3 space-y-2">
<li
v-for="row in analyticsModalData.sources.filter((source) => ['direct', 'other'].includes(source.sourceGroup))"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<span class="font-medium text-ink">{{ getTrafficGroupLabel(row.sourceGroup) }} · {{ row.sourceName }}</span>
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
</li>
</ul>
<p v-if="!analyticsModalData.sources.some((source) => ['direct', 'other'].includes(source.sourceGroup))" class="mt-3 text-xs text-muted">
해당 기간에 집계된 기타 유입이 없습니다.
</p>
</section>
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
디바이스
</h3>
<ul class="mt-3 space-y-2">
<li class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs">
<span class="font-medium text-ink">모바일</span>
<span>{{ formatAnalyticsDevicePercent(analyticsModalMobileDevice?.pageViews) }}</span>
</li>
<li class="flex items-center justify-between gap-3 text-xs">
<span class="font-medium text-ink">PC</span>
<span>{{ formatAnalyticsDevicePercent(analyticsModalPcDevice?.pageViews) }}</span>
</li>
</ul>
</section>
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4 lg:col-span-2">
<h3 class="text-xs font-semibold uppercase text-muted">
유입 키워드
</h3>
<ul v-if="analyticsModalData.keywords.length" class="mt-3 grid gap-2 md:grid-cols-2">
<li
v-for="row in analyticsModalData.keywords"
:key="`${row.sourceName}-${row.keyword}`"
class="flex items-center justify-between gap-3 border border-line bg-white px-3 py-2 text-xs"
>
<span class="min-w-0 truncate font-medium text-ink">{{ row.keyword }}</span>
<span class="shrink-0 text-right text-muted">{{ row.sourceName }} · {{ formatAdminNumber(row.pageViews) }}</span>
</li>
</ul>
<p v-else class="mt-3 text-xs text-muted">
해당 기간에 수집된 유입 키워드가 없습니다.
</p>
</section>
</div>
</template>
</div>
</article>
</div>
</Teleport>
</section>
</template>