Files
sb-front/src/views/UsersView.vue
T

410 lines
9.8 KiB
Vue
Raw Normal View History

2026-05-30 21:18:13 +09:00
<script setup>
import { computed, onMounted, ref } from 'vue'
import { adminApi } from '@/api/adminApi'
import { useAuthStore } from '@/stores/auth'
import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue'
2026-05-30 21:18:13 +09:00
const auth = useAuthStore()
const dialog = useDialog()
const myId = computed(() => auth.user?.id)
2026-05-30 21:18:13 +09:00
const members = ref([])
const loading = ref(false)
const error = ref(null)
const keyword = ref('')
2026-05-30 21:18:13 +09:00
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),
)
2026-05-30 21:18:13 +09:00
})
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
2026-05-30 21:18:13 +09:00
try {
members.value = await adminApi.members()
2026-05-30 21:18:13 +09:00
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
2026-05-30 21:18:13 +09:00
} finally {
loading.value = false
}
}
// 회원가입 허용 설정
const signupEnabled = ref(true)
const signupSaving = ref(false)
async function loadSignupSetting() {
try {
const res = await adminApi.getSignupSetting()
signupEnabled.value = !!res.enabled
} catch {
/* 무시 — 기본 허용 표시 */
}
}
async function toggleSignup() {
if (signupSaving.value) return
const next = !signupEnabled.value
signupSaving.value = true
try {
const res = await adminApi.setSignupSetting(next)
signupEnabled.value = !!res.enabled
} catch (e) {
alert(e.response?.data?.message || '설정 변경에 실패했습니다.')
} finally {
signupSaving.value = false
}
}
async function changeRole(m, role) {
if (role === m.role) return
try {
const updated = await adminApi.updateMemberRole(m.id, role)
Object.assign(m, updated)
} catch (e) {
alert(e.response?.data?.message || '역할 변경 실패')
await load()
2026-05-30 21:18:13 +09:00
}
}
async function changeStatus(m, status) {
if (status === m.status) return
2026-05-30 21:18:13 +09:00
try {
const updated = await adminApi.updateMemberStatus(m.id, status)
Object.assign(m, updated)
2026-05-30 21:18:13 +09:00
} catch (e) {
alert(e.response?.data?.message || '상태 변경 실패')
await load()
2026-05-30 21:18:13 +09:00
}
}
async function remove(m) {
if (!(await dialog.confirm(`'${m.name}(${m.loginId})' 회원을 삭제할까요?\n되돌릴 수 없습니다.`, { title: '회원 삭제', danger: true }))) return
try {
await adminApi.removeMember(m.id)
members.value = members.value.filter((x) => x.id !== m.id)
} catch (e) {
alert(e.response?.data?.message || '삭제 실패')
}
2026-05-30 21:18:13 +09:00
}
onMounted(() => {
load()
loadSignupSetting()
})
2026-05-30 21:18:13 +09:00
</script>
<template>
<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>
2026-05-30 21:18:13 +09:00
<!-- 회원가입 허용 토글 -->
<div class="signup-toggle" :class="{ off: !signupEnabled }">
<div class="st-text">
<strong>회원가입 {{ signupEnabled ? '허용' : '제한' }}</strong>
<span class="st-desc">{{ signupEnabled ? '누구나 회원가입할 수 있습니다.' : '신규 회원가입이 차단됩니다.' }}</span>
</div>
<button
type="button" class="switch" :class="{ on: signupEnabled }"
:disabled="signupSaving" role="switch" :aria-checked="signupEnabled"
@click="toggleSignup"
><span class="knob"></span></button>
</div>
2026-05-30 21:18:13 +09:00
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
2026-05-30 21:18:13 +09:00
<table v-else-if="filtered.length" class="member-table">
2026-05-30 21:18:13 +09:00
<thead>
<tr>
<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>
2026-05-30 21:18:13 +09:00
</tr>
</thead>
<tbody>
<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)" />
2026-05-30 21:18:13 +09:00
</td>
</tr>
</tbody>
</table>
<p v-else-if="!loading" class="msg">{{ keyword ? '검색 결과가 없습니다.' : '회원이 없습니다.' }}</p>
2026-05-30 21:18:13 +09:00
</main>
</template>
<style scoped>
.members {
max-width: 900px;
2026-05-30 21:18:13 +09:00
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
2026-05-30 21:18:13 +09:00
h1 {
font-size: 1.5rem;
}
.head-actions {
2026-05-30 21:18:13 +09:00
display: flex;
align-items: center;
2026-05-30 21:18:13 +09:00
gap: 0.5rem;
}
.search {
padding: 0.45rem 0.7rem;
2026-05-30 21:18:13 +09:00
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
min-width: 12rem;
2026-05-30 21:18:13 +09:00
}
.hint {
font-size: 0.85rem;
opacity: 0.7;
margin: 0.5rem 0 1rem;
}
.signup-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 1rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-background-soft);
}
.signup-toggle.off {
border-color: #c0392b;
background: rgba(192, 57, 43, 0.06);
}
.st-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.st-text strong {
font-size: 0.95rem;
}
.st-desc {
font-size: 0.8rem;
opacity: 0.7;
}
.switch {
flex-shrink: 0;
width: 48px;
height: 28px;
border: 0;
border-radius: 999px;
background: #b0b0b0;
position: relative;
cursor: pointer;
transition: background 0.18s;
padding: 0;
}
.switch.on {
background: hsla(160, 100%, 37%, 1);
}
.switch:disabled {
opacity: 0.6;
cursor: default;
}
.switch .knob {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
transition: transform 0.18s;
}
.switch.on .knob {
transform: translateX(20px);
}
.member-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.member-table th,
.member-table td {
padding: 0.5rem 0.5rem;
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: middle;
}
.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;
2026-05-30 21:18:13 +09:00
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.82rem;
2026-05-30 21:18:13 +09:00
}
.member-table select:disabled {
opacity: 0.5;
2026-05-30 21:18:13 +09:00
cursor: not-allowed;
}
.status-sel.active {
color: #2e7d32;
2026-05-30 21:18:13 +09:00
}
.status-sel.suspended {
color: #e67e22;
2026-05-30 21:18:13 +09:00
}
.status-sel.withdrawn {
color: #c0392b;
2026-05-30 21:18:13 +09:00
}
.msg {
margin: 1rem 0;
2026-05-30 21:18:13 +09:00
}
.msg.error {
color: #c0392b;
}
/* 모바일: 2줄 카드 */
@media (max-width: 768px) {
.members {
max-width: 100%;
}
.member-table thead {
display: none;
}
.member-table,
.member-table tbody,
.member-table tr,
.member-table td {
display: block;
}
.member-table tr {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 0.5rem;
row-gap: 0.3rem;
padding: 0.6rem 0.2rem;
border-bottom: 1px solid var(--color-border);
}
.member-table td {
border: 0;
padding: 0;
width: auto;
}
.c-user {
flex: 1 1 100%;
}
.c-id::before {
content: '#';
}
.c-id {
opacity: 0.6;
font-size: 0.8rem;
}
.c-email {
flex: 1 1 100%;
font-size: 0.8rem;
opacity: 0.75;
}
.c-date {
font-size: 0.78rem;
opacity: 0.65;
}
.c-act {
margin-left: auto;
}
}
2026-05-30 21:18:13 +09:00
</style>