Files
sb-front/src/views/UsersView.vue
T
ByungCheol 9861ba90ae
CI / build (push) Failing after 15m5s
feat: 멤버십(무료/유료) 게이팅 — 프론트
- auth 스토어 isPremium/isAdmin 추가 (plan===PREMIUM 또는 관리자)
- 사이드바: 통계·고정지출·예산·태그 메뉴에 무료 회원 잠금 배지(🔒)
- 라우트 가드: 유료 전용 화면(requiresPremium) → 무료 회원은 /upgrade
- UpgradeView: 무료/유료 기능 비교 + 업그레이드 안내
- http 인터셉터: 403 PREMIUM_REQUIRED → premium:required 이벤트 → 업그레이드 화면
- 무료 화면의 유료 API 호출 차단(홈 예산·내역 태그·OCR·카드알림 게이팅)
- 회원관리: 멤버십 컬럼 + 무료/유료 토글(관리자)
- 설정: 데이터 백업/복구 유료 게이팅, 계정정보에 멤버십 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:02:44 +09:00

439 lines
11 KiB
Vue

<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'
const auth = useAuthStore()
const dialog = useDialog()
const myId = computed(() => auth.user?.id)
const members = ref([])
const loading = ref(false)
const error = ref(null)
const keyword = ref('')
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),
)
})
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 {
members.value = await adminApi.members()
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} 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()
}
}
async function changePlan(m, plan) {
if (plan === m.plan) return
try {
const updated = await adminApi.updateMemberPlan(m.id, plan)
Object.assign(m, updated)
} catch (e) {
alert(e.response?.data?.message || '멤버십 변경 실패')
await load()
}
}
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 (!(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 || '삭제 실패')
}
}
onMounted(() => {
load()
loadSignupSetting()
})
</script>
<template>
<main class="members">
<header class="head" style="justify-content: flex-end">
<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>
<!-- 회원가입 허용 토글 -->
<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>
<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>
<tr>
<th class="c-id">ID</th>
<th>아이디 / 이름</th>
<th class="c-email">이메일</th>
<th class="c-role">역할</th>
<th class="c-plan">멤버십</th>
<th class="c-status">상태</th>
<th class="c-date">가입일</th>
<th class="c-act"></th>
</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-plan">
<select
:value="m.plan || 'FREE'"
class="plan-sel" :class="(m.plan || 'FREE').toLowerCase()"
@change="changePlan(m, $event.target.value)"
>
<option value="FREE">무료</option>
<option value="PREMIUM">유료</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">{{ keyword ? '검색 결과가 없습니다.' : '회원이 없습니다.' }}</p>
</main>
</template>
<style scoped>
.members {
max-width: 900px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
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;
}
.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-plan,
.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;
}
.plan-sel.premium {
color: #b8860b;
font-weight: 600;
}
.plan-sel.free {
color: var(--color-text);
}
.msg {
margin: 1rem 0;
}
.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;
}
}
</style>