v1.12.29 접속자 요약과 회원 작성 이력 및 댓글 표시 정리

This commit is contained in:
2026-10-21 18:17:03 +09:00
parent 184a971863
commit 51d5de148f
15 changed files with 114 additions and 47 deletions

View File

@@ -26,12 +26,12 @@ const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
</p>
<p v-else-if="!data?.items.length" class="admin-comment-list__empty py-1 text-xs text-muted">작성한 댓글이 없습니다.</p>
<ul v-else class="admin-comment-list__items divide-y divide-line">
<li v-for="comment in data.items" :key="comment.id" class="admin-comment-list__item grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5 py-1.5 text-xs md:grid-cols-[minmax(0,2fr)_7rem_8rem_minmax(0,3fr)]">
<li v-for="comment in data.items" :key="comment.id" class="admin-comment-list__item grid min-w-0 grid-cols-2 items-center gap-x-3 gap-y-0.5 py-1.5 text-xs md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_7rem_8rem]">
<NuxtLink :to="`/post/${encodeURIComponent(comment.postSlug)}#comments`" v-if="['published', 'members'].includes(comment.postStatus)" target="_blank" rel="noopener noreferrer" class="admin-comment-list__post truncate font-medium hover:underline" :title="comment.postTitle">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
<NuxtLink v-else :to="`/admin/posts/${comment.postId}`" class="admin-comment-list__post truncate font-medium hover:underline" :title="comment.postTitle">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
<NuxtLink :to="`/admin/members/${comment.userId}`" class="admin-comment-list__author truncate text-muted hover:underline" :title="comment.username">{{ comment.username }}</NuxtLink>
<time :datetime="comment.createdAt" class="admin-comment-list__time text-muted">{{ formatCreatedAt(comment.createdAt) }}</time>
<p class="admin-comment-list__body truncate text-muted" :title="comment.body">{{ comment.body }}</p>
<NuxtLink :to="`/admin/members/${comment.userId}`" class="admin-comment-list__author truncate text-muted hover:underline" :title="comment.username">{{ comment.username }}</NuxtLink>
<time :datetime="comment.createdAt" class="admin-comment-list__time whitespace-nowrap text-muted">{{ formatCreatedAt(comment.createdAt) }}</time>
</li>
</ul>
</section>

View File

@@ -57,26 +57,22 @@ const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
<p v-else-if="status === 'pending'" class="admin-member-activity__loading py-6 text-sm text-muted">목록을 불러오는 중입니다.</p>
<p v-else-if="!data?.items.length" class="admin-member-activity__empty py-6 text-sm text-muted">{{ filters.q ? '검색 결과가 없습니다.' : '작성한 글이 없습니다.' }}</p>
<div v-else class="admin-member-activity__table-wrap mt-3 overflow-x-auto">
<table class="admin-member-activity__table w-full min-w-[760px] table-fixed text-left text-xs">
<table class="admin-member-activity__table w-full min-w-[600px] table-fixed text-left text-xs">
<caption class="sr-only">회원이 작성한 게시글과 댓글 목록</caption>
<thead class="border-b border-line text-muted">
<tr>
<th scope="col" class="w-16 py-2 pr-3 font-medium">구분</th>
<th scope="col" class="w-[28%] py-2 pr-3 font-medium">게시글</th>
<th scope="col" class="w-28 py-2 pr-3 font-medium">작성자</th>
<th scope="col" class="w-44 py-2 pr-3 font-medium">작성일</th>
<th scope="col" class="py-2 font-medium">내용</th>
<th scope="col" class="w-[32%] py-2 pr-3 font-medium">게시글</th>
<th scope="col" class="py-2 pr-3 font-medium">내용</th>
<th scope="col" class="w-44 py-2 font-medium">작성일</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
<tr v-for="item in data.items" :key="`${item.kind}-${item.id}`" class="admin-member-activity__item">
<td class="admin-member-activity__kind whitespace-nowrap py-2 pr-3" :class="item.kind === 'post' ? 'text-blue-700' : 'text-muted'">{{ item.kind === 'post' ? '게시글' : '댓글' }}</td>
<td class="py-2 pr-3">
<NuxtLink :to="item.publicUrl || `/admin/posts/${item.postId}`" :target="item.publicUrl ? '_blank' : undefined" :rel="item.publicUrl ? 'noopener noreferrer' : undefined" class="admin-member-activity__post block truncate font-medium hover:underline" :title="item.postTitle">{{ item.postTitle || '(제목 없음)' }}</NuxtLink>
</td>
<td class="py-2 pr-3"><span class="admin-member-activity__author block truncate text-muted" :title="item.username">{{ item.username }}</span></td>
<td class="whitespace-nowrap py-2 pr-3 text-muted"><time :datetime="item.createdAt">{{ formatCreatedAt(item.createdAt) }}</time></td>
<td class="py-2"><p class="admin-member-activity__body truncate text-muted" :title="item.body">{{ item.body || '—' }}</p></td>
<td class="py-2 pr-3"><p class="admin-member-activity__body truncate text-muted" :title="item.body">{{ item.body || '—' }}</p></td>
<td class="whitespace-nowrap py-2 text-muted"><time :datetime="item.createdAt">{{ formatCreatedAt(item.createdAt) }}</time></td>
</tr>
</tbody>
</table>

View File

@@ -39,6 +39,7 @@ const cancelClose = () => { clearTimeout(closeTimer) }
* @returns {Promise<void>} 위치 반영
*/
const openPages = async () => {
if (pages.value.length <= 1) return
cancelClose()
isOpen.value = true
await nextTick()
@@ -103,6 +104,10 @@ const handleKeydown = (event) => {
}
}
watch(() => pages.value.length, (count) => {
if (count <= 1) closePages()
})
onMounted(() => {
document.addEventListener('pointerdown', handleOutside)
document.addEventListener('keydown', handleKeydown)
@@ -120,12 +125,14 @@ onBeforeUnmount(() => {
<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">
<button v-if="pages.length > 1" 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>
<span v-if="pages.length > 1" class="admin-realtime-pages__extra shrink-0 font-medium text-[#2563eb]">외 <strong class="admin-realtime-pages__extra-number font-bold">{{ formatAnalyticsCount(pages.length - 1) }}</strong>개</span>
</button>
<a v-else-if="getActivePageHref(pages[0])" :href="getActivePageHref(pages[0])" target="_blank" rel="noopener noreferrer" class="admin-realtime-pages__single block truncate text-xs text-muted hover:text-ink hover:underline">{{ getActivePageTitle(pages[0]) }}</a>
<span v-else class="admin-realtime-pages__single block truncate text-xs text-muted">{{ getActivePageTitle(pages[0]) }}</span>
<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">
<AdminPopoverSurface v-if="isOpen && pages.length > 1" :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>

View File

@@ -1,3 +1,7 @@
## 2026-10-21 v1.12.29 — 작성 이력 열과 접속자 요약의 의미를 정리한다
멤버별 이력에서는 작성자 정보가 반복되므로 게시글·내용·작성일만 표시하고 종류 선택은 기존 필터로 유지한다. 대시보드 댓글은 제목·내용·작성자·날짜 순서로 맞춘다. 접속자 목록에서는 여러 페이지를 보는 경우만 색상과 굵은 숫자로 강조하고 상세 팝오버를 제공한다. 전체 열린 화면 수는 표시된 최대 20명의 페이지를 더하지 않고 최근 90초 내 전체 활성 탭 세션을 집계한다. 같은 경로를 연 중복 탭도 화면 수에 포함하되 방문자별 팝오버는 기존 고유 페이지 기준을 유지한다. 30분 표시가 실제 읽은 시간으로 오해되지 않도록 기존 경과 시간의 상한과 백그라운드 포함 기준을 안내한다.
## 2026-10-21 v1.12.28 — 일상 배포 문서는 복사할 명령만 제공한다
운영자가 긴 배포 설명에서 필요한 절차를 골라야 했으므로 `docs/deploy.md`를 상황별 명령어 안내로 줄인다. 각 상황은 NAS 경로 이동부터 마지막 상태 확인까지 한 블록으로 복사하며, 실패한 단계 뒤에는 다음 명령이 실행되지 않도록 연결한다. 기존 설치·고급 설정·장애 설명은 `docs/deploy-reference.md`로 옮겨 필요할 때만 참조한다. 실행 스크립트와 Compose 동작은 변경하지 않는다.

View File

@@ -113,7 +113,7 @@
| lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·레거시 JSON 문자열 복구·공개 노출 목록 생성 |
| server/utils/backup-storage-summary.js | 관리자 사이트 설정 백업 현황용 업로드 파일 스캔·원본/자동 생성 이미지/프로필/내보내기 파일 용량 집계 |
| plugins/site-analytics.client.js | 공개 페이지 pageview/read/heartbeat 전송, 탭 단위 client session과 라우트 진입 단위 content session ID 관리 |
| server/repositories/analytics-repository.js | 자체 통계 일별 집계, 콘텐츠 세션 원장, 실시간 접속자, 유입·디바이스·게시물·페이지 통계 조회 |
| server/repositories/analytics-repository.js | 자체 통계 일별 집계, 콘텐츠 세션 원장, 실시간 접속자·전체 활성 탭 수, 유입·디바이스·게시물·페이지 통계 조회 |
## 문서
@@ -212,7 +212,7 @@
| tests/markdown-table.test.js | Markdown 표 행·정렬·블록 파싱 단위 회귀 테스트 |
| tests/admin-pagination.test.js | 관리자 페이지네이션 페이지 보정·ellipsis 항목 단위 테스트 |
| tests/playwright/e2e/admin-media-interactions.spec.js | 관리자 미디어 전체 선택·선택 유지 폴더 생성·다중 드래그/드롭 강조·이동 진행·취소/실패·목록 지연/최신 요청/재시도 E2E 회귀 검증 |
| tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·CSP·미디어 reconcile/fallback·세션 무효화 API 통합 10건 |
| tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·댓글/회원 작성 이력·실시간 활성 탭 집계·CSP·미디어 reconcile/fallback·세션 무효화 API 통합 14건 |
| tests/playwright/e2e/admin-guard-and-settings.spec.js | 설정 카드 독립 저장과 미저장 이탈 취소 브라우저 E2E 2건 |
| tests/playwright/e2e/admin-publish.spec.js | 관리자 로그인·게시물 발행·공개 화면 확인 브라우저 E2E 1건 |
| tests/playwright/e2e/admin-regressions.spec.js | 태그 추천 연속 선택·미디어 파생 탭 배지 숨김·단일 이미지 `{size=}` 너비 브라우저 회귀 테스트 |
@@ -366,7 +366,7 @@
| 파일 | 화면 |
|------|------|
| pages/admin/index.vue | 대시보드(최근 댓글 5개 작은 행·게시글/작성자/시각/한 줄 내용·30초 갱신, 상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), 마지막 섹션의 접속자 한 줄 목록·최근 페이지 외 N개·IP·hover/클릭/키보드 전체 페이지 팝오버) |
| pages/admin/index.vue | 대시보드(최근 댓글 5개 작은 행·게시글/한 줄 내용/작성자/날짜·30초 갱신, 상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), 마지막 섹션의 접속자 한 줄 목록·최근 페이지 외 N개·IP·전체/표시 인원·활성 화면 수·경과 시간 안내·다중 페이지 팝오버) |
| 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 반영·이탈 직전 플러시, 저장 토스트 |
@@ -638,18 +638,18 @@
| tests/markdown-html-block.test.js | 게시물 HTML sanitizer 공격 우회·허용 HTML 회귀 테스트 |
| tests/multipart-file-upload.test.js | 다중 multipart 디스크 스트리밍·파일 크기 제한 회귀 테스트 |
| docker-compose.build.yml | 운영 앱 이미지 빌드·CLI 빌드/배포용 Compose 정의 |
| components/admin/AdminCommentList.vue | 대시보드 최신 댓글 5개 작은 목록·게시글/작성자/시각/한 줄 내용·30초 갱신·조회 오류 재시도 |
| components/admin/AdminCommentList.vue | 대시보드 최신 댓글 5개 작은 목록·게시글/한 줄 내용/작성자/날짜·30초 갱신·조회 오류 재시도 |
| server/routes/admin/api/comments.get.js | 관리자 댓글 조회 인증·댓글 내용/게시물 제목 검색·Zod query 검증 |
| server/repositories/comment-repository.js | 공개 댓글 조회·작성·좋아요와 관리자 댓글 최신순 페이지 조회 |
| tests/playwright/e2e/admin-comments.spec.js | 멤버 통합 작성 이력 표·전체/댓글/게시글 필터·검색·페이지 이동·원문 링크·작은 대시보드 댓글·CSP 제거·게시글 댓글 수 E2E 검증 |
| components/admin/AdminMemberActivityList.vue | 회원별 게시글·댓글 통합 한 줄 표·종류 필터·전체 이력 최신순 검색·번호형 페이지네이션·게시물 이동 |
| tests/playwright/e2e/admin-comments.spec.js | 멤버 통합 작성 이력 3열 표·전체/댓글/게시글 필터·검색·페이지 이동·원문 링크·작은 대시보드 댓글·CSP 제거·게시글 댓글 수 E2E 검증 |
| components/admin/AdminMemberActivityList.vue | 회원별 게시글·댓글 통합 게시글/내용/작성일 3열 한 줄 표·종류 필터·전체 이력 최신순 검색·번호형 페이지네이션·게시물 이동 |
| 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/클릭/키보드 접근 |
| components/admin/AdminRealtimePages.vue | 접속자 최근 페이지 요약·외 N개 색상/숫자 굵기·단일 페이지 링크·다중 페이지 전용 화면 안 팝오버 위치·열린 페이지 링크·hover/클릭/키보드 접근 |
| tests/analytics-display.test.js | 천 단위 표시·최근순 중복 제거·공개 경로 검증 |
| tests/playwright/e2e/admin-dashboard.spec.js | 대시보드 쉼표·섹션 순서·한 줄 접속자·페이지 목록 접근 검증 |
| tests/playwright/e2e/admin-dashboard.spec.js | 대시보드 쉼표·섹션 순서·전체/표시 인원·전체 화면 수·다중 페이지 강조/팝오버·단일 페이지 링크 검증 |
| tests/production-compose.test.js | 운영 마이그레이션·썸네일 백필 스크립트의 Compose 파일·환경 파일 전달 격리 검증 |
| docker-compose.yaml | NAS Docker 앱용 독립 실행 구성·사전 빌드한 로컬 앱 이미지 사용·앱 원격 pull 금지 |
| scripts/build-production-image.sh | 운영 환경 파일을 사용해 앱 이미지만 빌드·Docker 오류 반환·컨테이너/DB 미변경 |

View File

@@ -682,7 +682,7 @@ components/content/
- `GET /admin/api/analytics/summary?period=weekly&days=56` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 누적 방문자·누적 조회수·공개 게시물 수, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, `collectedSince`, 선택 집계 단위 `trends`). `period`는 `daily`, `weekly`, `monthly`, `yearly`를 사용하며 대시보드는 일일·주간·월간·년간으로 전환한다.
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간)
- `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타, IP, 열린 페이지 수와 열린 페이지 목록 포함)
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록. `summary`는 `onlineNow`, `loggedInNow`, `anonymousNow`, `openPageCount` 제공. 최근 90초 내 활성 세션을 집계하며 `openPageCount`는 목록 제한과 관계없는 전체 탭 세션 수(같은 경로 중복 탭 포함). 목록은 로그인 사용자 닉네임·아바타, IP, 열린 페이지 수와 고유 페이지 목록 포함. 대시보드는 최대 20명 표시하며 페이지네이션 없음(API limit 최대 50).
- `GET /admin/api/analytics/traffic?days=30` - 기간 내 유입원·디바이스·유입 키워드 통계
- `GET /admin/api/analytics/range-detail?startDay=YYYY-MM-DD&endDay=YYYY-MM-DD&limit=5` - 통계 추이 막대 상세(요약·유입·디바이스·인기 게시물·인기 페이지). 인기 콘텐츠는 조회·읽음/방문자·최근 30일 조회·작성일·스크롤 도달 지표를 포함한다. 최대 31일 범위로 제한한다.
- `GET /admin/api/security/csp-violations?days=30&limit=8` - CSP Report-Only 위반 집계 관찰 요약. `days`는 1~90일, `limit`은 1~20개로 제한하며 전체 누적 횟수, 최근 관찰 항목의 누적 횟수(`recentViolations`: 기간 내 `last_seen_at`을 가진 행의 `violation_count` 합계), 마지막 관찰 시각, 상위 위반 목록, 지시어별 외부 origin 허용 후보를 반환한다. 후보는 최근 관찰 상위 200행에서 계산한다. 일별 원장이 없어 기간 내 실제 발생 횟수는 반환하지 않는다.
@@ -1089,13 +1089,13 @@ APP_PORT=43080
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
## 관리자 댓글·회원 작성 이력 조회 (v1.12.24)
## 관리자 댓글·회원 작성 이력 조회 (v1.12.29)
- `GET /admin/api/comments`: 관리자 세션 필수. `memberId`(선택 UUID), `q`(기본 빈 문자열, trim, 최대 200자, 댓글 내용·게시물 제목 부분 검색), `page`(기본 1, 1~1000000 정수), `pageSize`(기본 10, 1~100 정수). 잘못된 조건은 400, 미인증은 401.
- 응답 `{ items, pagination: { page, pageSize, total, pageCount } }`. 항목은 `id`, `body`, `status`, `parentId`, `createdAt`, `userId`, `username`, `postId`, `postTitle`, `postSlug`, `postStatus` 제공. 모든 상태의 댓글·답글 포함, 작성 시각·ID 내림차순. 범위 초과 페이지는 마지막 페이지로 보정.
- 멤버 상세는 `정보 / 작성한 글` 탭을 제공하며 정보로 진입한다. 정보 편집값은 탭 이동 시 유지한다. 작성한 글 탭에서 게시글·댓글 전체 이력을 `구분 / 게시글 / 작성자 / 작성일 / 내용` 한 줄 표로 통합 표시한다. 긴 제목·내용은 말줄임과 title로 표시하며 좁은 화면은 표 내부 가로 스크롤을 제공한다.
- 멤버 상세는 `정보 / 작성한 글` 탭을 제공하며 정보로 진입한다. 정보 편집값은 탭 이동 시 유지한다. 작성한 글 탭에서 게시글·댓글 전체 이력을 `게시글 / 내용 / 작성일` 3열 한 줄 표로 통합 표시한다. 작성자는 현재 회원이므로 표시하지 않으며 종류는 상단 필터로 선택한다. 긴 제목·내용은 말줄임과 title로 표시하며 좁은 화면은 표 내부 가로 스크롤을 제공한다.
- 상단 `전체 / 댓글 / 게시글` 종류 필터와 검색 버튼/Enter 검색을 제공한다. 검색·종류 변경 시 첫 페이지로 돌아가며 10개씩 번호형 페이지네이션으로 최신순 전체 이력을 조회한다.
- 대시보드는 최신 댓글 5개를 30초마다 갱신한다. 게시물·작성자·짧은 시각·말줄임 한 줄 내용만 작은 행으로 표시한다. 조회 오류는 재시도하며 이력 조회 중은 로딩 표시.
- 대시보드는 최신 댓글 5개를 30초마다 갱신한다. 게시물 제목·말줄임 한 줄 내용·작성자·짧은 날짜 순서로 작은 행을 표시한다. 조회 오류는 재시도하며 이력 조회 중은 로딩 표시.
- 댓글 내용은 일반 텍스트로 표시한다. 대시보드 작성자는 멤버 상세로 이동한다. 통합 표의 게시글 제목은 `publicUrl`이 있으면 새 창의 공개 화면으로, 없으면 관리자 편집으로 이동한다(공개 화면의 기존 접근 권한 적용).
- 게시글 목록 댓글 열은 기존 `commentCount` 사용. 공개 상태 댓글·답글만 집계.
@@ -1108,12 +1108,13 @@ APP_PORT=43080
- 응답 `{ 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)
## 대시보드 표시 정리 (v1.12.29)
- 화면 순서: 상단 요약 → 예상 수입 → 통계 추이 → 선택한 기간 상세 → 최근 댓글 → 현재 접속자.
- 방문자·게시물 수·조회·스크롤·유입·디바이스·차트 수치·댓글/작성 이력 건수는 `ko-KR` 천 단위 구분(예: `10,413`)으로 표시. API 숫자 형식은 유지.
- 접속자 대표 페이지는 포커스 여부가 아닌 마지막 확인 시각 기준. 현재 API의 회원별·익명 IP별 묶음은 유지. 인원 표시는 반환된 목록 수와 전체 접속자 수를 구분.
- 열린 페이지 팝오버는 hover 시 일시 표시, 클릭/Enter로 고정 전환. 포인터를 목록으로 이동해도 링크 접근을 유지하며, 화면 범위에 맞춰 위/아래 배치하고 긴 목록은 내부 스크롤 제공.
- 접속자 대표 페이지는 포커스 여부가 아닌 마지막 확인 시각 기준. 현재 API의 회원별·익명 IP별 묶음은 유지. 인원 표시는 `전체 N명 중 M명 표시 · 열린 화면 K개` 형식으로 전체·표시 인원과 전체 활성 탭 세션 수를 구분. 목록은 최대 20명이며 다음 페이지는 없음. 원본 세션을 최대 160행 조회 후 묶으므로 한 사람이 많은 탭을 열면 표시 인원은 20명보다 적을 수 있음.
- 고유 페이지가 여러 개면 `외 N개`를 파란색으로, 숫자는 굵게 표시. 고유 페이지가 하나면 공개 화면 링크만 제공하고 팝오버를 표시하지 않음. 열린 페이지 팝오버는 hover 시 일시 표시, 클릭/Enter로 고정 전환. 포인터를 목록으로 이동해도 링크 접근을 유지하며, 화면 범위에 맞춰 위/아래 배치하고 긴 목록은 내부 스크롤 제공.
- 접속자 시간은 열린 페이지 중 가장 긴 경과 시간이며, 페이지 진입 시 초기화하고 백그라운드 시간 포함·최대 1,800초(30분)로 기록. 실제 읽은 시간이나 만료까지 남은 시간이 아니며 마우스 안내로 의미 표시. 현재 접속 판정은 이 시간과 별도로 마지막 접속 신호 90초 기준.
## 운영 Compose 식별 (v1.12.27)

View File

@@ -4,7 +4,7 @@
## 2차 관리자 개발
- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글·회원 통합 작성 이력 검색/페이지 API 검증은 통과. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인.
- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글·회원 통합 작성 이력 검색/페이지·실시간 활성 탭 집계 API 검증은 통과. v1.12.29 API 검증은 12건 통과·기존 실패 1건·후속 미실행 1건. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인.
## 프론트엔드 개발

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v1.12.29
- 현재 접속자 외 N개 파란색·숫자 굵기 강조 및 여러 페이지일 때만 목록 팝오버 표시 수정.
- 전체 인원 중 표시 인원·전체 활성 탭 수 표시와 경과 시간 상한·백그라운드 포함 안내 추가.
- 멤버 작성 이력을 게시글·내용·작성일 3열로 정리.
- 대시보드 최근 댓글을 제목·내용·작성자·날짜 순서로 정리.
- JavaScript 358개 파일 문법·단위 테스트 283건·프로덕션 빌드 검증 진행.
- Chromium E2E 12건·API 12건 통과, 기존 미디어 사용처 실패 1건·후속 미실행 1건 유지.
## v1.12.28
- deploy 문서를 상황별 복사·실행 명령어 중심으로 축소.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<script setup>
import { formatAnalyticsCount as formatNumber } from '~/lib/analytics-display.js'
import { ANALYTICS_COLLECTED_SINCE_DEFAULT, getAnalyticsDayKey } from '~/lib/analytics-shared.js'
import { ANALYTICS_ACTIVE_SESSION_TTL_SECONDS, ANALYTICS_COLLECTED_SINCE_DEFAULT, ANALYTICS_MAX_DURATION_SECONDS, getAnalyticsDayKey } from '~/lib/analytics-shared.js'
import {
getAnalyticsTrendDescription,
getAnalyticsTrendDetailLabel,
@@ -57,7 +57,8 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/
summary: {
onlineNow: 0,
loggedInNow: 0,
anonymousNow: 0
anonymousNow: 0,
openPageCount: 0
},
sessions: []
})
@@ -1043,8 +1044,8 @@ watch(trendRows, () => {
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
현재 접속자
</h2>
<p class="admin-dashboard__live-count text-xs text-muted">
{{ formatNumber(realtime.sessions.length) }}명 표시 · 전체 {{ formatNumber(realtime.summary.onlineNow) }}명
<p class="admin-dashboard__live-count text-xs text-muted" :title="`최근 ${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS}초 안에 접속 신호가 있는 전체 탭 수이며, 같은 페이지의 중복 탭도 포함합니다.`">
전체 {{ formatNumber(realtime.summary.onlineNow) }}명 중 {{ formatNumber(realtime.sessions.length) }}명 표시 · 열린 화면 {{ formatNumber(realtime.summary.openPageCount) }}개
</p>
</div>
<ul v-if="realtime.sessions.length" class="admin-dashboard__live-list mt-3 divide-y divide-line">
@@ -1055,7 +1056,7 @@ watch(trendRows, () => {
<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-duration whitespace-nowrap text-right text-muted" :title="`열린 페이지 중 가장 긴 경과 시간입니다. 백그라운드 시간을 포함하며 최대 ${formatEngagedDuration(ANALYTICS_MAX_DURATION_SECONDS)}까지만 기록합니다.`">{{ 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>

View File

@@ -1876,7 +1876,8 @@ export const getAnalyticsRealtimeSummary = async () => {
return {
onlineNow: 0,
loggedInNow: 0,
anonymousNow: 0
anonymousNow: 0,
openPageCount: 0
}
}
@@ -1885,6 +1886,7 @@ export const getAnalyticsRealtimeSummary = async () => {
const rows = await sql`
SELECT
COUNT(*)::int AS open_page_count,
COUNT(DISTINCT CASE
WHEN user_id IS NOT NULL THEN 'user:' || user_id::text
ELSE 'ip:' || COALESCE(NULLIF(ip_address, ''), session_hash)
@@ -1901,7 +1903,8 @@ export const getAnalyticsRealtimeSummary = async () => {
return {
onlineNow,
loggedInNow,
anonymousNow: Number(rows[0]?.anonymous_now || 0)
anonymousNow: Number(rows[0]?.anonymous_now || 0),
openPageCount: Number(rows[0]?.open_page_count || 0)
}
}

View File

@@ -401,6 +401,33 @@ test('CSP 수집은 IP 해시 기준 분당 요청 상한을 적용한다', asyn
expect(Number(limitedResponse.headers()['retry-after'])).toBeGreaterThan(0)
})
test('실시간 접속 요약은 표시 제한 밖의 활성 탭까지 집계하고 만료 탭을 제외한다', async ({ request: anonymousApi }) => {
expect((await anonymousApi.get('/admin/api/analytics/realtime')).status()).toBe(401)
try {
await database`
INSERT INTO analytics_active_sessions (session_hash, path, ip_address)
SELECT 'realtime-api-test-' || value, '/post/shared', '198.51.100.' || value
FROM generate_series(1, 21) AS value
`
await database`
INSERT INTO analytics_active_sessions (session_hash, path, ip_address, last_seen_at)
VALUES
('realtime-api-test-extra-1', '/post/shared', '198.51.100.1', now()),
('realtime-api-test-extra-2', '/post/another', '198.51.100.1', now()),
('realtime-api-test-expired', '/post/expired', '198.51.100.99', now() - interval '2 minutes')
`
const response = await adminApi.get('/admin/api/analytics/realtime?limit=20')
expect(response.status()).toBe(200)
const result = await response.json()
expect(result.summary).toMatchObject({ onlineNow: 21, anonymousNow: 21, loggedInNow: 0, openPageCount: 23 })
expect(result.sessions).toHaveLength(20)
expect(result.sessions.find((session) => session.ipAddress === '198.51.100.1')).toMatchObject({ pageCount: 3 })
expect(result.sessions.some((session) => session.ipAddress === '198.51.100.99')).toBe(false)
} finally {
await database`DELETE FROM analytics_active_sessions WHERE session_hash LIKE 'realtime-api-test-%'`
}
})
test('미디어 카탈로그 reconcile과 DB 목록 계약 및 fallback을 유지한다', async () => {
const uploadRoot = process.env.SORI_TEST_UPLOAD_ROOT
const mediaDirectory = join(uploadRoot, 'posts', 'catalog-api-test')

View File

@@ -33,10 +33,10 @@ test('멤버 통합 작성 이력의 종류 필터·검색·페이지와 대시
await expect(list.locator('.admin-member-activity__post').first()).toHaveAttribute('href', '/post/comments-ui-test#comments')
await list.getByRole('button', { name: '다음 페이지' }).click()
await expect(items).toHaveCount(2)
await expect(list.locator('.admin-member-activity__kind')).toHaveText(['댓글', '게시글'])
await expect(list.getByRole('columnheader')).toHaveText(['게시글', '내용', '작성일'])
await expect(list.locator('.admin-member-activity__body').last()).toHaveText('본문')
await list.getByLabel('작성 종류').selectOption('comment')
await expect(items).toHaveCount(10)
await expect(list.locator('.admin-member-activity__kind').first()).toHaveText('댓글')
await list.getByRole('button', { name: '다음 페이지' }).click()
await expect(items).toHaveCount(1)
await list.getByLabel('작성한 글 검색').fill('화면 댓글 0')
@@ -48,7 +48,6 @@ test('멤버 통합 작성 이력의 종류 필터·검색·페이지와 대시
await list.getByLabel('작성한 글 검색').fill('')
await list.getByRole('button', { name: '검색', exact: true }).click()
await expect(items).toHaveCount(1)
await expect(list.locator('.admin-member-activity__kind')).toHaveText('게시글')
await expect(list.locator('.admin-member-activity__body')).toHaveText('본문')
await list.getByLabel('작성 종류').selectOption('all')
await expect(items).toHaveCount(10)
@@ -59,6 +58,11 @@ test('멤버 통합 작성 이력의 종류 필터·검색·페이지와 대시
await page.goto('/admin')
await expect(page.locator('.admin-comment-list__item')).toHaveCount(5)
await expect(page.locator('.admin-comment-list__body').first()).toHaveText('화면 댓글 10')
const recentComment = page.locator('.admin-comment-list__item').first()
expect(await recentComment.locator(':scope > *').evaluateAll((elements) => elements.map((element) => element.className.split(' ')[0]))).toEqual([
'admin-comment-list__post', 'admin-comment-list__body', 'admin-comment-list__author', 'admin-comment-list__time'
])
await expect(recentComment.locator('.admin-comment-list__author')).toHaveAttribute('href', `/admin/members/${admin.userId}`)
await expect(page.locator('.admin-dashboard__csp')).toHaveCount(0)
const recentBox = await page.locator('.admin-comment-list').boundingBox()
expect(recentBox.height).toBeLessThan(250)

View File

@@ -24,16 +24,25 @@ test('대시보드 숫자·섹션 순서와 한 줄 접속자 페이지 목록
...[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] } }))
const singlePageSession = {
activeKey: 'single-page-visitor', isLoggedIn: false, ipAddress: '198.51.100.2', durationSeconds: 60, pageCount: 1,
pages: [{ key: 'single', path: '/post/single', postTitle: '한 페이지만 보는 방문자' }]
}
await page.route('**/admin/api/analytics/realtime?*', (route) => route.fulfill({ json: { summary: { onlineNow: 1234, openPageCount: 12345 }, sessions: [session, singlePageSession] } }))
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(page.locator('.admin-dashboard__live-count')).toHaveText('전체 1,234명 중 2명 표시 · 열린 화면 12,345개')
const visitor = page.locator('.admin-dashboard__live-item').first()
await expect(visitor).toContainText('가장 최근 페이지')
await expect(visitor).toContainText('외 4개')
await expect(visitor.locator('.admin-realtime-pages__extra')).toHaveCSS('color', 'rgb(37, 99, 235)')
await expect(visitor.locator('.admin-realtime-pages__extra-number')).toHaveText('4')
await expect(visitor.locator('.admin-realtime-pages__extra-number')).toHaveCSS('font-weight', '700')
await expect(visitor.locator('.admin-dashboard__live-duration')).toHaveAttribute('title', /백그라운드 시간을 포함하며 최대 30분/)
expect((await visitor.boundingBox()).height).toBeLessThan(45)
const trigger = visitor.getByRole('button', { name: '열린 페이지 목록', exact: true })
await trigger.hover()
@@ -49,4 +58,10 @@ test('대시보드 숫자·섹션 순서와 한 줄 접속자 페이지 목록
await expect(popover).toBeVisible()
await popover.getByRole('button', { name: '열린 페이지 목록 닫기' }).click()
await expect(popover).toHaveCount(0)
const singleVisitor = page.locator('.admin-dashboard__live-item').last()
await singleVisitor.locator('.admin-realtime-pages__single').hover()
await expect(singleVisitor.getByRole('button', { name: '열린 페이지 목록', exact: true })).toHaveCount(0)
await expect(singleVisitor.locator('.admin-realtime-pages__extra')).toHaveCount(0)
await expect(singleVisitor.getByRole('link', { name: '한 페이지만 보는 방문자' })).toHaveAttribute('href', '/post/single')
await expect(popover).toHaveCount(0)
})