Merge branch 'dev' into main
Deploy / deploy (push) Failing after 12m32s
CI / build (push) Failing after 13m44s

This commit is contained in:
ByungCheol
2026-05-31 19:02:08 +09:00
4 changed files with 244 additions and 147 deletions
+14
View File
@@ -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}`)
},
} }
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
<form class="user-form" @submit.prevent="handleSubmit"> <div class="head-actions">
<input v-model="form.name" type="text" placeholder="이름" :disabled="submitting" /> <input v-model="keyword" type="search" class="search" placeholder="아이디·이름·이메일 검색" />
<input v-model="form.email" type="email" placeholder="이메일" :disabled="submitting" /> <IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="load" />
<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> </div>
<p v-if="error" class="msg error">{{ error }}</p> </header>
<p class="hint">가입한 회원의 역할·상태를 관리합니다. (관리자 전용)</p>
<table v-if="users.length" class="user-table"> <p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<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;
} }
} }