221 lines
8.5 KiB
Vue
221 lines
8.5 KiB
Vue
<script setup>
|
|
definePageMeta({
|
|
layout: 'admin'
|
|
})
|
|
|
|
const memberSearchQuery = ref('')
|
|
|
|
const { data: members } = await useFetch('/admin/api/members', {
|
|
default: () => []
|
|
})
|
|
|
|
const filteredMembers = computed(() => {
|
|
const query = memberSearchQuery.value.trim().toLowerCase()
|
|
|
|
if (!query) {
|
|
return members.value
|
|
}
|
|
|
|
return members.value.filter((member) => [
|
|
member.username,
|
|
member.email,
|
|
member.lastSeenIp,
|
|
member.activityStatus
|
|
].some((value) => String(value || '').toLowerCase().includes(query)))
|
|
})
|
|
|
|
const memberCountLabel = computed(() => {
|
|
const count = filteredMembers.value.length
|
|
return `${count}명`
|
|
})
|
|
|
|
/**
|
|
* 회원 상세 화면으로 이동한다.
|
|
* @param {Object} member - 회원 정보
|
|
* @returns {Promise<void>} 이동 처리
|
|
*/
|
|
const navigateToMember = async (member) => {
|
|
if (!member?.id) {
|
|
return
|
|
}
|
|
|
|
await navigateTo(`/admin/members/${member.id}`)
|
|
}
|
|
|
|
/**
|
|
* 회원 이니셜을 반환한다.
|
|
* @param {Object} member - 회원 정보
|
|
* @returns {string} 이니셜
|
|
*/
|
|
const getMemberInitial = (member) => String(member?.username || member?.email || '?').slice(0, 1).toUpperCase()
|
|
|
|
/**
|
|
* 날짜 표시 형식 변환
|
|
* @param {string | null} value - ISO 날짜 문자열
|
|
* @returns {string} 화면 표시 날짜
|
|
*/
|
|
const formatDate = (value) => {
|
|
if (!value) {
|
|
return '-'
|
|
}
|
|
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '-'
|
|
}
|
|
|
|
const year = date.getFullYear()
|
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
const day = String(date.getDate()).padStart(2, '0')
|
|
|
|
return `${year}.${month}.${day}`
|
|
}
|
|
|
|
/**
|
|
* 최근 활동 시각을 상대 시간으로 표시한다.
|
|
* @param {string | null} value - ISO 시각
|
|
* @returns {string} 상대 시간
|
|
*/
|
|
const formatRelativeTime = (value) => {
|
|
if (!value) {
|
|
return '최근 활동 없음'
|
|
}
|
|
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '최근 활동 없음'
|
|
}
|
|
|
|
const diffMs = Date.now() - date.getTime()
|
|
const minute = 1000 * 60
|
|
const hour = minute * 60
|
|
const day = hour * 24
|
|
|
|
if (diffMs < minute) {
|
|
return '방금 전'
|
|
}
|
|
|
|
if (diffMs < hour) {
|
|
return `${Math.floor(diffMs / minute)}분 전`
|
|
}
|
|
|
|
if (diffMs < day) {
|
|
return `${Math.floor(diffMs / hour)}시간 전`
|
|
}
|
|
|
|
if (diffMs < day * 30) {
|
|
return `${Math.floor(diffMs / day)}일 전`
|
|
}
|
|
|
|
return formatDate(value)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="admin-members bg-paper p-6">
|
|
<div class="admin-members__header flex flex-col gap-5 pb-6 xl:flex-row xl:items-center xl:justify-between">
|
|
<div>
|
|
<p class="admin-members__eyebrow text-xs font-semibold uppercase text-muted">
|
|
Members
|
|
</p>
|
|
<h1 class="admin-members__title mt-2 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
|
멤버
|
|
</h1>
|
|
</div>
|
|
<div class="admin-members__actions flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<label class="admin-members__search relative block w-full sm:w-[290px]">
|
|
<span class="sr-only">멤버 검색</span>
|
|
<svg class="admin-members__search-icon pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9aa4b2]" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M23.245 23.996a.743.743 0 01-.53-.22L16.2 17.26a9.824 9.824 0 01-2.553 1.579 9.766 9.766 0 01-7.51.069 9.745 9.745 0 01-5.359-5.262c-1.025-2.412-1.05-5.08-.069-7.51S3.558 1.802 5.97.777a9.744 9.744 0 017.51-.069 9.745 9.745 0 015.359 5.262 9.748 9.748 0 01.069 7.51 9.807 9.807 0 01-1.649 2.718l6.517 6.518a.75.75 0 01-.531 1.28zM9.807 1.49a8.259 8.259 0 00-3.25.667 8.26 8.26 0 00-4.458 4.54 8.26 8.26 0 00.058 6.362 8.26 8.26 0 004.54 4.458 8.259 8.259 0 006.362-.059 8.285 8.285 0 002.594-1.736.365.365 0 01.077-.076 8.245 8.245 0 001.786-2.728 8.255 8.255 0 00-.059-6.362 8.257 8.257 0 00-4.54-4.458 8.28 8.28 0 00-3.11-.608z" fill="currentColor" />
|
|
</svg>
|
|
<input
|
|
v-model="memberSearchQuery"
|
|
class="admin-members__search-input h-11 w-full rounded-md border border-transparent bg-[#eef1f4] pl-10 pr-3 text-sm text-[#15171a] outline-none transition focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]"
|
|
type="search"
|
|
placeholder="멤버 검색..."
|
|
aria-label="멤버 검색"
|
|
>
|
|
</label>
|
|
<NuxtLink
|
|
class="admin-members__new inline-flex h-11 items-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-[#2b2f35] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-2"
|
|
to="/admin/members/new"
|
|
>
|
|
멤버 추가
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-members__table mt-7 overflow-x-auto">
|
|
<table class="admin-members__table-inner min-w-[920px] w-full border-collapse text-left text-sm">
|
|
<thead class="admin-members__table-head border-b border-line text-xs uppercase tracking-[0.02em] text-[#15171a]">
|
|
<tr>
|
|
<th class="admin-members__cell w-[32%] py-4 pr-5 font-semibold">{{ memberCountLabel }}</th>
|
|
<th class="admin-members__cell w-[15%] px-5 py-4 font-semibold">상태</th>
|
|
<th class="admin-members__cell w-[16%] px-5 py-4 font-semibold">댓글 작성</th>
|
|
<th class="admin-members__cell w-[17%] px-5 py-4 font-semibold">접속 IP</th>
|
|
<th class="admin-members__cell w-[20%] py-4 pl-5 font-semibold">가입일</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="admin-members__table-body divide-y divide-line/70 bg-paper">
|
|
<tr
|
|
v-for="member in filteredMembers"
|
|
:key="member.id"
|
|
class="admin-members__row cursor-pointer align-middle transition-colors hover:bg-[#f7f8fa] focus-within:bg-[#f7f8fa]"
|
|
tabindex="0"
|
|
@click="navigateToMember(member)"
|
|
@keydown.enter.prevent="navigateToMember(member)"
|
|
>
|
|
<td class="admin-members__cell py-5 pr-5">
|
|
<div class="admin-members__profile flex min-w-0 items-center gap-3">
|
|
<img
|
|
v-if="member.avatarUrl"
|
|
:src="member.avatarUrl"
|
|
:alt="member.username"
|
|
class="admin-members__avatar h-11 w-11 shrink-0 rounded-full object-cover"
|
|
>
|
|
<span v-else class="admin-members__avatar grid h-11 w-11 shrink-0 place-items-center rounded-full bg-[#15171a] text-sm font-semibold text-white">
|
|
{{ getMemberInitial(member) }}
|
|
</span>
|
|
<span class="admin-members__identity min-w-0">
|
|
<span class="admin-members__name block truncate text-base font-semibold text-[#15171a]">
|
|
{{ member.username || '이름 없음' }}
|
|
</span>
|
|
<span class="admin-members__email mt-0.5 block truncate text-sm text-[#657080]">
|
|
{{ member.email }}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
|
<span
|
|
class="admin-members__status inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold"
|
|
:class="member.activityStatus === '활성'
|
|
? 'border-green-200 bg-green-50 text-green-700'
|
|
: 'border-line bg-white text-[#657080]'"
|
|
>
|
|
{{ member.activityStatus }}
|
|
</span>
|
|
</td>
|
|
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
|
<span class="admin-members__comments font-semibold text-[#15171a]">{{ member.commentCount }}</span>
|
|
<span class="admin-members__comments-label ml-1 text-xs text-[#8c96a3]">개</span>
|
|
</td>
|
|
<td class="admin-members__cell px-5 py-5 text-sm text-[#657080]">
|
|
{{ member.lastSeenIp || '-' }}
|
|
</td>
|
|
<td class="admin-members__cell py-5 pl-5">
|
|
<span class="admin-members__created block text-sm text-[#15171a]">{{ formatDate(member.createdAt) }}</span>
|
|
<span class="admin-members__last-seen mt-1 block text-sm text-[#9aa4b2]">{{ formatRelativeTime(member.lastSeenAt) }}</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="filteredMembers.length === 0">
|
|
<td colspan="5" class="admin-members__empty px-4 py-12 text-center text-sm text-muted">
|
|
검색 결과가 없습니다.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</template>
|