v1.3.5: 관리자 로그인·대시보드 차트·통계 보관 정리
운영 HTTP에서 관리자 세션이 유지되지 않던 문제를 쿠키 공통화로 수정하고, 통계 클라이언트 분리·조회 오류·기간별 차트를 보강했다. 방문자 해시는 32일 초과분만 정리하고 일별 집계는 누적 보관한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,7 +7,20 @@ const { data: posts } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const analyticsRangeOptions = [
|
||||
{ label: '7일', days: 7 },
|
||||
{ label: '30일', days: 30 },
|
||||
{ label: '3개월', days: 90 },
|
||||
{ label: '6개월', days: 180 },
|
||||
{ label: '12개월', days: 365 }
|
||||
]
|
||||
const selectedAnalyticsDays = ref(30)
|
||||
const analyticsQuery = computed(() => ({
|
||||
days: selectedAnalyticsDays.value
|
||||
}))
|
||||
|
||||
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
|
||||
query: analyticsQuery,
|
||||
default: () => ({
|
||||
todayVisitors: 0,
|
||||
visitorsLast7Days: 0,
|
||||
@@ -15,12 +28,18 @@ const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/adm
|
||||
onlineNow: 0,
|
||||
loggedInNow: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50Reach: 0
|
||||
scroll50Reach: 0,
|
||||
trends: []
|
||||
})
|
||||
})
|
||||
|
||||
const { data: topPosts } = await useFetch('/admin/api/analytics/posts', {
|
||||
query: { days: 30, limit: 5 },
|
||||
const topPostsQuery = computed(() => ({
|
||||
days: selectedAnalyticsDays.value,
|
||||
limit: 5
|
||||
}))
|
||||
|
||||
const { data: topPosts, refresh: refreshTopPosts } = await useFetch('/admin/api/analytics/posts', {
|
||||
query: topPostsQuery,
|
||||
default: () => []
|
||||
})
|
||||
|
||||
@@ -38,6 +57,12 @@ 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일'
|
||||
})
|
||||
const trendRows = computed(() => analyticsSummary.value.trends || [])
|
||||
const trendStartDay = computed(() => trendRows.value[0]?.day || '')
|
||||
const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '')
|
||||
|
||||
/**
|
||||
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
|
||||
@@ -56,24 +81,104 @@ const formatEngagedDuration = (seconds) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 활동 시각을 상대 표시로 변환한다.
|
||||
* @param {string|null} iso - ISO 시각
|
||||
* @returns {string} 상대 시각
|
||||
* 선택 기간의 추세 합계를 반환한다.
|
||||
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
|
||||
* @returns {number} 합계 또는 평균
|
||||
*/
|
||||
const formatLastSeen = (iso) => {
|
||||
if (!iso) {
|
||||
return '-'
|
||||
const getTrendSummaryValue = (key) => {
|
||||
const rows = trendRows.value
|
||||
if (key === 'avgEngagedSeconds') {
|
||||
const nonZeroRows = rows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
|
||||
if (!nonZeroRows.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const total = nonZeroRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
|
||||
return Math.round(total / nonZeroRows.length)
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - new Date(iso).getTime()
|
||||
const diffSec = Math.max(Math.floor(diffMs / 1000), 0)
|
||||
return rows.reduce((sum, row) => sum + Number(row[key] || 0), 0)
|
||||
}
|
||||
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec}초 전`
|
||||
/**
|
||||
* 추세 값 표시 문자열을 반환한다.
|
||||
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
|
||||
* @param {number} value - 값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatTrendValue = (key, value) => {
|
||||
if (key === 'avgEngagedSeconds') {
|
||||
return formatEngagedDuration(value)
|
||||
}
|
||||
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
return `${diffMin}분 전`
|
||||
return `${Number(value || 0)}`
|
||||
}
|
||||
|
||||
const chartMetrics = computed(() => [
|
||||
{
|
||||
key: 'visitors',
|
||||
title: '방문자수',
|
||||
label: `${getTrendSummaryValue('visitors')}명`
|
||||
},
|
||||
{
|
||||
key: 'avgEngagedSeconds',
|
||||
title: '평균 체류시간',
|
||||
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds'))
|
||||
},
|
||||
{
|
||||
key: 'scroll50Reach',
|
||||
title: '50% 스크롤 도달',
|
||||
label: `${getTrendSummaryValue('scroll50Reach')}회`
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 차트 막대 높이 비율을 반환한다.
|
||||
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
|
||||
* @param {Object} row - 추세 행
|
||||
* @returns {number} 높이 %
|
||||
*/
|
||||
const getTrendBarHeight = (key, row) => {
|
||||
const rows = trendRows.value
|
||||
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
|
||||
|
||||
if (maxValue <= 0) {
|
||||
return 3
|
||||
}
|
||||
|
||||
const value = Number(row[key] || 0)
|
||||
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* 추세 시작·종료 날짜 라벨을 반환한다.
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @returns {string} 날짜 라벨
|
||||
*/
|
||||
const formatTrendDayLabel = (day) => {
|
||||
if (!day) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const [, month, date] = day.split('-')
|
||||
return `${month}.${date}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 접속자가 보고 있는 화면명을 반환한다.
|
||||
* @param {Object} session - 접속 세션
|
||||
* @returns {string} 화면명
|
||||
*/
|
||||
const getSessionViewingTitle = (session) => {
|
||||
if (session.postTitle) {
|
||||
return session.postTitle
|
||||
}
|
||||
|
||||
if (session.path === '/') {
|
||||
return '홈'
|
||||
}
|
||||
|
||||
return session.path || '알 수 없음'
|
||||
}
|
||||
|
||||
let refreshTimer = null
|
||||
@@ -81,6 +186,7 @@ let refreshTimer = null
|
||||
onMounted(() => {
|
||||
refreshTimer = window.setInterval(() => {
|
||||
refreshSummary()
|
||||
refreshTopPosts()
|
||||
refreshRealtime()
|
||||
}, 30000)
|
||||
})
|
||||
@@ -90,6 +196,11 @@ onUnmounted(() => {
|
||||
window.clearInterval(refreshTimer)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedAnalyticsDays, () => {
|
||||
refreshSummary()
|
||||
refreshTopPosts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,51 +214,80 @@ onUnmounted(() => {
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-dashboard__body space-y-6 bg-paper p-6">
|
||||
<section class="admin-dashboard__analytics grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
현재 접속자
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ realtime.summary.onlineNow }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
로그인 {{ realtime.summary.loggedInNow }} · 익명 {{ realtime.summary.anonymousNow }}
|
||||
</p>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
오늘 방문
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.todayVisitors }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
7일 {{ analyticsSummary.visitorsLast7Days }}
|
||||
</p>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
평균 체류
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ formatEngagedDuration(analyticsSummary.avgEngagedSeconds) }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
30일 기준
|
||||
</p>
|
||||
</article>
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
50% 스크롤
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ analyticsSummary.scroll50Reach }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
30일 조회 {{ analyticsSummary.pageViewsLast30Days }}
|
||||
</p>
|
||||
</article>
|
||||
<section class="admin-dashboard__summary flex flex-wrap items-center gap-x-8 gap-y-2 border border-line bg-white px-4 py-3 text-sm">
|
||||
<p class="admin-dashboard__summary-item text-muted">
|
||||
현재 접속자
|
||||
<strong class="ml-2 text-lg text-ink">{{ realtime.summary.onlineNow }}</strong>
|
||||
</p>
|
||||
<p class="admin-dashboard__summary-item text-muted">
|
||||
오늘 접속자
|
||||
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.todayVisitors }}</strong>
|
||||
</p>
|
||||
<p class="admin-dashboard__summary-item text-muted">
|
||||
게시물 수
|
||||
<strong class="ml-2 text-lg text-ink">{{ posts.length }}</strong>
|
||||
<span class="ml-1 text-xs">발행 {{ publishedCount }} · 초안 {{ draftCount }}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__charts border border-line bg-white p-4">
|
||||
<div class="admin-dashboard__charts-header flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
|
||||
통계 추이
|
||||
</h2>
|
||||
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
|
||||
선택한 기간의 방문자수, 평균 체류시간, 50% 스크롤 도달 추이
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-dashboard__range flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="option in analyticsRangeOptions"
|
||||
:key="option.days"
|
||||
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"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-dashboard__chart-grid mt-4 grid gap-4 lg:grid-cols-3">
|
||||
<article
|
||||
v-for="metric in chartMetrics"
|
||||
:key="metric.key"
|
||||
class="admin-dashboard__chart border border-line bg-paper p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase text-muted">
|
||||
{{ metric.title }}
|
||||
</p>
|
||||
<strong class="text-sm text-ink">
|
||||
{{ metric.label }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line">
|
||||
<div
|
||||
v-for="row in trendRows"
|
||||
:key="`${metric.key}-${row.day}`"
|
||||
class="admin-dashboard__chart-bar-wrap flex min-w-[3px] flex-1 items-end"
|
||||
:title="`${row.day} · ${formatTrendValue(metric.key, row[metric.key])}`"
|
||||
>
|
||||
<div
|
||||
class="admin-dashboard__chart-bar w-full bg-[#15171a]/80"
|
||||
:style="{ height: `${getTrendBarHeight(metric.key, row)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-between text-[11px] text-muted">
|
||||
<span>{{ formatTrendDayLabel(trendStartDay) }}</span>
|
||||
<span>{{ analyticsRangeLabel }}</span>
|
||||
<span>{{ formatTrendDayLabel(trendEndDay) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__live border border-line bg-white p-4">
|
||||
@@ -185,13 +325,12 @@ onUnmounted(() => {
|
||||
{{ session.isLoggedIn ? session.user?.username : '익명 방문자' }}
|
||||
</p>
|
||||
<p class="admin-dashboard__live-path mt-0.5 truncate text-xs text-muted">
|
||||
{{ session.path }}
|
||||
{{ getSessionViewingTitle(session) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-dashboard__live-meta shrink-0 text-right text-xs text-muted">
|
||||
<p>{{ formatLastSeen(session.lastSeenAt) }}</p>
|
||||
<p class="mt-1">
|
||||
{{ formatEngagedDuration(session.durationSeconds) }}
|
||||
<p class="font-medium text-ink">
|
||||
접속 유지 {{ formatEngagedDuration(session.durationSeconds) }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -204,23 +343,9 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__posts-meta grid gap-4 md:grid-cols-3">
|
||||
<article class="admin-dashboard__metric border border-line bg-white p-4">
|
||||
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase text-muted">
|
||||
게시물
|
||||
</p>
|
||||
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
|
||||
{{ posts.length }}
|
||||
</strong>
|
||||
<p class="admin-dashboard__metric-note mt-2 text-xs text-muted">
|
||||
발행 {{ publishedCount }} · 초안 {{ draftCount }}
|
||||
</p>
|
||||
</article>
|
||||
</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">
|
||||
인기 게시물 (30일)
|
||||
인기 게시물 ({{ analyticsRangeLabel }})
|
||||
</h2>
|
||||
<ul
|
||||
v-if="topPosts.length"
|
||||
|
||||
@@ -10,12 +10,9 @@ const form = reactive({
|
||||
|
||||
const pending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const emailInput = ref(null)
|
||||
const passwordInput = ref(null)
|
||||
|
||||
/**
|
||||
* 로그인 제출 가능 여부(이메일·비밀번호가 모두 채워졌는지)
|
||||
* @returns {boolean} 제출 가능 여부
|
||||
*/
|
||||
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
hasUsers: true,
|
||||
@@ -23,26 +20,68 @@ const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 브라우저/비밀번호 관리자 자동완성 값을 로그인 폼 상태에 반영한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncAdminLoginAutofill = () => {
|
||||
const emailValue = emailInput.value?.value || ''
|
||||
const passwordValue = passwordInput.value?.value || ''
|
||||
|
||||
if (emailValue && emailValue !== form.email) {
|
||||
form.email = emailValue
|
||||
}
|
||||
if (passwordValue && passwordValue !== form.password) {
|
||||
form.password = passwordValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 로그인 제출
|
||||
* @returns {Promise<void>} 로그인 처리 결과
|
||||
*/
|
||||
const submitLogin = async () => {
|
||||
syncAdminLoginAutofill()
|
||||
|
||||
pending.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
body: {
|
||||
email: form.email.trim(),
|
||||
password: form.password
|
||||
},
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (import.meta.client) {
|
||||
window.location.assign('/admin')
|
||||
return
|
||||
}
|
||||
|
||||
await navigateTo('/admin')
|
||||
} catch {
|
||||
errorMessage.value = '이메일 또는 비밀번호를 확인해 주세요.'
|
||||
} catch (error) {
|
||||
const statusCode = Number(error?.statusCode || error?.response?.status || 0)
|
||||
|
||||
if (statusCode === 401) {
|
||||
errorMessage.value = '이메일 또는 비밀번호를 확인해 주세요.'
|
||||
} else if (statusCode >= 500) {
|
||||
errorMessage.value = '서버 또는 데이터베이스 연결을 확인해 주세요.'
|
||||
} else {
|
||||
errorMessage.value = '로그인에 실패했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncAdminLoginAutofill()
|
||||
window.setTimeout(syncAdminLoginAutofill, 100)
|
||||
window.setTimeout(syncAdminLoginAutofill, 500)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,21 +107,29 @@ const submitLogin = async () => {
|
||||
<label class="admin-login__field grid gap-2 text-sm">
|
||||
<span class="admin-login__label font-medium">이메일</span>
|
||||
<input
|
||||
ref="emailInput"
|
||||
v-model="form.email"
|
||||
class="admin-login__input rounded border border-line bg-white px-3 py-2"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
required
|
||||
@change="syncAdminLoginAutofill"
|
||||
@focus="syncAdminLoginAutofill"
|
||||
@input="syncAdminLoginAutofill"
|
||||
>
|
||||
</label>
|
||||
<label class="admin-login__field grid gap-2 text-sm">
|
||||
<span class="admin-login__label font-medium">비밀번호</span>
|
||||
<input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
class="admin-login__input rounded border border-line bg-white px-3 py-2"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
@change="syncAdminLoginAutofill"
|
||||
@focus="syncAdminLoginAutofill"
|
||||
@input="syncAdminLoginAutofill"
|
||||
>
|
||||
</label>
|
||||
<p v-if="errorMessage" class="admin-login__error text-sm text-red-600">
|
||||
@@ -91,7 +138,7 @@ const submitLogin = async () => {
|
||||
<button
|
||||
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="pending || !canSubmitAdminLogin"
|
||||
:disabled="pending"
|
||||
>
|
||||
{{ pending ? '확인 중' : '로그인' }}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user