통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
1188 lines
45 KiB
Vue
1188 lines
45 KiB
Vue
<script setup>
|
|
import { toAdminPostStoredTitle } from '~/lib/admin-post-title.js'
|
|
|
|
definePageMeta({
|
|
layout: 'admin'
|
|
})
|
|
|
|
const deletingId = ref('')
|
|
const togglingFeaturedId = ref('')
|
|
const openPostMenuId = ref('')
|
|
const postMenuStyle = ref({})
|
|
const errorMessage = ref('')
|
|
const statusFilter = ref('all')
|
|
const tagFilter = ref('all')
|
|
const featuredFilter = ref('all')
|
|
const sortOrder = ref('newest')
|
|
const searchQuery = ref('')
|
|
const analyticsModalPost = ref(null)
|
|
const analyticsModalData = ref(createDefaultPostAnalytics())
|
|
const analyticsModalPeriod = ref('daily')
|
|
const analyticsModalMetric = ref('views')
|
|
const analyticsModalChartPageOffset = ref(0)
|
|
const analyticsModalLoading = ref(false)
|
|
const analyticsModalError = ref('')
|
|
const postMenuTriggerRefs = new Map()
|
|
|
|
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
|
default: () => []
|
|
})
|
|
|
|
const { data: tags } = await useFetch('/admin/api/tags', {
|
|
default: () => []
|
|
})
|
|
|
|
const { data: siteSettings } = await useFetch('/admin/api/settings', {
|
|
default: () => ({ showPostUpdatedAt: false })
|
|
})
|
|
|
|
const defaultTagColor = '#15171a'
|
|
const analyticsPeriodOptions = [
|
|
{ label: '일간', value: 'daily', days: 14 },
|
|
{ label: '주간', value: 'weekly', days: 84 },
|
|
{ label: '월간', value: 'monthly', days: 365 }
|
|
]
|
|
|
|
const analyticsMetricOptions = [
|
|
{ label: '조회수', value: 'views' },
|
|
{ label: '방문자', value: 'visitors' }
|
|
]
|
|
|
|
/**
|
|
* 게시물별 통계 기본값을 생성한다.
|
|
* @returns {Object} 게시물별 통계 기본값
|
|
*/
|
|
function createDefaultPostAnalytics() {
|
|
return {
|
|
updatedAt: '',
|
|
total: {
|
|
views: 0,
|
|
visitors: 0,
|
|
reads: 0,
|
|
avgEngagedSeconds: 0,
|
|
scroll50: 0,
|
|
scroll75: 0,
|
|
scroll100: 0
|
|
},
|
|
period: {
|
|
views: 0,
|
|
visitors: 0,
|
|
reads: 0,
|
|
avgEngagedSeconds: 0,
|
|
scroll50: 0,
|
|
scroll75: 0,
|
|
scroll100: 0
|
|
},
|
|
trends: [],
|
|
sources: [],
|
|
devices: [],
|
|
keywords: []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 숫자를 관리자 목록 표시 형식으로 변환한다.
|
|
* @param {number} value - 숫자 값
|
|
* @returns {string} 표시 문자열
|
|
*/
|
|
const formatAdminNumber = (value) => {
|
|
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
|
|
}
|
|
|
|
/**
|
|
* 날짜 값을 YYYY.MM.DD HH:mm 형식으로 표시한다.
|
|
* @param {string | null} value - 날짜 값
|
|
* @returns {string} 표시 문자열
|
|
*/
|
|
const formatAdminDateTimeLabel = (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 {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}분`
|
|
}
|
|
|
|
/**
|
|
* 게시물 통계 기간 표시명을 반환한다.
|
|
* @returns {string} 기간 표시명
|
|
*/
|
|
const getAnalyticsPeriodLabel = () => {
|
|
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.label || '일간'
|
|
}
|
|
|
|
/**
|
|
* 게시물 통계 기간 조회 일수를 반환한다.
|
|
* @returns {number} 조회 일수
|
|
*/
|
|
const getAnalyticsPeriodDays = () => {
|
|
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.days || 14
|
|
}
|
|
|
|
/**
|
|
* 게시물 발행일 라벨을 반환한다.
|
|
* @param {Object} post - 게시물
|
|
* @returns {string} 발행일 또는 '-'
|
|
*/
|
|
const getPublishDateLabel = (post) => {
|
|
if (!post.publishedAt) {
|
|
return '-'
|
|
}
|
|
|
|
return formatPostDateTime(post.publishedAt)
|
|
}
|
|
|
|
/**
|
|
* 게시물 수정일 보조 라벨을 반환한다.
|
|
* @param {Object} post - 게시물
|
|
* @returns {string} 수정일 라벨 또는 빈 문자열
|
|
*/
|
|
const getUpdatedDateLabel = (post) => {
|
|
if (!siteSettings.value?.showPostUpdatedAt || !wasPostUpdatedAfterPublish(post)) {
|
|
return ''
|
|
}
|
|
|
|
return `수정: ${formatPostDateTime(post.updatedAt)}`
|
|
}
|
|
|
|
/**
|
|
* 목록 정렬용 시각(ms). 발행일이 있으면 발행일, 없으면 수정일(초안 등).
|
|
* @param {Object} post - 게시물
|
|
* @returns {number} 정렬 기준 시각
|
|
*/
|
|
const getListSortTimestamp = (post) => {
|
|
const sortAt = post.publishedAt || post.updatedAt || post.createdAt
|
|
|
|
return new Date(sortAt || 0).getTime()
|
|
}
|
|
|
|
/**
|
|
* 게시물 공개 여부 확인
|
|
* @param {Object} post - 게시물
|
|
* @returns {boolean} 공개 여부
|
|
*/
|
|
const isPublicPost = (post) => post.status === 'published'
|
|
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date())
|
|
|
|
/**
|
|
* 게시물 상태 필터 키 생성
|
|
* @param {Object} post - 게시물
|
|
* @returns {'published' | 'scheduled' | 'draft' | 'members' | 'private'} 상태 키
|
|
*/
|
|
const getPostStatusKey = (post) => {
|
|
if (post.status === 'members' || post.status === 'private') {
|
|
return post.status
|
|
}
|
|
|
|
if (post.status === 'published' && !isPublicPost(post)) {
|
|
return 'scheduled'
|
|
}
|
|
|
|
if (post.status === 'published') {
|
|
return 'published'
|
|
}
|
|
|
|
return 'draft'
|
|
}
|
|
|
|
/**
|
|
* 게시물 상태 표시 문자열 생성
|
|
* @param {Object} post - 게시물
|
|
* @returns {string} 상태 표시 문자열
|
|
*/
|
|
const getPostStatusLabel = (post) => {
|
|
const statusKey = getPostStatusKey(post)
|
|
|
|
if (statusKey === 'scheduled') {
|
|
return '예약'
|
|
}
|
|
|
|
if (statusKey === 'published') {
|
|
return '발행'
|
|
}
|
|
|
|
if (statusKey === 'members') {
|
|
return '멤버십'
|
|
}
|
|
|
|
if (statusKey === 'private') {
|
|
return '비공개'
|
|
}
|
|
|
|
return '초안'
|
|
}
|
|
|
|
/**
|
|
* 게시물 상태 텍스트 클래스 생성
|
|
* @param {Object} post - 게시물
|
|
* @returns {string} 상태 텍스트 클래스
|
|
*/
|
|
const getPostStatusClass = (post) => {
|
|
const statusKey = getPostStatusKey(post)
|
|
|
|
if (statusKey === 'scheduled') {
|
|
return 'font-bold text-[#30cf43]'
|
|
}
|
|
|
|
if (statusKey === 'draft') {
|
|
return 'font-bold text-[#fb2d8d]'
|
|
}
|
|
|
|
if (statusKey === 'members') {
|
|
return 'font-bold text-[#5a63d8]'
|
|
}
|
|
|
|
if (statusKey === 'private') {
|
|
return 'font-bold text-[#15171a]'
|
|
}
|
|
|
|
if (statusKey === 'published') {
|
|
return 'text-[#99A3AD]'
|
|
}
|
|
|
|
return 'font-bold text-[#8e9cac]'
|
|
}
|
|
|
|
/**
|
|
* 태그 슬러그의 표시 이름을 조회한다.
|
|
* @param {string} slug - 태그 슬러그
|
|
* @returns {string} 태그 표시 이름
|
|
*/
|
|
const getTagName = (slug) => tags.value.find((tag) => tag.slug === slug)?.name || slug
|
|
|
|
/**
|
|
* 태그 슬러그의 고유 색상을 반환한다.
|
|
* @param {string} slug - 태그 슬러그
|
|
* @returns {string} 태그 색상
|
|
*/
|
|
const getTagColor = (slug) => tags.value.find((tag) => tag.slug === slug)?.color || defaultTagColor
|
|
|
|
/**
|
|
* 태그 배지 스타일을 생성한다.
|
|
* @param {string} color - 태그 색상
|
|
* @returns {Object} 배지 인라인 스타일
|
|
*/
|
|
const createTagBadgeStyle = (color) => ({
|
|
backgroundColor: `color-mix(in srgb, ${color} 14%, white)`,
|
|
borderColor: `color-mix(in srgb, ${color} 34%, white)`,
|
|
color
|
|
})
|
|
|
|
const usedTagSlugs = computed(() => {
|
|
const slugs = new Set()
|
|
for (const post of posts.value) {
|
|
for (const tag of post.tags || []) {
|
|
slugs.add(tag)
|
|
}
|
|
}
|
|
return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko'))
|
|
})
|
|
|
|
/** 전체 게시물 수 */
|
|
const totalPostCount = computed(() => posts.value.length)
|
|
|
|
/** 추천(`isFeatured`) 게시물 수 */
|
|
const featuredPostCount = computed(() => posts.value.filter((post) => post.isFeatured).length)
|
|
|
|
/** 필터가 적용됐는지 */
|
|
const hasActiveListFilters = computed(() => statusFilter.value !== 'all'
|
|
|| tagFilter.value !== 'all'
|
|
|| featuredFilter.value !== 'all'
|
|
|| Boolean(searchQuery.value.trim()))
|
|
|
|
/**
|
|
* 게시물이 검색어와 일치하는지 확인한다.
|
|
* @param {Object} post - 게시물
|
|
* @param {string} query - 검색어
|
|
* @returns {boolean} 일치 여부
|
|
*/
|
|
const doesPostMatchSearch = (post, query) => {
|
|
const keyword = query.trim().toLowerCase()
|
|
if (!keyword) {
|
|
return true
|
|
}
|
|
|
|
const tagText = (post.tags || [])
|
|
.map((tag) => `${tag} ${getTagName(tag)}`)
|
|
.join(' ')
|
|
|
|
const haystack = [
|
|
post.title,
|
|
post.slug,
|
|
post.excerpt,
|
|
post.content,
|
|
tagText
|
|
].join(' ').toLowerCase()
|
|
|
|
return haystack.includes(keyword)
|
|
}
|
|
|
|
const filteredPosts = computed(() => {
|
|
const filtered = posts.value.filter((post) => {
|
|
const matchesStatus = statusFilter.value === 'all' || getPostStatusKey(post) === statusFilter.value
|
|
const matchesTag = tagFilter.value === 'all' || (post.tags || []).includes(tagFilter.value)
|
|
const matchesFeatured = featuredFilter.value === 'all'
|
|
|| (featuredFilter.value === 'featured' && post.isFeatured)
|
|
const matchesSearch = doesPostMatchSearch(post, searchQuery.value)
|
|
|
|
return matchesStatus && matchesTag && matchesFeatured && matchesSearch
|
|
})
|
|
|
|
return [...filtered].sort((a, b) => {
|
|
const left = getListSortTimestamp(a)
|
|
const right = getListSortTimestamp(b)
|
|
|
|
return sortOrder.value === 'oldest' ? left - right : right - left
|
|
})
|
|
})
|
|
|
|
/** 게시물 통계 모달이 열려 있는지 */
|
|
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPost.value))
|
|
|
|
/** 통계 모달 검색 유입 조회수 */
|
|
const analyticsModalSearchViews = computed(() => getAnalyticsSourceGroupViews(['search']))
|
|
|
|
/** 통계 모달 SNS 유입 조회수 */
|
|
const analyticsModalSnsViews = computed(() => getAnalyticsSourceGroupViews(['sns']))
|
|
|
|
/** 통계 모달 기타 유입 조회수 */
|
|
const analyticsModalOtherViews = computed(() => getAnalyticsSourceGroupViews(['direct', 'other']))
|
|
|
|
/** 통계 모달 모바일 디바이스 행 */
|
|
const analyticsModalMobileDevice = computed(() => {
|
|
return (analyticsModalData.value.devices || []).find((row) => row.deviceType === '모바일')
|
|
})
|
|
|
|
/** 통계 모달 PC 디바이스 행 */
|
|
const analyticsModalPcDevice = computed(() => {
|
|
return (analyticsModalData.value.devices || []).find((row) => row.deviceType === 'PC')
|
|
})
|
|
|
|
/** 통계 모달 디바이스 총 조회수 */
|
|
const analyticsModalTotalDevicePageViews = computed(() => {
|
|
return (analyticsModalData.value.devices || []).reduce((sum, row) => sum + Number(row.pageViews || 0), 0)
|
|
})
|
|
|
|
/**
|
|
* 유입 그룹 표시명을 반환한다.
|
|
* @param {string} sourceGroup - 유입 그룹
|
|
* @returns {string} 표시명
|
|
*/
|
|
const getTrafficGroupLabel = (sourceGroup) => {
|
|
if (sourceGroup === 'search') {
|
|
return '검색'
|
|
}
|
|
|
|
if (sourceGroup === 'sns') {
|
|
return 'SNS'
|
|
}
|
|
|
|
if (sourceGroup === 'direct') {
|
|
return '직접'
|
|
}
|
|
|
|
return '기타'
|
|
}
|
|
|
|
/**
|
|
* 유입 그룹별 조회수를 합산한다.
|
|
* @param {string[]} groups - 유입 그룹
|
|
* @returns {number} 조회수 합계
|
|
*/
|
|
const getAnalyticsSourceGroupViews = (groups) => {
|
|
return (analyticsModalData.value.sources || [])
|
|
.filter((source) => groups.includes(source.sourceGroup))
|
|
.reduce((sum, source) => sum + Number(source.pageViews || 0), 0)
|
|
}
|
|
|
|
/**
|
|
* 디바이스 조회 비율을 반환한다.
|
|
* @param {number} value - 조회수
|
|
* @returns {string} 비율
|
|
*/
|
|
const formatAnalyticsDevicePercent = (value) => {
|
|
const total = analyticsModalTotalDevicePageViews.value
|
|
if (!total) {
|
|
return '0.0%'
|
|
}
|
|
|
|
return `${((Number(value || 0) / total) * 100).toFixed(1)}%`
|
|
}
|
|
|
|
/**
|
|
* 게시물 통계 모달 데이터를 조회한다.
|
|
* @returns {Promise<void>} 조회 완료
|
|
*/
|
|
const fetchAnalyticsModalData = async () => {
|
|
if (!analyticsModalPost.value?.id) {
|
|
return
|
|
}
|
|
|
|
analyticsModalLoading.value = true
|
|
analyticsModalError.value = ''
|
|
|
|
try {
|
|
analyticsModalData.value = await $fetch(`/admin/api/analytics/posts/${analyticsModalPost.value.id}`, {
|
|
query: {
|
|
period: analyticsModalPeriod.value,
|
|
days: getAnalyticsPeriodDays()
|
|
}
|
|
})
|
|
} catch (error) {
|
|
analyticsModalData.value = createDefaultPostAnalytics()
|
|
analyticsModalError.value = error?.data?.message || '게시물 통계를 불러오지 못했습니다.'
|
|
} finally {
|
|
analyticsModalLoading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시물 통계 모달을 연다.
|
|
* @param {Object} post - 게시물
|
|
* @returns {Promise<void>} 모달 열기 완료
|
|
*/
|
|
const openAnalyticsModal = async (post) => {
|
|
closePostMenu()
|
|
analyticsModalPost.value = post
|
|
analyticsModalPeriod.value = 'daily'
|
|
analyticsModalMetric.value = 'views'
|
|
analyticsModalChartPageOffset.value = 0
|
|
analyticsModalData.value = createDefaultPostAnalytics()
|
|
await fetchAnalyticsModalData()
|
|
}
|
|
|
|
/**
|
|
* 게시물 통계 모달을 닫는다.
|
|
* @returns {void}
|
|
*/
|
|
const closeAnalyticsModal = () => {
|
|
analyticsModalPost.value = null
|
|
analyticsModalError.value = ''
|
|
analyticsModalData.value = createDefaultPostAnalytics()
|
|
}
|
|
|
|
/**
|
|
* 통계 모달 집계 단위를 변경한다.
|
|
* @param {string} period - 집계 단위
|
|
* @returns {Promise<void>} 기간 변경 완료
|
|
*/
|
|
const selectAnalyticsModalPeriod = async (period) => {
|
|
analyticsModalPeriod.value = period
|
|
analyticsModalChartPageOffset.value = 0
|
|
await fetchAnalyticsModalData()
|
|
}
|
|
|
|
/**
|
|
* 게시물 수정 API 본문을 구성한다.
|
|
* @param {Object} post - 게시물
|
|
* @param {Object} [overrides] - 덮어쓸 필드
|
|
* @returns {Object} PUT 본문
|
|
*/
|
|
const buildPostUpdateBody = (post, overrides = {}) => ({
|
|
title: toAdminPostStoredTitle(post.title),
|
|
slug: post.slug,
|
|
content: post.content ?? '',
|
|
excerpt: post.excerpt ?? '',
|
|
featuredImage: post.featuredImage ?? null,
|
|
isFeatured: overrides.isFeatured ?? Boolean(post.isFeatured),
|
|
seoTitle: post.seoTitle ?? '',
|
|
seoDescription: post.seoDescription ?? '',
|
|
canonicalUrl: post.canonicalUrl ?? '',
|
|
noindex: Boolean(post.noindex),
|
|
ogImage: post.ogImage ?? null,
|
|
status: post.status,
|
|
publishedAt: post.publishedAt,
|
|
tags: post.tags ?? []
|
|
})
|
|
|
|
/**
|
|
* 열린 행 메뉴를 닫는다.
|
|
* @returns {void}
|
|
*/
|
|
const closePostMenu = () => {
|
|
openPostMenuId.value = ''
|
|
}
|
|
|
|
/**
|
|
* 게시물 행 메뉴 트리거 엘리먼트를 저장한다.
|
|
* @param {string} postId - 게시물 ID
|
|
* @param {Element|null} element - 트리거 엘리먼트
|
|
* @returns {void}
|
|
*/
|
|
const setPostMenuTriggerRef = (postId, element) => {
|
|
if (!element) {
|
|
postMenuTriggerRefs.delete(postId)
|
|
return
|
|
}
|
|
|
|
postMenuTriggerRefs.set(postId, element)
|
|
}
|
|
|
|
/**
|
|
* 게시물 행 메뉴 위치를 화면 기준으로 계산한다.
|
|
* @returns {void}
|
|
*/
|
|
const updatePostMenuPosition = () => {
|
|
if (!import.meta.client || !openPostMenuId.value) {
|
|
return
|
|
}
|
|
|
|
const trigger = postMenuTriggerRefs.get(openPostMenuId.value)
|
|
if (!trigger) {
|
|
return
|
|
}
|
|
|
|
const rect = trigger.getBoundingClientRect()
|
|
const menuWidth = 176
|
|
const estimatedHeight = 112
|
|
const margin = 8
|
|
const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin))
|
|
const opensUp = rect.bottom + estimatedHeight + margin > window.innerHeight
|
|
const top = opensUp
|
|
? Math.max(margin, rect.top - estimatedHeight - 4)
|
|
: rect.bottom + 4
|
|
|
|
postMenuStyle.value = {
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
width: `${menuWidth}px`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 행 메뉴 열기/닫기
|
|
* @param {string} postId - 게시물 ID
|
|
* @returns {void}
|
|
*/
|
|
const togglePostMenu = (postId) => {
|
|
openPostMenuId.value = openPostMenuId.value === postId ? '' : postId
|
|
}
|
|
|
|
/**
|
|
* 외부 클릭 시 행 메뉴 닫기
|
|
* @param {PointerEvent} event - 포인터 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onPostMenuDocumentPointerDown = (event) => {
|
|
if (!openPostMenuId.value || !(event.target instanceof HTMLElement)) {
|
|
return
|
|
}
|
|
|
|
if (event.target.closest('[data-admin-post-row-menu]')) {
|
|
return
|
|
}
|
|
|
|
closePostMenu()
|
|
}
|
|
|
|
/**
|
|
* 문서 키 입력을 처리한다.
|
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const onDocumentKeydown = (event) => {
|
|
if (event.key === 'Escape' && isAnalyticsModalOpen.value) {
|
|
closeAnalyticsModal()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시물 추천 여부를 토글한다.
|
|
* @param {Object} post - 게시물
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const togglePostFeatured = async (post) => {
|
|
closePostMenu()
|
|
togglingFeaturedId.value = post.id
|
|
errorMessage.value = ''
|
|
|
|
try {
|
|
await $fetch(`/admin/api/posts/${post.id}`, {
|
|
method: 'PUT',
|
|
body: buildPostUpdateBody(post, { isFeatured: !post.isFeatured })
|
|
})
|
|
await refresh()
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '추천 설정을 변경하지 못했습니다.'
|
|
} finally {
|
|
togglingFeaturedId.value = ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 게시물 삭제
|
|
* @param {Object} post - 삭제할 게시물
|
|
* @returns {Promise<void>} 삭제 처리 결과
|
|
*/
|
|
const deletePost = async (post) => {
|
|
closePostMenu()
|
|
|
|
if (!confirm(`"${post.title || '(제목 없음)'}" 글을 삭제할까요?`)) {
|
|
return
|
|
}
|
|
|
|
deletingId.value = post.id
|
|
errorMessage.value = ''
|
|
|
|
try {
|
|
await $fetch(`/admin/api/posts/${post.id}`, {
|
|
method: 'DELETE'
|
|
})
|
|
await refresh()
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
|
} finally {
|
|
deletingId.value = ''
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
|
|
document.addEventListener('keydown', onDocumentKeydown)
|
|
window.addEventListener('resize', updatePostMenuPosition)
|
|
window.addEventListener('scroll', updatePostMenuPosition, true)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
|
|
document.removeEventListener('keydown', onDocumentKeydown)
|
|
window.removeEventListener('resize', updatePostMenuPosition)
|
|
window.removeEventListener('scroll', updatePostMenuPosition, true)
|
|
})
|
|
|
|
watch(openPostMenuId, async (postId) => {
|
|
if (!postId) {
|
|
return
|
|
}
|
|
|
|
await nextTick()
|
|
updatePostMenuPosition()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<section class="admin-posts bg-paper p-6">
|
|
<div class="admin-posts__header flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<p class="admin-posts__eyebrow text-xs font-semibold uppercase text-muted">
|
|
Posts
|
|
</p>
|
|
<h1 class="admin-posts__title mt-2 text-3xl font-semibold">
|
|
글 목록
|
|
</h1>
|
|
<p class="admin-posts__count mt-2 text-sm text-muted">
|
|
총 {{ totalPostCount }}개
|
|
<template v-if="featuredPostCount > 0">
|
|
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
|
|
<span class="admin-posts__count-featured">추천 {{ featuredPostCount }}개</span>
|
|
</template>
|
|
<template v-if="hasActiveListFilters && filteredPosts.length !== totalPostCount">
|
|
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
|
|
<span class="admin-posts__count-filtered">표시 {{ filteredPosts.length }}개</span>
|
|
</template>
|
|
</p>
|
|
</div>
|
|
<div class="admin-posts__header-actions flex min-w-0 flex-1 flex-wrap items-center justify-start gap-2 lg:flex-nowrap lg:justify-end">
|
|
<div class="admin-posts__filters flex min-w-0 flex-wrap items-center gap-2">
|
|
<label class="admin-posts__search relative">
|
|
<span class="sr-only">글 검색</span>
|
|
<svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="m21 21-4.34-4.34" />
|
|
<circle cx="11" cy="11" r="8" />
|
|
</svg>
|
|
<input
|
|
v-model="searchQuery"
|
|
class="admin-posts__search-input h-10 w-52 rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac]"
|
|
type="search"
|
|
placeholder="글 검색"
|
|
>
|
|
</label>
|
|
<label class="admin-posts__filter">
|
|
<span class="sr-only">상태 필터</span>
|
|
<span class="admin-posts__filter-select-wrap relative block">
|
|
<select v-model="statusFilter" class="admin-posts__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
|
<option value="all">전체 상태</option>
|
|
<option value="published">발행</option>
|
|
<option value="draft">초안</option>
|
|
<option value="scheduled">예약</option>
|
|
<option value="members">멤버십</option>
|
|
<option value="private">비공개</option>
|
|
</select>
|
|
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
</span>
|
|
</label>
|
|
<label class="admin-posts__filter">
|
|
<span class="sr-only">태그 필터</span>
|
|
<span class="admin-posts__filter-select-wrap relative block">
|
|
<select v-model="tagFilter" class="admin-posts__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
|
<option value="all">전체 태그</option>
|
|
<option v-for="tag in usedTagSlugs" :key="tag" :value="tag">
|
|
{{ getTagName(tag) }}
|
|
</option>
|
|
</select>
|
|
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
</span>
|
|
</label>
|
|
<label class="admin-posts__filter">
|
|
<span class="sr-only">추천 필터</span>
|
|
<span class="admin-posts__filter-select-wrap relative block">
|
|
<select v-model="featuredFilter" class="admin-posts__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
|
<option value="all">전체 글</option>
|
|
<option value="featured">추천만</option>
|
|
</select>
|
|
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
</span>
|
|
</label>
|
|
<label class="admin-posts__filter">
|
|
<span class="sr-only">정렬</span>
|
|
<span class="admin-posts__filter-select-wrap relative block">
|
|
<select v-model="sortOrder" class="admin-posts__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
|
<option value="newest">최신순</option>
|
|
<option value="oldest">오래된순</option>
|
|
</select>
|
|
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<NuxtLink class="admin-posts__new shrink-0 rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/posts/new">
|
|
새 글
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="errorMessage" class="admin-posts__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
{{ errorMessage }}
|
|
</p>
|
|
|
|
<div class="admin-posts__table mt-8 overflow-x-auto border border-line">
|
|
<table class="admin-posts__table-inner w-full border-collapse text-left text-sm">
|
|
<thead class="admin-posts__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
|
<tr>
|
|
<th class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-3 text-center">
|
|
<span class="sr-only">추천</span>
|
|
<span class="inline-flex size-4 items-center justify-center text-[#f5a623]" aria-hidden="true">★</span>
|
|
</th>
|
|
<th class="admin-posts__cell admin-posts__cell-thumbnail w-16 px-2 py-3">
|
|
<span class="sr-only">썸네일</span>
|
|
</th>
|
|
<th class="admin-posts__cell px-4 py-3">제목</th>
|
|
<th class="admin-posts__cell px-4 py-3">상태</th>
|
|
<th class="admin-posts__cell px-4 py-3">태그</th>
|
|
<th class="admin-posts__cell px-4 py-3">조회수</th>
|
|
<th class="admin-posts__cell px-4 py-3">발행일</th>
|
|
<th class="admin-posts__cell admin-posts__cell-actions w-12 px-2 py-3 text-right">
|
|
<span class="sr-only">관리</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
|
|
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row">
|
|
<td class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-4 text-center">
|
|
<span
|
|
v-if="post.isFeatured"
|
|
class="admin-posts__featured-star inline-flex size-8 items-center justify-center text-[#f5a623]"
|
|
title="추천 글"
|
|
aria-label="추천 글"
|
|
>
|
|
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
</svg>
|
|
</span>
|
|
</td>
|
|
<td class="admin-posts__cell admin-posts__cell-thumbnail w-16 px-2 py-4">
|
|
<span class="admin-posts__thumbnail block h-9 w-12 overflow-hidden rounded bg-[#eef1f4]">
|
|
<img
|
|
v-if="post.featuredImage"
|
|
class="h-full w-full object-cover"
|
|
:src="post.featuredImage"
|
|
:alt="post.title || '게시물 썸네일'"
|
|
loading="lazy"
|
|
>
|
|
</span>
|
|
</td>
|
|
<td class="admin-posts__cell px-4 py-4">
|
|
<div class="admin-posts__title-row flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
|
|
{{ post.title || '(제목 없음)' }}
|
|
</NuxtLink>
|
|
<span class="admin-posts__comment-count text-xs font-medium text-muted">
|
|
댓글 {{ post.commentCount || 0 }}
|
|
</span>
|
|
</div>
|
|
<p class="admin-posts__slug mt-1 text-xs text-muted">
|
|
/post/{{ post.slug }}
|
|
</p>
|
|
</td>
|
|
<td class="admin-posts__cell px-4 py-4">
|
|
<span
|
|
class="admin-posts__status-text text-xs"
|
|
:class="getPostStatusClass(post)"
|
|
>
|
|
{{ getPostStatusLabel(post) }}
|
|
</span>
|
|
</td>
|
|
<td class="admin-posts__cell px-4 py-4">
|
|
<div v-if="post.tags?.length" class="admin-posts__tag-list flex flex-wrap gap-1.5">
|
|
<span
|
|
v-for="tag in post.tags"
|
|
:key="tag"
|
|
class="admin-posts__tag-badge inline-flex h-6 items-center rounded-[3px] border px-2 text-xs font-semibold"
|
|
:style="createTagBadgeStyle(getTagColor(tag))"
|
|
>
|
|
{{ getTagName(tag) }}
|
|
</span>
|
|
</div>
|
|
<span v-else class="admin-posts__tag-empty text-muted">-</span>
|
|
</td>
|
|
<td class="admin-posts__cell admin-posts__cell-views px-4 py-4">
|
|
<p class="admin-posts__view-count text-[#394047]">
|
|
{{ formatAdminNumber(post.totalViews) }}
|
|
</p>
|
|
<button
|
|
class="admin-posts__analytics-link mt-1 inline-flex text-xs text-muted transition-colors hover:text-[#2f6feb]"
|
|
type="button"
|
|
@pointerdown.stop
|
|
@click.stop="openAnalyticsModal(post)"
|
|
>
|
|
통계
|
|
</button>
|
|
</td>
|
|
<td class="admin-posts__cell px-4 py-4">
|
|
<p class="admin-posts__publish-date text-[#394047]">
|
|
{{ getPublishDateLabel(post) }}
|
|
</p>
|
|
<p v-if="getUpdatedDateLabel(post)" class="admin-posts__updated-date mt-1 text-xs text-muted">
|
|
{{ getUpdatedDateLabel(post) }}
|
|
</p>
|
|
</td>
|
|
<td class="admin-posts__cell admin-posts__cell-actions relative w-12 px-2 py-4 text-right">
|
|
<div
|
|
class="admin-posts__row-menu relative inline-flex justify-end"
|
|
data-admin-post-row-menu
|
|
>
|
|
<button
|
|
:ref="(element) => setPostMenuTriggerRef(post.id, element)"
|
|
class="admin-posts__row-menu-trigger inline-flex size-9 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#eceff2] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
|
|
type="button"
|
|
:disabled="deletingId === post.id || togglingFeaturedId === post.id"
|
|
:aria-expanded="openPostMenuId === post.id"
|
|
aria-haspopup="menu"
|
|
:aria-label="openPostMenuId === post.id ? '게시글 메뉴 닫기' : '게시글 메뉴'"
|
|
@click.stop="togglePostMenu(post.id)"
|
|
>
|
|
<span
|
|
v-if="deletingId === post.id || togglingFeaturedId === post.id"
|
|
class="text-[10px] font-semibold text-muted"
|
|
aria-hidden="true"
|
|
>…</span>
|
|
<svg
|
|
v-else
|
|
class="size-6 shrink-0"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 -960 960 960"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z" />
|
|
</svg>
|
|
</button>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="openPostMenuId === post.id"
|
|
class="admin-posts__row-menu-popover fixed z-[80] overflow-hidden rounded-xl border border-[#e2e5e9] bg-white py-2 text-sm text-[#3f4650] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
|
|
:style="postMenuStyle"
|
|
role="menu"
|
|
data-admin-post-row-menu
|
|
>
|
|
<button
|
|
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7] disabled:opacity-50"
|
|
type="button"
|
|
role="menuitem"
|
|
:disabled="togglingFeaturedId === post.id"
|
|
@click="togglePostFeatured(post)"
|
|
>
|
|
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
|
|
</button>
|
|
<button
|
|
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left text-[#c0392b] hover:bg-[#fef2f2] disabled:opacity-50"
|
|
type="button"
|
|
role="menuitem"
|
|
:disabled="deletingId === post.id"
|
|
@click="deletePost(post)"
|
|
>
|
|
게시글 삭제
|
|
</button>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p v-if="filteredPosts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
|
|
{{ posts.length === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
|
|
</p>
|
|
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="isAnalyticsModalOpen"
|
|
class="admin-posts__analytics-modal fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-4 py-6"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="admin-posts-analytics-modal-title"
|
|
@pointerdown.self="closeAnalyticsModal"
|
|
>
|
|
<article class="admin-posts__analytics-modal-panel max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-lg border border-line bg-white shadow-xl">
|
|
<header class="admin-posts__analytics-modal-header flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
|
<div class="min-w-0">
|
|
<p class="text-xs font-semibold uppercase text-muted">
|
|
게시물 통계
|
|
</p>
|
|
<h2 id="admin-posts-analytics-modal-title" class="mt-1 truncate text-lg font-semibold text-ink">
|
|
{{ analyticsModalPost?.title || '(제목 없음)' }}
|
|
</h2>
|
|
<p class="mt-1 text-xs text-muted">
|
|
{{ formatAdminDateTimeLabel(analyticsModalData.updatedAt) }} 기준
|
|
</p>
|
|
</div>
|
|
<button
|
|
class="admin-posts__analytics-modal-close border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
|
|
type="button"
|
|
@click="closeAnalyticsModal"
|
|
>
|
|
닫기
|
|
</button>
|
|
</header>
|
|
|
|
<div class="admin-posts__analytics-modal-body space-y-4 p-5">
|
|
<p v-if="analyticsModalLoading" class="text-sm text-muted">
|
|
게시물 통계를 불러오는 중입니다.
|
|
</p>
|
|
<p v-else-if="analyticsModalError" class="text-sm text-red-600">
|
|
{{ analyticsModalError }}
|
|
</p>
|
|
|
|
<template v-else>
|
|
<div class="admin-posts__analytics-modal-summary 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">
|
|
{{ formatAdminNumber(analyticsModalData.total.views) }}
|
|
</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">
|
|
{{ formatAdminNumber(analyticsModalData.period.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(analyticsModalData.period.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">
|
|
{{ formatAdminNumber(analyticsModalData.period.scroll50) }}
|
|
</strong>
|
|
</article>
|
|
</div>
|
|
|
|
<section class="admin-posts__analytics-modal-chart border border-line bg-paper p-4">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 class="text-xs font-semibold uppercase text-muted">
|
|
통계 추이
|
|
</h3>
|
|
<p class="mt-1 text-xs text-muted">
|
|
{{ getAnalyticsPeriodLabel() }} 기준
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<div class="inline-flex border border-line bg-white">
|
|
<button
|
|
v-for="metric in analyticsMetricOptions"
|
|
:key="metric.value"
|
|
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
|
|
:class="analyticsModalMetric === metric.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
|
|
type="button"
|
|
@click="analyticsModalMetric = metric.value"
|
|
>
|
|
{{ metric.label }}
|
|
</button>
|
|
</div>
|
|
<div class="inline-flex border border-line bg-white">
|
|
<button
|
|
v-for="option in analyticsPeriodOptions"
|
|
:key="option.value"
|
|
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
|
|
:class="analyticsModalPeriod === option.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
|
|
type="button"
|
|
@click="selectAnalyticsModalPeriod(option.value)"
|
|
>
|
|
{{ option.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AdminAnalyticsTrendChart
|
|
class="mt-4"
|
|
:rows="analyticsModalData.trends"
|
|
:period="analyticsModalPeriod"
|
|
:metric-key="analyticsModalMetric"
|
|
:metric-label="analyticsModalMetric === 'views' ? '조회수' : '방문자'"
|
|
:format-value="formatAdminNumber"
|
|
v-model:page-offset="analyticsModalChartPageOffset"
|
|
compact
|
|
bar-height-class="h-36"
|
|
/>
|
|
</section>
|
|
|
|
<div class="grid gap-4 lg:grid-cols-2">
|
|
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
|
|
<h3 class="text-xs font-semibold uppercase text-muted">
|
|
검색
|
|
</h3>
|
|
<p class="mt-1 text-xs text-muted">
|
|
조회 {{ formatAdminNumber(analyticsModalSearchViews) }}
|
|
</p>
|
|
<ul class="mt-3 space-y-2">
|
|
<li
|
|
v-for="row in analyticsModalData.sources.filter((source) => source.sourceGroup === 'search')"
|
|
:key="`${row.sourceGroup}-${row.sourceName}`"
|
|
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
|
>
|
|
<span class="font-medium text-ink">{{ row.sourceName }}</span>
|
|
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
|
|
</li>
|
|
</ul>
|
|
<p v-if="!analyticsModalData.sources.some((source) => source.sourceGroup === 'search')" class="mt-3 text-xs text-muted">
|
|
해당 기간에 집계된 검색 유입이 없습니다.
|
|
</p>
|
|
</section>
|
|
|
|
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
|
|
<h3 class="text-xs font-semibold uppercase text-muted">
|
|
SNS
|
|
</h3>
|
|
<p class="mt-1 text-xs text-muted">
|
|
조회 {{ formatAdminNumber(analyticsModalSnsViews) }}
|
|
</p>
|
|
<ul class="mt-3 space-y-2">
|
|
<li
|
|
v-for="row in analyticsModalData.sources.filter((source) => source.sourceGroup === 'sns')"
|
|
:key="`${row.sourceGroup}-${row.sourceName}`"
|
|
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
|
>
|
|
<span class="font-medium text-ink">{{ row.sourceName }}</span>
|
|
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
|
|
</li>
|
|
</ul>
|
|
<p v-if="!analyticsModalData.sources.some((source) => source.sourceGroup === 'sns')" class="mt-3 text-xs text-muted">
|
|
해당 기간에 집계된 SNS 유입이 없습니다.
|
|
</p>
|
|
</section>
|
|
|
|
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
|
|
<h3 class="text-xs font-semibold uppercase text-muted">
|
|
기타
|
|
</h3>
|
|
<p class="mt-1 text-xs text-muted">
|
|
조회 {{ formatAdminNumber(analyticsModalOtherViews) }}
|
|
</p>
|
|
<ul class="mt-3 space-y-2">
|
|
<li
|
|
v-for="row in analyticsModalData.sources.filter((source) => ['direct', 'other'].includes(source.sourceGroup))"
|
|
:key="`${row.sourceGroup}-${row.sourceName}`"
|
|
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
|
>
|
|
<span class="font-medium text-ink">{{ getTrafficGroupLabel(row.sourceGroup) }} · {{ row.sourceName }}</span>
|
|
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
|
|
</li>
|
|
</ul>
|
|
<p v-if="!analyticsModalData.sources.some((source) => ['direct', 'other'].includes(source.sourceGroup))" class="mt-3 text-xs text-muted">
|
|
해당 기간에 집계된 기타 유입이 없습니다.
|
|
</p>
|
|
</section>
|
|
|
|
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
|
|
<h3 class="text-xs font-semibold uppercase text-muted">
|
|
디바이스
|
|
</h3>
|
|
<ul class="mt-3 space-y-2">
|
|
<li class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs">
|
|
<span class="font-medium text-ink">모바일</span>
|
|
<span>{{ formatAnalyticsDevicePercent(analyticsModalMobileDevice?.pageViews) }}</span>
|
|
</li>
|
|
<li class="flex items-center justify-between gap-3 text-xs">
|
|
<span class="font-medium text-ink">PC</span>
|
|
<span>{{ formatAnalyticsDevicePercent(analyticsModalPcDevice?.pageViews) }}</span>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4 lg:col-span-2">
|
|
<h3 class="text-xs font-semibold uppercase text-muted">
|
|
유입 키워드
|
|
</h3>
|
|
<ul v-if="analyticsModalData.keywords.length" class="mt-3 grid gap-2 md:grid-cols-2">
|
|
<li
|
|
v-for="row in analyticsModalData.keywords"
|
|
:key="`${row.sourceName}-${row.keyword}`"
|
|
class="flex items-center justify-between gap-3 border border-line bg-white px-3 py-2 text-xs"
|
|
>
|
|
<span class="min-w-0 truncate font-medium text-ink">{{ row.keyword }}</span>
|
|
<span class="shrink-0 text-right text-muted">{{ row.sourceName }} · {{ formatAdminNumber(row.pageViews) }}</span>
|
|
</li>
|
|
</ul>
|
|
<p v-else class="mt-3 text-xs text-muted">
|
|
해당 기간에 수집된 유입 키워드가 없습니다.
|
|
</p>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</Teleport>
|
|
</section>
|
|
</template>
|