기간 필터 hover 스타일 충돌을 수정하고, 통계 추이 기본값을 일간으로 변경한다. Co-authored-by: Cursor <cursoragent@cursor.com>
1058 lines
38 KiB
Vue
1058 lines
38 KiB
Vue
<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>
|