관리자 통계 대시보드 개선 및 v1.9.6 반영
통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
<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'
|
||||
})
|
||||
@@ -8,15 +11,20 @@ const { data: posts } = await useFetch('/admin/api/posts', {
|
||||
})
|
||||
|
||||
const analyticsRangeOptions = [
|
||||
{ label: '7일', days: 7 },
|
||||
{ label: '30일', days: 30 },
|
||||
{ label: '3개월', days: 90 },
|
||||
{ label: '6개월', days: 180 },
|
||||
{ label: '12개월', days: 365 }
|
||||
{ label: '일일', period: 'daily', days: 7 },
|
||||
{ label: '주간', period: 'weekly', days: 56 },
|
||||
{ label: '월간', period: 'monthly', days: 365 },
|
||||
{ label: '년간', period: 'yearly', days: 365 * 8 }
|
||||
]
|
||||
const selectedAnalyticsDays = ref(30)
|
||||
const selectedAnalyticsPeriod = ref('weekly')
|
||||
const analyticsChartPageOffset = ref(0)
|
||||
const selectedAnalyticsRange = computed(() => {
|
||||
return analyticsRangeOptions.find((option) => option.period === selectedAnalyticsPeriod.value) || analyticsRangeOptions[1]
|
||||
})
|
||||
const selectedAnalyticsDays = computed(() => selectedAnalyticsRange.value.days)
|
||||
const analyticsQuery = computed(() => ({
|
||||
days: selectedAnalyticsDays.value
|
||||
days: selectedAnalyticsDays.value,
|
||||
period: selectedAnalyticsPeriod.value
|
||||
}))
|
||||
|
||||
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
|
||||
@@ -29,6 +37,7 @@ const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/adm
|
||||
loggedInNow: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50Reach: 0,
|
||||
collectedSince: ANALYTICS_COLLECTED_SINCE_DEFAULT,
|
||||
trends: []
|
||||
})
|
||||
})
|
||||
@@ -73,18 +82,55 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/
|
||||
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일'
|
||||
return selectedAnalyticsRange.value.label
|
||||
})
|
||||
const trendRows = computed(() => analyticsSummary.value.trends || [])
|
||||
const trendStartDay = computed(() => trendRows.value[0]?.day || '')
|
||||
const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '')
|
||||
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 - 날짜 값
|
||||
@@ -205,127 +251,86 @@ const formatTrafficPercent = (value) => {
|
||||
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')}명`
|
||||
label: `${getTrendSummaryValue('visitors')}명`,
|
||||
metricLabel: '방문자수'
|
||||
},
|
||||
{
|
||||
key: 'avgEngagedSeconds',
|
||||
title: '평균 체류시간',
|
||||
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds'))
|
||||
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds')),
|
||||
metricLabel: '평균 체류시간'
|
||||
},
|
||||
{
|
||||
key: 'scroll50Reach',
|
||||
title: '50% 스크롤 도달',
|
||||
label: `${getTrendSummaryValue('scroll50Reach')}회`
|
||||
label: `${getTrendSummaryValue('scroll50Reach')}회`,
|
||||
metricLabel: '50% 스크롤 도달'
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 차트 막대 높이 비율을 반환한다.
|
||||
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
|
||||
* 체류시간 증감 표시 문자열을 반환한다.
|
||||
* @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 {number} 높이 %
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const getTrendBarHeight = (key, row) => {
|
||||
const rows = chartTrendRows.value
|
||||
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
|
||||
const openTrendDetail = async (row) => {
|
||||
const startDay = row.startDay || row.day
|
||||
const endDay = row.endDay || row.day
|
||||
|
||||
if (maxValue <= 0) {
|
||||
return 3
|
||||
if (!startDay || !endDay) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = Number(row[key] || 0)
|
||||
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
|
||||
}
|
||||
isTrendDetailOpen.value = true
|
||||
isTrendDetailLoading.value = true
|
||||
trendDetailError.value = ''
|
||||
selectedTrendDetail.value = null
|
||||
selectedTrendDetailLabel.value = getAnalyticsTrendDetailLabel(selectedAnalyticsPeriod.value, row)
|
||||
|
||||
/**
|
||||
* 추세 시작·종료 날짜 라벨을 반환한다.
|
||||
* @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)
|
||||
try {
|
||||
selectedTrendDetail.value = await $fetch('/admin/api/analytics/range-detail', {
|
||||
query: {
|
||||
startDay,
|
||||
endDay,
|
||||
limit: 5
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
trendDetailError.value = '날짜별 통계 상세를 불러오지 못했습니다.'
|
||||
} finally {
|
||||
isTrendDetailLoading.value = false
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
const chartTrendRows = computed(() => {
|
||||
return buildTrendChartRows(trendRows.value, getTrendChartBucketSize(selectedAnalyticsDays.value))
|
||||
})
|
||||
/**
|
||||
* 추세 날짜 상세 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeTrendDetail = () => {
|
||||
isTrendDetailOpen.value = false
|
||||
selectedTrendDetail.value = null
|
||||
trendDetailError.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 접속자가 보고 있는 화면명을 반환한다.
|
||||
@@ -366,7 +371,8 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedAnalyticsDays, () => {
|
||||
watch(selectedAnalyticsPeriod, () => {
|
||||
analyticsChartPageOffset.value = 0
|
||||
refreshSummary()
|
||||
refreshTopPosts()
|
||||
refreshTopPages()
|
||||
@@ -408,20 +414,38 @@ watch(selectedAnalyticsDays, () => {
|
||||
통계 추이
|
||||
</h2>
|
||||
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
|
||||
선택한 기간의 방문자수, 평균 체류시간, 50% 스크롤 도달 추이
|
||||
{{ analyticsTrendDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-dashboard__range flex flex-wrap gap-1">
|
||||
<div class="admin-dashboard__range flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
v-for="option in analyticsRangeOptions"
|
||||
:key="option.days"
|
||||
:key="option.period"
|
||||
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"
|
||||
:class="option.period === selectedAnalyticsPeriod ? 'bg-[#15171a] text-white hover:bg-[#15171a] hover:text-white' : 'bg-white'"
|
||||
@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>
|
||||
|
||||
@@ -439,40 +463,23 @@ watch(selectedAnalyticsDays, () => {
|
||||
{{ 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>
|
||||
<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>
|
||||
@@ -823,5 +830,226 @@ watch(selectedAnalyticsDays, () => {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user