feat: 웹 안내페이지(앱전용)·회원가입 제한/봇차단·카드 인식 보정
- 웹(브라우저)은 안내 페이지만, 실제 이용은 앱(Capacitor). 개발모드는 예외 - 회원가입: 약관동의, 관리자 제한 토글, 진입부터 차단, 허니팟 - 카드 알림: pending 건 계좌종류 '카드' 기본선택(현금 X), 확인실패 시 HTTP 상태 표시 - 네이티브 알림필터: '카드' 키워드 제거+광고성 표현 차단 - docs/release-2026-06-06.md 정리 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -49,11 +49,18 @@ public class CardNotifListenerService extends NotificationListenerService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 결제 알림 후보 판별: 금액(####원) + 승인/결제/카드 키워드 */
|
||||
/**
|
||||
* 결제 알림 후보 판별: 금액(####원) + 결제/승인/출금 키워드.
|
||||
* '카드'는 광고에도 흔해 제외. 강한 승인신호(승인/출금/취소/환불) 없이 광고성 표현이 있으면 제외.
|
||||
* (1차 필터 — 최종 판별은 백엔드 파서)
|
||||
*/
|
||||
private boolean looksLikePayment(String s) {
|
||||
boolean money = s.matches("(?s).*\\d[\\d,]{2,}\\s*원.*");
|
||||
boolean kw = s.contains("승인") || s.contains("결제") || s.contains("카드");
|
||||
return money && kw;
|
||||
boolean kw = s.contains("승인") || s.contains("결제") || s.contains("출금");
|
||||
if (!money || !kw) return false;
|
||||
boolean strong = s.contains("승인") || s.contains("출금") || s.contains("취소") || s.contains("환불");
|
||||
boolean adLike = s.matches("(?s).*(이벤트|광고|혜택|쿠폰|할인|적립|포인트|캐시백|마일리지|리워드|당첨|응모|추첨|프로모션|배너|무료|증정|모집).*");
|
||||
return strong || !adLike;
|
||||
}
|
||||
|
||||
private void post(String pkg, String title, String text, String token) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Slim Budget — 변경 내역 (2026-06-06 배포)
|
||||
|
||||
이번 작업 세션에서 추가·수정한 기능 정리. (프론트 `sb_pt`, 백엔드 `sb_bt`, 안드로이드 앱 포함)
|
||||
|
||||
---
|
||||
|
||||
## 1. 홈 대시보드
|
||||
- **일별 수입·지출 캘린더** 추가 (예산 대비 지출 아래). 날짜에 마우스 오버/탭 시 그 날의 내역 목록 표시.
|
||||
|
||||
## 2. 투자 / 계좌
|
||||
- **시세 자동조회**: 종목코드(6자리)로 네이버 금융에서 현재가 자동 갱신. 투자 탭 진입·포트폴리오 펼침 시 자동, "↻ 시세 갱신" 버튼도 제공.
|
||||
- **퇴직연금·연금 등 평가액 직접입력형 계좌**: 종목 없이 현재 평가액만 입력해 관리(누적 적립원금 대비 손익% 표시).
|
||||
- 보유 종목 표기 2줄 정리(수량 / 평단·현재가).
|
||||
- **계좌번호 암호화**: DB에 AES-256-GCM으로 저장(키는 서버 `.env` `ACCOUNT_CRYPTO_KEY`). 화면에서는 **끝 4자리만 노출 + 👁 전체보기 토글**로 마스킹.
|
||||
|
||||
## 3. 가계부 내역 / 고정 지출
|
||||
- "정기 거래" → **"고정 지출"** 명칭 변경, 월간/년간 분리·카테고리 소계/합계.
|
||||
- 내역·고정지출 추가 시 계좌 종류 **라디오 + 셀렉트** 패턴, 저장 버튼 sticky.
|
||||
|
||||
## 4. 카드 결제 알림 자동인식 (버그 수정)
|
||||
- **현금으로 잘못 선택되던 문제**: 알림 자동인식(pending) 건은 매칭 실패해도 계좌 종류를 **'카드'로 기본 선택**. 백엔드 카드 매칭을 양방향 부분일치(`KB국민↔국민`) + 카드가 1개뿐이면 자동 매칭으로 개선.
|
||||
- **광고 푸시 오인식 차단**: 강한 승인신호(승인/출금/취소/환불) 없이 광고성 표현(이벤트·할인·적립·포인트·캐시백·쿠폰 등)이 있으면 결제로 보지 않음. 네이티브 1차 필터에서 '카드' 키워드 제거.
|
||||
- 확인(확정) 실패 시 **HTTP 상태코드 노출** + 즉시 UI 반영.
|
||||
|
||||
## 5. 게시판
|
||||
- "자유게시판" → **커뮤니티 / 짠테크 수다방 / 재테크 팁** 3개 게시판으로 분리(카테고리).
|
||||
- 사이드바를 **가계부 / 게시판 / 관리자 영역 구분선**으로 정리.
|
||||
- 글 번호 컬럼 미노출.
|
||||
|
||||
## 6. 회원가입 / 인증
|
||||
- **이용약관·개인정보 수집 동의**(필수 체크) 추가.
|
||||
- **관리자 회원가입 제한 토글**: 회원 관리 화면에서 신규 가입 on/off. 제한 시 가입 진입(랜딩·로그인·가입 팝업)부터 차단.
|
||||
- **봇 가입 차단**: 허니팟(숨김 필드) + IP 레이트리밋(Redis, 1시간 5회). *Cloudflare Turnstile은 앱 환경 제약으로 제외.*
|
||||
- **모바일 웹 새로고침 시 로그아웃 버그 수정**: 세션 복원을 localStorage 우선으로(Preferences 예외에 견고).
|
||||
- **세션 유실(주기적 로그아웃) 해결**: Redis AOF 활성화로 재시작에도 세션 영속(서버 설정).
|
||||
|
||||
## 7. 웹 접근 정책
|
||||
- **PC·모바일 웹은 안내 페이지만** 노출(앱 다운로드 안내). 실제 이용은 **앱(Capacitor)에서만**. 개발 모드(`npm run dev`)는 예외로 전체 앱 표시.
|
||||
|
||||
---
|
||||
|
||||
## 운영 반영 시 체크
|
||||
- 서버 `/opt/sb-backend/.env`
|
||||
- `ACCOUNT_CRYPTO_KEY` — 계좌번호 암호화를 켤 때만(미설정 시 평문, 깨지지 않음).
|
||||
- `app_setting` 테이블 — 배포 시 `member.sql` 초기화로 자동 생성(회원가입 허용 플래그).
|
||||
- Redis `appendonly yes` — 세션 영속(이미 적용).
|
||||
+17
-1
@@ -7,6 +7,8 @@ import AppFooter from '@/components/layout/AppFooter.vue'
|
||||
import LoginModal from '@/components/LoginModal.vue'
|
||||
import SignupModal from '@/components/SignupModal.vue'
|
||||
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
|
||||
import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
@@ -14,12 +16,20 @@ const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const route = useRoute()
|
||||
|
||||
// 앱(Capacitor 네이티브)에서만 전체 이용. 웹(브라우저)은 안내 페이지만 노출.
|
||||
// 단, 개발 모드(npm run dev)에서는 브라우저로도 전체 앱을 테스트할 수 있게 예외.
|
||||
const isApp = Capacitor.isNativePlatform() || import.meta.env.DEV
|
||||
|
||||
// http.js 의 401 처리에서 발생시키는 이벤트 → 세션 정리 후 로그인 팝업
|
||||
function onUnauthorized() {
|
||||
auth.clear()
|
||||
ui.openLogin()
|
||||
}
|
||||
onMounted(() => window.addEventListener('auth:unauthorized', onUnauthorized))
|
||||
onMounted(() => {
|
||||
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized)
|
||||
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener('auth:unauthorized', onUnauthorized))
|
||||
|
||||
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
||||
@@ -27,6 +37,11 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 웹(브라우저): 안내 페이지만 -->
|
||||
<WebOnlyNotice v-if="!isApp" />
|
||||
|
||||
<!-- 앱(Capacitor): 전체 기능 -->
|
||||
<template v-else>
|
||||
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen }">
|
||||
<AppHeader class="layout-top" />
|
||||
<AppSidebar class="layout-left" />
|
||||
@@ -40,6 +55,7 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
<LoginModal />
|
||||
<SignupModal />
|
||||
<ChangePasswordModal />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -30,6 +30,14 @@ export const adminApi = {
|
||||
return http.put('/admin/board-setting', { tagCategoryId })
|
||||
},
|
||||
|
||||
// 앱 설정 (관리자)
|
||||
getSignupSetting() {
|
||||
return http.get('/admin/signup-setting')
|
||||
},
|
||||
setSignupSetting(enabled) {
|
||||
return http.put('/admin/signup-setting', { enabled })
|
||||
},
|
||||
|
||||
// 회원 관리 (관리자)
|
||||
members() {
|
||||
return http.get('/admin/members')
|
||||
|
||||
@@ -5,6 +5,9 @@ export const authApi = {
|
||||
signup(payload) {
|
||||
return http.post('/auth/signup', payload)
|
||||
},
|
||||
signupEnabled() {
|
||||
return http.get('/auth/signup-enabled')
|
||||
},
|
||||
login(payload) {
|
||||
return http.post('/auth/login', payload)
|
||||
},
|
||||
|
||||
@@ -86,8 +86,11 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
</button>
|
||||
|
||||
<p class="links">
|
||||
<template v-if="ui.signupEnabled">
|
||||
계정이 없으신가요?
|
||||
<a href="#" @click.prevent="goSignup">회원가입</a>
|
||||
</template>
|
||||
<span v-else class="signup-off">회원가입이 제한되어 있습니다.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { TERMS_OF_SERVICE, PRIVACY_POLICY } from '@/constants/terms'
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const form = reactive({ loginId: '', password: '', passwordConfirm: '', name: '', email: '' })
|
||||
// website = 허니팟(숨김 필드, 봇이 채우면 서버에서 차단)
|
||||
const form = reactive({ loginId: '', password: '', passwordConfirm: '', name: '', email: '', website: '' })
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
@@ -30,7 +31,7 @@ watch(
|
||||
() => ui.signupOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
Object.assign(form, { loginId: '', password: '', passwordConfirm: '', name: '', email: '' })
|
||||
Object.assign(form, { loginId: '', password: '', passwordConfirm: '', name: '', email: '', website: '' })
|
||||
agree.terms = false
|
||||
agree.privacy = false
|
||||
openDoc.value = ''
|
||||
@@ -41,6 +42,10 @@ watch(
|
||||
|
||||
async function handleSignup() {
|
||||
error.value = null
|
||||
if (!ui.signupEnabled) {
|
||||
error.value = '현재 회원가입이 제한되어 있습니다.'
|
||||
return
|
||||
}
|
||||
if (!form.loginId || !form.password || !form.name) {
|
||||
error.value = '아이디, 비밀번호, 이름은 필수입니다.'
|
||||
return
|
||||
@@ -64,6 +69,7 @@ async function handleSignup() {
|
||||
password: form.password,
|
||||
name: form.name,
|
||||
email: form.email || null,
|
||||
website: form.website, // 허니팟(정상 사용자는 빈 값)
|
||||
})
|
||||
alert('회원가입이 완료되었습니다. 로그인해 주세요.')
|
||||
ui.openLogin() // 회원가입 닫고 로그인 팝업으로 전환
|
||||
@@ -89,12 +95,20 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
<button class="close" type="button" aria-label="닫기" @click="ui.closeSignup()">×</button>
|
||||
<h2>회원가입</h2>
|
||||
|
||||
<form class="form" @submit.prevent="handleSignup">
|
||||
<!-- 회원가입 제한 시: 폼 대신 안내 -->
|
||||
<div v-if="!ui.signupEnabled" class="signup-blocked">
|
||||
<p class="blk-title">🔒 현재 회원가입이 제한되어 있습니다.</p>
|
||||
<p class="blk-desc">관리자 설정에 따라 신규 회원가입을 받지 않고 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<form v-else class="form" @submit.prevent="handleSignup">
|
||||
<input v-model="form.loginId" type="text" placeholder="아이디 (4~50자)" autocomplete="username" :disabled="loading" />
|
||||
<input v-model="form.password" type="password" placeholder="비밀번호 (8자 이상)" autocomplete="new-password" :disabled="loading" />
|
||||
<input v-model="form.passwordConfirm" type="password" placeholder="비밀번호 확인" autocomplete="new-password" :disabled="loading" />
|
||||
<input v-model="form.name" type="text" placeholder="이름" :disabled="loading" />
|
||||
<input v-model="form.email" type="email" placeholder="이메일 (선택)" autocomplete="email" :disabled="loading" />
|
||||
<!-- 허니팟: 사람에겐 숨김, 봇이 채우면 차단 -->
|
||||
<input v-model="form.website" type="text" name="website" class="hp-field" tabindex="-1" autocomplete="off" aria-hidden="true" />
|
||||
|
||||
<!-- 약관 동의 -->
|
||||
<div class="agree">
|
||||
@@ -266,6 +280,29 @@ h2 {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* 허니팟: 화면 밖으로 숨김(스크린리더/봇 인식 위해 display:none 대신 off-screen) */
|
||||
.hp-field {
|
||||
position: absolute !important;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.signup-blocked {
|
||||
text-align: center;
|
||||
padding: 1.5rem 0.5rem 0.5rem;
|
||||
}
|
||||
.blk-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #c0392b;
|
||||
}
|
||||
.blk-desc {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.links {
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
// PC·모바일 웹에서 노출되는 안내 페이지. 실제 이용은 앱(Capacitor)에서만.
|
||||
// 앱 다운로드 링크는 준비되면 download() / href 에 연결.
|
||||
const downloadReady = false
|
||||
function download() {
|
||||
// TODO: 앱 다운로드 링크 연결 (Play 스토어 / 직접 APK URL)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="web-only">
|
||||
<div class="card">
|
||||
<h1 class="brand">Slim Budget</h1>
|
||||
<p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p>
|
||||
|
||||
<div class="notice">
|
||||
<p class="lead">📱 이 서비스는 <b>앱에서</b> 이용할 수 있어요.</p>
|
||||
<p class="sub">PC·모바일 웹에서는 안내만 제공됩니다. 앱을 설치한 뒤 로그인해 주세요.</p>
|
||||
</div>
|
||||
|
||||
<button type="button" class="dl-btn" :disabled="!downloadReady" @click="download">
|
||||
{{ downloadReady ? '앱 다운로드' : '앱 다운로드 (준비 중)' }}
|
||||
</button>
|
||||
|
||||
<ul class="features">
|
||||
<li><span class="f-icon">📒</span><b>간편한 가계부</b><span>수입·지출을 빠르게 기록</span></li>
|
||||
<li><span class="f-icon">🏦</span><b>자산·부채 관리</b><span>계좌·카드·대출·투자까지</span></li>
|
||||
<li><span class="f-icon">🎯</span><b>예산과 분석</b><span>예산 대비 지출·차트</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.web-only {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
radial-gradient(120% 120% at 50% 0%, hsla(160, 100%, 37%, 0.12), transparent 60%),
|
||||
var(--color-background);
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
padding: 2.4rem 1.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
.brand {
|
||||
font-size: 2rem;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.tagline {
|
||||
margin-top: 0.4rem;
|
||||
opacity: 0.75;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.notice {
|
||||
margin: 1.6rem 0 1.2rem;
|
||||
}
|
||||
.lead {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.86rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.dl-btn {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid hsla(160, 100%, 37%, 1);
|
||||
border-radius: 10px;
|
||||
background: hsla(160, 100%, 37%, 1);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dl-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1.8rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
.features li {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 0.7rem;
|
||||
align-items: center;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.features .f-icon {
|
||||
grid-row: 1 / 3;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.features b {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.features li > span:last-child {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
+15
-1
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { authApi } from '@/api/authApi'
|
||||
|
||||
// 전역 UI 상태 (로그인/회원가입 레이어 팝업 등)
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
@@ -7,6 +8,17 @@ export const useUiStore = defineStore('ui', () => {
|
||||
const signupOpen = ref(false)
|
||||
const redirectPath = ref('')
|
||||
|
||||
// 회원가입 허용 여부(관리자 설정). 비로그인도 조회 가능한 공개 API.
|
||||
const signupEnabled = ref(true)
|
||||
async function loadSignupEnabled() {
|
||||
try {
|
||||
const res = await authApi.signupEnabled()
|
||||
signupEnabled.value = !!res.enabled
|
||||
} catch {
|
||||
signupEnabled.value = true // 조회 실패 시 기본 허용(서버가 최종 검증)
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일 사이드바 드로어
|
||||
const sidebarOpen = ref(false)
|
||||
function toggleSidebar() {
|
||||
@@ -36,10 +48,11 @@ export const useUiStore = defineStore('ui', () => {
|
||||
redirectPath.value = ''
|
||||
}
|
||||
|
||||
// 회원가입 팝업 (로그인 팝업은 닫고 전환)
|
||||
// 회원가입 팝업 (로그인 팝업은 닫고 전환). 열 때 최신 허용 여부 갱신.
|
||||
function openSignup() {
|
||||
loginOpen.value = false
|
||||
signupOpen.value = true
|
||||
loadSignupEnabled()
|
||||
}
|
||||
function closeSignup() {
|
||||
signupOpen.value = false
|
||||
@@ -47,6 +60,7 @@ export const useUiStore = defineStore('ui', () => {
|
||||
|
||||
return {
|
||||
loginOpen, signupOpen, redirectPath, openLogin, closeLogin, openSignup, closeSignup,
|
||||
signupEnabled, loadSignupEnabled,
|
||||
sidebarOpen, toggleSidebar, closeSidebar,
|
||||
passwordOpen, openPassword, closePassword,
|
||||
}
|
||||
|
||||
@@ -278,9 +278,9 @@ onMounted(load)
|
||||
<p class="tagline">슬림하게 관리하는 가계부 · 자산 · 예산</p>
|
||||
<div class="cta">
|
||||
<button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button>
|
||||
<button type="button" class="btn" @click="ui.openSignup()">회원가입</button>
|
||||
<button v-if="ui.signupEnabled" type="button" class="btn" @click="ui.openSignup()">회원가입</button>
|
||||
</div>
|
||||
<p class="cta-note">로그인하면 나의 가계부 요약을 볼 수 있어요.</p>
|
||||
<p class="cta-note">{{ ui.signupEnabled ? '로그인하면 나의 가계부 요약을 볼 수 있어요.' : '현재 회원가입이 제한되어 있습니다.' }}</p>
|
||||
</div>
|
||||
|
||||
<ul class="features">
|
||||
|
||||
+102
-1
@@ -41,6 +41,31 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
// 회원가입 허용 설정
|
||||
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 {
|
||||
@@ -73,7 +98,10 @@ async function remove(m) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadSignupSetting()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -87,6 +115,19 @@ onMounted(load)
|
||||
</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>
|
||||
|
||||
@@ -171,6 +212,66 @@ h1 {
|
||||
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;
|
||||
|
||||
@@ -39,10 +39,12 @@ async function loadPendingCount() {
|
||||
async function confirmRow(e) {
|
||||
try {
|
||||
await accountApi.confirmEntry(e.id, {})
|
||||
e.pending = false // UI 즉시 반영(목록 재로딩 전)
|
||||
await load()
|
||||
await loadPendingCount()
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.message || '확인 처리에 실패했습니다.')
|
||||
const st = err.response?.status
|
||||
alert(err.response?.data?.message || `확인 처리에 실패했습니다.${st ? ' (HTTP ' + st + ')' : ' (네트워크 오류)'}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +380,10 @@ function openEdit(e) {
|
||||
category: e.category || '',
|
||||
amount: e.amount,
|
||||
memo: e.memo || '',
|
||||
walletKind: e.walletId ? walletKindOf(e.walletId) : (e.type === 'TRANSFER' ? '' : 'CASH'),
|
||||
// 알림 자동인식(pending) 건은 카드 결제이므로 매칭 실패해도 '카드'로 기본 선택(현금 X)
|
||||
walletKind: e.walletId
|
||||
? walletKindOf(e.walletId)
|
||||
: (e.pending ? 'CARD' : (e.type === 'TRANSFER' ? '' : 'CASH')),
|
||||
walletId: e.walletId || '',
|
||||
toWalletKind: walletKindOf(e.toWalletId),
|
||||
toWalletId: e.toWalletId || '',
|
||||
|
||||
Reference in New Issue
Block a user