관리자 유입 통계 추가 v1.5.35
This commit is contained in:
@@ -48,6 +48,16 @@ const { data: topPages, refresh: refreshTopPages } = await useFetch('/admin/api/
|
||||
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: () => ({
|
||||
@@ -68,6 +78,34 @@ const analyticsRangeLabel = computed(() => {
|
||||
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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
|
||||
@@ -123,6 +161,50 @@ const formatTrendValue = (key, value) => {
|
||||
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 - 지표 정보
|
||||
@@ -273,6 +355,7 @@ onMounted(() => {
|
||||
refreshSummary()
|
||||
refreshTopPosts()
|
||||
refreshTopPages()
|
||||
refreshTrafficSummary()
|
||||
refreshRealtime()
|
||||
}, 30000)
|
||||
})
|
||||
@@ -287,6 +370,7 @@ watch(selectedAnalyticsDays, () => {
|
||||
refreshSummary()
|
||||
refreshTopPosts()
|
||||
refreshTopPages()
|
||||
refreshTrafficSummary()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -393,6 +477,143 @@ watch(selectedAnalyticsDays, () => {
|
||||
</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">
|
||||
@@ -489,6 +710,22 @@ watch(selectedAnalyticsDays, () => {
|
||||
{{ 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">
|
||||
체류
|
||||
|
||||
Reference in New Issue
Block a user