관리자 통계 대시보드 개선 및 v1.9.6 반영
통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
426
pages/admin/posts/[id]/analytics.vue
Normal file
426
pages/admin/posts/[id]/analytics.vue
Normal 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">
|
||||
< 글 목록
|
||||
</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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user