Files
sori.studio/pages/admin/index.vue
2026-07-29 16:07:36 +09:00

1344 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ANALYTICS_COLLECTED_SINCE_DEFAULT, getAnalyticsDayKey } from '~/lib/analytics-shared.js'
import {
getAnalyticsTrendDescription,
getAnalyticsTrendDetailLabel,
isAnalyticsCurrentTrendRow,
paginateAnalyticsTrendRows
} from '~/lib/analytics-chart-ui.js'
definePageMeta({
layout: 'admin'
})
const { data: posts } = await useFetch('/admin/api/posts', {
default: () => []
})
const analyticsRangeOptions = [
{ label: '일간', period: 'daily', days: 7 },
{ label: '주간', period: 'weekly', days: 56 },
{ label: '월간', period: 'monthly', days: 365 },
{ label: '년간', period: 'yearly', days: 365 * 8 }
]
const selectedAnalyticsPeriod = ref('daily')
const analyticsChartPageOffset = ref(0)
const selectedAnalyticsRange = computed(() => {
return analyticsRangeOptions.find((option) => option.period === selectedAnalyticsPeriod.value) || analyticsRangeOptions[0]
})
const selectedAnalyticsDays = computed(() => selectedAnalyticsRange.value.days)
const analyticsQuery = computed(() => ({
days: selectedAnalyticsDays.value,
period: selectedAnalyticsPeriod.value
}))
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
query: analyticsQuery,
default: () => ({
todayVisitors: 0,
totalVisitors: 0,
totalPageViews: 0,
totalPosts: 0,
visitorsLast7Days: 0,
pageViewsLast30Days: 0,
onlineNow: 0,
loggedInNow: 0,
avgEngagedSeconds: 0,
scroll50Reach: 0,
collectedSince: ANALYTICS_COLLECTED_SINCE_DEFAULT,
trends: []
})
})
const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/analytics/realtime', {
query: { limit: 20 },
default: () => ({
summary: {
onlineNow: 0,
loggedInNow: 0,
anonymousNow: 0
},
sessions: []
})
})
const createEmptyAdsensePeriod = (label) => ({
label,
key: '',
estimatedEarnings: 0,
pageViews: 0,
impressions: 0,
clicks: 0,
pageViewsRpm: 0,
currencyCode: ''
})
const createEmptyAdsenseBalance = () => ({
balanceAmount: '',
lastPaymentAmount: '',
lastPaymentDate: ''
})
const {
data: adsenseSummary
} = await useFetch('/admin/api/adsense/summary', {
server: false,
default: () => ({
configured: false,
ok: false,
message: '',
currencyCode: 'USD',
generatedAt: '',
periods: [],
balance: createEmptyAdsenseBalance()
})
})
const {
data: cspViolationSummary,
refresh: refreshCspViolationSummary
} = await useFetch('/admin/api/security/csp-violations', {
query: { days: 30, limit: 8 },
server: false,
default: () => ({
days: 30,
totalViolations: 0,
totalRows: 0,
recentViolations: 0,
latestSeenAt: null,
topViolations: []
})
})
const todayKey = getAnalyticsDayKey()
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
const trendRows = computed(() => analyticsSummary.value.trends || [])
const analyticsTrendDescription = computed(() => {
return getAnalyticsTrendDescription(
selectedAnalyticsPeriod.value,
analyticsSummary.value?.collectedSince || ANALYTICS_COLLECTED_SINCE_DEFAULT
)
})
const selectedTrendRange = ref({
startDay: todayKey,
endDay: todayKey,
label: '오늘'
})
const shouldSelectDefaultTrendDetail = ref(true)
const trendDetailQuery = computed(() => ({
startDay: selectedTrendRange.value.startDay,
endDay: selectedTrendRange.value.endDay,
limit: 5
}))
const {
data: selectedTrendDetail,
pending: isTrendDetailLoading,
error: selectedTrendDetailError,
refresh: refreshTrendDetail
} = await useFetch('/admin/api/analytics/range-detail', {
query: trendDetailQuery,
default: () => ({
startDay: todayKey,
endDay: todayKey,
rangeDays: 1,
summary: {
pageViews: 0,
visitors: 0,
avgEngagedSeconds: 0,
scroll50Reach: 0
},
sources: [],
devices: [],
topPosts: [],
topPages: []
})
})
const selectedTrendDetailLabel = computed(() => selectedTrendRange.value.label || '오늘')
const trafficSources = computed(() => selectedTrendDetail.value?.sources || [])
const trafficDevices = computed(() => selectedTrendDetail.value?.devices || [])
const totalDeviceVisitors = computed(() => {
return trafficDevices.value.reduce((sum, row) => sum + Number(row.visitors || 0), 0)
})
const groupedTrafficDevices = computed(() => {
const groups = new Map()
for (const row of trafficDevices.value) {
const key = row.deviceType || '기타'
const existing = groups.get(key) || {
deviceType: key,
pageViews: 0,
visitors: 0,
rows: []
}
existing.pageViews += Number(row.pageViews || 0)
existing.visitors += Number(row.visitors || 0)
existing.rows.push(row)
groups.set(key, existing)
}
return [...groups.values()]
.map((group) => ({
...group,
rows: [...group.rows].sort((a, b) => {
return Number(b.visitors || 0) - Number(a.visitors || 0)
|| Number(b.pageViews || 0) - Number(a.pageViews || 0)
})
}))
.sort((a, b) => b.visitors - a.visitors || b.pageViews - a.pageViews)
})
const topPosts = computed(() => selectedTrendDetail.value?.topPosts || [])
const topPages = computed(() => selectedTrendDetail.value?.topPages || [])
const trendDetailError = computed(() => selectedTrendDetailError.value ? '선택한 기간 통계 상세를 불러오지 못했습니다.' : '')
const selectedSummaryPageViews = computed(() => Number(selectedTrendDetail.value?.summary?.pageViews || 0))
const totalTrafficPageViews = computed(() => {
return trafficSources.value.reduce((sum, row) => sum + Number(row.pageViews || 0), 0)
})
const chartPagination = computed(() => {
return paginateAnalyticsTrendRows(trendRows.value, selectedAnalyticsPeriod.value, analyticsChartPageOffset.value)
})
const adsenseCards = computed(() => adsenseSummary.value.periods || [])
const adsenseBalance = computed(() => adsenseSummary.value.balance || createEmptyAdsenseBalance())
const cspViolations = computed(() => cspViolationSummary.value?.topViolations || [])
const hasCspViolations = computed(() => Number(cspViolationSummary.value?.totalViolations || 0) > 0)
/**
* AdSense 통화 코드를 표시 가능한 값으로 보정한다.
* @returns {string} 통화 코드
*/
const getAdsenseCurrencyCode = (currencyCode) => {
const value = currencyCode || adsenseSummary.value?.currencyCode || 'USD'
try {
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: value
}).format(0)
return value
} catch {
return 'USD'
}
}
/**
* 숫자를 천 단위 구분 문자열로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 표시 문자열
*/
const formatNumber = (value) => {
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
}
/**
* AdSense 금액을 통화 문자열로 변환한다.
* @param {number} value - 금액
* @returns {string} 표시 문자열
*/
const formatAdsenseCurrency = (value, currencyCode = '') => {
const resolvedCurrencyCode = getAdsenseCurrencyCode(currencyCode)
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: resolvedCurrencyCode,
minimumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2,
maximumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2
}).format(Number(value || 0))
}
/**
* AdSense RPM을 표시 문자열로 변환한다.
* @param {number} value - RPM
* @returns {string} 표시 문자열
*/
const formatAdsenseRpm = (value, currencyCode = '') => {
const resolvedCurrencyCode = getAdsenseCurrencyCode(currencyCode)
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: resolvedCurrencyCode,
minimumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2,
maximumFractionDigits: resolvedCurrencyCode === 'KRW' ? 0 : 2
}).format(Number(value || 0))
}
/**
* ISO 날짜를 관리자 대시보드용 시각으로 변환한다.
* @param {string | null | undefined} value - ISO 날짜
* @returns {string} 표시 문자열
*/
const formatDashboardDateTime = (value) => {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
return new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}
/**
* 통계 차트 이전 기간으로 이동한다.
* @returns {void}
*/
const goAnalyticsChartPrevPage = () => {
if (!chartPagination.value.canGoPrev) {
return
}
analyticsChartPageOffset.value += chartPagination.value.pageSize
}
/**
* 통계 차트 다음 기간으로 이동한다.
* @returns {void}
*/
const goAnalyticsChartNextPage = () => {
if (!chartPagination.value.canGoNext) {
return
}
analyticsChartPageOffset.value = Math.max(analyticsChartPageOffset.value - chartPagination.value.pageSize, 0)
}
/**
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
* @param {number} seconds - 초
* @returns {string} 표시 문자열
*/
const formatEngagedDuration = (seconds) => {
const value = Number(seconds) || 0
if (value < 60) {
return `${value}초`
}
const minutes = Math.floor(value / 60)
const remainSeconds = value % 60
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
}
/**
* 선택 기간의 추세 합계를 반환한다.
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
* @returns {number} 합계 또는 평균
*/
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)
}
return rows.reduce((sum, row) => sum + Number(row[key] || 0), 0)
}
/**
* 추세 값 표시 문자열을 반환한다.
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
* @param {number} value - 값
* @returns {string} 표시 문자열
*/
const formatTrendValue = (key, value) => {
if (key === 'avgEngagedSeconds') {
return formatEngagedDuration(value)
}
if (key === 'visitors') {
return `${Number(value || 0)}명`
}
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 {{ pageViews?: number, visitors?: number }} row - 디바이스 집계
* @returns {string} 표시 문자열
*/
const formatDeviceSummary = (row) => {
const visitors = Number(row?.visitors || 0)
const pageViews = Number(row?.pageViews || 0)
return `${visitors}명 / ${pageViews}회 / ${formatDeviceVisitorPercent(visitors)}`
}
/**
* 디바이스 방문자 비율을 표시한다.
* @param {number} value - 방문자 수
* @returns {string} 비율
*/
const formatDeviceVisitorPercent = (value) => {
const total = totalDeviceVisitors.value
if (!total) {
return '0%'
}
return `${Math.round((Number(value || 0) / total) * 100)}%`
}
const chartMetrics = computed(() => [
{
key: 'visitors',
title: '방문자수',
label: `${getTrendSummaryValue('visitors')}명`,
metricLabel: '방문자수'
},
{
key: 'avgEngagedSeconds',
title: '평균 체류시간',
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds')),
metricLabel: '평균 체류시간'
},
{
key: 'scroll50Reach',
title: '50% 스크롤 도달',
label: `${getTrendSummaryValue('scroll50Reach')}회`,
metricLabel: '50% 스크롤 도달'
}
])
/**
* 체류시간 증감 표시 문자열을 반환한다.
* @param {number} delta - 증감 초
* @returns {string} 표시 문자열
*/
const formatEngagedDurationDelta = (delta) => {
const value = Math.abs(Number(delta) || 0)
if (value < 60) {
return `${value}초`
}
const minutes = Math.floor(value / 60)
const remainSeconds = value % 60
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
}
/**
* 차트 행을 하단 상세 패널의 선택 범위로 적용한다.
* @param {Object} row - 추세 행
* @param {{ manual?: boolean }} [options] - 선택 옵션
* @returns {void}
*/
const selectTrendDetail = (row, options = {}) => {
const startDay = row.startDay || row.day
const endDay = row.endDay || row.day
if (!startDay || !endDay) {
return
}
selectedTrendRange.value = {
startDay,
endDay,
label: getAnalyticsTrendDetailLabel(selectedAnalyticsPeriod.value, row)
}
if (options.manual) {
shouldSelectDefaultTrendDetail.value = false
}
}
/**
* 현재 차트 기간에서 기본으로 보여줄 상세 행을 선택한다.
* @returns {void}
*/
const selectDefaultTrendDetail = () => {
const rows = trendRows.value
if (!rows.length) {
selectedTrendRange.value = {
startDay: todayKey,
endDay: todayKey,
label: '오늘'
}
shouldSelectDefaultTrendDetail.value = false
return
}
const currentRow = rows.find((row) => isAnalyticsCurrentTrendRow(selectedAnalyticsPeriod.value, row, todayKey))
selectTrendDetail(currentRow || rows[rows.length - 1])
shouldSelectDefaultTrendDetail.value = false
}
/**
* 현재 접속자가 보고 있는 화면명을 반환한다.
* @param {Object} session - 접속 세션
* @returns {string} 화면명
*/
const getSessionViewingTitle = (session) => {
if (session.postTitle) {
return session.postTitle
}
if (session.pageTitle) {
return session.pageTitle
}
if (session.path === '/') {
return '홈'
}
return session.path || '알 수 없음'
}
/**
* 현재 접속자가 열어둔 페이지명을 반환한다.
* @param {Object} page - 접속 페이지
* @returns {string} 페이지명
*/
const getActivePageTitle = (page) => {
if (page.postTitle) {
return page.postTitle
}
if (page.pageTitle) {
return page.pageTitle
}
if (page.path === '/') {
return '홈'
}
return page.path || '알 수 없음'
}
/**
* 접속 페이지의 공개 바로가기 경로를 반환한다.
* @param {Object} page - 접속 페이지
* @returns {string} 공개 경로
*/
const getActivePageHref = (page) => {
if (page.postSlug) {
return `/post/${encodeURIComponent(page.postSlug)}`
}
if (page.pageSlug) {
return `/pages/${encodeURIComponent(page.pageSlug)}`
}
const path = String(page.path || '').trim()
if (path.startsWith('/') && !path.startsWith('/admin')) {
return path
}
return ''
}
/**
* 통계 목록의 날짜를 짧은 문자열로 변환한다.
* @param {string | null | undefined} value - ISO 날짜
* @returns {string} 표시 날짜
*/
const formatCompactDate = (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}`
}
/**
* 스크롤 도달 지표를 짧은 문자열로 반환한다.
* @param {Object} row - 통계 행
* @returns {string} 스크롤 50/75/100 도달 수
*/
const formatScrollCompact = (row) => {
return `${Number(row.scroll50 || 0)}.${Number(row.scroll75 || 0)}.${Number(row.scroll100 || 0)}`
}
/**
* 스크롤 도달 지표를 단계별 표시 조각으로 반환한다.
* @param {Object} row - 통계 행
* @returns {Array<{ key: string, value: number, colorClass: string }>} 스크롤 단계 조각
*/
const formatScrollParts = (row) => {
return [
{ key: '50', value: Number(row.scroll50 || 0), colorClass: 'text-[#a1a1aa]' },
{ key: '75', value: Number(row.scroll75 || 0), colorClass: 'text-[#71717a]' },
{ key: '100', value: Number(row.scroll100 || 0), colorClass: 'text-[#3f3f46]' }
]
}
/**
* 인기 게시물 메타 정보를 표시 조각으로 반환한다.
* @param {Object} row - 게시물 통계 행
* @returns {Array<{ key: string, label: string, value: string | number, colorClass: string }>} 압축 메타 조각
*/
const formatTopPostMeta = (row) => {
return [
{ key: 'views', label: 'V', value: Number(row.views || 0), colorClass: 'text-[#2563eb]' },
{ key: 'reads', label: 'R', value: Number(row.reads || 0), colorClass: 'text-[#16a34a]' },
{ key: 'monthly', label: 'M', value: Number(row.monthlyViews || 0), colorClass: 'text-[#d97706]' },
{ key: 'created', label: 'C', value: formatCompactDate(row.createdAt), colorClass: 'text-muted' },
{ key: 'scroll', label: 'S', value: formatScrollCompact(row), colorClass: 'text-muted', parts: formatScrollParts(row) }
]
}
/**
* 인기 페이지 메타 정보를 표시 조각으로 반환한다.
* @param {Object} row - 페이지 통계 행
* @returns {Array<{ key: string, label: string, value: string | number, colorClass: string }>} 압축 메타 조각
*/
const formatTopPageMeta = (row) => {
return [
{ key: 'views', label: 'V', value: Number(row.views || 0), colorClass: 'text-[#2563eb]' },
{ key: 'visitors', label: 'R', value: Number(row.visitors || 0), colorClass: 'text-[#16a34a]' },
{ key: 'monthly', label: 'M', value: Number(row.monthlyViews || 0), colorClass: 'text-[#d97706]' },
{ key: 'created', label: 'C', value: formatCompactDate(row.createdAt), colorClass: 'text-muted' },
{ key: 'scroll', label: 'S', value: formatScrollCompact(row), colorClass: 'text-muted', parts: formatScrollParts(row) }
]
}
let refreshTimer = null
onMounted(() => {
refreshTimer = window.setInterval(() => {
refreshSummary()
refreshTrendDetail()
refreshRealtime()
refreshCspViolationSummary()
}, 30000)
})
onUnmounted(() => {
if (refreshTimer) {
window.clearInterval(refreshTimer)
}
})
watch(selectedAnalyticsPeriod, () => {
analyticsChartPageOffset.value = 0
shouldSelectDefaultTrendDetail.value = true
})
watch(trendRows, () => {
if (!shouldSelectDefaultTrendDetail.value) {
return
}
selectDefaultTrendDetail()
}, {
immediate: true
})
</script>
<template>
<section class="admin-dashboard">
<div class="admin-dashboard__header border-b border-line bg-paper p-6">
<p class="admin-dashboard__eyebrow text-xs font-semibold uppercase text-muted">
Admin
</p>
<h1 class="admin-dashboard__title mt-2 text-3xl font-semibold">
대시보드
</h1>
</div>
<div class="admin-dashboard__body space-y-6 bg-paper p-6">
<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">{{ analyticsSummary.totalVisitors }}</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__adsense">
<p
v-if="adsenseSummary.configured && !adsenseSummary.ok"
class="admin-dashboard__adsense-message mt-3 border border-red-100 bg-red-50 px-3 py-2 text-xs text-red-700"
>
{{ adsenseSummary.message || 'AdSense 통계를 불러오지 못했습니다.' }}
</p>
<div class="admin-dashboard__adsense-layout grid gap-3 xl:grid-cols-[minmax(0,5fr)_minmax(220px,1fr)]">
<div class="admin-dashboard__adsense-earnings bg-[#2f6cc4] p-4 text-white shadow-sm">
<h3 class="text-sm font-semibold">
예상 수입
</h3>
<div class="mt-5 grid gap-5 md:grid-cols-3 xl:grid-cols-5">
<article
v-for="card in adsenseCards"
:key="card.key || card.label"
class="admin-dashboard__adsense-card min-w-0"
>
<p class="text-xs font-semibold text-white/90">
{{ card.label }}
</p>
<strong class="mt-2 block text-2xl font-medium leading-tight text-white">
{{ formatAdsenseCurrency(card.estimatedEarnings, card.currencyCode) }}
</strong>
<p class="mt-2 text-[11px] leading-snug text-white/80">
페이지뷰 {{ formatNumber(card.pageViews) }} · 노출 {{ formatNumber(card.impressions) }} · 클릭 {{ formatNumber(card.clicks) }}
</p>
<p class="mt-1 text-[11px] leading-snug text-white/80">
RPM {{ formatAdsenseRpm(card.pageViewsRpm, card.currencyCode) }}
</p>
</article>
</div>
</div>
<aside class="admin-dashboard__adsense-balance bg-[#2f6cc4] p-4 text-white shadow-sm">
<h3 class="text-sm font-semibold">
잔고
</h3>
<p class="mt-2 text-[11px] leading-snug text-white/80">
지급 계정 통화 기준
</p>
<strong class="mt-6 block text-2xl font-medium leading-tight text-white">
{{ adsenseBalance.balanceAmount || '-' }}
</strong>
<p class="mt-2 text-[11px] leading-snug text-white/85">
최종 지급일
</p>
<p class="mt-0.5 text-[11px] leading-snug text-white/85">
{{ adsenseBalance.lastPaymentDate || '-' }}
<span v-if="adsenseBalance.lastPaymentAmount"> · {{ adsenseBalance.lastPaymentAmount }}</span>
</p>
</aside>
</div>
</section>
<section
v-if="hasCspViolations"
class="admin-dashboard__csp border border-line bg-white p-4"
>
<div class="admin-dashboard__csp-header flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
CSP 관찰
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
최근 {{ cspViolationSummary.days }}일 Report-Only 위반 상위 항목
</p>
</div>
<div class="admin-dashboard__csp-summary text-right text-xs text-muted">
<p>
최근 {{ formatNumber(cspViolationSummary.recentViolations) }}회
</p>
<p class="mt-1">
전체 {{ formatNumber(cspViolationSummary.totalViolations) }}회 · 마지막 {{ formatDashboardDateTime(cspViolationSummary.latestSeenAt) }}
</p>
</div>
</div>
<div
v-if="cspViolations.length"
class="admin-dashboard__csp-table mt-4 overflow-x-auto"
>
<table class="min-w-full text-left text-xs">
<thead class="border-b border-line text-[11px] uppercase text-muted">
<tr>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
지시어
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
차단 출처
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
문서
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
브라우저
</th>
<th class="whitespace-nowrap px-2 py-2 text-right font-semibold">
횟수
</th>
<th class="whitespace-nowrap px-2 py-2 text-right font-semibold">
최근
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in cspViolations"
:key="`${row.policyVersion}-${row.effectiveDirective}-${row.blockedOrigin}-${row.documentPath}-${row.userAgentFamily}`"
class="border-b border-line last:border-b-0"
>
<td class="whitespace-nowrap px-2 py-2 font-medium text-ink">
{{ row.effectiveDirective }}
</td>
<td class="max-w-[260px] truncate px-2 py-2 text-muted">
{{ row.blockedOrigin }}
</td>
<td class="max-w-[240px] truncate px-2 py-2 text-muted">
{{ row.documentPath }}
</td>
<td class="whitespace-nowrap px-2 py-2 text-muted">
{{ row.userAgentFamily }}
</td>
<td class="whitespace-nowrap px-2 py-2 text-right font-semibold text-ink">
{{ formatNumber(row.violationCount) }}
</td>
<td class="whitespace-nowrap px-2 py-2 text-right text-muted">
{{ formatDashboardDateTime(row.lastSeenAt) }}
</td>
</tr>
</tbody>
</table>
</div>
<p
v-else
class="admin-dashboard__csp-empty mt-4 border border-line bg-paper px-3 py-2 text-xs text-muted"
>
최근 {{ cspViolationSummary.days }}일에 집계된 CSP 위반은 없습니다.
</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">
{{ analyticsTrendDescription }}
</p>
</div>
<div class="admin-dashboard__range flex flex-wrap items-center gap-1">
<button
v-for="option in analyticsRangeOptions"
:key="option.period"
type="button"
class="admin-dashboard__range-button border border-line px-3 py-1 text-xs font-medium transition-colors"
:class="option.period === selectedAnalyticsPeriod
? 'bg-[#15171a] !text-white hover:!bg-[#15171a] hover:!text-white'
: 'bg-white text-muted hover:bg-paper hover:text-ink'"
@click="selectedAnalyticsPeriod = option.period"
>
{{ option.label }}
</button>
<button
class="admin-dashboard__range-nav grid h-[26px] w-7 place-items-center border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
type="button"
aria-label="이전 기간"
:disabled="!chartPagination.canGoPrev"
@click="goAnalyticsChartPrevPage"
>
‹
</button>
<button
class="admin-dashboard__range-nav grid h-[26px] w-7 place-items-center border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
type="button"
aria-label="다음 기간"
:disabled="!chartPagination.canGoNext"
@click="goAnalyticsChartNextPage"
>
›
</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>
<AdminAnalyticsTrendChart
class="mt-4"
:rows="trendRows"
:period="selectedAnalyticsPeriod"
:metric-key="metric.key"
:metric-label="metric.metricLabel"
:format-value="(value) => formatTrendValue(metric.key, value)"
:format-delta="metric.key === 'avgEngagedSeconds' ? formatEngagedDurationDelta : undefined"
:traffic-stack="metric.key === 'visitors'"
v-model:page-offset="analyticsChartPageOffset"
:show-header="false"
:show-navigation="false"
compact
clickable
bar-height-class="h-32"
@bar-click="(row) => selectTrendDetail(row, { manual: true })"
/>
</article>
</div>
</section>
<section class="admin-dashboard__selected-detail 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">
선택한 기간 상세
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
{{ selectedTrendDetailLabel }} 기준 유입·디바이스·인기 콘텐츠
</p>
</div>
<p class="admin-dashboard__traffic-total text-xs text-muted">
페이지뷰 {{ selectedSummaryPageViews }}회
</p>
</div>
<p
v-if="isTrendDetailLoading && !selectedTrendDetail"
class="admin-dashboard__selected-loading mt-4 text-sm text-muted"
>
선택한 기간 통계를 불러오는 중입니다.
</p>
<p
v-else-if="trendDetailError && !selectedTrendDetail"
class="admin-dashboard__selected-error mt-4 text-sm text-red-600"
>
{{ trendDetailError }}
</p>
<template v-if="selectedTrendDetail">
<div class="admin-dashboard__selected-summary mt-4 grid gap-3 sm:grid-cols-4">
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
페이지뷰
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.pageViews }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
방문자
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.visitors }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
평균 체류
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatEngagedDuration(selectedTrendDetail.summary.avgEngagedSeconds) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
50% 스크롤
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.scroll50Reach }}
</strong>
</article>
</div>
<div class="admin-dashboard__selected-grid mt-4 grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(240px,1fr)]">
<div class="admin-dashboard__top-content space-y-4">
<section class="admin-dashboard__top-posts border border-line bg-paper p-3">
<h3 class="text-xs font-semibold uppercase text-muted">
인기 게시물
</h3>
<ul
v-if="topPosts.length"
class="mt-3 space-y-2"
>
<li
v-for="row in topPosts"
:key="row.id"
class="border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<NuxtLink
:to="`/post/${row.slug}`"
class="block font-medium text-ink hover:underline"
target="_blank"
>
{{ row.title }}
</NuxtLink>
<p class="mt-1 overflow-x-auto whitespace-nowrap text-[11px]">
<template
v-for="(meta, metaIndex) in formatTopPostMeta(row)"
:key="meta.key"
>
<span
v-if="metaIndex > 0"
class="mx-1 text-[#d4d4d8]"
>
|
</span>
<span class="inline-flex items-center">
<span
class="mr-1 font-semibold"
:class="meta.colorClass"
>
{{ meta.label }}
</span>
<template v-if="meta.parts">
<template
v-for="(part, partIndex) in meta.parts"
:key="part.key"
>
<span
v-if="partIndex > 0"
class="text-[#d4d4d8]"
>
.
</span>
<span :class="part.colorClass">{{ part.value }}</span>
</template>
</template>
<template v-else>
<span class="text-muted">{{ meta.value }}</span>
</template>
</span>
</template>
</p>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
해당 기간에 집계된 게시물 조회가 없습니다.
</p>
</section>
<section class="admin-dashboard__top-pages border border-line bg-paper p-3">
<h3 class="text-xs font-semibold uppercase text-muted">
인기 페이지
</h3>
<ul
v-if="topPages.length"
class="mt-3 space-y-2"
>
<li
v-for="row in topPages"
:key="row.id"
class="border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<NuxtLink
:to="`/pages/${row.slug}`"
class="block font-medium text-ink hover:underline"
target="_blank"
>
{{ row.title }}
</NuxtLink>
<p class="mt-1 overflow-x-auto whitespace-nowrap text-[11px]">
<template
v-for="(meta, metaIndex) in formatTopPageMeta(row)"
:key="meta.key"
>
<span
v-if="metaIndex > 0"
class="mx-1 text-[#d4d4d8]"
>
|
</span>
<span class="inline-flex items-center">
<span
class="mr-1 font-semibold"
:class="meta.colorClass"
>
{{ meta.label }}
</span>
<template v-if="meta.parts">
<template
v-for="(part, partIndex) in meta.parts"
:key="part.key"
>
<span
v-if="partIndex > 0"
class="text-[#d4d4d8]"
>
.
</span>
<span :class="part.colorClass">{{ part.value }}</span>
</template>
</template>
<template v-else>
<span class="text-muted">{{ meta.value }}</span>
</template>
</span>
</template>
</p>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
해당 기간에 집계된 페이지 조회가 없습니다.
</p>
</section>
</div>
<aside class="admin-dashboard__side-detail space-y-4">
<section class="admin-dashboard__traffic-sources border border-line bg-paper p-3">
<h3 class="text-xs font-semibold uppercase text-muted">
유입
</h3>
<div class="mt-3 grid gap-3 md:grid-cols-3 xl:grid-cols-1">
<article
v-for="group in ['search', 'sns', 'other']"
:key="group"
class="border border-line bg-white p-3"
>
<h4 class="text-xs font-semibold uppercase text-muted">
{{ getTrafficGroupLabel(group) }}
</h4>
<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 w-1/2 overflow-hidden rounded-full bg-paper">
<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>
</section>
<section 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="groupedTrafficDevices.length"
class="mt-3 space-y-2"
>
<li
v-for="group in groupedTrafficDevices"
:key="group.deviceType"
class="border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<div class="flex items-center justify-between gap-3">
<p class="font-medium text-ink">
{{ group.deviceType }}
</p>
<p class="shrink-0 text-right font-medium text-muted">
{{ formatDeviceSummary(group) }}
</p>
</div>
<ul class="mt-2 space-y-1 text-[11px] text-muted">
<li
v-for="row in group.rows"
:key="`${group.deviceType}-${row.osName}`"
class="flex items-center justify-between gap-2"
>
<span>{{ row.osName || '-' }}</span>
<span>{{ row.visitors }}명 · {{ formatDeviceVisitorPercent(row.visitors) }}</span>
</li>
</ul>
</li>
</ul>
<p
v-else
class="mt-3 text-xs text-muted"
>
아직 집계된 디바이스 데이터가 없습니다.
</p>
</section>
</aside>
</div>
</template>
</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">
현재 접속자
</h2>
<p class="admin-dashboard__live-count text-xs text-muted">
{{ realtime.summary.onlineNow }}명 표시
</p>
</div>
<ul
v-if="realtime.sessions.length"
class="admin-dashboard__live-list mt-4 divide-y divide-line"
>
<li
v-for="session in realtime.sessions"
:key="session.activeKey"
class="admin-dashboard__live-item flex items-center gap-3 py-3"
>
<img
v-if="session.user?.avatarUrl"
:src="session.user.avatarUrl"
:alt="session.user.username"
class="admin-dashboard__live-avatar h-9 w-9 shrink-0 rounded-full object-cover"
>
<div
v-else
class="admin-dashboard__live-avatar admin-dashboard__live-avatar--placeholder flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-paper text-xs font-semibold text-muted"
>
{{ session.isLoggedIn ? (session.user?.username || '?').slice(0, 1) : '?' }}
</div>
<div class="min-w-0 flex-1">
<p class="admin-dashboard__live-name text-sm font-medium text-ink">
{{ session.isLoggedIn ? session.user?.username : '익명 방문자' }}
</p>
<p
v-if="Number(session.pageCount || 0) <= 1"
class="admin-dashboard__live-path group mt-0.5 flex min-w-0 items-center gap-1 text-xs text-muted"
>
<span class="min-w-0 truncate">{{ getSessionViewingTitle(session) }}</span>
<a
v-if="getActivePageHref(session)"
:href="getActivePageHref(session)"
target="_blank"
rel="noopener noreferrer"
class="admin-dashboard__live-link grid h-5 w-5 shrink-0 place-items-center rounded border border-line bg-white text-muted opacity-0 transition hover:border-ink hover:text-ink group-hover:opacity-100 focus:opacity-100"
aria-label="현재 페이지 새 창으로 열기"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 5h5v5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10 14 19 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M19 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</p>
<ul
v-if="Number(session.pageCount || 0) > 1"
class="admin-dashboard__live-pages mt-1 space-y-0.5 text-[11px] leading-snug text-muted"
>
<li
v-for="page in session.pages"
:key="page.key"
class="admin-dashboard__live-page group flex min-w-0 items-center gap-1"
>
<span class="min-w-0 truncate">{{ getActivePageTitle(page) }}</span>
<a
v-if="getActivePageHref(page)"
:href="getActivePageHref(page)"
target="_blank"
rel="noopener noreferrer"
class="admin-dashboard__live-link grid h-5 w-5 shrink-0 place-items-center rounded border border-line bg-white text-muted opacity-0 transition hover:border-ink hover:text-ink group-hover:opacity-100 focus:opacity-100"
aria-label="열린 페이지 새 창으로 열기"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 5h5v5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10 14 19 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M19 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</li>
</ul>
</div>
<div class="admin-dashboard__live-meta shrink-0 text-right text-xs text-muted">
<p class="font-medium text-ink">
접속 유지 {{ formatEngagedDuration(session.durationSeconds) }}
</p>
<p class="mt-1 text-[11px] leading-snug text-muted">
IP {{ session.ipAddress || '-' }}
</p>
<p v-if="Number(session.pageCount || 0) > 1" class="mt-0.5 text-[11px] leading-snug text-muted">
열린 페이지 {{ session.pageCount }}개
</p>
</div>
</li>
</ul>
<p
v-else
class="admin-dashboard__live-empty mt-4 text-sm text-muted"
>
현재 접속 중인 방문자가 없습니다.
</p>
</section>
</div>
</section>
</template>