v1.12.25 대시보드 숫자 표기 및 접속자 목록 간소화

This commit is contained in:
2026-10-21 17:17:57 +09:00
parent 93dc6c04bd
commit 9e2fec6d6c
14 changed files with 349 additions and 195 deletions

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
import {
ANALYTICS_SINGLE_BAR_COLORS,
formatAnalyticsChartHeaderLabel,
@@ -33,7 +34,7 @@ const props = defineProps({
},
formatValue: {
type: Function,
default: (value) => String(value ?? 0)
default: (value) => formatAnalyticsCount(value)
},
formatDelta: {
type: Function,
@@ -188,7 +189,7 @@ const formatDeltaLabel = (delta) => {
}
const absolute = Math.abs(delta)
return Number.isFinite(absolute) ? String(absolute) : '0'
return formatAnalyticsCount(absolute)
}
/**
@@ -309,11 +310,11 @@ const getRowDelta = (row) => {
v-if="trafficStack && getStackSegments(row).length > 1"
class="admin-analytics-trend-chart__tooltip-breakdown mt-1 text-[10px] leading-relaxed text-muted"
>
<span class="text-[#e5484d]">검색 {{ row.trafficSearch || 0 }}</span>
<span class="text-[#e5484d]">검색 {{ formatAnalyticsCount(row.trafficSearch) }}</span>
·
<span class="text-[#12a594]">SNS {{ row.trafficSns || 0 }}</span>
<span class="text-[#12a594]">SNS {{ formatAnalyticsCount(row.trafficSns) }}</span>
·
<span class="text-[#8b8d98]">기타 {{ row.trafficOther || 0 }}</span>
<span class="text-[#8b8d98]">기타 {{ formatAnalyticsCount(row.trafficOther) }}</span>
</p>
<p class="admin-analytics-trend-chart__tooltip-period mt-1 text-[10px] leading-none text-muted">
{{ getAnalyticsTrendDetailLabel(period, row, todayKey) }}

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
const { data, error, refresh } = await useFetch('/admin/api/comments', {
query: { pageSize: 5 },
default: () => ({ items: [], pagination: { total: 0 } })
@@ -19,7 +20,7 @@ const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
<template>
<section class="admin-comment-list border border-line bg-white px-4 py-3">
<h2 class="admin-comment-list__heading mb-1 text-xs font-semibold text-muted">최근 댓글 <span class="font-normal">· 전체 {{ data?.pagination.total || 0 }}개</span></h2>
<h2 class="admin-comment-list__heading mb-1 text-xs font-semibold text-muted">최근 댓글 <span class="font-normal">· 전체 {{ formatAnalyticsCount(data?.pagination.total) }}개</span></h2>
<p v-if="error" class="admin-comment-list__error text-xs text-red-700" role="alert">
댓글을 불러오지 못했습니다. <button type="button" class="underline" @click="refresh()">다시 시도</button>
</p>

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
const props = defineProps({ memberId: { type: String, required: true } })
const searchInput = ref('')
const filters = ref({ q: '', type: 'all', page: 1 })
@@ -41,7 +42,7 @@ const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
<template>
<section class="admin-member-activity border border-line bg-white p-4" :aria-busy="status === 'pending'">
<div class="admin-member-activity__toolbar flex flex-wrap items-center justify-between gap-3">
<h2 class="admin-member-activity__heading text-sm font-semibold">작성한 글 <span class="text-xs font-normal text-muted">{{ data?.pagination.total || 0 }}개 · 최신순</span></h2>
<h2 class="admin-member-activity__heading text-sm font-semibold">작성한 글 <span class="text-xs font-normal text-muted">{{ formatAnalyticsCount(data?.pagination.total) }}개 · 최신순</span></h2>
<form class="admin-member-activity__search flex w-full flex-wrap items-center gap-2 sm:w-auto" role="search" @submit.prevent="applySearch">
<select class="admin-member-activity__type h-10 rounded border border-line bg-white px-3 text-sm" aria-label="작성 종류" :value="filters.type" @change="changeType">
<option value="all">전체</option>

View File

@@ -0,0 +1,145 @@
<script setup>
import { formatAnalyticsCount, getActivePageHref, getActivePageTitle, getRealtimePages } from '~/lib/analytics-display.js'
const props = defineProps({ session: { type: Object, required: true } })
const pages = computed(() => getRealtimePages(props.session))
const triggerRef = ref(null)
const panelRef = ref(null)
const isOpen = ref(false)
const isPinned = ref(false)
const popoverStyle = ref({})
const popoverId = useId()
let closeTimer = null
/**
* 열린 페이지 목록이 화면 안에 표시되도록 위치를 계산한다.
* @returns {void}
*/
const updatePosition = () => {
if (!triggerRef.value || !isOpen.value) return
const rect = triggerRef.value.getBoundingClientRect()
const width = Math.min(480, window.innerWidth - 24)
const maxHeight = Math.min(320, window.innerHeight - 24)
const height = Math.min(panelRef.value?.parentElement?.offsetHeight || maxHeight, maxHeight)
const top = rect.bottom + height + 8 <= window.innerHeight ? rect.bottom + 4 : Math.max(12, rect.top - height - 4)
popoverStyle.value = {
left: `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`,
top: `${top}px`, width: `${width}px`, maxHeight: `${maxHeight}px`
}
}
/**
* 목록 닫기 예약을 취소한다.
* @returns {void}
*/
const cancelClose = () => { clearTimeout(closeTimer) }
/**
* 열린 페이지 목록을 표시한다.
* @returns {Promise<void>} 위치 반영
*/
const openPages = async () => {
cancelClose()
isOpen.value = true
await nextTick()
updatePosition()
}
/**
* 고정하지 않은 목록을 포인터 이동 후 닫는다.
* @returns {void}
*/
const scheduleClose = () => {
cancelClose()
closeTimer = setTimeout(() => {
if (!isPinned.value && !panelRef.value?.contains(document.activeElement) && document.activeElement !== triggerRef.value) isOpen.value = false
}, 150)
}
/**
* 클릭이나 Enter로 목록 고정을 전환한다.
* @returns {void}
*/
const togglePinned = () => {
isPinned.value = !isPinned.value
if (isPinned.value) openPages()
else isOpen.value = false
}
/**
* 목록을 닫고 필요하면 트리거로 포커스를 돌린다.
* @param {boolean} [restoreFocus] - 포커스 복원 여부
* @returns {void}
*/
const closePages = (restoreFocus = false) => {
isPinned.value = false
if (restoreFocus) triggerRef.value?.focus()
isOpen.value = false
}
/**
* 목록 바깥 클릭을 처리한다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const handleOutside = (event) => {
if (!triggerRef.value?.contains(event.target) && !panelRef.value?.contains(event.target)) closePages()
}
/**
* Escape로 페이지 목록을 닫는다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleKeydown = (event) => {
if (!isOpen.value) return
if (event.key === 'Escape') {
event.preventDefault()
closePages(true)
}
if (event.key === 'ArrowDown' && document.activeElement === triggerRef.value) {
event.preventDefault()
panelRef.value?.querySelector('a, button')?.focus()
}
}
onMounted(() => {
document.addEventListener('pointerdown', handleOutside)
document.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
})
onBeforeUnmount(() => {
cancelClose()
document.removeEventListener('pointerdown', handleOutside)
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
})
</script>
<template>
<div class="admin-realtime-pages min-w-0">
<button ref="triggerRef" type="button" class="admin-realtime-pages__trigger flex w-full min-w-0 items-center gap-1 text-left text-xs text-muted hover:text-ink" :aria-expanded="isOpen" :aria-controls="popoverId" aria-label="열린 페이지 목록" @mouseenter="openPages" @mouseleave="scheduleClose" @focus="openPages" @blur="scheduleClose" @click="togglePinned">
<span class="admin-realtime-pages__latest min-w-0 truncate">{{ getActivePageTitle(pages[0]) }}</span>
<span v-if="pages.length > 1" class="admin-realtime-pages__extra shrink-0 font-medium">외 {{ formatAnalyticsCount(pages.length - 1) }}개</span>
</button>
<Teleport to="body">
<AdminPopoverSurface v-if="isOpen" :id="popoverId" root-class="admin-realtime-pages__popover admin-layout--light-controls fixed z-[80] overflow-y-auto rounded-lg border-line p-3 text-xs text-[#15171a]" :surface-style="popoverStyle" @mouseenter="cancelClose" @mouseleave="scheduleClose" @focusin="cancelClose" @focusout="scheduleClose">
<div ref="panelRef">
<div class="mb-2 flex items-center justify-between gap-3">
<p class="font-semibold">열린 페이지 {{ formatAnalyticsCount(pages.length) }}개 · 최근 확인순</p>
<button type="button" class="shrink-0 text-muted hover:text-ink" aria-label="열린 페이지 목록 닫기" @click="closePages(true)">닫기</button>
</div>
<p class="mb-2 text-muted">IP {{ session.ipAddress || '-' }}</p>
<ul class="admin-realtime-pages__list divide-y divide-line">
<li v-for="(page, index) in pages" :key="page.key || index" class="py-2">
<a v-if="getActivePageHref(page)" :href="getActivePageHref(page)" target="_blank" rel="noopener noreferrer" class="admin-realtime-pages__link block break-words hover:underline">{{ getActivePageTitle(page) }} <span aria-hidden="true">↗</span><span class="sr-only"> 새 창으로 열기</span></a>
<span v-else class="break-words">{{ getActivePageTitle(page) }}</span>
</li>
</ul>
</div>
</AdminPopoverSurface>
</Teleport>
</div>
</template>

View File

@@ -1,3 +1,7 @@
## 2026-10-21 v1.12.25 — 접속자 목록은 최근 페이지만 요약한다
한 방문자가 여러 탭을 열면 접속자 행 높이가 크게 늘어나므로 이름·가장 최근 확인된 페이지·나머지 고유 페이지 수·체류·IP를 한 줄로 표시한다. 상세 목록은 hover뿐 아니라 키보드와 클릭으로도 접근할 수 있게 하며, heartbeat의 마지막 확인 순서를 사용한다. 같은 경로 중복은 표시 목록에서 제거해 `외 N개`와 펼친 목록의 수를 일치시킨다. 기존 API 집계 기준은 유지하고, 자주 확인할 수입·추이·상세·댓글을 접속자 목록 위에 배치한다.
## 2026-10-21 v1.12.24 — 멤버 작성 이력은 종류 필터가 있는 통합 표로 표시한다
멤버의 게시글과 댓글은 작성한 글 탭 하나에서 종류 열로 구분한다. 대시보드처럼 작성 대상·작성자·시각·내용을 한 줄로 표시해 여러 활동을 빠르게 훑도록 한다. 두 종류를 브라우저에서 따로 조회·합치면 전체 페이지와 최신순 정렬이 틀어질 수 있으므로 서버에서 작성자 조건을 적용한 게시글·댓글을 합친 뒤 종류 필터·검색·전체 건수·페이지를 계산한다. 기존 개별 API는 호환을 유지한다.

View File

@@ -366,7 +366,7 @@
| 파일 | 화면 |
|------|------|
| pages/admin/index.vue | 대시보드(최근 댓글 5개 작은 행·게시글/작성자/시각/한 줄 내용·30초 갱신, 상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), IP·줄바꿈된 열린 페이지 수·열린 페이지 목록 포함 접속자 목록) |
| pages/admin/index.vue | 대시보드(최근 댓글 5개 작은 행·게시글/작성자/시각/한 줄 내용·30초 갱신, 상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), 마지막 섹션의 접속자 한 줄 목록·최근 페이지 외 N개·IP·hover/클릭/키보드 전체 페이지 팝오버) |
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, `F`/`ㄹ` 검색창 포커스, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 별도 댓글 수 열, 제목 아래 주소·공개 보기 hover 아이콘, 백링크 개수 열과 고정 크기 참조 게시물 목록 모달·내부 검색·이동 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(고정 전용 API·추천 전용 API·삭제 전 참조 경고·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·첫 저장 준비 오버레이·첫 생성 중 추가 입력 PUT 반영·이탈 직전 플러시, 저장 토스트 |
@@ -646,3 +646,7 @@
| server/routes/admin/api/members/[id]/posts.get.js | 관리자 회원 작성 글 조회 인증·Zod 검증·검색·페이지 API |
| server/repositories/member-activity-repository.js | 회원별 게시글·댓글 통합 최신순 검색·종류 필터·전체 건수 및 페이지 조회 |
| server/routes/admin/api/members/[id]/activity.get.js | 관리자 회원 통합 작성 이력 인증·Zod 조건 검증 API |
| lib/analytics-display.js | 통계 천 단위 숫자 표시·접속 페이지 제목/공개 링크·최근순 고유 페이지 정리 |
| components/admin/AdminRealtimePages.vue | 접속자 최근 페이지 요약·화면 안 팝오버 위치·열린 페이지 링크·hover/클릭/키보드 접근 |
| tests/analytics-display.test.js | 천 단위 표시·최근순 중복 제거·공개 경로 검증 |
| tests/playwright/e2e/admin-dashboard.spec.js | 대시보드 쉼표·섹션 순서·한 줄 접속자·페이지 목록 접근 검증 |

View File

@@ -602,7 +602,7 @@ components/content/
- 클라이언트는 탭 단위 `clientSessionId`와 라우트 진입 단위 `contentSessionId`를 분리해 전송한다. 서버는 원문 ID를 저장하지 않고 visitor hash와 통계 시크릿으로 `session_hash`, `content_session_hash`를 만든다.
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송한다. 콘텐츠 세션 원장이 있는 경우 같은 콘텐츠 세션의 `read_recorded`가 처음 켜질 때만 일별 `reads`를 증가시킨다.
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다. 체류·스크롤 증분은 `analytics_content_sessions`의 콘텐츠 세션별 이전 값 기준으로 계산해 같은 탭에서 여러 게시물·페이지를 이동해도 스크롤 구간과 engaged 집계가 서로 섞이지 않게 한다.
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 활성 페이지 행에 마우스를 올리면 공개 경로 새 창 바로가기 버튼을 표시한다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다.
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 접속자는 이름·최근 페이지 제목과 `외 N개`·접속 유지시간·IP를 한 줄로 표시한다. 활성 페이지 목록은 최근 `lastSeenAt` 순으로 정렬하고 동일 경로를 중복 제거하며 `N`도 고유 페이지 수 기준이다. 제목에 hover/포커스/클릭하면 열린 페이지 전체 목록과 새 창 링크를 제공한다. ArrowDown으로 목록에 진입하고 Escape·닫기·바깥 클릭으로 닫는다. 작은 화면의 IP는 페이지 목록 안에서도 확인할 수 있다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다.
- 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 선택지는 일간·주간·월간·년간이며 기본값은 일간이다. 집계는 `ANALYTICS_COLLECTED_SINCE` 환경 변수(기본 `2026-06-01`) 이후 데이터만 합산하고 그 이전 구간은 0으로 표시한다. 평균 체류시간과 50% 스크롤 도달은 차트 버킷이 콘텐츠 세션 원장 보관 범위 안에 있고 원장 데이터가 있으면 원장 기준으로 보정하고, 없는 버킷은 기존 일별 누적 집계 값을 유지한다. **방문자수** 막대는 검색(빨강)·SNS(청록)·기타(회색) 유입 비율을 스택으로 표시하며, hover 시 해당 막대만 색상을 강조한다. 안내 문구는 `통계는 2026년 6월 1일부터 집계한 데이터를 표시합니다.` 형식이다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 막대 클릭 시 하단 **선택한 기간 상세** 패널을 해당 날짜 또는 묶음 구간 기준으로 갱신한다. 표(table)나 외부 차트 라이브러리는 사용하지 않는다.
- 관리자 대시보드 **선택한 기간 상세**는 기본값으로 오늘 통계를 표시하고, 차트 막대 클릭 시 선택 범위의 페이지뷰·방문자·평균 체류·50% 스크롤·검색/SNS/직접 포함 유입원·디바이스/OS·인기 게시물·인기 페이지를 표시한다. 자동 갱신 중에는 사용자가 선택한 날짜·기간을 유지하고, 상세 재조회 중에도 기존 내용을 유지해 화면 높이가 줄어들지 않게 한다. 인기 게시물·인기 페이지는 데스크톱에서 왼쪽 75% 영역에 세로 목록으로 표시하고, 유입·디바이스는 오른쪽 25% 영역에 세로 배치한다. 디바이스는 모바일·PC 같은 상위 기기 기준으로 한 카드에 합산하고, 상위 기기 제목 오른쪽에 방문자 수·조회수·전체 디바이스 방문자 대비 비율을 한 줄로 표시한다. 카드 안의 Android/iOS/Windows 같은 OS별 목록은 방문자 수와 전체 디바이스 방문자 대비 비율만 표시한다. 인기 콘텐츠 메타는 `V 조회`, `R 읽음/방문자`, `M 최근 30일 조회`, `C 작성일`, `S 50/75/100% 스크롤` 형식의 한 줄 압축 표기를 사용한다. `V/R/M` 알파벳만 서로 다른 색상으로 강조하고 숫자는 보조 텍스트 색상으로 표시한다. 구분자 `|`는 연한 회색으로 표시하며, `S`의 50/75/100 스크롤 값은 단계가 깊어질수록 진하게 표시한다. 관리자 통계 응답은 표시 안정성을 위해 `읽음 <= 조회`, `100% 스크롤 <= 75% 스크롤 <= 50% 스크롤 <= 조회` 범위로 정규화한다. 유입 프로그래스 바는 보조 지표로 보이도록 카드 폭의 절반만 사용한다. 검색엔진이 대부분 검색어를 전달하지 않는 현실을 반영해 대시보드의 유입 키워드 영역은 표시하지 않는다.
- 관리자 대시보드 요약·선택한 기간 상세와 게시물·페이지별 통계 상세의 기간 요약에서 평균 체류·스크롤·읽음처럼 같은 콘텐츠 세션 중복 제거가 중요한 지표는 선택 기간이 `analytics_content_sessions` 보관 범위 안에 있고 원장 데이터가 있을 때 원장 기준으로 계산한다. 보관 범위 밖 기간이나 원장 데이터가 없는 기간은 기존 일별 누적 집계로 fallback한다.
@@ -1107,3 +1107,10 @@ APP_PORT=43080
- 게시글(`posts.author_id`)과 댓글·답글(`comments.user_id`)을 `UNION ALL`로 합쳐 `createdAt DESC, kind ASC, id DESC`로 정렬한 뒤 전체 건수·페이지를 계산한다. 검색은 게시글 제목·주소·요약·본문 또는 댓글 내용·대상 게시글 제목의 문자 그대로 부분 검색. 모든 상태를 포함하며 초과 페이지는 마지막 페이지로 보정한다.
- 응답 `{ items: [{ kind, id, postId, postTitle, postSlug, postStatus, publishedAt, createdAt, username, body, publicUrl }], pagination: { page, pageSize, total, pageCount } }`. `kind`는 `post`/`comment`. 게시글 body는 최대 320자 일반 텍스트 요약, 댓글은 원문 텍스트. 공개 접근 가능한 게시글은 `publicUrl` 제공(댓글은 `#comments`), 초안·비공개·예약 글은 null이며 관리자 편집으로 이동.
- 기존 `/admin/api/comments`, `/admin/api/members/:id/posts` 응답 계약은 유지하며 멤버 작성한 글 화면은 통합 `/activity`를 사용한다.
## 대시보드 표시 정리 (v1.12.25)
- 화면 순서: 상단 요약 → 예상 수입 → 통계 추이 → 선택한 기간 상세 → 최근 댓글 → 현재 접속자.
- 방문자·게시물 수·조회·스크롤·유입·디바이스·차트 수치·댓글/작성 이력 건수는 `ko-KR` 천 단위 구분(예: `10,413`)으로 표시. API 숫자 형식은 유지.
- 접속자 대표 페이지는 포커스 여부가 아닌 마지막 확인 시각 기준. 현재 API의 회원별·익명 IP별 묶음은 유지. 인원 표시는 반환된 목록 수와 전체 접속자 수를 구분.
- 열린 페이지 팝오버는 hover 시 일시 표시, 클릭/Enter로 고정 전환. 포인터를 목록으로 이동해도 링크 접근을 유지하며, 화면 범위에 맞춰 위/아래 배치하고 긴 목록은 내부 스크롤 제공.

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v1.12.25
- 대시보드 요약·통계·차트·목록 건수에 천 단위 쉼표 표시 적용.
- 대시보드 요약·예상 수입·통계 추이·선택 기간 상세·댓글·현재 접속자 순서 정리.
- 현재 접속자 한 줄 표시와 최근 페이지 제목 외 N개 요약 추가.
- 열린 페이지 최근 확인순·동일 경로 중복 제거·hover/클릭/키보드 팝오버 및 원문 이동 추가.
- 접속자 표시 수와 전체 인원을 구분하고 페이지 목록 화면 안 위치 보정 추가.
- JavaScript 문법 검사·단위 테스트 276건·프로덕션 빌드·Chromium E2E 12건 검증 진행.
## v1.12.24
- 멤버 작성 글·댓글 탭을 작성한 글 탭 하나로 통합.

45
lib/analytics-display.js Normal file
View File

@@ -0,0 +1,45 @@
/**
* 통계 수치에 한국어 천 단위 구분을 적용한다.
* @param {unknown} value - 표시할 수치
* @returns {string} 구분 기호가 있는 숫자
*/
export const formatAnalyticsCount = (value) => {
const number = Number(value || 0)
return new Intl.NumberFormat('ko-KR').format(Number.isFinite(number) ? number : 0)
}
/**
* 접속 페이지의 표시 제목을 반환한다.
* @param {Object} page - 활성 페이지
* @returns {string} 페이지 제목
*/
export const getActivePageTitle = (page) => page.postTitle || page.pageTitle || (page.path === '/' ? '홈' : page.path) || '알 수 없음'
/**
* 접속 페이지의 사이트 내부 공개 경로를 반환한다.
* @param {Object} page - 활성 페이지
* @returns {string} 바로가기 경로
*/
export 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()
return path.startsWith('/') && !path.startsWith('//') && !path.includes('\\') && !/^\/admin(?:[/?#]|$)/.test(path) ? path : ''
}
/**
* 열린 페이지를 최근 확인 순으로 정렬하고 같은 경로를 중복 제거한다.
* @param {Object} session - 묶인 접속자
* @returns {Array<Object>} 최신 페이지부터의 고유 목록
*/
export const getRealtimePages = (session) => {
const pages = session.pages?.length ? session.pages : [session]
const ordered = [...pages].sort((left, right) => (Date.parse(right.lastSeenAt) || 0) - (Date.parse(left.lastSeenAt) || 0))
const seen = new Set()
return ordered.filter((page) => {
const key = getActivePageHref(page) || page.path || page.key || ''
if (seen.has(key)) return false
seen.add(key)
return true
})
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.12.24",
"version": "1.12.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.12.24",
"version": "1.12.25",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.12.24",
"version": "1.12.25",
"private": true,
"type": "module",
"imports": {

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatAnalyticsCount as formatNumber } from '~/lib/analytics-display.js'
import { ANALYTICS_COLLECTED_SINCE_DEFAULT, getAnalyticsDayKey } from '~/lib/analytics-shared.js'
import {
getAnalyticsTrendDescription,
@@ -204,15 +205,6 @@ const getAdsenseCurrencyCode = (currencyCode) => {
}
}
/**
* 숫자를 천 단위 구분 문자열로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 표시 문자열
*/
const formatNumber = (value) => {
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
}
/**
* AdSense 금액을 통화 문자열로 변환한다.
* @param {number} value - 금액
@@ -317,10 +309,10 @@ const formatTrendValue = (key, value) => {
}
if (key === 'visitors') {
return `${Number(value || 0)}명`
return `${formatNumber(value)}명`
}
return `${Number(value || 0)}회`
return `${formatNumber(value)}회`
}
/**
@@ -376,7 +368,7 @@ const formatDeviceSummary = (row) => {
const visitors = Number(row?.visitors || 0)
const pageViews = Number(row?.pageViews || 0)
return `${visitors}명 / ${pageViews}회 / ${formatDeviceVisitorPercent(visitors)}`
return `${formatNumber(visitors)}명 / ${formatNumber(pageViews)}회 / ${formatDeviceVisitorPercent(visitors)}`
}
/**
@@ -398,7 +390,7 @@ const chartMetrics = computed(() => [
{
key: 'visitors',
title: '방문자수',
label: `${getTrendSummaryValue('visitors')}명`,
label: `${formatNumber(getTrendSummaryValue('visitors'))}명`,
metricLabel: '방문자수'
},
{
@@ -410,7 +402,7 @@ const chartMetrics = computed(() => [
{
key: 'scroll50Reach',
title: '50% 스크롤 도달',
label: `${getTrendSummaryValue('scroll50Reach')}회`,
label: `${formatNumber(getTrendSummaryValue('scroll50Reach'))}회`,
metricLabel: '50% 스크롤 도달'
}
])
@@ -477,70 +469,6 @@ const selectDefaultTrendDetail = () => {
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 날짜
@@ -568,7 +496,7 @@ const formatCompactDate = (value) => {
* @returns {string} 스크롤 50/75/100 도달 수
*/
const formatScrollCompact = (row) => {
return `${Number(row.scroll50 || 0)}.${Number(row.scroll75 || 0)}.${Number(row.scroll100 || 0)}`
return `${formatNumber(row.scroll50)}.${formatNumber(row.scroll75)}.${formatNumber(row.scroll100)}`
}
/**
@@ -591,9 +519,9 @@ const formatScrollParts = (row) => {
*/
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: 'views', label: 'V', value: formatNumber(row.views), colorClass: 'text-[#2563eb]' },
{ key: 'reads', label: 'R', value: formatNumber(row.reads), colorClass: 'text-[#16a34a]' },
{ key: 'monthly', label: 'M', value: formatNumber(row.monthlyViews), 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) }
]
@@ -606,9 +534,9 @@ const formatTopPostMeta = (row) => {
*/
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: 'views', label: 'V', value: formatNumber(row.views), colorClass: 'text-[#2563eb]' },
{ key: 'visitors', label: 'R', value: formatNumber(row.visitors), colorClass: 'text-[#16a34a]' },
{ key: 'monthly', label: 'M', value: formatNumber(row.monthlyViews), 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) }
]
@@ -660,25 +588,23 @@ watch(trendRows, () => {
<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>
<strong class="ml-2 text-lg text-ink">{{ formatNumber(realtime.summary.onlineNow) }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
오늘 방문자
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.todayVisitors }}</strong>
<strong class="ml-2 text-lg text-ink">{{ formatNumber(analyticsSummary.todayVisitors) }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
누적 방문자
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.totalVisitors }}</strong>
<strong class="ml-2 text-lg text-ink">{{ formatNumber(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>
<strong class="ml-2 text-lg text-ink">{{ formatNumber(posts.length) }}</strong>
<span class="ml-1 text-xs">발행 {{ formatNumber(publishedCount) }} · 초안 {{ formatNumber(draftCount) }}</span>
</p>
</section>
<AdminCommentList class="admin-dashboard__recent-comments" />
<section class="admin-dashboard__adsense">
<p
v-if="adsenseSummary.configured && !adsenseSummary.ok"
@@ -825,7 +751,7 @@ watch(trendRows, () => {
</p>
</div>
<p class="admin-dashboard__traffic-total text-xs text-muted">
페이지뷰 {{ selectedSummaryPageViews }}회
페이지뷰 {{ formatNumber(selectedSummaryPageViews) }}회
</p>
</div>
@@ -849,7 +775,7 @@ watch(trendRows, () => {
페이지뷰
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.pageViews }}
{{ formatNumber(selectedTrendDetail.summary.pageViews) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
@@ -857,7 +783,7 @@ watch(trendRows, () => {
방문자
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.visitors }}
{{ formatNumber(selectedTrendDetail.summary.visitors) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
@@ -873,7 +799,7 @@ watch(trendRows, () => {
50% 스크롤
</p>
<strong class="mt-1 block text-lg text-ink">
{{ selectedTrendDetail.summary.scroll50Reach }}
{{ formatNumber(selectedTrendDetail.summary.scroll50Reach) }}
</strong>
</article>
</div>
@@ -929,7 +855,7 @@ watch(trendRows, () => {
>
.
</span>
<span :class="part.colorClass">{{ part.value }}</span>
<span :class="part.colorClass">{{ formatNumber(part.value) }}</span>
</template>
</template>
<template v-else>
@@ -997,7 +923,7 @@ watch(trendRows, () => {
>
.
</span>
<span :class="part.colorClass">{{ part.value }}</span>
<span :class="part.colorClass">{{ formatNumber(part.value) }}</span>
</template>
</template>
<template v-else>
@@ -1051,7 +977,7 @@ watch(trendRows, () => {
/>
</div>
<p class="mt-1 text-[11px] text-muted">
조회 {{ row.pageViews }} · 방문자 {{ row.visitors }}
조회 {{ formatNumber(row.pageViews) }} · 방문자 {{ formatNumber(row.visitors) }}
</p>
</li>
</ul>
@@ -1093,7 +1019,7 @@ watch(trendRows, () => {
class="flex items-center justify-between gap-2"
>
<span>{{ row.osName || '-' }}</span>
<span>{{ row.visitors }}명 · {{ formatDeviceVisitorPercent(row.visitors) }}</span>
<span>{{ formatNumber(row.visitors) }}명 · {{ formatDeviceVisitorPercent(row.visitors) }}</span>
</li>
</ul>
</li>
@@ -1110,98 +1036,27 @@ watch(trendRows, () => {
</template>
</section>
<AdminCommentList class="admin-dashboard__recent-comments" />
<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 }}명 표시
{{ formatNumber(realtime.sessions.length) }}명 표시 · 전체 {{ formatNumber(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>
<ul v-if="realtime.sessions.length" class="admin-dashboard__live-list mt-3 divide-y divide-line">
<li v-for="session in realtime.sessions" :key="session.activeKey" class="admin-dashboard__live-item grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] items-center gap-3 py-2 text-xs xl:grid-cols-[minmax(0,1fr)_minmax(0,3fr)_6rem_8rem]">
<div class="admin-dashboard__live-identity flex min-w-0 items-center gap-2">
<img v-if="session.user?.avatarUrl" :src="session.user.avatarUrl" :alt="session.user.username" class="admin-dashboard__live-avatar size-6 shrink-0 rounded-full object-cover">
<span v-else class="admin-dashboard__live-avatar grid size-6 shrink-0 place-items-center rounded-full bg-paper text-muted">{{ session.isLoggedIn ? (session.user?.username || '?').slice(0, 1) : '?' }}</span>
<span class="admin-dashboard__live-name truncate font-medium text-ink" :title="session.isLoggedIn ? session.user?.username : '익명 방문자'">{{ session.isLoggedIn ? session.user?.username : '익명 방문자' }}</span>
</div>
<AdminRealtimePages :session="session" />
<span class="admin-dashboard__live-duration whitespace-nowrap text-right text-muted" title="접속 유지시간">{{ formatEngagedDuration(session.durationSeconds) }}</span>
<span class="admin-dashboard__live-ip hidden truncate text-right text-muted xl:block" :title="session.ipAddress">{{ session.ipAddress || '-' }}</span>
</li>
</ul>
<p

View File

@@ -0,0 +1,30 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { formatAnalyticsCount, getActivePageHref, getActivePageTitle, getRealtimePages } from '../lib/analytics-display.js'
test('통계 수치는 천 단위 쉼표와 빈 값 보정을 제공한다', () => {
assert.equal(formatAnalyticsCount(10413), '10,413')
assert.equal(formatAnalyticsCount('1234567'), '1,234,567')
assert.equal(formatAnalyticsCount(null), '0')
assert.equal(formatAnalyticsCount(NaN), '0')
})
test('실시간 페이지는 최근 확인순으로 같은 경로를 중복 제거한다', () => {
const pages = getRealtimePages({ pages: [
{ path: '/post/a', postTitle: '이전 제목', lastSeenAt: '2026-10-21T00:00:00Z' },
{ path: '/post/b', postTitle: '두 번째', lastSeenAt: '2026-10-21T01:00:00Z' },
{ path: '/post/a', postTitle: '최신 제목', lastSeenAt: '2026-10-21T02:00:00Z' }
] })
assert.equal(pages.length, 2)
assert.equal(pages[0].postTitle, '최신 제목')
assert.equal(getActivePageTitle({ path: '/' }), '홈')
assert.equal(getRealtimePages({ path: '/', pages: [] }).length, 1)
})
test('실시간 바로가기는 공개 내부 경로만 사용한다', () => {
assert.equal(getActivePageHref({ postSlug: '한글 제목' }), '/post/%ED%95%9C%EA%B8%80%20%EC%A0%9C%EB%AA%A9')
assert.equal(getActivePageHref({ path: '/' }), '/')
for (const path of ['//example.com', '/\\example.com', '/admin', '/admin/settings', 'https://example.com']) {
assert.equal(getActivePageHref({ path }), '')
}
})

View File

@@ -0,0 +1,52 @@
import { expect, test } from '@playwright/test'
test('대시보드 숫자·섹션 순서와 한 줄 접속자 페이지 목록을 표시한다', async ({ page }) => {
const login = await page.request.post('/admin/api/auth/login', {
data: { email: 'playwright-admin@example.com', password: 'playwright-admin-password' }
})
expect(login.ok()).toBeTruthy()
await page.goto('/admin/posts')
await page.waitForLoadState('networkidle')
await page.route('**/admin/api/posts', (route) => route.fulfill({ json: Array.from({ length: 1234 }, (_, index) => ({ id: String(index), status: 'published' })) }))
await page.route('**/admin/api/analytics/summary?*', (route) => route.fulfill({ json: {
todayVisitors: 1234, totalVisitors: 10413, trends: [], collectedSince: '2026-06-01'
} }))
await page.route('**/admin/api/analytics/range-detail?*', (route) => route.fulfill({ json: {
summary: { pageViews: 12345, visitors: 3456, avgEngagedSeconds: 90, scroll50Reach: 2345 },
sources: [], devices: [], topPosts: [], topPages: []
} }))
const session = {
activeKey: 'test-visitor', isLoggedIn: false, ipAddress: '198.51.100.1', durationSeconds: 1800, pageCount: 6,
pages: [
{ key: 'old', path: '/post/old', postTitle: '이전 페이지', lastSeenAt: '2026-10-21T01:00:00Z' },
{ key: 'new', path: '/post/new', postTitle: '가장 최근 페이지', lastSeenAt: '2026-10-21T06:00:00Z' },
{ key: 'dup', path: '/post/new', postTitle: '가장 최근 페이지', lastSeenAt: '2026-10-21T05:00:00Z' },
...[2, 3, 4].map((index) => ({ key: String(index), path: `/post/page-${index}`, postTitle: `열린 페이지 ${index}`, lastSeenAt: `2026-10-21T0${index}:00:00Z` }))
]
}
await page.route('**/admin/api/analytics/realtime?*', (route) => route.fulfill({ json: { summary: { onlineNow: 1234 }, sessions: [session] } }))
await page.getByRole('link', { name: '대시보드', exact: true }).click()
await expect(page.locator('.admin-dashboard__summary')).toContainText('10,413')
await expect(page.locator('.admin-dashboard__summary')).toContainText('1,234')
await expect(page.locator('.admin-dashboard__traffic-total')).toContainText('12,345')
const sections = await page.locator('.admin-dashboard__body > section').evaluateAll((elements) => elements.map((element) => element.className))
expect(sections.map((value) => value.match(/admin-dashboard__(summary|adsense|charts|selected-detail|recent-comments|live)\b/)[1])).toEqual(['summary', 'adsense', 'charts', 'selected-detail', 'recent-comments', 'live'])
const visitor = page.locator('.admin-dashboard__live-item')
await expect(visitor).toContainText('가장 최근 페이지')
await expect(visitor).toContainText('외 4개')
expect((await visitor.boundingBox()).height).toBeLessThan(45)
const trigger = visitor.getByRole('button', { name: '열린 페이지 목록', exact: true })
await trigger.hover()
const popover = page.locator('.admin-realtime-pages__popover')
await expect(popover).toBeVisible()
await expect(popover.locator('a')).toHaveCount(5)
await expect(popover.locator('a').first()).toHaveAttribute('href', '/post/new')
await popover.hover()
await expect(popover).toBeVisible()
await page.keyboard.press('Escape')
await expect(popover).toHaveCount(0)
await trigger.press('Enter')
await expect(popover).toBeVisible()
await popover.getByRole('button', { name: '열린 페이지 목록 닫기' }).click()
await expect(popover).toHaveCount(0)
})