828 lines
28 KiB
Vue
828 lines
28 KiB
Vue
<script setup>
|
|
definePageMeta({
|
|
layout: 'admin'
|
|
})
|
|
|
|
const { data: posts } = await useFetch('/admin/api/posts', {
|
|
default: () => []
|
|
})
|
|
|
|
const analyticsRangeOptions = [
|
|
{ label: '7일', days: 7 },
|
|
{ label: '30일', days: 30 },
|
|
{ label: '3개월', days: 90 },
|
|
{ label: '6개월', days: 180 },
|
|
{ label: '12개월', days: 365 }
|
|
]
|
|
const selectedAnalyticsDays = ref(30)
|
|
const analyticsQuery = computed(() => ({
|
|
days: selectedAnalyticsDays.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,
|
|
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 analyticsRangeOptions.find((option) => option.days === selectedAnalyticsDays.value)?.label || '30일'
|
|
})
|
|
const trendRows = computed(() => analyticsSummary.value.trends || [])
|
|
const trendStartDay = computed(() => trendRows.value[0]?.day || '')
|
|
const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '')
|
|
const trafficSources = computed(() => trafficSummary.value.sources || [])
|
|
const trafficDevices = computed(() => trafficSummary.value.devices || [])
|
|
const trafficKeywords = computed(() => trafficSummary.value.keywords || [])
|
|
const totalTrafficPageViews = computed(() => {
|
|
return trafficSources.value.reduce((sum, row) => sum + Number(row.pageViews || 0), 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)}%`
|
|
}
|
|
|
|
/**
|
|
* 추세 막대 툴팁 문구를 반환한다.
|
|
* @param {{ key: 'visitors' | 'avgEngagedSeconds' | 'scroll50Reach', title: string }} metric - 지표 정보
|
|
* @param {Object} row - 추세 행
|
|
* @returns {string} 툴팁 문구
|
|
*/
|
|
const getTrendTooltipLabel = (metric, row) => {
|
|
const dayLabel = row.label || formatTrendDayLabel(row.day)
|
|
return `${dayLabel} · ${metric.title} ${formatTrendValue(metric.key, row[metric.key])}`
|
|
}
|
|
|
|
const chartMetrics = computed(() => [
|
|
{
|
|
key: 'visitors',
|
|
title: '방문자수',
|
|
label: `${getTrendSummaryValue('visitors')}명`
|
|
},
|
|
{
|
|
key: 'avgEngagedSeconds',
|
|
title: '평균 체류시간',
|
|
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds'))
|
|
},
|
|
{
|
|
key: 'scroll50Reach',
|
|
title: '50% 스크롤 도달',
|
|
label: `${getTrendSummaryValue('scroll50Reach')}회`
|
|
}
|
|
])
|
|
|
|
/**
|
|
* 차트 막대 높이 비율을 반환한다.
|
|
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
|
|
* @param {Object} row - 추세 행
|
|
* @returns {number} 높이 %
|
|
*/
|
|
const getTrendBarHeight = (key, row) => {
|
|
const rows = chartTrendRows.value
|
|
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
|
|
|
|
if (maxValue <= 0) {
|
|
return 3
|
|
}
|
|
|
|
const value = Number(row[key] || 0)
|
|
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
|
|
}
|
|
|
|
/**
|
|
* 추세 시작·종료 날짜 라벨을 반환한다.
|
|
* @param {string} day - YYYY-MM-DD
|
|
* @returns {string} 날짜 라벨
|
|
*/
|
|
const formatTrendDayLabel = (day) => {
|
|
if (!day) {
|
|
return ''
|
|
}
|
|
|
|
const [, month, date] = day.split('-')
|
|
return `${month}.${date}`
|
|
}
|
|
|
|
/**
|
|
* 선택 기간에 맞는 차트 표시 집계 단위를 반환한다.
|
|
* @param {number} days - 선택 기간
|
|
* @returns {number} 묶을 일수
|
|
*/
|
|
const getTrendChartBucketSize = (days) => {
|
|
if (days <= 14) {
|
|
return 1
|
|
}
|
|
|
|
if (days <= 60) {
|
|
return 7
|
|
}
|
|
|
|
if (days <= 180) {
|
|
return 14
|
|
}
|
|
|
|
return 30
|
|
}
|
|
|
|
/**
|
|
* 차트 표시용 추세 묶음 행을 만든다.
|
|
* @param {Array<Object>} rows - 일자별 추세 행
|
|
* @param {number} bucketSize - 묶을 일수
|
|
* @returns {Array<Object>} 차트 표시 행
|
|
*/
|
|
const buildTrendChartRows = (rows, bucketSize) => {
|
|
if (bucketSize <= 1) {
|
|
return rows.map((row) => ({
|
|
...row,
|
|
label: formatTrendDayLabel(row.day)
|
|
}))
|
|
}
|
|
|
|
const buckets = []
|
|
for (let index = 0; index < rows.length; index += bucketSize) {
|
|
const bucketRows = rows.slice(index, index + bucketSize)
|
|
const firstRow = bucketRows[0]
|
|
const lastRow = bucketRows[bucketRows.length - 1]
|
|
const engagedRows = bucketRows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
|
|
const totalEngagedSeconds = engagedRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
|
|
const startLabel = formatTrendDayLabel(firstRow?.day)
|
|
const endLabel = formatTrendDayLabel(lastRow?.day)
|
|
|
|
buckets.push({
|
|
day: firstRow?.day || '',
|
|
label: startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`,
|
|
visitors: bucketRows.reduce((sum, row) => sum + Number(row.visitors || 0), 0),
|
|
avgEngagedSeconds: engagedRows.length ? Math.round(totalEngagedSeconds / engagedRows.length) : 0,
|
|
scroll50Reach: bucketRows.reduce((sum, row) => sum + Number(row.scroll50Reach || 0), 0)
|
|
})
|
|
}
|
|
|
|
return buckets
|
|
}
|
|
|
|
const chartTrendRows = computed(() => {
|
|
return buildTrendChartRows(trendRows.value, getTrendChartBucketSize(selectedAnalyticsDays.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(selectedAnalyticsDays, () => {
|
|
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">
|
|
선택한 기간의 방문자수, 평균 체류시간, 50% 스크롤 도달 추이
|
|
</p>
|
|
</div>
|
|
<div class="admin-dashboard__range flex flex-wrap gap-1">
|
|
<button
|
|
v-for="option in analyticsRangeOptions"
|
|
:key="option.days"
|
|
type="button"
|
|
class="admin-dashboard__range-button border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
|
|
:class="option.days === selectedAnalyticsDays ? 'bg-[#15171a] text-white hover:bg-[#15171a] hover:text-white' : 'bg-white'"
|
|
@click="selectedAnalyticsDays = option.days"
|
|
>
|
|
{{ option.label }}
|
|
</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>
|
|
<div
|
|
class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line"
|
|
role="img"
|
|
:aria-label="`${metric.title} ${analyticsRangeLabel} 추이`"
|
|
>
|
|
<div
|
|
v-for="row in chartTrendRows"
|
|
:key="`${metric.key}-${row.day}`"
|
|
class="admin-dashboard__chart-bar-wrap group relative flex h-full min-w-[3px] flex-1 items-end"
|
|
tabindex="0"
|
|
:aria-label="getTrendTooltipLabel(metric, row)"
|
|
>
|
|
<div
|
|
class="admin-dashboard__chart-tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 whitespace-nowrap rounded bg-[#15171a] px-2 py-1 text-[11px] font-medium text-white shadow-lg group-hover:block group-focus-visible:block"
|
|
>
|
|
{{ getTrendTooltipLabel(metric, row) }}
|
|
</div>
|
|
<div
|
|
class="admin-dashboard__chart-bar w-full rounded-t-sm bg-[#15171a] transition-colors group-hover:bg-[#2f6feb] group-focus-visible:bg-[#2f6feb]"
|
|
:style="{ height: `${getTrendBarHeight(metric.key, row)}%` }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p
|
|
v-if="!chartTrendRows.length"
|
|
class="admin-dashboard__chart-empty mt-4 text-center text-xs text-muted"
|
|
>
|
|
선택한 기간에 표시할 추이 데이터가 없습니다.
|
|
</p>
|
|
<div class="mt-2 flex justify-between text-[11px] text-muted">
|
|
<span>{{ formatTrendDayLabel(trendStartDay) }}</span>
|
|
<span>{{ analyticsRangeLabel }}</span>
|
|
<span>{{ formatTrendDayLabel(trendEndDay) }}</span>
|
|
</div>
|
|
</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>
|
|
</section>
|
|
</template>
|