916 lines
32 KiB
Vue
916 lines
32 KiB
Vue
<script setup>
|
||
import { ANALYTICS_COLLECTED_SINCE_DEFAULT, getAnalyticsDayKey } from '~/lib/analytics-shared.js'
|
||
import {
|
||
getAnalyticsTrendDescription,
|
||
getAnalyticsTrendDetailLabel,
|
||
isAnalyticsCurrentTrendRow,
|
||
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 { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/analytics/realtime', {
|
||
query: { limit: 20 },
|
||
default: () => ({
|
||
summary: {
|
||
onlineNow: 0,
|
||
loggedInNow: 0,
|
||
anonymousNow: 0
|
||
},
|
||
sessions: []
|
||
})
|
||
})
|
||
|
||
const todayKey = getAnalyticsDayKey()
|
||
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
|
||
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
|
||
const trendRows = computed(() => analyticsSummary.value.trends || [])
|
||
const analyticsTrendDescription = computed(() => {
|
||
return getAnalyticsTrendDescription(
|
||
selectedAnalyticsPeriod.value,
|
||
analyticsSummary.value?.collectedSince || ANALYTICS_COLLECTED_SINCE_DEFAULT
|
||
)
|
||
})
|
||
const selectedTrendRange = ref({
|
||
startDay: todayKey,
|
||
endDay: todayKey,
|
||
label: '오늘'
|
||
})
|
||
const shouldSelectDefaultTrendDetail = ref(true)
|
||
const trendDetailQuery = computed(() => ({
|
||
startDay: selectedTrendRange.value.startDay,
|
||
endDay: selectedTrendRange.value.endDay,
|
||
limit: 5
|
||
}))
|
||
const {
|
||
data: selectedTrendDetail,
|
||
pending: isTrendDetailLoading,
|
||
error: selectedTrendDetailError,
|
||
refresh: refreshTrendDetail
|
||
} = await useFetch('/admin/api/analytics/range-detail', {
|
||
query: trendDetailQuery,
|
||
default: () => ({
|
||
startDay: todayKey,
|
||
endDay: todayKey,
|
||
rangeDays: 1,
|
||
summary: {
|
||
pageViews: 0,
|
||
visitors: 0,
|
||
avgEngagedSeconds: 0,
|
||
scroll50Reach: 0
|
||
},
|
||
sources: [],
|
||
devices: [],
|
||
topPosts: [],
|
||
topPages: []
|
||
})
|
||
})
|
||
const selectedTrendDetailLabel = computed(() => selectedTrendRange.value.label || '오늘')
|
||
const trafficSources = computed(() => selectedTrendDetail.value?.sources || [])
|
||
const trafficDevices = computed(() => selectedTrendDetail.value?.devices || [])
|
||
const topPosts = computed(() => selectedTrendDetail.value?.topPosts || [])
|
||
const topPages = computed(() => selectedTrendDetail.value?.topPages || [])
|
||
const trendDetailError = computed(() => selectedTrendDetailError.value ? '선택한 기간 통계 상세를 불러오지 못했습니다.' : '')
|
||
const selectedSummaryPageViews = computed(() => Number(selectedTrendDetail.value?.summary?.pageViews || 0))
|
||
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)
|
||
}
|
||
|
||
/**
|
||
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
|
||
* @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 - 추세 행
|
||
* @param {{ manual?: boolean }} [options] - 선택 옵션
|
||
* @returns {void}
|
||
*/
|
||
const selectTrendDetail = (row, options = {}) => {
|
||
const startDay = row.startDay || row.day
|
||
const endDay = row.endDay || row.day
|
||
|
||
if (!startDay || !endDay) {
|
||
return
|
||
}
|
||
|
||
selectedTrendRange.value = {
|
||
startDay,
|
||
endDay,
|
||
label: getAnalyticsTrendDetailLabel(selectedAnalyticsPeriod.value, row)
|
||
}
|
||
|
||
if (options.manual) {
|
||
shouldSelectDefaultTrendDetail.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 현재 차트 기간에서 기본으로 보여줄 상세 행을 선택한다.
|
||
* @returns {void}
|
||
*/
|
||
const selectDefaultTrendDetail = () => {
|
||
const rows = trendRows.value
|
||
if (!rows.length) {
|
||
selectedTrendRange.value = {
|
||
startDay: todayKey,
|
||
endDay: todayKey,
|
||
label: '오늘'
|
||
}
|
||
shouldSelectDefaultTrendDetail.value = false
|
||
return
|
||
}
|
||
|
||
const currentRow = rows.find((row) => isAnalyticsCurrentTrendRow(selectedAnalyticsPeriod.value, row, todayKey))
|
||
selectTrendDetail(currentRow || rows[rows.length - 1])
|
||
shouldSelectDefaultTrendDetail.value = false
|
||
}
|
||
|
||
/**
|
||
* 현재 접속자가 보고 있는 화면명을 반환한다.
|
||
* @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 || '알 수 없음'
|
||
}
|
||
|
||
/**
|
||
* 통계 목록의 날짜를 짧은 문자열로 변환한다.
|
||
* @param {string | null | undefined} value - ISO 날짜
|
||
* @returns {string} 표시 날짜
|
||
*/
|
||
const formatCompactDate = (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 {Object} row - 통계 행
|
||
* @returns {string} 스크롤 50/75/100 도달 수
|
||
*/
|
||
const formatScrollCompact = (row) => {
|
||
return `${Number(row.scroll50 || 0)}.${Number(row.scroll75 || 0)}.${Number(row.scroll100 || 0)}`
|
||
}
|
||
|
||
/**
|
||
* 스크롤 도달 지표를 단계별 표시 조각으로 반환한다.
|
||
* @param {Object} row - 통계 행
|
||
* @returns {Array<{ key: string, value: number, colorClass: string }>} 스크롤 단계 조각
|
||
*/
|
||
const formatScrollParts = (row) => {
|
||
return [
|
||
{ key: '50', value: Number(row.scroll50 || 0), colorClass: 'text-[#a1a1aa]' },
|
||
{ key: '75', value: Number(row.scroll75 || 0), colorClass: 'text-[#71717a]' },
|
||
{ key: '100', value: Number(row.scroll100 || 0), colorClass: 'text-[#3f3f46]' }
|
||
]
|
||
}
|
||
|
||
/**
|
||
* 인기 게시물 메타 정보를 표시 조각으로 반환한다.
|
||
* @param {Object} row - 게시물 통계 행
|
||
* @returns {Array<{ key: string, label: string, value: string | number, colorClass: string }>} 압축 메타 조각
|
||
*/
|
||
const formatTopPostMeta = (row) => {
|
||
return [
|
||
{ key: 'views', label: 'V', value: Number(row.views || 0), colorClass: 'text-[#2563eb]' },
|
||
{ key: 'reads', label: 'R', value: Number(row.reads || 0), colorClass: 'text-[#16a34a]' },
|
||
{ key: 'monthly', label: 'M', value: Number(row.monthlyViews || 0), colorClass: 'text-[#d97706]' },
|
||
{ key: 'created', label: 'C', value: formatCompactDate(row.createdAt), colorClass: 'text-muted' },
|
||
{ key: 'scroll', label: 'S', value: formatScrollCompact(row), colorClass: 'text-muted', parts: formatScrollParts(row) }
|
||
]
|
||
}
|
||
|
||
/**
|
||
* 인기 페이지 메타 정보를 표시 조각으로 반환한다.
|
||
* @param {Object} row - 페이지 통계 행
|
||
* @returns {Array<{ key: string, label: string, value: string | number, colorClass: string }>} 압축 메타 조각
|
||
*/
|
||
const formatTopPageMeta = (row) => {
|
||
return [
|
||
{ key: 'views', label: 'V', value: Number(row.views || 0), colorClass: 'text-[#2563eb]' },
|
||
{ key: 'visitors', label: 'R', value: Number(row.visitors || 0), colorClass: 'text-[#16a34a]' },
|
||
{ key: 'monthly', label: 'M', value: Number(row.monthlyViews || 0), colorClass: 'text-[#d97706]' },
|
||
{ key: 'created', label: 'C', value: formatCompactDate(row.createdAt), colorClass: 'text-muted' },
|
||
{ key: 'scroll', label: 'S', value: formatScrollCompact(row), colorClass: 'text-muted', parts: formatScrollParts(row) }
|
||
]
|
||
}
|
||
|
||
/**
|
||
* 실시간 세션 해시를 짧게 표시한다.
|
||
* @param {string} value - 세션 해시
|
||
* @returns {string} 짧은 세션 ID
|
||
*/
|
||
const formatSessionId = (value) => {
|
||
return String(value || '').slice(0, 8) || '-'
|
||
}
|
||
|
||
let refreshTimer = null
|
||
|
||
onMounted(() => {
|
||
refreshTimer = window.setInterval(() => {
|
||
refreshSummary()
|
||
refreshTrendDetail()
|
||
refreshRealtime()
|
||
}, 30000)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (refreshTimer) {
|
||
window.clearInterval(refreshTimer)
|
||
}
|
||
})
|
||
|
||
watch(selectedAnalyticsPeriod, () => {
|
||
analyticsChartPageOffset.value = 0
|
||
shouldSelectDefaultTrendDetail.value = true
|
||
})
|
||
|
||
watch(trendRows, () => {
|
||
if (!shouldSelectDefaultTrendDetail.value) {
|
||
return
|
||
}
|
||
|
||
selectDefaultTrendDetail()
|
||
}, {
|
||
immediate: true
|
||
})
|
||
</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="(row) => selectTrendDetail(row, { manual: true })"
|
||
/>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="admin-dashboard__selected-detail 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">
|
||
선택한 기간 상세
|
||
</h2>
|
||
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
|
||
{{ selectedTrendDetailLabel }} 기준 유입·디바이스·인기 콘텐츠
|
||
</p>
|
||
</div>
|
||
<p class="admin-dashboard__traffic-total text-xs text-muted">
|
||
페이지뷰 {{ selectedSummaryPageViews }}회
|
||
</p>
|
||
</div>
|
||
|
||
<p
|
||
v-if="isTrendDetailLoading && !selectedTrendDetail"
|
||
class="admin-dashboard__selected-loading mt-4 text-sm text-muted"
|
||
>
|
||
선택한 기간 통계를 불러오는 중입니다.
|
||
</p>
|
||
<p
|
||
v-else-if="trendDetailError && !selectedTrendDetail"
|
||
class="admin-dashboard__selected-error mt-4 text-sm text-red-600"
|
||
>
|
||
{{ trendDetailError }}
|
||
</p>
|
||
|
||
<template v-if="selectedTrendDetail">
|
||
<div class="admin-dashboard__selected-summary mt-4 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="admin-dashboard__selected-grid mt-4 grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(240px,1fr)]">
|
||
<div class="admin-dashboard__top-content space-y-4">
|
||
<section class="admin-dashboard__top-posts border border-line bg-paper p-3">
|
||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||
인기 게시물
|
||
</h3>
|
||
<ul
|
||
v-if="topPosts.length"
|
||
class="mt-3 space-y-2"
|
||
>
|
||
<li
|
||
v-for="row in topPosts"
|
||
:key="row.id"
|
||
class="border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
||
>
|
||
<NuxtLink
|
||
:to="`/post/${row.slug}`"
|
||
class="block font-medium text-ink hover:underline"
|
||
target="_blank"
|
||
>
|
||
{{ row.title }}
|
||
</NuxtLink>
|
||
<p class="mt-1 overflow-x-auto whitespace-nowrap text-[11px]">
|
||
<template
|
||
v-for="(meta, metaIndex) in formatTopPostMeta(row)"
|
||
:key="meta.key"
|
||
>
|
||
<span
|
||
v-if="metaIndex > 0"
|
||
class="mx-1 text-[#d4d4d8]"
|
||
>
|
||
|
|
||
</span>
|
||
<span class="inline-flex items-center">
|
||
<span
|
||
class="mr-1 font-semibold"
|
||
:class="meta.colorClass"
|
||
>
|
||
{{ meta.label }}
|
||
</span>
|
||
<template v-if="meta.parts">
|
||
<template
|
||
v-for="(part, partIndex) in meta.parts"
|
||
:key="part.key"
|
||
>
|
||
<span
|
||
v-if="partIndex > 0"
|
||
class="text-[#d4d4d8]"
|
||
>
|
||
.
|
||
</span>
|
||
<span :class="part.colorClass">{{ part.value }}</span>
|
||
</template>
|
||
</template>
|
||
<template v-else>
|
||
<span class="text-muted">{{ meta.value }}</span>
|
||
</template>
|
||
</span>
|
||
</template>
|
||
</p>
|
||
</li>
|
||
</ul>
|
||
<p
|
||
v-else
|
||
class="mt-3 text-xs text-muted"
|
||
>
|
||
해당 기간에 집계된 게시물 조회가 없습니다.
|
||
</p>
|
||
</section>
|
||
|
||
<section class="admin-dashboard__top-pages border border-line bg-paper p-3">
|
||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||
인기 페이지
|
||
</h3>
|
||
<ul
|
||
v-if="topPages.length"
|
||
class="mt-3 space-y-2"
|
||
>
|
||
<li
|
||
v-for="row in topPages"
|
||
:key="row.id"
|
||
class="border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
||
>
|
||
<NuxtLink
|
||
:to="`/pages/${row.slug}`"
|
||
class="block font-medium text-ink hover:underline"
|
||
target="_blank"
|
||
>
|
||
{{ row.title }}
|
||
</NuxtLink>
|
||
<p class="mt-1 overflow-x-auto whitespace-nowrap text-[11px]">
|
||
<template
|
||
v-for="(meta, metaIndex) in formatTopPageMeta(row)"
|
||
:key="meta.key"
|
||
>
|
||
<span
|
||
v-if="metaIndex > 0"
|
||
class="mx-1 text-[#d4d4d8]"
|
||
>
|
||
|
|
||
</span>
|
||
<span class="inline-flex items-center">
|
||
<span
|
||
class="mr-1 font-semibold"
|
||
:class="meta.colorClass"
|
||
>
|
||
{{ meta.label }}
|
||
</span>
|
||
<template v-if="meta.parts">
|
||
<template
|
||
v-for="(part, partIndex) in meta.parts"
|
||
:key="part.key"
|
||
>
|
||
<span
|
||
v-if="partIndex > 0"
|
||
class="text-[#d4d4d8]"
|
||
>
|
||
.
|
||
</span>
|
||
<span :class="part.colorClass">{{ part.value }}</span>
|
||
</template>
|
||
</template>
|
||
<template v-else>
|
||
<span class="text-muted">{{ meta.value }}</span>
|
||
</template>
|
||
</span>
|
||
</template>
|
||
</p>
|
||
</li>
|
||
</ul>
|
||
<p
|
||
v-else
|
||
class="mt-3 text-xs text-muted"
|
||
>
|
||
해당 기간에 집계된 페이지 조회가 없습니다.
|
||
</p>
|
||
</section>
|
||
</div>
|
||
|
||
<aside class="admin-dashboard__side-detail space-y-4">
|
||
<section class="admin-dashboard__traffic-sources border border-line bg-paper p-3">
|
||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||
유입
|
||
</h3>
|
||
<div class="mt-3 grid gap-3 md:grid-cols-3 xl:grid-cols-1">
|
||
<article
|
||
v-for="group in ['search', 'sns', 'other']"
|
||
:key="group"
|
||
class="border border-line bg-white p-3"
|
||
>
|
||
<h4 class="text-xs font-semibold uppercase text-muted">
|
||
{{ getTrafficGroupLabel(group) }}
|
||
</h4>
|
||
<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 w-1/2 overflow-hidden rounded-full bg-paper">
|
||
<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>
|
||
</section>
|
||
|
||
<section 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>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
</template>
|
||
</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>
|
||
<p class="mt-1 text-[11px] leading-snug text-muted">
|
||
IP {{ session.ipAddress || '-' }} · SID {{ formatSessionId(session.sessionHash) }}
|
||
</p>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
<p
|
||
v-else
|
||
class="admin-dashboard__live-empty mt-4 text-sm text-muted"
|
||
>
|
||
현재 접속 중인 방문자가 없습니다.
|
||
</p>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</template>
|