관리자 멤버 목록 테이블 정리
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-13 v0.0.110
|
||||||
|
|
||||||
|
### 관리자 멤버 목록 정보 밀도 정리
|
||||||
|
|
||||||
|
멤버 목록에서 이름, 이메일, 접속일, 권한 변경 컨트롤을 모두 별도 컬럼으로 두면 한 사람의 정보가 가로로 흩어지고 목록이 지나치게 넓어진다. Ghost 관리자처럼 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 묶어 읽는 구조로 바꾸고, 권한 변경은 사용자를 선택한 뒤 처리하는 후속 화면의 책임으로 분리한다. 뉴스레터 지표는 이 프로젝트에 없으므로 같은 위치에는 댓글 작성 개수를 표시한다.
|
||||||
|
|
||||||
## 2026-05-13 v0.0.109
|
## 2026-05-13 v0.0.109
|
||||||
|
|
||||||
### 관리자 사이드바 하단 사용자 영역 정리
|
### 관리자 사이드바 하단 사용자 영역 정리
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
| pages/admin/tags/new.vue | 태그 생성 |
|
| pages/admin/tags/new.vue | 태그 생성 |
|
||||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||||
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
||||||
| pages/admin/members/index.vue | 관리자 멤버 목록(닉네임, 이메일, 최근 접속, IP, 댓글 수, 활동 상태) |
|
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태) |
|
||||||
|
|
||||||
## 공개 페이지
|
## 공개 페이지
|
||||||
|
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ components/content/
|
|||||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||||
- `GET /admin/api/members` - 회원 목록(권한, 최근 접속, 접속 IP, 댓글 수 포함)
|
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||||
|
|
||||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||||
@@ -550,7 +550,10 @@ components/content/
|
|||||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
|
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
|
||||||
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
||||||
- 관리자 멤버 화면에서 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계로 표시하고 변경할 수 있다.
|
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
|
||||||
|
- 관리자 멤버 목록은 멤버 검색과 멤버 추가 버튼을 제공한다. 멤버 추가 버튼의 실제 생성 화면 연결은 후속 작업에서 처리한다.
|
||||||
|
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
|
||||||
|
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.
|
||||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
||||||
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
||||||
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.110
|
||||||
|
|
||||||
|
- 관리자 멤버 목록을 Ghost형 테이블 구조로 재정리.
|
||||||
|
- 멤버 이름 아래 이메일, 가입일 아래 최근 활동을 함께 표시하도록 수정.
|
||||||
|
- 멤버 목록에서 권한 변경 선택·저장 UI 제거.
|
||||||
|
- 멤버 검색 입력과 멤버 추가 버튼 추가.
|
||||||
|
- 뉴스레터 Open rate 대체 컬럼으로 댓글 작성 개수 표시 유지.
|
||||||
|
- 패키지 버전 `0.0.110`으로 갱신.
|
||||||
|
|
||||||
## v0.0.109
|
## v0.0.109
|
||||||
|
|
||||||
- 관리자 사이드바 `메뉴` 항목을 `네비게이션`으로 변경하고 전용 아이콘 적용.
|
- 관리자 사이드바 `메뉴` 항목을 `네비게이션`으로 변경하고 전용 아이콘 적용.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.109",
|
"version": "0.0.110",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.109",
|
"version": "0.0.110",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.109",
|
"version": "0.0.110",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -3,24 +3,45 @@ definePageMeta({
|
|||||||
layout: 'admin'
|
layout: 'admin'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const memberSearchQuery = ref('')
|
||||||
|
|
||||||
const { data: members } = await useFetch('/admin/api/members', {
|
const { data: members } = await useFetch('/admin/api/members', {
|
||||||
default: () => []
|
default: () => []
|
||||||
})
|
})
|
||||||
const roleSavingIds = ref([])
|
|
||||||
const roleMessage = ref('')
|
|
||||||
|
|
||||||
const roleOptions = [
|
const filteredMembers = computed(() => {
|
||||||
{ value: 'owner', label: '소유자' },
|
const query = memberSearchQuery.value.trim().toLowerCase()
|
||||||
{ value: 'admin', label: '관리자' },
|
|
||||||
{ value: 'member', label: '멤버' }
|
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 {string | null} value - ISO 시각
|
* @param {Object} member - 회원 정보
|
||||||
* @returns {string} 표시 문자열
|
* @returns {string} 이니셜
|
||||||
*/
|
*/
|
||||||
const formatLastSeen = (value) => {
|
const getMemberInitial = (member) => String(member?.username || member?.email || '?').slice(0, 1).toUpperCase()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 화면 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatDate = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
@@ -30,154 +51,147 @@ const formatLastSeen = (value) => {
|
|||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
return date.toLocaleString('ko-KR', {
|
const year = date.getFullYear()
|
||||||
year: 'numeric',
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
month: '2-digit',
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
return `${year}.${month}.${day}`
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 저장 진행 여부를 확인한다.
|
* 최근 활동 시각을 상대 시간으로 표시한다.
|
||||||
* @param {string} memberId - 회원 ID
|
* @param {string | null} value - ISO 시각
|
||||||
* @returns {boolean} 진행 여부
|
* @returns {string} 상대 시간
|
||||||
*/
|
*/
|
||||||
const isSavingRole = (memberId) => roleSavingIds.value.includes(memberId)
|
const formatRelativeTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
/**
|
return '최근 활동 없음'
|
||||||
* 회원 권한을 변경한다.
|
|
||||||
* @param {Object} member - 회원 정보
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const updateRole = async (member) => {
|
|
||||||
if (!member?.id || isSavingRole(member.id)) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
roleSavingIds.value = [...roleSavingIds.value, member.id]
|
const date = new Date(value)
|
||||||
roleMessage.value = ''
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '최근 활동 없음'
|
||||||
try {
|
|
||||||
const result = await $fetch(`/admin/api/members/${member.id}/role`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: {
|
|
||||||
role: member.roleCode
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
members.value = members.value.map((item) => {
|
|
||||||
if (item.id !== member.id) {
|
|
||||||
return item
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const diffMs = Date.now() - date.getTime()
|
||||||
...item,
|
const minute = 1000 * 60
|
||||||
role: result.role,
|
const hour = minute * 60
|
||||||
roleCode: result.roleCode,
|
const day = hour * 24
|
||||||
isAdmin: Boolean(result.isAdmin)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
roleMessage.value = '권한이 변경되었습니다.'
|
if (diffMs < minute) {
|
||||||
} catch (error) {
|
return '방금 전'
|
||||||
roleMessage.value = error?.data?.message || '권한 변경에 실패했습니다.'
|
|
||||||
} finally {
|
|
||||||
roleSavingIds.value = roleSavingIds.value.filter((id) => id !== member.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="admin-members min-h-screen bg-paper">
|
<section class="admin-members bg-paper p-6">
|
||||||
<div class="border-b border-line bg-paper px-6 py-5">
|
<div class="admin-members__header flex flex-col gap-5 border-b border-line pb-6 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<p class="text-xs font-semibold uppercase text-muted">Admin</p>
|
<div>
|
||||||
<h1 class="mt-2 text-2xl font-semibold text-ink">멤버</h1>
|
<h1 class="admin-members__title 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>
|
||||||
|
<button
|
||||||
|
class="admin-members__new h-11 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"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
멤버 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-6 py-5">
|
<div class="admin-members__table mt-7 overflow-x-auto">
|
||||||
<div class="overflow-x-auto rounded-[10px] border border-line bg-white">
|
<table class="admin-members__table-inner min-w-[920px] w-full border-collapse text-left text-sm">
|
||||||
<table class="min-w-full text-left text-sm">
|
<thead class="admin-members__table-head border-b border-line text-xs uppercase tracking-[0.02em] text-[#15171a]">
|
||||||
<thead class="bg-[#f7f7f5] text-xs uppercase text-muted">
|
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2.5">닉네임</th>
|
<th class="admin-members__cell w-[32%] py-4 pr-5 font-semibold">{{ memberCountLabel }}</th>
|
||||||
<th class="px-3 py-2.5">권한</th>
|
<th class="admin-members__cell w-[15%] px-5 py-4 font-semibold">상태</th>
|
||||||
<th class="px-3 py-2.5">이메일</th>
|
<th class="admin-members__cell w-[16%] px-5 py-4 font-semibold">댓글 작성</th>
|
||||||
<th class="px-3 py-2.5">최근 활동</th>
|
<th class="admin-members__cell w-[17%] px-5 py-4 font-semibold">접속 IP</th>
|
||||||
<th class="px-3 py-2.5">접속 IP</th>
|
<th class="admin-members__cell w-[20%] py-4 pl-5 font-semibold">가입일</th>
|
||||||
<th class="px-3 py-2.5">활동 현황</th>
|
|
||||||
<th class="px-3 py-2.5 text-right">댓글 개수</th>
|
|
||||||
<th class="px-3 py-2.5 text-right">권한 변경</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="admin-members__table-body divide-y divide-line/70 bg-paper">
|
||||||
<tr v-for="member in members" :key="member.id" class="border-t border-line/70">
|
<tr v-for="member in filteredMembers" :key="member.id" class="admin-members__row align-middle transition-colors hover:bg-[#f7f8fa]">
|
||||||
<td class="px-3 py-3">
|
<td class="admin-members__cell py-5 pr-5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="admin-members__profile flex min-w-0 items-center gap-3">
|
||||||
<img
|
<img
|
||||||
v-if="member.avatarUrl"
|
v-if="member.avatarUrl"
|
||||||
:src="member.avatarUrl"
|
:src="member.avatarUrl"
|
||||||
:alt="member.username"
|
:alt="member.username"
|
||||||
class="h-7 w-7 rounded-full object-cover"
|
class="admin-members__avatar h-11 w-11 shrink-0 rounded-full object-cover"
|
||||||
>
|
>
|
||||||
<span v-else class="grid h-7 w-7 place-items-center rounded-full bg-[#efefec] text-xs font-semibold text-ink">
|
<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">
|
||||||
{{ (member.username || '?').slice(0, 1).toUpperCase() }}
|
{{ 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>
|
</span>
|
||||||
<span>{{ member.username }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3">
|
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
||||||
<span
|
<span
|
||||||
class="rounded-full border px-2 py-0.5 text-xs"
|
class="admin-members__status inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold"
|
||||||
:class="member.roleCode === 'owner'
|
:class="member.activityStatus === '활성'
|
||||||
? 'border-[#8a56ff]/35 text-[#8a56ff]'
|
? 'border-green-200 bg-green-50 text-green-700'
|
||||||
: member.roleCode === 'admin'
|
: 'border-line bg-white text-[#657080]'"
|
||||||
? 'border-[#ff4f2e]/30 text-[#ff4f2e]'
|
|
||||||
: 'border-line text-muted'"
|
|
||||||
>
|
>
|
||||||
{{ member.role || '멤버' }}
|
{{ member.activityStatus }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3 text-muted">{{ member.email }}</td>
|
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
||||||
<td class="px-3 py-3 text-muted">{{ formatLastSeen(member.lastSeenAt) }}</td>
|
<span class="admin-members__comments font-semibold text-[#15171a]">{{ member.commentCount }}</span>
|
||||||
<td class="px-3 py-3 text-muted">{{ member.lastSeenIp || '-' }}</td>
|
<span class="admin-members__comments-label ml-1 text-xs text-[#8c96a3]">개</span>
|
||||||
<td class="px-3 py-3">
|
|
||||||
<span class="rounded-full border border-line px-2 py-0.5 text-xs">{{ member.activityStatus }}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3 text-right font-semibold text-ink">{{ member.commentCount }}</td>
|
<td class="admin-members__cell px-5 py-5 text-sm text-[#657080]">
|
||||||
<td class="px-3 py-3">
|
{{ member.lastSeenIp || '-' }}
|
||||||
<div class="flex justify-end gap-2">
|
</td>
|
||||||
<select
|
<td class="admin-members__cell py-5 pl-5">
|
||||||
v-model="member.roleCode"
|
<span class="admin-members__created block text-sm text-[#15171a]">{{ formatDate(member.createdAt) }}</span>
|
||||||
class="rounded border border-line bg-white px-2 py-1 text-xs"
|
<span class="admin-members__last-seen mt-1 block text-sm text-[#9aa4b2]">{{ formatRelativeTime(member.lastSeenAt) }}</span>
|
||||||
:disabled="isSavingRole(member.id)"
|
|
||||||
>
|
|
||||||
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded border border-line px-2 py-1 text-xs font-semibold hover:bg-paper disabled:opacity-50"
|
|
||||||
:disabled="isSavingRole(member.id)"
|
|
||||||
@click="updateRole(member)"
|
|
||||||
>
|
|
||||||
{{ isSavingRole(member.id) ? '저장 중' : '저장' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="members.length === 0">
|
<tr v-if="filteredMembers.length === 0">
|
||||||
<td colspan="8" class="px-3 py-6 text-center text-sm text-muted">등록된 회원이 없습니다.</td>
|
<td colspan="5" class="admin-members__empty px-4 py-12 text-center text-sm text-muted">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="roleMessage" class="mt-3 text-xs text-muted">
|
|
||||||
{{ roleMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user