인기 페이지 통계와 추천 사이트 메타데이터 추가 v1.5.9

This commit is contained in:
2026-05-27 10:34:07 +09:00
parent d7a3149ea1
commit fd9416c0e4
22 changed files with 596 additions and 94 deletions

View File

@@ -43,6 +43,11 @@ const { data: topPosts, refresh: refreshTopPosts } = await useFetch('/admin/api/
default: () => []
})
const { data: topPages, refresh: refreshTopPages } = await useFetch('/admin/api/analytics/pages', {
query: topPostsQuery,
default: () => []
})
const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/analytics/realtime', {
query: { limit: 20 },
default: () => ({
@@ -250,6 +255,10 @@ const getSessionViewingTitle = (session) => {
return session.postTitle
}
if (session.pageTitle) {
return session.pageTitle
}
if (session.path === '/') {
return '홈'
}
@@ -263,6 +272,7 @@ onMounted(() => {
refreshTimer = window.setInterval(() => {
refreshSummary()
refreshTopPosts()
refreshTopPages()
refreshRealtime()
}, 30000)
})
@@ -276,6 +286,7 @@ onUnmounted(() => {
watch(selectedAnalyticsDays, () => {
refreshSummary()
refreshTopPosts()
refreshTopPages()
})
</script>
@@ -435,74 +446,145 @@ watch(selectedAnalyticsDays, () => {
</p>
</section>
<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="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"
>
<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>
<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>
<div class="mt-1">
<dt class="inline">
읽음
</dt>
<dd class="inline font-semibold text-ink">
{{ item.reads }}
</dd>
<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">
{{ 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>
<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>
<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>

View File

@@ -17,6 +17,8 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', {
const items = ref(navigationItems.value.map((item) => ({
...item,
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder),
isVisible: true
@@ -50,6 +52,8 @@ const serializeNavigationItems = (list) => JSON.stringify(
id: String(item.id || '').trim(),
label: String(item.label || '').trim(),
url: String(item.url || '').trim(),
descriptionText: String(item.descriptionText || '').trim(),
thumbnailUrl: String(item.thumbnailUrl || '').trim(),
location: item.location,
sortOrder: Number(item.sortOrder || 0),
parentId: item.parentId ? String(item.parentId).trim() : null
@@ -601,6 +605,8 @@ const addPrimaryRoot = () => {
id: crypto.randomUUID(),
label: '새 메뉴',
url: '/',
descriptionText: '',
thumbnailUrl: '',
location: 'primary',
parentId: null,
sortOrder: maxOrder + 10,
@@ -620,6 +626,8 @@ const addFooterItem = () => {
id: crypto.randomUUID(),
label: '',
url: '/',
descriptionText: '',
thumbnailUrl: '',
location: 'footer',
parentId: null,
sortOrder: maxOrder + 10,
@@ -639,6 +647,8 @@ const addRecommendedItem = () => {
id: crypto.randomUUID(),
label: '',
url: 'https://',
descriptionText: '',
thumbnailUrl: '',
location: 'recommended',
parentId: null,
sortOrder: maxOrder + 10,
@@ -668,6 +678,8 @@ const saveNavigation = async () => {
id: item.id,
label: item.label,
url: item.url,
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
location: item.location,
sortOrder: Number(item.sortOrder || 0),
isVisible: true,
@@ -679,6 +691,8 @@ const saveNavigation = async () => {
items.value = savedItems.map((item) => ({
...item,
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder),
isVisible: true
@@ -941,7 +955,7 @@ const saveNavigation = async () => {
<div v-show="activeTab === 'recommended'" class="admin-navigation__panel-recommended space-y-4">
<p class="admin-navigation__recommended-note max-w-xl text-sm text-muted">
공개 우측 사이드바 Recommended 영역에 표시됩니다. <code class="rounded bg-[#f0f1f3] px-1 py-0.5 text-xs">https://</code> 링크는 아이콘에 Google 파비콘 프록시를 사용합니다(내부 경로 <code class="rounded bg-[#f0f1f3] px-1 py-0.5 text-xs">/</code>만 있으면 아이콘은 생략).
공개 우측 사이드바 Recommended 영역에 표시됩니다. 대체 텍스트가 있으면 카드 하단에 URL 대신 표시하고, 썸네일 URL이 있으면 Google 파비콘 대신 썸네일을 사용합니다.
</p>
<div class="flex flex-wrap gap-2">
<button
@@ -970,6 +984,12 @@ const saveNavigation = async () => {
<th class="admin-navigation__recommended-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
대체 텍스트
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
썸네일 URL
</th>
<th class="admin-navigation__recommended-cell admin-navigation__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
@@ -1008,6 +1028,22 @@ const saveNavigation = async () => {
required
>
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<input
v-model="item.descriptionText"
class="w-full min-w-[10rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL 대신 표시할 문구"
>
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<input
v-model="item.thumbnailUrl"
class="w-full min-w-[12rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="/uploads/…"
>
</td>
<td class="admin-navigation__recommended-cell admin-navigation__cell-actions relative w-12 px-2 py-4 text-right">
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"