대시보드 AdSense 요약 추가

This commit is contained in:
2026-07-20 16:57:09 +09:00
parent 14c53826c6
commit e7c4f952a6
12 changed files with 525 additions and 6 deletions

View File

@@ -62,6 +62,33 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/
})
})
const createEmptyAdsensePeriod = (label) => ({
label,
estimatedEarnings: 0,
pageViews: 0,
impressions: 0,
clicks: 0,
pageViewsRpm: 0
})
const {
data: adsenseSummary,
pending: isAdsenseLoading,
refresh: refreshAdsenseSummary
} = await useFetch('/admin/api/adsense/summary', {
server: false,
default: () => ({
configured: false,
ok: false,
message: '',
currencyCode: 'KRW',
generatedAt: '',
today: createEmptyAdsensePeriod('오늘'),
yesterday: createEmptyAdsensePeriod('어제'),
month: createEmptyAdsensePeriod('이번 달')
})
})
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)
@@ -152,6 +179,79 @@ const chartPagination = computed(() => {
return paginateAnalyticsTrendRows(trendRows.value, selectedAnalyticsPeriod.value, analyticsChartPageOffset.value)
})
const adsenseCards = computed(() => [
adsenseSummary.value.today || createEmptyAdsensePeriod('오늘'),
adsenseSummary.value.yesterday || createEmptyAdsensePeriod('어제'),
adsenseSummary.value.month || createEmptyAdsensePeriod('이번 달')
])
const adsenseGeneratedLabel = computed(() => {
const value = adsenseSummary.value?.generatedAt
if (!value) {
return ''
}
return formatCompactDateTime(value)
})
/**
* AdSense 통화 코드를 표시 가능한 값으로 보정한다.
* @returns {string} 통화 코드
*/
const getAdsenseCurrencyCode = () => {
const currencyCode = adsenseSummary.value?.currencyCode || 'KRW'
try {
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: currencyCode
}).format(0)
return currencyCode
} catch {
return 'KRW'
}
}
/**
* 숫자를 천 단위 구분 문자열로 변환한다.
* @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) => {
const currencyCode = getAdsenseCurrencyCode()
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: currencyCode,
maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2
}).format(Number(value || 0))
}
/**
* AdSense RPM을 표시 문자열로 변환한다.
* @param {number} value - RPM
* @returns {string} 표시 문자열
*/
const formatAdsenseRpm = (value) => {
const currencyCode = getAdsenseCurrencyCode()
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: currencyCode,
maximumFractionDigits: currencyCode === 'KRW' ? 0 : 2
}).format(Number(value || 0))
}
/**
* 통계 차트 이전 기간으로 이동한다.
* @returns {void}
@@ -447,6 +547,29 @@ const formatCompactDate = (value) => {
return `${year}.${month}.${day}`
}
/**
* 통계 갱신 시각을 짧은 문자열로 변환한다.
* @param {string | null | undefined} value - ISO 날짜
* @returns {string} 표시 날짜와 시간
*/
const formatCompactDateTime = (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')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}.${month}.${day} ${hours}:${minutes}`
}
/**
* 스크롤 도달 지표를 짧은 문자열로 반환한다.
* @param {Object} row - 통계 행
@@ -562,6 +685,64 @@ watch(trendRows, () => {
</p>
</section>
<section class="admin-dashboard__adsense border border-line bg-white p-4">
<div class="admin-dashboard__adsense-header flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
AdSense
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
예상 수익·페이지뷰·클릭 기준 요약
<span v-if="adsenseGeneratedLabel">· {{ adsenseGeneratedLabel }} 갱신</span>
</p>
</div>
<button
type="button"
class="admin-dashboard__adsense-refresh border border-line bg-white px-3 py-1 text-xs font-medium text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isAdsenseLoading"
@click="refreshAdsenseSummary"
>
{{ isAdsenseLoading ? '갱신 중' : '새로고침' }}
</button>
</div>
<p
v-if="!adsenseSummary.configured"
class="admin-dashboard__adsense-message mt-3 border border-dashed border-line bg-paper px-3 py-2 text-xs text-muted"
>
AdSense 환경 변수를 설정하면 이곳에 수익 요약이 표시됩니다.
</p>
<p
v-else-if="!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-grid mt-3 grid gap-3 lg:grid-cols-3">
<article
v-for="card in adsenseCards"
:key="card.label"
class="admin-dashboard__adsense-card border border-line bg-paper p-3"
>
<div class="flex items-start justify-between gap-3">
<p class="text-xs font-semibold uppercase text-muted">
{{ card.label }}
</p>
<p class="text-[11px] text-muted">
RPM {{ formatAdsenseRpm(card.pageViewsRpm) }}
</p>
</div>
<strong class="mt-2 block text-xl text-ink">
{{ formatAdsenseCurrency(card.estimatedEarnings) }}
</strong>
<p class="mt-2 text-xs text-muted">
페이지뷰 {{ formatNumber(card.pageViews) }} · 노출 {{ formatNumber(card.impressions) }} · 클릭 {{ formatNumber(card.clicks) }}
</p>
</article>
</div>
</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>