Merge branch 'dev' into main
This commit is contained in:
@@ -29,4 +29,18 @@ export const adminApi = {
|
|||||||
setBoardSetting(tagCategoryId) {
|
setBoardSetting(tagCategoryId) {
|
||||||
return http.put('/admin/board-setting', { tagCategoryId })
|
return http.put('/admin/board-setting', { tagCategoryId })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 회원 관리 (관리자)
|
||||||
|
members() {
|
||||||
|
return http.get('/admin/members')
|
||||||
|
},
|
||||||
|
updateMemberRole(id, role) {
|
||||||
|
return http.put(`/admin/members/${id}/role`, { role })
|
||||||
|
},
|
||||||
|
updateMemberStatus(id, status) {
|
||||||
|
return http.put(`/admin/members/${id}/status`, { status })
|
||||||
|
},
|
||||||
|
removeMember(id) {
|
||||||
|
return http.delete(`/admin/members/${id}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const inAccount = computed(() => route.path.startsWith('/account'))
|
|||||||
<RouterLink to="/account/budget" class="menu-item sub">예산 설정</RouterLink>
|
<RouterLink to="/account/budget" class="menu-item sub">예산 설정</RouterLink>
|
||||||
<RouterLink to="/account/tags" class="menu-item sub">태그 관리</RouterLink>
|
<RouterLink to="/account/tags" class="menu-item sub">태그 관리</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
<RouterLink v-if="auth.isAuthenticated" to="/users" class="menu-item">사용자 관리</RouterLink>
|
<RouterLink v-if="auth.user?.role === 'ADMIN'" to="/users" class="menu-item">회원 관리</RouterLink>
|
||||||
<RouterLink v-if="auth.user?.role === 'ADMIN'" to="/admin/tags" class="menu-item">태그 관리</RouterLink>
|
<RouterLink v-if="auth.user?.role === 'ADMIN'" to="/admin/tags" class="menu-item">태그 관리</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ const router = createRouter({
|
|||||||
path: '/users',
|
path: '/users',
|
||||||
name: 'users',
|
name: 'users',
|
||||||
component: () => import('../views/UsersView.vue'),
|
component: () => import('../views/UsersView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/board',
|
path: '/board',
|
||||||
|
|||||||
+228
-145
@@ -1,222 +1,305 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { adminApi } from '@/api/adminApi'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const auth = useAuthStore()
|
||||||
const { users, loading, error } = storeToRefs(userStore)
|
const myId = computed(() => auth.user?.id)
|
||||||
|
|
||||||
const form = reactive({
|
const members = ref([])
|
||||||
name: '',
|
const loading = ref(false)
|
||||||
email: '',
|
const error = ref(null)
|
||||||
})
|
const keyword = ref('')
|
||||||
const submitting = ref(false)
|
|
||||||
const formError = ref(null)
|
|
||||||
|
|
||||||
onMounted(() => {
|
const filtered = computed(() => {
|
||||||
userStore.fetchUsers()
|
const k = keyword.value.trim().toLowerCase()
|
||||||
|
if (!k) return members.value
|
||||||
|
return members.value.filter(
|
||||||
|
(m) =>
|
||||||
|
(m.loginId || '').toLowerCase().includes(k) ||
|
||||||
|
(m.name || '').toLowerCase().includes(k) ||
|
||||||
|
(m.email || '').toLowerCase().includes(k),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleSubmit() {
|
function fmtDate(v) {
|
||||||
formError.value = null
|
if (!v) return '-'
|
||||||
if (!form.name.trim() || !form.email.trim()) {
|
const d = new Date(v)
|
||||||
formError.value = '이름과 이메일을 모두 입력하세요.'
|
return Number.isNaN(d.getTime()) ? v : `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
|
||||||
return
|
}
|
||||||
}
|
|
||||||
submitting.value = true
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
try {
|
try {
|
||||||
await userStore.addUser({ name: form.name.trim(), email: form.email.trim() })
|
members.value = await adminApi.members()
|
||||||
form.name = ''
|
|
||||||
form.email = ''
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
formError.value = e.response?.data?.message || e.message || '등록에 실패했습니다.'
|
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(user) {
|
async function changeRole(m, role) {
|
||||||
if (!confirm(`'${user.name}' 사용자를 삭제하시겠습니까?`)) return
|
if (role === m.role) return
|
||||||
try {
|
try {
|
||||||
await userStore.removeUser(user.id)
|
const updated = await adminApi.updateMemberRole(m.id, role)
|
||||||
|
Object.assign(m, updated)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.response?.data?.message || e.message || '삭제에 실패했습니다.')
|
alert(e.response?.data?.message || '역할 변경 실패')
|
||||||
|
await load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value) {
|
async function changeStatus(m, status) {
|
||||||
if (!value) return '-'
|
if (status === m.status) return
|
||||||
const d = new Date(value)
|
try {
|
||||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleString()
|
const updated = await adminApi.updateMemberStatus(m.id, status)
|
||||||
|
Object.assign(m, updated)
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.message || '상태 변경 실패')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove(m) {
|
||||||
|
if (!confirm(`'${m.name}(${m.loginId})' 회원을 삭제할까요? 되돌릴 수 없습니다.`)) return
|
||||||
|
try {
|
||||||
|
await adminApi.removeMember(m.id)
|
||||||
|
members.value = members.value.filter((x) => x.id !== m.id)
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.message || '삭제 실패')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="users">
|
<main class="members">
|
||||||
<h1>사용자 관리</h1>
|
<header class="head">
|
||||||
|
<h1>회원 관리</h1>
|
||||||
|
<div class="head-actions">
|
||||||
|
<input v-model="keyword" type="search" class="search" placeholder="아이디·이름·이메일 검색" />
|
||||||
|
<IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="load" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p class="hint">가입한 회원의 역할·상태를 관리합니다. (관리자 전용)</p>
|
||||||
|
|
||||||
<form class="user-form" @submit.prevent="handleSubmit">
|
|
||||||
<input v-model="form.name" type="text" placeholder="이름" :disabled="submitting" />
|
|
||||||
<input v-model="form.email" type="email" placeholder="이메일" :disabled="submitting" />
|
|
||||||
<IconBtn icon="plus" title="추가" variant="primary" type="submit" :disabled="submitting" />
|
|
||||||
</form>
|
|
||||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
|
||||||
|
|
||||||
<div class="status">
|
|
||||||
<IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="userStore.fetchUsers()" />
|
|
||||||
<span v-if="loading">불러오는 중...</span>
|
|
||||||
<span v-else>총 {{ users.length }}명</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="error" class="msg error">{{ error }}</p>
|
<p v-if="error" class="msg error">{{ error }}</p>
|
||||||
|
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||||
|
|
||||||
<table v-if="users.length" class="user-table">
|
<table v-else-if="filtered.length" class="member-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th class="c-id">ID</th>
|
||||||
<th>이름</th>
|
<th>아이디 / 이름</th>
|
||||||
<th>이메일</th>
|
<th class="c-email">이메일</th>
|
||||||
<th>생성일</th>
|
<th class="c-role">역할</th>
|
||||||
<th></th>
|
<th class="c-status">상태</th>
|
||||||
|
<th class="c-date">가입일</th>
|
||||||
|
<th class="c-act"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="user in users" :key="user.id">
|
<tr v-for="m in filtered" :key="m.id" :class="{ me: m.id === myId, suspended: m.status !== 'ACTIVE' }">
|
||||||
<td>{{ user.id }}</td>
|
<td class="c-id">{{ m.id }}</td>
|
||||||
<td>{{ user.name }}</td>
|
<td class="c-user">
|
||||||
<td>{{ user.email }}</td>
|
<div class="login">{{ m.loginId || '(소셜)' }}<span v-if="m.id === myId" class="badge-me">나</span></div>
|
||||||
<td>{{ formatDate(user.createdAt) }}</td>
|
<div class="name">{{ m.name }}<span v-if="m.provider && m.provider !== 'LOCAL'" class="prov">{{ m.provider }}</span></div>
|
||||||
<td>
|
</td>
|
||||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="handleRemove(user)" />
|
<td class="c-email">{{ m.email || '-' }}</td>
|
||||||
|
<td class="c-role">
|
||||||
|
<select :value="m.role" :disabled="m.id === myId" @change="changeRole(m, $event.target.value)">
|
||||||
|
<option value="USER">USER</option>
|
||||||
|
<option value="ADMIN">ADMIN</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="c-status">
|
||||||
|
<select
|
||||||
|
:value="m.status" :disabled="m.id === myId"
|
||||||
|
class="status-sel" :class="m.status.toLowerCase()"
|
||||||
|
@change="changeStatus(m, $event.target.value)"
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">활성</option>
|
||||||
|
<option value="SUSPENDED">정지</option>
|
||||||
|
<option value="WITHDRAWN">탈퇴</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="c-date">{{ fmtDate(m.createdAt) }}</td>
|
||||||
|
<td class="c-act">
|
||||||
|
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" :disabled="m.id === myId" @click="remove(m)" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-else-if="!loading" class="msg">등록된 사용자가 없습니다.</p>
|
<p v-else-if="!loading" class="msg">{{ keyword ? '검색 결과가 없습니다.' : '회원이 없습니다.' }}</p>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.users {
|
.members {
|
||||||
max-width: 800px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
.head {
|
||||||
h1 {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-form input {
|
|
||||||
flex: 1 1 180px;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--color-background-soft);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--color-background-soft);
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.danger,
|
|
||||||
button.danger:hover {
|
|
||||||
border-color: #c0392b;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
justify-content: space-between;
|
||||||
margin: 1rem 0 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
h1 {
|
||||||
.user-table {
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
color: var(--color-text);
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
.member-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin-top: 0.5rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
.member-table th,
|
||||||
.user-table th,
|
.member-table td {
|
||||||
.user-table td {
|
padding: 0.5rem 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
.c-id {
|
||||||
.msg {
|
width: 48px;
|
||||||
margin: 0.5rem 0;
|
}
|
||||||
|
.c-role,
|
||||||
|
.c-status {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
.c-date {
|
||||||
|
width: 100px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.c-act {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
.member-table tr.me {
|
||||||
|
background: hsla(160, 100%, 37%, 0.06);
|
||||||
|
}
|
||||||
|
.member-table tr.suspended .name,
|
||||||
|
.member-table tr.suspended .login {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.login {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-me {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
border: 1px solid hsla(160, 100%, 37%, 1);
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.02rem 0.3rem;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.prov {
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.member-table select {
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.member-table select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.status-sel.active {
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.status-sel.suspended {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
.status-sel.withdrawn {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg.error {
|
.msg.error {
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 모바일: 2줄 카드 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.users {
|
.members {
|
||||||
padding: 1rem;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.user-table thead {
|
.member-table thead {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.user-table,
|
.member-table,
|
||||||
.user-table tbody,
|
.member-table tbody,
|
||||||
.user-table tr,
|
.member-table tr,
|
||||||
.user-table td {
|
.member-table td {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.user-table tr {
|
.member-table tr {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
column-gap: 0.6rem;
|
column-gap: 0.5rem;
|
||||||
row-gap: 0.15rem;
|
row-gap: 0.3rem;
|
||||||
padding: 0.6rem 0.1rem;
|
padding: 0.6rem 0.2rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.user-table td {
|
.member-table td {
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.85rem;
|
width: auto;
|
||||||
}
|
}
|
||||||
.user-table td:nth-child(1)::before {
|
.c-user {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
.c-id::before {
|
||||||
content: '#';
|
content: '#';
|
||||||
}
|
}
|
||||||
.user-table td:nth-child(2) {
|
.c-id {
|
||||||
font-weight: 600;
|
opacity: 0.6;
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.user-table td:nth-child(3) {
|
|
||||||
width: 100%;
|
|
||||||
opacity: 0.75;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.user-table td:nth-child(4) {
|
.c-email {
|
||||||
display: none; /* 생성일은 모바일에서 숨김 */
|
flex: 1 1 100%;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
.user-table td:nth-child(5) {
|
.c-date {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.c-act {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user