Merge branch 'dev' into main
This commit is contained in:
@@ -29,4 +29,18 @@ export const adminApi = {
|
||||
setBoardSetting(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/tags" class="menu-item sub">태그 관리</RouterLink>
|
||||
</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>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ const router = createRouter({
|
||||
path: '/users',
|
||||
name: 'users',
|
||||
component: () => import('../views/UsersView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/board',
|
||||
|
||||
+228
-145
@@ -1,222 +1,305 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/adminApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { users, loading, error } = storeToRefs(userStore)
|
||||
const auth = useAuthStore()
|
||||
const myId = computed(() => auth.user?.id)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const formError = ref(null)
|
||||
const members = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const keyword = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
userStore.fetchUsers()
|
||||
const filtered = computed(() => {
|
||||
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() {
|
||||
formError.value = null
|
||||
if (!form.name.trim() || !form.email.trim()) {
|
||||
formError.value = '이름과 이메일을 모두 입력하세요.'
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
function fmtDate(v) {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime()) ? v : `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await userStore.addUser({ name: form.name.trim(), email: form.email.trim() })
|
||||
form.name = ''
|
||||
form.email = ''
|
||||
members.value = await adminApi.members()
|
||||
} catch (e) {
|
||||
formError.value = e.response?.data?.message || e.message || '등록에 실패했습니다.'
|
||||
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(user) {
|
||||
if (!confirm(`'${user.name}' 사용자를 삭제하시겠습니까?`)) return
|
||||
async function changeRole(m, role) {
|
||||
if (role === m.role) return
|
||||
try {
|
||||
await userStore.removeUser(user.id)
|
||||
const updated = await adminApi.updateMemberRole(m.id, role)
|
||||
Object.assign(m, updated)
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.message || e.message || '삭제에 실패했습니다.')
|
||||
alert(e.response?.data?.message || '역할 변경 실패')
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleString()
|
||||
async function changeStatus(m, status) {
|
||||
if (status === m.status) return
|
||||
try {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<main class="users">
|
||||
<h1>사용자 관리</h1>
|
||||
<main class="members">
|
||||
<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="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<table v-if="users.length" class="user-table">
|
||||
<table v-else-if="filtered.length" class="member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>이름</th>
|
||||
<th>이메일</th>
|
||||
<th>생성일</th>
|
||||
<th></th>
|
||||
<th class="c-id">ID</th>
|
||||
<th>아이디 / 이름</th>
|
||||
<th class="c-email">이메일</th>
|
||||
<th class="c-role">역할</th>
|
||||
<th class="c-status">상태</th>
|
||||
<th class="c-date">가입일</th>
|
||||
<th class="c-act"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.name }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ formatDate(user.createdAt) }}</td>
|
||||
<td>
|
||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="handleRemove(user)" />
|
||||
<tr v-for="m in filtered" :key="m.id" :class="{ me: m.id === myId, suspended: m.status !== 'ACTIVE' }">
|
||||
<td class="c-id">{{ m.id }}</td>
|
||||
<td class="c-user">
|
||||
<div class="login">{{ m.loginId || '(소셜)' }}<span v-if="m.id === myId" class="badge-me">나</span></div>
|
||||
<div class="name">{{ m.name }}<span v-if="m.provider && m.provider !== 'LOCAL'" class="prov">{{ m.provider }}</span></div>
|
||||
</td>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else-if="!loading" class="msg">등록된 사용자가 없습니다.</p>
|
||||
<p v-else-if="!loading" class="msg">{{ keyword ? '검색 결과가 없습니다.' : '회원이 없습니다.' }}</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users {
|
||||
max-width: 800px;
|
||||
.members {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
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 {
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0 0.5rem;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
h1 {
|
||||
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%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.user-table th,
|
||||
.user-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
.member-table th,
|
||||
.member-table td {
|
||||
padding: 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: 0.5rem 0;
|
||||
.c-id {
|
||||
width: 48px;
|
||||
}
|
||||
.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);
|
||||
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 {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* 모바일: 2줄 카드 */
|
||||
@media (max-width: 768px) {
|
||||
.users {
|
||||
padding: 1rem;
|
||||
.members {
|
||||
max-width: 100%;
|
||||
}
|
||||
.user-table thead {
|
||||
.member-table thead {
|
||||
display: none;
|
||||
}
|
||||
.user-table,
|
||||
.user-table tbody,
|
||||
.user-table tr,
|
||||
.user-table td {
|
||||
.member-table,
|
||||
.member-table tbody,
|
||||
.member-table tr,
|
||||
.member-table td {
|
||||
display: block;
|
||||
}
|
||||
.user-table tr {
|
||||
.member-table tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 0.6rem;
|
||||
row-gap: 0.15rem;
|
||||
padding: 0.6rem 0.1rem;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.3rem;
|
||||
padding: 0.6rem 0.2rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.user-table td {
|
||||
.member-table td {
|
||||
border: 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: '#';
|
||||
}
|
||||
.user-table td:nth-child(2) {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.user-table td:nth-child(3) {
|
||||
width: 100%;
|
||||
opacity: 0.75;
|
||||
.c-id {
|
||||
opacity: 0.6;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.user-table td:nth-child(4) {
|
||||
display: none; /* 생성일은 모바일에서 숨김 */
|
||||
.c-email {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user