v1.12.24 멤버 작성 이력 통합 표 및 종류 필터 추가
This commit is contained in:
@@ -1,70 +1,85 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
memberId: { type: String, required: true },
|
||||
kind: { type: String, required: true }
|
||||
})
|
||||
const isComments = computed(() => props.kind === 'comments')
|
||||
const props = defineProps({ memberId: { type: String, required: true } })
|
||||
const searchInput = ref('')
|
||||
const filters = ref({ q: '', page: 1 })
|
||||
const query = computed(() => ({
|
||||
...filters.value, pageSize: 10,
|
||||
...(isComments.value ? { memberId: props.memberId } : {})
|
||||
}))
|
||||
const filters = ref({ q: '', type: 'all', page: 1 })
|
||||
const query = computed(() => ({ ...filters.value, pageSize: 10 }))
|
||||
const { data, status, error, refresh } = await useFetch(
|
||||
() => isComments.value ? '/admin/api/comments' : `/admin/api/members/${props.memberId}/posts`,
|
||||
() => `/admin/api/members/${props.memberId}/activity`,
|
||||
{ query, default: () => ({ items: [], pagination: { page: 1, pageCount: 1, total: 0 } }) }
|
||||
)
|
||||
const statusLabels = { published: '공개', draft: '초안', members: '멤버십', private: '비공개', pending: '승인 대기', blocked: '차단됨' }
|
||||
|
||||
/**
|
||||
* 검색어와 페이지를 함께 적용해 검색 결과 첫 페이지를 조회한다.
|
||||
* 검색어를 적용하고 첫 페이지를 조회한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applySearch = () => { filters.value = { q: searchInput.value.trim(), page: 1 } }
|
||||
const applySearch = () => { filters.value = { ...filters.value, q: searchInput.value.trim(), page: 1 } }
|
||||
|
||||
/**
|
||||
* 전체 검색 결과에서 선택한 페이지를 조회한다.
|
||||
* 작성 종류를 바꾸고 첫 페이지에서 다시 조회한다.
|
||||
* @param {Event} event - 종류 선택 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const changeType = (event) => { filters.value = { q: searchInput.value.trim(), type: event.target.value, page: 1 } }
|
||||
|
||||
/**
|
||||
* 통합 검색 결과에서 선택한 페이지를 조회한다.
|
||||
* @param {number} page - 페이지 번호
|
||||
* @returns {void}
|
||||
*/
|
||||
const changePage = (page) => { filters.value = { ...filters.value, page } }
|
||||
|
||||
/**
|
||||
* 활동의 작성 시각을 한국어로 표시한다.
|
||||
* 작성 시각을 짧은 한국어 날짜로 표시한다.
|
||||
* @param {string} value - 작성 시각
|
||||
* @returns {string} 날짜·시각
|
||||
*/
|
||||
const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Seoul'
|
||||
year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul'
|
||||
}).format(new Date(value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-member-activity border border-line bg-white p-5" :aria-busy="status === 'pending'">
|
||||
<div class="admin-member-activity__toolbar flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 class="admin-member-activity__heading text-base font-semibold">{{ isComments ? '작성한 댓글' : '작성한 글' }} <span class="text-xs font-normal text-muted">{{ data?.pagination.total || 0 }}개 · 최신순</span></h2>
|
||||
<form class="admin-member-activity__search flex w-full max-w-full items-center gap-2 sm:w-auto" role="search" @submit.prevent="applySearch">
|
||||
<AdminSearchInput root-class="min-w-0 flex-1" width-class="w-full sm:w-64" :value="searchInput" :label="isComments ? '작성한 댓글 검색' : '작성한 글 검색'" :placeholder="isComments ? '댓글 내용·게시물 제목 검색' : '제목·주소·본문 검색'" @input="searchInput = $event.target.value" />
|
||||
<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>
|
||||
<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>
|
||||
<option value="comment">댓글</option>
|
||||
<option value="post">게시글</option>
|
||||
</select>
|
||||
<AdminSearchInput root-class="min-w-0 flex-1" width-class="w-full sm:w-64" :value="searchInput" label="작성한 글 검색" placeholder="게시글 제목·내용 검색" @input="searchInput = $event.target.value" />
|
||||
<button type="submit" class="admin-member-activity__search-button h-10 shrink-0 rounded border border-line px-3 text-sm">검색</button>
|
||||
</form>
|
||||
</div>
|
||||
<p v-if="error" class="admin-member-activity__error py-6 text-sm text-red-700" role="alert">목록을 불러오지 못했습니다. <button type="button" class="underline" @click="refresh()">다시 시도</button></p>
|
||||
<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 ? '검색 결과가 없습니다.' : isComments ? '작성한 댓글이 없습니다.' : '작성한 글이 없습니다.' }}</p>
|
||||
<ul v-else class="admin-member-activity__items mt-4 divide-y divide-line">
|
||||
<li v-for="item in data.items" :key="item.id" class="admin-member-activity__item py-4 text-sm">
|
||||
<div class="admin-member-activity__meta flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<time :datetime="item.createdAt">{{ formatCreatedAt(item.createdAt) }}</time>
|
||||
<span v-if="isComments && item.parentId">답글</span>
|
||||
<span>{{ statusLabels[item.status] || item.status }}</span>
|
||||
</div>
|
||||
<p v-if="isComments" class="admin-member-activity__body my-2 whitespace-pre-wrap break-words">{{ item.body }}</p>
|
||||
<div class="admin-member-activity__post mt-2 flex flex-wrap items-center gap-3">
|
||||
<NuxtLink :to="`/admin/posts/${isComments ? item.postId : item.id}`" class="break-words font-medium hover:underline">{{ (isComments ? item.postTitle : item.title) || '(제목 없음)' }}</NuxtLink>
|
||||
<NuxtLink v-if="['published', 'members'].includes(isComments ? item.postStatus : item.status)" :to="`/post/${encodeURIComponent(isComments ? item.postSlug : item.slug)}${isComments ? '#comments' : ''}`" target="_blank" rel="noopener noreferrer" class="shrink-0 text-xs text-muted underline">게시물 보기</NuxtLink>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<AdminPagination v-if="status !== 'pending' && !error" :page="data?.pagination.page" :page-count="data?.pagination.pageCount" :label="isComments ? '작성한 댓글 페이지' : '작성한 글 페이지'" @update:page="changePage" />
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<AdminPagination v-if="status !== 'pending' && !error" :page="data?.pagination.page" :page-count="data?.pagination.pageCount" label="작성한 글 페이지" @update:page="changePage" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -24,8 +24,7 @@ const { data: adminSession } = await useFetch('/admin/api/auth/me', {
|
||||
const selectedMemberTab = ref('info')
|
||||
const memberTabs = [
|
||||
{ value: 'info', label: '정보' },
|
||||
{ value: 'posts', label: '작성한 글' },
|
||||
{ value: 'comments', label: '작성한 댓글' }
|
||||
{ value: 'posts', label: '작성한 글' }
|
||||
]
|
||||
|
||||
const isNewMember = computed(() => props.mode === 'new')
|
||||
@@ -762,7 +761,7 @@ watch(() => props.member, () => {
|
||||
</nav>
|
||||
<AdminMemberActivityList
|
||||
v-if="!isNewMember && selectedMemberTab !== 'info'"
|
||||
:key="`${member.id}-${selectedMemberTab}`" :member-id="member.id" :kind="selectedMemberTab"
|
||||
:key="`${member.id}-${selectedMemberTab}`" :member-id="member.id"
|
||||
class="admin-member-form__content-tab mt-6"
|
||||
/>
|
||||
<div v-show="selectedMemberTab === 'info'" class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
## 2026-10-21 v1.12.24 — 멤버 작성 이력은 종류 필터가 있는 통합 표로 표시한다
|
||||
|
||||
멤버의 게시글과 댓글은 작성한 글 탭 하나에서 종류 열로 구분한다. 대시보드처럼 작성 대상·작성자·시각·내용을 한 줄로 표시해 여러 활동을 빠르게 훑도록 한다. 두 종류를 브라우저에서 따로 조회·합치면 전체 페이지와 최신순 정렬이 틀어질 수 있으므로 서버에서 작성자 조건을 적용한 게시글·댓글을 합친 뒤 종류 필터·검색·전체 건수·페이지를 계산한다. 기존 개별 API는 호환을 유지한다.
|
||||
|
||||
## 2026-10-21 v1.12.23 — 대시보드는 최근 활동 요약, 멤버 이력은 탭으로 나눈다
|
||||
|
||||
대시보드는 새 댓글의 게시물·작성자·시각을 빠르게 파악하도록 최근 댓글을 작은 한 줄 목록으로 줄이고 CSP 관찰 표와 주기 조회를 제거한다. 이는 관리자 화면 정리이며, 제공된 누적 보고로 보안 정책 강화 여부를 확정한 결정은 아니다. 기존 수집·집계와 보안 헤더는 유지한다.
|
||||
|
||||
@@ -312,7 +312,7 @@
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 슬래시 메뉴 위치·슬래시 명령 실행·공통 포커스·블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘 ON/OFF 토글·비활성 아이콘 영역·배경색·코드 언어 셀렉트·줄번호 ON/OFF 토글·토글), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·Google 로그인 연동 상태·활동 요약·정보/작성한 글/작성한 댓글 탭·전체 이력 검색 및 번호형 페이지네이션, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 소유자 전용 Google 연동 해제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·Google 로그인 연동 상태·활동 요약·정보/작성한 글 탭·게시글/댓글 통합 표·종류 필터·검색 및 번호형 페이지네이션, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 소유자 전용 Google 연동 해제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
|
||||
| components/admin/AdminModalFrame.vue | 관리자 공통 일반 모달 프레임(오버레이, dialog 접근성 속성, 기본 패널 스타일, 바깥 클릭 닫기) |
|
||||
@@ -641,6 +641,8 @@
|
||||
| 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 | 멤버 통합 작성 이력 표·전체/댓글/게시글 필터·검색·페이지 이동·원문 링크·작은 대시보드 댓글·CSP 제거·게시글 댓글 수 E2E 검증 |
|
||||
| components/admin/AdminMemberActivityList.vue | 회원별 게시글·댓글 통합 한 줄 표·종류 필터·전체 이력 최신순 검색·번호형 페이지네이션·게시물 이동 |
|
||||
| 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 |
|
||||
|
||||
12
docs/spec.md
12
docs/spec.md
@@ -1089,15 +1089,21 @@ APP_PORT=43080
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
## 관리자 댓글·회원 작성 이력 조회 (v1.12.23)
|
||||
## 관리자 댓글·회원 작성 이력 조회 (v1.12.24)
|
||||
|
||||
- `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 내림차순. 범위 초과 페이지는 마지막 페이지로 보정.
|
||||
- 멤버 상세는 `정보 / 작성한 글 / 작성한 댓글` 탭을 제공하며 정보로 진입한다. 정보 편집값은 탭 이동 시 유지한다. 이력은 선택한 탭에서만 조회하고, 10개씩 번호형 페이지네이션·검색 버튼/Enter로 검색한다. 검색 시 첫 페이지로 돌아가며 최신 작성순으로 전체 이력을 조회한다.
|
||||
- 멤버 상세는 `정보 / 작성한 글` 탭을 제공하며 정보로 진입한다. 정보 편집값은 탭 이동 시 유지한다. 작성한 글 탭에서 게시글·댓글 전체 이력을 `구분 / 게시글 / 작성자 / 작성일 / 내용` 한 줄 표로 통합 표시한다. 긴 제목·내용은 말줄임과 title로 표시하며 좁은 화면은 표 내부 가로 스크롤을 제공한다.
|
||||
- 상단 `전체 / 댓글 / 게시글` 종류 필터와 검색 버튼/Enter 검색을 제공한다. 검색·종류 변경 시 첫 페이지로 돌아가며 10개씩 번호형 페이지네이션으로 최신순 전체 이력을 조회한다.
|
||||
- 대시보드는 최신 댓글 5개를 30초마다 갱신한다. 게시물·작성자·짧은 시각·말줄임 한 줄 내용만 작은 행으로 표시한다. 조회 오류는 재시도하며 이력 조회 중은 로딩 표시.
|
||||
- 댓글 내용은 일반 텍스트로 표시. 작성자는 멤버 상세, 게시물 제목은 관리자 편집으로 이동. 공개·멤버십 글은 새 창의 `/post/:slug#comments` 링크 제공(공개 화면의 기존 접근 권한 적용).
|
||||
- 댓글 내용은 일반 텍스트로 표시한다. 대시보드 작성자는 멤버 상세로 이동한다. 통합 표의 게시글 제목은 `publicUrl`이 있으면 새 창의 공개 화면으로, 없으면 관리자 편집으로 이동한다(공개 화면의 기존 접근 권한 적용).
|
||||
- 게시글 목록 댓글 열은 기존 `commentCount` 사용. 공개 상태 댓글·답글만 집계.
|
||||
|
||||
- `GET /admin/api/members/:id/posts`: 관리자 세션 필수. 회원 UUID와 `q`(trim, 최대 200자), `page`(1~1000000, 기본 1), `pageSize`(1~100, 기본 10) 검증. 잘못된 조건 400, 미인증 401.
|
||||
- 작성 글은 `posts.author_id`로 필터하고 초안·비공개·멤버십·공개 전체 포함, `created_at DESC, id DESC` 순서. `q`는 제목·주소·요약·본문의 문자 그대로 부분 검색이며 댓글 검색도 `%`, `_`를 와일드카드로 취급하지 않는다.
|
||||
- 작성 글 응답 `{ items: [{ id, title, slug, status, createdAt, publishedAt }], pagination: { page, pageSize, total, pageCount } }`. 범위 초과 페이지는 마지막으로 보정하고 미작성·검색 결과 없음은 빈 목록 반환.
|
||||
|
||||
- `GET /admin/api/members/:id/activity`: 관리자 회원 통합 작성 이력 조회. `memberId` UUID, `type`은 `all`(기본)/`post`/`comment`, `q`(trim, 최대 200자), `page`(기본 1, 1~1000000), `pageSize`(기본 10, 1~100). 미인증 401, 잘못된 조건 400.
|
||||
- 게시글(`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`를 사용한다.
|
||||
|
||||
@@ -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 검증은 통과. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인.
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.12.24
|
||||
|
||||
- 멤버 작성 글·댓글 탭을 작성한 글 탭 하나로 통합.
|
||||
- 구분·게시글·작성자·작성일·한 줄 내용 표와 전체/댓글/게시글 필터 추가.
|
||||
- 게시글·댓글 통합 최신순 검색·페이지 조회 API와 검색·필터 변경 시 첫 페이지 조회 추가.
|
||||
- 통합 정렬·작성자 격리·종류 필터·검색·페이지 이동 API/E2E 검증 보강.
|
||||
- JavaScript 문법 검사·단위 테스트 273건·프로덕션 빌드·Chromium E2E 11건 검증 진행.
|
||||
- API 통합 검증 11건 통과, 기존 미디어 사용처 검증 실패 1건·후속 미실행 1건 유지.
|
||||
|
||||
## v1.12.23
|
||||
|
||||
- 대시보드 CSP 관찰 표·조회·자동 갱신 제거.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.23",
|
||||
"version": "1.12.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.23",
|
||||
"version": "1.12.24",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.23",
|
||||
"version": "1.12.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
55
server/repositories/member-activity-repository.js
Normal file
55
server/repositories/member-activity-repository.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
import { createPostSummary } from '../../composables/createPostSummary.js'
|
||||
|
||||
/**
|
||||
* 회원의 게시글·댓글을 합쳐 최신순으로 검색하고 페이지 조회한다.
|
||||
* @param {{ memberId: string, type: 'all'|'post'|'comment', q: string, page: number, pageSize: number }} options - 조회 조건
|
||||
* @returns {Promise<Object>} 통합 작성 이력과 페이지 정보
|
||||
*/
|
||||
export const listMemberActivityForAdmin = async ({ memberId, type, q, page, pageSize }) => {
|
||||
const sql = getPostgresClient()
|
||||
if (!sql) return { items: [], pagination: { page: 1, pageSize, total: 0, pageCount: 1 } }
|
||||
|
||||
return sql.begin(async (transaction) => {
|
||||
await transaction`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`
|
||||
/**
|
||||
* 집계와 목록에 동일한 작성자·종류·검색 조건을 적용한다.
|
||||
* @returns {Object} SQL 조회 조각
|
||||
*/
|
||||
const activityQuery = () => transaction`
|
||||
SELECT 'post'::text AS kind, posts.id, posts.id AS "postId", posts.title AS "postTitle",
|
||||
posts.slug AS "postSlug", posts.status AS "postStatus", posts.published_at AS "publishedAt",
|
||||
posts.created_at AS "createdAt", users.username,
|
||||
LEFT(COALESCE(NULLIF(posts.excerpt, ''), posts.content, ''), 1000) AS body
|
||||
FROM posts INNER JOIN users ON users.id = posts.author_id
|
||||
WHERE posts.author_id = ${memberId} AND ${type} <> 'comment'
|
||||
AND (${q} = '' OR strpos(lower(concat_ws(' ', posts.title, posts.slug, posts.excerpt, posts.content)), lower(${q})) > 0)
|
||||
UNION ALL
|
||||
SELECT 'comment'::text AS kind, comments.id, posts.id AS "postId", posts.title AS "postTitle",
|
||||
posts.slug AS "postSlug", posts.status AS "postStatus", posts.published_at AS "publishedAt",
|
||||
comments.created_at AS "createdAt", users.username, comments.body
|
||||
FROM comments
|
||||
INNER JOIN posts ON posts.id = comments.post_id
|
||||
INNER JOIN users ON users.id = comments.user_id
|
||||
WHERE comments.user_id = ${memberId} AND ${type} <> 'post'
|
||||
AND (${q} = '' OR strpos(lower(concat_ws(' ', comments.body, posts.title)), lower(${q})) > 0)
|
||||
`
|
||||
const [count] = await transaction`SELECT COUNT(*)::int AS total FROM (${activityQuery()}) AS activity`
|
||||
const total = Number(count.total)
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pageCount)
|
||||
const rows = await transaction`
|
||||
SELECT * FROM (${activityQuery()}) AS activity
|
||||
ORDER BY "createdAt" DESC, kind ASC, id DESC
|
||||
LIMIT ${pageSize} OFFSET ${(currentPage - 1) * pageSize}
|
||||
`
|
||||
const items = rows.map((row) => ({
|
||||
...row,
|
||||
body: row.kind === 'post' ? createPostSummary('', row.body, { maxLength: 320 }) : row.body,
|
||||
publicUrl: row.postStatus === 'members' || (row.postStatus === 'published' && (!row.publishedAt || row.publishedAt <= new Date()))
|
||||
? `/post/${encodeURIComponent(row.postSlug)}${row.kind === 'comment' ? '#comments' : ''}`
|
||||
: null
|
||||
}))
|
||||
return { items, pagination: { page: currentPage, pageSize, total, pageCount } }
|
||||
})
|
||||
}
|
||||
26
server/routes/admin/api/members/[id]/activity.get.js
Normal file
26
server/routes/admin/api/members/[id]/activity.get.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createError, getQuery, getRouterParam } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { listMemberActivityForAdmin } from '../../../../../repositories/member-activity-repository'
|
||||
|
||||
const querySchema = z.object({
|
||||
memberId: z.string().uuid(),
|
||||
type: z.enum(['all', 'post', 'comment']).default('all'),
|
||||
q: z.string().trim().max(200).default(''),
|
||||
page: z.coerce.number().int().min(1).max(1000000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(10)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 회원 작성 이력을 게시글·댓글 통합 페이지로 조회한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 통합 작성 이력
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
requireAdminSession(event)
|
||||
const result = querySchema.safeParse({ ...getQuery(event), memberId: getRouterParam(event, 'id') })
|
||||
if (!result.success) {
|
||||
throw createError({ statusCode: 400, message: '작성 이력 조회 조건이 올바르지 않습니다.' })
|
||||
}
|
||||
return listMemberActivityForAdmin(result.data)
|
||||
})
|
||||
@@ -293,6 +293,38 @@ test('멤버 작성 글 조회는 작성자를 분리하고 전체 이력을 검
|
||||
'draft', ${author.id}, now() + index * interval '1 second'
|
||||
FROM generate_series(1, 11) AS index
|
||||
`
|
||||
const [targetPost] = await database`SELECT id FROM posts WHERE author_id = ${author.id} ORDER BY created_at DESC LIMIT 1`
|
||||
await database`
|
||||
INSERT INTO comments (post_id, user_id, body, created_at)
|
||||
VALUES (${targetPost.id}, ${author.id}, '통합 댓글 최신', now() + interval '20 second'),
|
||||
(${targetPost.id}, ${author.id}, '통합 댓글 과거', now() - interval '1 day'),
|
||||
(${targetPost.id}, ${adminUser.userId}, '다른 회원 댓글', now() + interval '30 second')
|
||||
`
|
||||
const activityUrl = `/admin/api/members/${author.id}/activity`
|
||||
expect((await anonymousApi.get(activityUrl)).status()).toBe(401)
|
||||
for (const query of ['type=invalid', 'page=0', 'pageSize=101']) {
|
||||
expect((await adminApi.get(`${activityUrl}?${query}`)).status()).toBe(400)
|
||||
}
|
||||
expect((await adminApi.get('/admin/api/members/invalid/activity')).status()).toBe(400)
|
||||
const mixed = await (await adminApi.get(activityUrl)).json()
|
||||
expect(mixed.pagination).toMatchObject({ total: 13, pageCount: 2, page: 1 })
|
||||
expect(mixed.items).toHaveLength(10)
|
||||
expect(mixed.items[0]).toMatchObject({ kind: 'comment', body: '통합 댓글 최신', postId: targetPost.id, publicUrl: null })
|
||||
expect(mixed.items[1]).toMatchObject({ kind: 'post', postTitle: '작성 이력 11', body: '검색 본문 11' })
|
||||
const mixedLast = await (await adminApi.get(`${activityUrl}?page=999`)).json()
|
||||
expect(mixedLast.pagination.page).toBe(2)
|
||||
expect(mixedLast.items).toHaveLength(3)
|
||||
expect(mixedLast.items[2].body).toBe('통합 댓글 과거')
|
||||
const onlyComments = await (await adminApi.get(`${activityUrl}?type=comment`)).json()
|
||||
expect(onlyComments.pagination.total).toBe(2)
|
||||
expect(onlyComments.items.every((item) => item.kind === 'comment')).toBe(true)
|
||||
const onlyPosts = await (await adminApi.get(`${activityUrl}?type=post`)).json()
|
||||
expect(onlyPosts.pagination.total).toBe(11)
|
||||
expect(onlyPosts.items.every((item) => item.kind === 'post')).toBe(true)
|
||||
const activitySearch = await (await adminApi.get(`${activityUrl}?q=${encodeURIComponent('통합 댓글 최신')}`)).json()
|
||||
expect(activitySearch.pagination.total).toBe(1)
|
||||
const activityEmpty = await (await adminApi.get(`${activityUrl}?type=post&q=%25`)).json()
|
||||
expect(activityEmpty.items).toEqual([])
|
||||
const authorUrl = `/admin/api/members/${author.id}/posts`
|
||||
const first = await (await adminApi.get(authorUrl)).json()
|
||||
expect(first.pagination).toMatchObject({ total: 11, pageCount: 2, page: 1 })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import postgres from 'postgres'
|
||||
|
||||
test('멤버 댓글 페이지 이동·게시물 링크와 대시보드 최근 댓글을 표시한다', async ({ page }) => {
|
||||
test('멤버 통합 작성 이력의 종류 필터·검색·페이지와 대시보드 댓글을 표시한다', async ({ page }) => {
|
||||
const database = postgres(process.env.DATABASE_URL, { max: 1 })
|
||||
let postId
|
||||
try {
|
||||
@@ -24,28 +24,36 @@ test('멤버 댓글 페이지 이동·게시물 링크와 대시보드 최근
|
||||
await page.goto(`/admin/members/${admin.userId}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.locator('.admin-member-activity')).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '작성한 댓글', exact: true }).click()
|
||||
const list = page.locator('.admin-member-activity')
|
||||
await expect(list.locator('.admin-member-activity__item')).toHaveCount(10)
|
||||
await expect(list.locator('.admin-member-activity__body').first()).toHaveText('화면 댓글 10')
|
||||
await expect(list.getByRole('link', { name: '게시물 보기' }).first()).toHaveAttribute('href', '/post/comments-ui-test#comments')
|
||||
await list.getByRole('button', { name: '다음 페이지' }).click()
|
||||
await expect(list.locator('.admin-member-activity__item')).toHaveCount(1)
|
||||
await expect(list.locator('.admin-member-activity__body')).toHaveText('화면 댓글 0')
|
||||
await list.getByRole('button', { name: '이전 페이지' }).click()
|
||||
await expect(list.locator('.admin-member-activity__item')).toHaveCount(10)
|
||||
await list.getByLabel('작성한 댓글 검색').fill('화면 댓글 0')
|
||||
await list.getByRole('button', { name: '검색', exact: true }).click()
|
||||
await expect(list.locator('.admin-member-activity__item')).toHaveCount(1)
|
||||
await expect(list.locator('.admin-member-activity__body')).toHaveText('화면 댓글 0')
|
||||
await list.getByLabel('작성한 댓글 검색').fill('결과없음')
|
||||
await list.getByRole('button', { name: '검색', exact: true }).click()
|
||||
await expect(list).toContainText('검색 결과가 없습니다.')
|
||||
await expect(page.getByRole('button', { name: '작성한 댓글', exact: true })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '작성한 글', exact: true }).click()
|
||||
await expect(page.locator('.admin-member-activity__item')).toHaveCount(1)
|
||||
await page.getByLabel('작성한 글 검색').fill('없는글')
|
||||
await page.locator('.admin-member-activity').getByRole('button', { name: '검색', exact: true }).click()
|
||||
await expect(page.locator('.admin-member-activity')).toContainText('검색 결과가 없습니다.')
|
||||
const list = page.locator('.admin-member-activity')
|
||||
const items = list.locator('.admin-member-activity__item')
|
||||
await expect(items).toHaveCount(10)
|
||||
await expect(list.locator('.admin-member-activity__body').first()).toHaveText('화면 댓글 10')
|
||||
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 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')
|
||||
await list.getByRole('button', { name: '검색', exact: true }).click()
|
||||
await expect(items).toHaveCount(1)
|
||||
await expect(list.locator('.admin-member-activity__body')).toHaveText('화면 댓글 0')
|
||||
await list.getByLabel('작성 종류').selectOption('post')
|
||||
await expect(list).toContainText('검색 결과가 없습니다.')
|
||||
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)
|
||||
const rowBox = await items.first().boundingBox()
|
||||
expect(rowBox.height).toBeLessThan(40)
|
||||
await page.getByRole('button', { name: '정보', exact: true }).click()
|
||||
await expect(page.locator('.admin-member-form__body')).toBeVisible()
|
||||
await page.goto('/admin')
|
||||
|
||||
Reference in New Issue
Block a user