v1.12.23 대시보드 간소화 및 멤버 작성 이력 탭 추가

This commit is contained in:
2026-10-21 16:37:55 +09:00
parent c8e00c1190
commit 7c95797fd2
18 changed files with 287 additions and 251 deletions

View File

@@ -1,69 +1,37 @@
<script setup>
const props = defineProps({
memberId: { type: String, default: '' },
recent: { type: Boolean, default: false }
const { data, error, refresh } = await useFetch('/admin/api/comments', {
query: { pageSize: 5 },
default: () => ({ items: [], pagination: { total: 0 } })
})
const page = ref(1)
const query = computed(() => ({
...(props.memberId ? { memberId: props.memberId } : {}),
page: page.value,
pageSize: props.recent ? 5 : 10
}))
const { data, status, error, refresh } = await useFetch('/admin/api/comments', {
query,
default: () => ({ items: [], pagination: { page: 1, pageCount: 1, total: 0 } })
})
watch(() => props.memberId, () => { page.value = 1 })
let refreshTimer = null
onMounted(() => {
if (props.recent) refreshTimer = window.setInterval(() => refresh(), 30000)
})
onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer)
})
onMounted(() => { refreshTimer = window.setInterval(() => refresh(), 30000) })
onUnmounted(() => { if (refreshTimer) window.clearInterval(refreshTimer) })
/**
* 댓글 작성 시각을 한국어 날짜로 표시한다.
* 최근 댓글의 작성 시각을 짧게 표시한다.
* @param {string} value - 작성 시각
* @returns {string} 표시 날짜
* @returns {string} 한국어 날짜·시각
*/
const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Seoul'
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul'
}).format(new Date(value))
</script>
<template>
<section class="admin-comment-list rounded-xl border border-line bg-white p-5 md:p-6" :aria-busy="status === 'pending'">
<h2 class="admin-comment-list__heading text-lg font-semibold">
{{ recent ? '최근 댓글' : '작성한 댓글' }}
<span class="text-sm font-normal text-muted">전체 {{ data?.pagination.total || 0 }}개 · 답글 포함</span>
</h2>
<div v-if="error" class="admin-comment-list__error mt-4 text-sm text-red-700" role="alert">
댓글을 불러오지 못했습니다.
<button type="button" class="underline" @click="refresh()">다시 시도</button>
</div>
<p v-else-if="status === 'pending'" class="admin-comment-list__loading py-6 text-sm text-muted">댓글을 불러오는 중입니다.</p>
<p v-else-if="!data?.items.length" class="admin-comment-list__empty py-6 text-sm text-muted">작성한 댓글이 없습니다.</p>
<ul v-else class="admin-comment-list__items mt-3 divide-y divide-line">
<li v-for="comment in data.items" :key="comment.id" class="admin-comment-list__item py-4">
<div class="admin-comment-list__meta flex flex-wrap items-center gap-2 text-xs text-muted">
<NuxtLink :to="`/admin/members/${comment.userId}`" class="font-semibold hover:underline">{{ comment.username }}</NuxtLink>
<time :datetime="comment.createdAt">{{ formatCreatedAt(comment.createdAt) }}</time>
<span v-if="comment.parentId">답글</span>
<span v-if="comment.status !== 'published'">{{ comment.status === 'pending' ? '승인 대기' : '차단됨' }}</span>
</div>
<p class="admin-comment-list__body my-2 whitespace-pre-wrap break-words text-sm">{{ comment.body }}</p>
<div class="admin-comment-list__post flex flex-wrap items-center gap-3 text-sm">
<NuxtLink :to="`/admin/posts/${comment.postId}`" class="min-w-0 break-words font-medium hover:underline">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
<NuxtLink v-if="['published', 'members'].includes(comment.postStatus)" :to="`/post/${encodeURIComponent(comment.postSlug)}#comments`" target="_blank" rel="noopener noreferrer" class="shrink-0 text-muted underline">게시물 보기</NuxtLink>
<span v-else class="text-xs text-muted">미공개 게시물</span>
</div>
<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>
<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>
<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)]">
<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>
</li>
</ul>
<nav v-if="!recent && data?.pagination.pageCount > 1" class="admin-comment-list__pagination mt-4 flex items-center justify-between gap-3 text-sm" aria-label="댓글 페이지">
<button type="button" class="rounded border border-line px-3 py-2 disabled:opacity-40" :disabled="status === 'pending' || data.pagination.page <= 1" @click="page = data.pagination.page - 1">이전</button>
<span>{{ data.pagination.page }} / {{ data.pagination.pageCount }} 페이지</span>
<button type="button" class="rounded border border-line px-3 py-2 disabled:opacity-40" :disabled="status === 'pending' || data.pagination.page >= data.pagination.pageCount" @click="page = data.pagination.page + 1">다음</button>
</nav>
</section>
</template>

View File

@@ -0,0 +1,70 @@
<script setup>
const props = defineProps({
memberId: { type: String, required: true },
kind: { type: String, required: true }
})
const isComments = computed(() => props.kind === 'comments')
const searchInput = ref('')
const filters = ref({ q: '', page: 1 })
const query = computed(() => ({
...filters.value, pageSize: 10,
...(isComments.value ? { memberId: props.memberId } : {})
}))
const { data, status, error, refresh } = await useFetch(
() => isComments.value ? '/admin/api/comments' : `/admin/api/members/${props.memberId}/posts`,
{ 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 } }
/**
* 전체 검색 결과에서 선택한 페이지를 조회한다.
* @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'
}).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" />
<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" />
</section>
</template>

View File

@@ -21,6 +21,13 @@ const { data: adminSession } = await useFetch('/admin/api/auth/me', {
})
})
const selectedMemberTab = ref('info')
const memberTabs = [
{ value: 'info', label: '정보' },
{ value: 'posts', label: '작성한 글' },
{ value: 'comments', label: '작성한 댓글' }
]
const isNewMember = computed(() => props.mode === 'new')
const isSaving = ref(false)
const savedMemberSnapshot = ref('')
@@ -699,7 +706,7 @@ watch(() => props.member, () => {
{{ pageTitle }}
</h1>
</div>
<div class="admin-member-form__actions flex items-center gap-3">
<div v-show="selectedMemberTab === 'info'" class="admin-member-form__actions flex items-center gap-3">
<div v-if="!isNewMember && canManageMemberActions" class="admin-member-form__action-menu relative">
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
@@ -744,7 +751,21 @@ watch(() => props.member, () => {
</p>
</div>
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
<nav v-if="!isNewMember" class="admin-member-form__tabs mt-5 flex gap-6 border-b border-line" aria-label="멤버 상세 메뉴">
<button
v-for="tab in memberTabs" :key="tab.value" type="button"
class="admin-member-form__tab border-b-2 px-1 py-3 text-sm font-semibold"
:class="selectedMemberTab === tab.value ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
:aria-current="selectedMemberTab === tab.value ? 'page' : undefined"
@click="selectedMemberTab = tab.value"
>{{ tab.label }}</button>
</nav>
<AdminMemberActivityList
v-if="!isNewMember && selectedMemberTab !== 'info'"
:key="`${member.id}-${selectedMemberTab}`" :member-id="member.id" :kind="selectedMemberTab"
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">
<aside class="admin-member-form__summary">
<div class="admin-member-form__identity flex items-center gap-4">
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
@@ -924,8 +945,6 @@ watch(() => props.member, () => {
</div>
</section>
<AdminCommentList v-if="!isNewMember && member?.id" :key="member.id" :member-id="member.id" />
<section v-if="!isNewMember" class="admin-member-form__activity">
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">