인기 페이지 통계와 추천 사이트 메타데이터 추가 v1.5.9
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user