관리자 유입 통계 추가 v1.5.35

This commit is contained in:
2026-06-02 14:46:56 +09:00
parent 5b78a8c92f
commit 1bcd2f6898
15 changed files with 718 additions and 12 deletions

View File

@@ -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">
체류