Files
sori.studio/pages/admin/index.vue
zenn 901463e5f3 관리자 통계 UI 개선 v1.9.7
기간 필터 hover 스타일 충돌을 수정하고, 통계 추이 기본값을 일간으로 변경한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:34:22 +09:00

1058 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
})
const { data: posts } = await useFetch('/admin/api/posts', {
default: () => []
})
const analyticsRangeOptions = [
{ label: '일간', period: 'daily', days: 7 },
{ label: '주간', period: 'weekly', days: 56 },
{ label: '월간', period: 'monthly', days: 365 },
{ label: '년간', period: 'yearly', days: 365 * 8 }
]
const selectedAnalyticsPeriod = ref('daily')
const analyticsChartPageOffset = ref(0)
const selectedAnalyticsRange = computed(() => {
return analyticsRangeOptions.find((option) => option.period === selectedAnalyticsPeriod.value) || analyticsRangeOptions[0]
})
const selectedAnalyticsDays = computed(() => selectedAnalyticsRange.value.days)
const analyticsQuery = computed(() => ({
days: selectedAnalyticsDays.value,
period: selectedAnalyticsPeriod.value
}))
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
query: analyticsQuery,
default: () => ({
todayVisitors: 0,
visitorsLast7Days: 0,
pageViewsLast30Days: 0,
onlineNow: 0,
loggedInNow: 0,
avgEngagedSeconds: 0,
scroll50Reach: 0,
collectedSince: ANALYTICS_COLLECTED_SINCE_DEFAULT,
trends: []
})
})
const topPostsQuery = computed(() => ({
days: selectedAnalyticsDays.value,
limit: 5
}))
const { data: topPosts, refresh: refreshTopPosts } = await useFetch('/admin/api/analytics/posts', {
query: topPostsQuery,
default: () => []
})
const { data: topPages, refresh: refreshTopPages } = await useFetch('/admin/api/analytics/pages', {
query: topPostsQuery,
default: () => []
})
const { data: trafficSummary, refresh: refreshTrafficSummary } = await useFetch('/admin/api/analytics/traffic', {
query: analyticsQuery,
default: () => ({
sources: [],
devices: [],
keywords: [],
days: 30
})
})
const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/analytics/realtime', {
query: { limit: 20 },
default: () => ({
summary: {
onlineNow: 0,
loggedInNow: 0,
anonymousNow: 0
},
sessions: []
})
})
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
const analyticsRangeLabel = computed(() => {
return selectedAnalyticsRange.value.label
})
const trendRows = computed(() => analyticsSummary.value.trends || [])
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 - 날짜 값
* @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}`
}
/**
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
* @param {number} seconds - 초
* @returns {string} 표시 문자열
*/
const formatEngagedDuration = (seconds) => {
const value = Number(seconds) || 0
if (value < 60) {
return `${value}초`
}
const minutes = Math.floor(value / 60)
const remainSeconds = value % 60
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
}
/**
* 선택 기간의 추세 합계를 반환한다.
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
* @returns {number} 합계 또는 평균
*/
const getTrendSummaryValue = (key) => {
const rows = trendRows.value
if (key === 'avgEngagedSeconds') {
const nonZeroRows = rows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
if (!nonZeroRows.length) {
return 0
}
const total = nonZeroRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
return Math.round(total / nonZeroRows.length)
}
return rows.reduce((sum, row) => sum + Number(row[key] || 0), 0)
}
/**
* 추세 값 표시 문자열을 반환한다.
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
* @param {number} value - 값
* @returns {string} 표시 문자열
*/
const formatTrendValue = (key, value) => {
if (key === 'avgEngagedSeconds') {
return formatEngagedDuration(value)
}
if (key === 'visitors') {
return `${Number(value || 0)}명`
}
return `${Number(value || 0)}회`
}
/**
* 유입 그룹 표시명을 반환한다.
* @param {string} sourceGroup - 유입 그룹
* @returns {string} 표시명
*/
const getTrafficGroupLabel = (sourceGroup) => {
if (sourceGroup === 'search') {
return '검색'
}
if (sourceGroup === 'sns') {
return 'SNS'
}
return '기타'
}
/**
* 유입 그룹별 행을 반환한다.
* @param {string} sourceGroup - 유입 그룹
* @returns {Array<Object>} 유입 행
*/
const getTrafficRowsByGroup = (sourceGroup) => {
if (sourceGroup === 'other') {
return trafficSources.value.filter((row) => row.sourceGroup === 'direct' || row.sourceGroup === 'other')
}
return trafficSources.value.filter((row) => row.sourceGroup === sourceGroup)
}
/**
* 유입 비율을 표시한다.
* @param {number} value - 값
* @returns {string} 비율
*/
const formatTrafficPercent = (value) => {
const total = totalTrafficPageViews.value
if (!total) {
return '0%'
}
return `${Math.round((Number(value || 0) / total) * 100)}%`
}
const chartMetrics = computed(() => [
{
key: 'visitors',
title: '방문자수',
label: `${getTrendSummaryValue('visitors')}명`,
metricLabel: '방문자수'
},
{
key: 'avgEngagedSeconds',
title: '평균 체류시간',
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds')),
metricLabel: '평균 체류시간'
},
{
key: 'scroll50Reach',
title: '50% 스크롤 도달',
label: `${getTrendSummaryValue('scroll50Reach')}회`,
metricLabel: '50% 스크롤 도달'
}
])
/**
* 체류시간 증감 표시 문자열을 반환한다.
* @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 {Promise<void>}
*/
const openTrendDetail = async (row) => {
const startDay = row.startDay || row.day
const endDay = row.endDay || row.day
if (!startDay || !endDay) {
return
}
isTrendDetailOpen.value = true
isTrendDetailLoading.value = true
trendDetailError.value = ''
selectedTrendDetail.value = null
selectedTrendDetailLabel.value = getAnalyticsTrendDetailLabel(selectedAnalyticsPeriod.value, row)
try {
selectedTrendDetail.value = await $fetch('/admin/api/analytics/range-detail', {
query: {
startDay,
endDay,
limit: 5
}
})
} catch {
trendDetailError.value = '날짜별 통계 상세를 불러오지 못했습니다.'
} finally {
isTrendDetailLoading.value = false
}
}
/**
* 추세 날짜 상세 모달을 닫는다.
* @returns {void}
*/
const closeTrendDetail = () => {
isTrendDetailOpen.value = false
selectedTrendDetail.value = null
trendDetailError.value = ''
}
/**
* 현재 접속자가 보고 있는 화면명을 반환한다.
* @param {Object} session - 접속 세션
* @returns {string} 화면명
*/
const getSessionViewingTitle = (session) => {
if (session.postTitle) {
return session.postTitle
}
if (session.pageTitle) {
return session.pageTitle
}
if (session.path === '/') {
return '홈'
}
return session.path || '알 수 없음'
}
let refreshTimer = null
onMounted(() => {
refreshTimer = window.setInterval(() => {
refreshSummary()
refreshTopPosts()
refreshTopPages()
refreshTrafficSummary()
refreshRealtime()
}, 30000)
})
onUnmounted(() => {
if (refreshTimer) {
window.clearInterval(refreshTimer)
}
})
watch(selectedAnalyticsPeriod, () => {
analyticsChartPageOffset.value = 0
refreshSummary()
refreshTopPosts()
refreshTopPages()
refreshTrafficSummary()
})
</script>
<template>
<section class="admin-dashboard">
<div class="admin-dashboard__header border-b border-line bg-paper p-6">
<p class="admin-dashboard__eyebrow text-xs font-semibold uppercase text-muted">
Admin
</p>
<h1 class="admin-dashboard__title mt-2 text-3xl font-semibold">
대시보드
</h1>
</div>
<div class="admin-dashboard__body space-y-6 bg-paper p-6">
<section class="admin-dashboard__summary flex flex-wrap items-center gap-x-8 gap-y-2 border border-line bg-white px-4 py-3 text-sm">
<p class="admin-dashboard__summary-item text-muted">
현재 접속자
<strong class="ml-2 text-lg text-ink">{{ realtime.summary.onlineNow }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
오늘 접속자
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.todayVisitors }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
게시물 수
<strong class="ml-2 text-lg text-ink">{{ posts.length }}</strong>
<span class="ml-1 text-xs">발행 {{ publishedCount }} · 초안 {{ draftCount }}</span>
</p>
</section>
<section class="admin-dashboard__charts border border-line bg-white p-4">
<div class="admin-dashboard__charts-header flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
통계 추이
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
{{ analyticsTrendDescription }}
</p>
</div>
<div class="admin-dashboard__range flex flex-wrap items-center gap-1">
<button
v-for="option in analyticsRangeOptions"
:key="option.period"
type="button"
class="admin-dashboard__range-button border border-line px-3 py-1 text-xs font-medium transition-colors"
:class="option.period === selectedAnalyticsPeriod
? 'bg-[#15171a] !text-white hover:!bg-[#15171a] hover:!text-white'
: 'bg-white text-muted hover:bg-paper hover:text-ink'"
@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>
<div class="admin-dashboard__chart-grid mt-4 grid gap-4 lg:grid-cols-3">
<article
v-for="metric in chartMetrics"
:key="metric.key"
class="admin-dashboard__chart border border-line bg-paper p-4"
>
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold uppercase text-muted">
{{ metric.title }}
</p>
<strong class="text-sm text-ink">
{{ metric.label }}
</strong>
</div>
<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>
<section class="admin-dashboard__traffic border border-line bg-white p-4">
<div class="admin-dashboard__traffic-header flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
방문자 유입 정보 ({{ analyticsRangeLabel }})
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
검색·SNS·직접 유입과 디바이스 기준 집계
</p>
</div>
<p class="admin-dashboard__traffic-total text-xs text-muted">
페이지뷰 {{ totalTrafficPageViews }}회
</p>
</div>
<div class="admin-dashboard__traffic-grid mt-4 grid gap-4 xl:grid-cols-[1.4fr_1fr]">
<div class="admin-dashboard__traffic-sources grid gap-3 md:grid-cols-3">
<article
v-for="group in ['search', 'sns', 'other']"
:key="group"
class="admin-dashboard__traffic-source border border-line bg-paper p-3"
>
<h3 class="text-xs font-semibold uppercase text-muted">
{{ getTrafficGroupLabel(group) }}
</h3>
<ul
v-if="getTrafficRowsByGroup(group).length"
class="mt-3 space-y-2"
>
<li
v-for="row in getTrafficRowsByGroup(group)"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="text-xs"
>
<div class="flex items-center justify-between gap-3">
<span class="font-medium text-ink">{{ row.sourceName }}</span>
<span class="text-muted">{{ formatTrafficPercent(row.pageViews) }}</span>
</div>
<div class="mt-1 h-1.5 overflow-hidden rounded-full bg-white">
<div
class="h-full rounded-full bg-[#15171a]"
:style="{ width: formatTrafficPercent(row.pageViews) }"
/>
</div>
<p class="mt-1 text-[11px] text-muted">
조회 {{ row.pageViews }} · 방문자 {{ row.visitors }}
</p>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
아직 집계된 데이터가 없습니다.
</p>
</article>
</div>
<article class="admin-dashboard__traffic-devices border border-line bg-paper p-3">
<h3 class="text-xs font-semibold uppercase text-muted">
디바이스
</h3>
<ul
v-if="trafficDevices.length"
class="mt-3 space-y-2"
>
<li
v-for="row in trafficDevices"
: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"
>
<div>
<p class="font-medium text-ink">
{{ row.deviceType }}
</p>
<p class="mt-0.5 text-[11px] text-muted">
{{ row.osName }}
</p>
</div>
<p class="text-right text-muted">
<span class="font-semibold text-ink">{{ row.pageViews }}</span>회
<br>
{{ row.visitors }}명
</p>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
아직 집계된 디바이스 데이터가 없습니다.
</p>
</article>
</div>
<article class="admin-dashboard__traffic-keywords mt-4 border border-line bg-paper p-3">
<div class="flex items-center justify-between gap-3">
<h3 class="text-xs font-semibold uppercase text-muted">
유입 키워드
</h3>
<p class="text-[11px] text-muted">
검색엔진이 키워드를 전달한 경우만 표시
</p>
</div>
<ul
v-if="trafficKeywords.length"
class="mt-3 grid gap-2 md:grid-cols-2"
>
<li
v-for="row in trafficKeywords"
:key="`${row.sourceName}-${row.keyword}`"
class="flex items-center justify-between gap-3 border border-line bg-white px-3 py-2 text-xs"
>
<div class="min-w-0">
<p class="truncate font-medium text-ink">
{{ row.keyword }}
</p>
<p class="mt-0.5 text-[11px] text-muted">
{{ row.sourceName }}
</p>
</div>
<p class="shrink-0 text-right text-muted">
<span class="font-semibold text-ink">{{ row.pageViews }}</span>회
<br>
{{ row.visitors }}명
</p>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
아직 수집된 유입 키워드가 없습니다.
</p>
</article>
</section>
<section class="admin-dashboard__live border border-line bg-white p-4">
<div class="admin-dashboard__live-header flex items-center justify-between gap-4">
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
현재 접속자
</h2>
<p class="admin-dashboard__live-count text-xs text-muted">
{{ realtime.sessions.length }}명 표시
</p>
</div>
<ul
v-if="realtime.sessions.length"
class="admin-dashboard__live-list mt-4 divide-y divide-line"
>
<li
v-for="session in realtime.sessions"
:key="session.sessionHash"
class="admin-dashboard__live-item flex items-center gap-3 py-3"
>
<img
v-if="session.user?.avatarUrl"
:src="session.user.avatarUrl"
:alt="session.user.username"
class="admin-dashboard__live-avatar h-9 w-9 shrink-0 rounded-full object-cover"
>
<div
v-else
class="admin-dashboard__live-avatar admin-dashboard__live-avatar--placeholder flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-paper text-xs font-semibold text-muted"
>
{{ session.isLoggedIn ? (session.user?.username || '?').slice(0, 1) : '?' }}
</div>
<div class="min-w-0 flex-1">
<p class="admin-dashboard__live-name text-sm font-medium text-ink">
{{ session.isLoggedIn ? session.user?.username : '익명 방문자' }}
</p>
<p class="admin-dashboard__live-path mt-0.5 truncate text-xs text-muted">
{{ getSessionViewingTitle(session) }}
</p>
</div>
<div class="admin-dashboard__live-meta shrink-0 text-right text-xs text-muted">
<p class="font-medium text-ink">
접속 유지 {{ formatEngagedDuration(session.durationSeconds) }}
</p>
</div>
</li>
</ul>
<p
v-else
class="admin-dashboard__live-empty mt-4 text-sm text-muted"
>
현재 접속 중인 방문자가 없습니다.
</p>
</section>
<div class="admin-dashboard__popular-grid grid gap-6 xl:grid-cols-2">
<section class="admin-dashboard__top-posts border border-line bg-white p-4">
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
인기 게시물 ({{ analyticsRangeLabel }})
</h2>
<ul
v-if="topPosts.length"
class="admin-dashboard__top-posts-list mt-4 space-y-3 text-sm"
>
<li
v-for="(item, index) in topPosts"
:key="item.id"
class="admin-dashboard__top-posts-item flex items-start justify-between gap-4 border-b border-line pb-3 last:border-b-0 last:pb-0"
>
<div class="min-w-0 flex-1">
<p class="admin-dashboard__top-posts-rank text-xs font-semibold uppercase text-muted">
#{{ index + 1 }}
</p>
<NuxtLink
:to="`/post/${item.slug}`"
class="admin-dashboard__top-posts-title mt-1 block truncate font-medium text-ink hover:underline"
target="_blank"
>
{{ item.title }}
</NuxtLink>
</div>
<dl class="admin-dashboard__top-posts-stats shrink-0 text-right text-xs text-muted">
<div>
<dt class="inline">
조회
</dt>
<dd class="inline font-semibold text-ink">
{{ item.views }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
읽음
</dt>
<dd class="inline font-semibold text-ink">
{{ item.reads }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
월간
</dt>
<dd class="inline font-semibold text-ink">
{{ item.monthlyViews }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
작성일
</dt>
<dd class="inline font-semibold text-ink">
{{ formatAdminDate(item.createdAt) }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
체류
</dt>
<dd class="inline font-semibold text-ink">
{{ formatEngagedDuration(item.avgEngagedSeconds) }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
50/75/100%
</dt>
<dd class="inline font-semibold text-ink">
{{ item.scroll50 }}/{{ item.scroll75 }}/{{ item.scroll100 }}
</dd>
</div>
</dl>
</li>
</ul>
<p
v-else
class="admin-dashboard__top-posts-empty mt-4 text-sm text-muted"
>
아직 집계된 게시물 조회 데이터가 없습니다.
</p>
</section>
<section class="admin-dashboard__top-pages border border-line bg-white p-4">
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
인기 페이지 ({{ analyticsRangeLabel }})
</h2>
<ul
v-if="topPages.length"
class="admin-dashboard__top-pages-list mt-4 space-y-3 text-sm"
>
<li
v-for="(item, index) in topPages"
:key="item.id"
class="admin-dashboard__top-pages-item flex items-start justify-between gap-4 border-b border-line pb-3 last:border-b-0 last:pb-0"
>
<div class="min-w-0 flex-1">
<p class="admin-dashboard__top-pages-rank text-xs font-semibold uppercase text-muted">
#{{ index + 1 }}
</p>
<NuxtLink
:to="`/pages/${item.slug}`"
class="admin-dashboard__top-pages-title mt-1 block truncate font-medium text-ink hover:underline"
target="_blank"
>
{{ item.title }}
</NuxtLink>
</div>
<dl class="admin-dashboard__top-pages-stats shrink-0 text-right text-xs text-muted">
<div>
<dt class="inline">
조회
</dt>
<dd class="inline font-semibold text-ink">
{{ item.views }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
방문자
</dt>
<dd class="inline font-semibold text-ink">
{{ item.visitors }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
체류
</dt>
<dd class="inline font-semibold text-ink">
{{ formatEngagedDuration(item.avgEngagedSeconds) }}
</dd>
</div>
<div class="mt-1">
<dt class="inline">
50/75/100%
</dt>
<dd class="inline font-semibold text-ink">
{{ item.scroll50 }}/{{ item.scroll75 }}/{{ item.scroll100 }}
</dd>
</div>
</dl>
</li>
</ul>
<p
v-else
class="admin-dashboard__top-pages-empty mt-4 text-sm text-muted"
>
아직 집계된 페이지 조회 데이터가 없습니다.
</p>
</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>