Merge branch 'dev'
Deploy / deploy (push) Failing after 13m35s

This commit is contained in:
ByungCheol
2026-06-28 11:08:46 +09:00
13 changed files with 314 additions and 33 deletions
+5 -1
View File
@@ -1,10 +1,14 @@
// Slim Budget 데스크톱(Electron) 메인 프로세스.
// 라이브 사이트(https://app.sblog.kr)를 직접 로드한다 → 프론트 변경이 자동 반영(재설치 불필요),
// 같은 출처라 CORS 우회도 불필요. 배포된 웹 게이트가 Electron 을 감지해 안내페이지 대신 전체 앱을 띄운다.
const { app, BrowserWindow, shell, session, ipcMain } = require('electron')
const { app, BrowserWindow, shell, session, ipcMain, Menu } = require('electron')
const fs = require('fs')
const path = require('path')
// 기본 애플리케이션 메뉴(File/Edit/View/Window) 제거 — 메인 창뿐 아니라
// 구글 로그인 팝업 등 자식 창에도 메뉴바가 안 붙도록 전역으로 비활성화.
Menu.setApplicationMenu(null)
// 네이티브 대화상자(alert/confirm) 제목은 app.getName() 을 쓴다 → package.json name('sb_pt') 대신 표기.
app.setName('돈돼지 가계부')
+9 -1
View File
@@ -36,12 +36,20 @@ function onUnauthorized() {
auth.clear()
ui.openLogin()
}
// http.js 의 403 PREMIUM_REQUIRED 처리 → 업그레이드 안내 화면으로
function onPremiumRequired() {
if (route.name !== 'upgrade') router.push({ name: 'upgrade' })
}
onMounted(() => {
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
window.addEventListener('auth:unauthorized', onUnauthorized)
window.addEventListener('premium:required', onPremiumRequired)
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
})
onUnmounted(() => window.removeEventListener('auth:unauthorized', onUnauthorized))
onUnmounted(() => {
window.removeEventListener('auth:unauthorized', onUnauthorized)
window.removeEventListener('premium:required', onPremiumRequired)
})
// 페이지 이동 시 모바일 사이드바 자동 닫힘
watch(() => route.fullPath, () => ui.closeSidebar())
+3
View File
@@ -62,6 +62,9 @@ export const adminApi = {
updateMemberRole(id, role) {
return http.put(`/admin/members/${id}/role`, { role })
},
updateMemberPlan(id, plan) {
return http.put(`/admin/members/${id}/plan`, { plan })
},
updateMemberStatus(id, status) {
return http.put(`/admin/members/${id}/status`, { status })
},
+3
View File
@@ -31,6 +31,9 @@ http.interceptors.response.use(
localStorage.removeItem('token')
localStorage.removeItem('auth_user')
window.dispatchEvent(new CustomEvent('auth:unauthorized'))
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
window.dispatchEvent(new CustomEvent('premium:required'))
}
return Promise.reject(error)
},
+6 -3
View File
@@ -10,6 +10,8 @@ const auth = useAuthStore()
const ui = useUiStore()
// 로그인 또는 둘러보기(데모)면 메뉴 노출. 데모에선 잠금 배지를 함께 표시.
const showMenu = computed(() => auth.isAuthenticated || demo.on)
// 유료 전용 메뉴 잠금 배지: 데모이거나, 로그인했지만 무료 회원일 때.
const lockPremium = computed(() => demo.on || (auth.isAuthenticated && !auth.isPremium))
// 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업.
const icons = {
@@ -45,11 +47,12 @@ const icons = {
<RouterLink to="/account/stats" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.stats" />
<span>통계</span>
<span v-if="demo.on" class="lock-badge">🔒</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
</RouterLink>
<RouterLink to="/account/recurrings" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" />
<span>고정 지출</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
</RouterLink>
<RouterLink to="/account/wallets" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
@@ -62,12 +65,12 @@ const icons = {
<RouterLink to="/account/budget" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.budget" />
<span>예산 설정</span>
<span v-if="demo.on" class="lock-badge">🔒</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
</RouterLink>
<RouterLink to="/account/tags" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.tags" />
<span>태그 관리</span>
<span v-if="demo.on" class="lock-badge">🔒</span>
<span v-if="lockPremium" class="lock-badge">🔒</span>
</RouterLink>
<!-- 게시판 영역 -->
+15 -4
View File
@@ -24,6 +24,13 @@ const router = createRouter({
name: 'demo-locked',
component: () => import('../views/DemoLockedView.vue'),
},
{
// 유료 멤버십 안내/업그레이드 (로그인 필요, 무료 회원도 접근 가능)
path: '/upgrade',
name: 'upgrade',
component: () => import('../views/UpgradeView.vue'),
meta: { requiresAuth: true },
},
{
path: '/users',
name: 'users',
@@ -85,13 +92,13 @@ const router = createRouter({
path: '/account/stats',
name: 'account-stats',
component: () => import('../views/account/AccountDashboardView.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, requiresPremium: true },
},
{
path: '/account/tags',
name: 'account-tags',
component: () => import('../views/account/AccountTagView.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, requiresPremium: true },
},
{
path: '/account/wallets',
@@ -109,13 +116,13 @@ const router = createRouter({
path: '/account/budget',
name: 'account-budget',
component: () => import('../views/account/BudgetView.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, requiresPremium: true },
},
{
path: '/account/recurrings',
name: 'account-recurrings',
component: () => import('../views/account/RecurringView.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, requiresPremium: true },
},
// 설정 (앱 하단 내비게이션 → 설정)
{
@@ -161,6 +168,10 @@ router.beforeEach((to, from) => {
if (to.meta.requiresAdmin && auth.user?.role !== 'ADMIN') {
return { name: 'home' }
}
// 유료 전용 페이지 — 무료 회원은 업그레이드 안내로
if (to.meta.requiresPremium && !auth.isPremium) {
return { name: 'upgrade' }
}
})
// 새 배포로 청크 해시가 바뀐 뒤, 캐시된 옛 index.html 이 삭제된 옛 청크를 부르면
+4 -1
View File
@@ -15,6 +15,9 @@ export const useAuthStore = defineStore('auth', () => {
const ready = ref(false)
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'ADMIN')
// 유료(PREMIUM) 멤버십 여부. 관리자는 항상 유료 기능 사용 가능.
const isPremium = computed(() => isAdmin.value || user.value?.plan === 'PREMIUM')
// localStorage 동기화 (http.js 가 동기로 토큰을 읽음)
function mirrorLocal() {
@@ -109,7 +112,7 @@ export const useAuthStore = defineStore('auth', () => {
await persist()
}
return { token, user, ready, isAuthenticated, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear }
return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear }
})
function safeParse(value) {
+8 -4
View File
@@ -113,10 +113,11 @@ async function load() {
loading.value = true
error.value = null
try {
// 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
const [sum, nw, status, list] = await Promise.all([
accountApi.summary({ year, month }),
accountApi.netWorth(),
accountApi.budgetStatus({ year, month }),
auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]),
accountApi.list({ year, month }),
])
summary.value = sum
@@ -204,12 +205,14 @@ onMounted(load)
</div>
</div>
<!-- 예산 대비 지출 -->
<!-- 예산 대비 지출 (유료 전용 무료 회원은 업그레이드 안내) -->
<div class="card budget-card">
<div class="card-head">
<span class="card-title">{{ month }} 예산 대비 지출</span>
<RouterLink to="/account/budget" class="more">예산 </RouterLink>
<RouterLink :to="auth.isPremium ? '/account/budget' : '/upgrade'" class="more">예산 </RouterLink>
</div>
<RouterLink v-if="!auth.isPremium" to="/upgrade" class="budget-empty">👑 예산 관리는 유료 멤버십 기능이에요 </RouterLink>
<template v-else>
<template v-if="budgetTotal > 0">
<div class="budget-bar">
<div
@@ -225,7 +228,8 @@ onMounted(load)
</span>
</div>
</template>
<RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 있어요 </RouterLink>
<RouterLink v-else to="/account/budget" class="budget-empty">예산을 설정하면 지출 진행률을 있어요 </RouterLink>
</template>
</div>
<!-- 일별 수입/지출 캘린더 -->
+171
View File
@@ -0,0 +1,171 @@
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const isPremium = computed(() => auth.isPremium)
// 무료 / 유료 기능 비교 (docs/plan-membership-tiers.md 기준)
const freeFeatures = [
'가계부 내역 기록 · 조회',
'계좌 관리 (개수 제한 없음)',
'자주 쓰는 내역',
'월별 수입/지출 요약',
'기본 분류',
'게시판 · 다크 모드',
]
const premiumFeatures = [
'전체 통계 (분류별·추이·자산)',
'예산 설정',
'고정 지출 자동 반영',
'영수증 OCR 인식',
'카드 알림 자동 인식',
'투자 · 자산 관리',
'태그 · 소분류 무제한',
'데이터 백업 / 복구 (엑셀)',
'광고 제거',
]
function goBack() {
if (window.history.length > 1) router.back()
else router.push('/account/entries')
}
</script>
<template>
<div class="upgrade">
<header class="up-head">
<div class="crown">👑</div>
<h1 v-if="isPremium">유료 멤버십 이용 </h1>
<h1 v-else>유료 멤버십으로 많은 기능을</h1>
<p v-if="isPremium" class="sub">모든 프리미엄 기능을 사용할 있습니다. 감사합니다!</p>
<p v-else class="sub">통계·예산·자동화·백업까지, 가계부를 200% 활용하세요.</p>
</header>
<div class="plans">
<section class="plan">
<h2>무료</h2>
<ul>
<li v-for="f in freeFeatures" :key="f"><span class="dot ok"></span>{{ f }}</li>
</ul>
</section>
<section class="plan premium">
<h2>유료 <span class="badge">PREMIUM</span></h2>
<ul>
<li v-for="f in premiumFeatures" :key="f"><span class="dot pro"></span>{{ f }}</li>
</ul>
</section>
</div>
<div class="cta">
<p v-if="!isPremium" class="notice">
결제 기능은 준비 중입니다. 먼저 사용해 보고 싶다면 관리자에게 문의해 주세요.
</p>
<button type="button" class="ghost" @click="goBack">돌아가기</button>
</div>
</div>
</template>
<style scoped>
.upgrade {
max-width: 760px;
margin: 0 auto;
}
.up-head {
text-align: center;
margin-bottom: 1.5rem;
}
.crown {
font-size: 2.4rem;
}
.up-head h1 {
font-size: 1.4rem;
margin: 0.4rem 0 0.3rem;
color: var(--color-heading);
}
.sub {
color: var(--color-text);
opacity: 0.8;
font-size: 0.95rem;
}
.plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.plan {
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1.1rem 1.2rem;
background: var(--color-background-soft);
}
.plan.premium {
border-color: hsla(40, 90%, 55%, 0.7);
background: hsla(40, 90%, 55%, 0.06);
}
.plan h2 {
font-size: 1.05rem;
margin: 0 0 0.8rem;
color: var(--color-heading);
}
.badge {
font-size: 0.65rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: hsla(40, 90%, 50%, 0.9);
color: #3a2a00;
vertical-align: middle;
}
.plan ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.plan li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.dot {
flex: none;
width: 18px;
text-align: center;
}
.dot.ok {
color: hsla(160, 100%, 37%, 1);
}
.dot.pro {
color: hsla(40, 90%, 50%, 1);
}
.cta {
margin-top: 1.6rem;
text-align: center;
}
.notice {
font-size: 0.88rem;
color: var(--color-text);
opacity: 0.85;
margin-bottom: 0.9rem;
}
.ghost {
padding: 0.55rem 1.4rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-weight: 600;
}
@media (max-width: 560px) {
.plans {
grid-template-columns: 1fr;
}
}
</style>
+31 -1
View File
@@ -79,6 +79,17 @@ async function changeRole(m, role) {
}
}
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 {
@@ -114,7 +125,7 @@ onMounted(() => {
<IconBtn icon="refresh" title="새로고침" size="sm" :disabled="loading" @click="load" />
</div>
</header>
<p class="hint">가입한 회원의 역할·상태를 관리합니다. (관리자 전용)</p>
<p class="hint">가입한 회원의 역할·멤버십·상태를 관리합니다. (관리자 전용)</p>
<!-- 회원가입 허용 토글 -->
<div class="signup-toggle" :class="{ off: !signupEnabled }">
@@ -139,6 +150,7 @@ onMounted(() => {
<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>
@@ -158,6 +170,16 @@ onMounted(() => {
<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"
@@ -289,6 +311,7 @@ h1 {
width: 48px;
}
.c-role,
.c-plan,
.c-status {
width: 90px;
}
@@ -347,6 +370,13 @@ h1 {
.status-sel.withdrawn {
color: #c0392b;
}
.plan-sel.premium {
color: #b8860b;
font-weight: 600;
}
.plan-sel.free {
color: var(--color-text);
}
.msg {
margin: 1rem 0;
}
+11 -2
View File
@@ -1,6 +1,7 @@
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { accountApi } from '@/api/accountApi'
import { useAuthStore } from '@/stores/auth'
import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
@@ -9,6 +10,8 @@ import { Capacitor } from '@capacitor/core'
// @capacitor/camera 는 네이티브에서만 동적 로드 (웹은 file input 사용 — 정적 import 시 모바일 웹 청크 로드 실패 유발)
const dialog = useDialog()
const auth = useAuthStore()
const isPremium = computed(() => auth.isPremium) // 태그·OCR 등 유료 기능 노출 제어
const now = new Date()
const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1)
@@ -329,6 +332,11 @@ function toggleTag(id) {
}
async function loadTagOptions() {
// 태그는 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지)
if (!isPremium.value) {
tagOptions.value = []
return
}
try {
tagOptions.value = await accountApi.tags()
} catch {
@@ -832,7 +840,7 @@ onMounted(async () => {
</div>
<!-- 카드 결제 알림 자동인식 권한 안내 (네이티브, 미허용 ) -->
<div v-if="notifNative && !notifEnabled" class="notif-banner">
<div v-if="isPremium && notifNative && !notifEnabled" class="notif-banner">
<span>💳 카드 결제 알림을 자동으로 가계부에 등록하려면 <b>알림 접근 권한</b> 필요합니다.</span>
<button type="button" class="notif-btn" @click="openNotifSettings">설정 열기</button>
</div>
@@ -959,6 +967,7 @@ onMounted(async () => {
/>
<div class="rb-actions">
<button
v-if="isPremium"
type="button" class="receipt-btn"
:disabled="submitting || ocrRunning" @click="pickReceipt"
>📷 영수증 스캔</button>
@@ -1074,7 +1083,7 @@ onMounted(async () => {
<label v-if="!isRepayment">금액<input v-model.number="form.amount" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>
<div v-if="!isRepayment" class="tag-field">
<div v-if="!isRepayment && isPremium" class="tag-field">
<span class="tag-label">태그</span>
<div class="tag-badges">
<button
+19 -1
View File
@@ -1,6 +1,6 @@
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
@@ -8,6 +8,8 @@ const router = useRouter()
const u = computed(() => auth.user || {})
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
const isPremium = computed(() => auth.isPremium)
const planLabel = computed(() => (isPremium.value ? '유료 멤버십' : '무료'))
function goEdit() {
router.push('/settings/account/edit')
@@ -30,6 +32,13 @@ function goEdit() {
<span class="k">권한</span>
<span class="v">{{ roleLabel }}</span>
</div>
<div class="row">
<span class="k">멤버십</span>
<span class="v">
<span :class="{ premium: isPremium }">{{ planLabel }}</span>
<RouterLink v-if="!isPremium" to="/upgrade" class="up-link">업그레이드 </RouterLink>
</span>
</div>
</section>
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
@@ -92,6 +101,15 @@ function goEdit() {
text-align: right;
word-break: break-all;
}
.v .premium {
color: #b8860b;
}
.up-link {
margin-left: 0.5rem;
font-size: 0.82rem;
font-weight: 600;
color: hsla(160, 100%, 37%, 1);
}
.primary-btn {
width: 100%;
margin-top: 1.25rem;
+29 -15
View File
@@ -1,13 +1,16 @@
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { Preferences } from '@capacitor/preferences'
import { useDialog } from '@/composables/dialog'
import { useUiStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth'
import { exportBackup, importBackup } from '@/utils/backup'
const dialog = useDialog()
const ui = useUiStore()
const auth = useAuthStore()
const isPremium = computed(() => auth.isPremium) // 데이터 백업/복구는 유료 전용
const exporting = ref(false)
async function doExport() {
@@ -143,23 +146,34 @@ async function clearAppData() {
</div>
</section>
<!-- 데이터 백업 -->
<!-- 데이터 백업 (유료 전용) -->
<section class="card">
<button type="button" class="row row-btn" :disabled="exporting" @click="doExport">
<template v-if="isPremium">
<button type="button" class="row row-btn" :disabled="exporting" @click="doExport">
<div class="row-main">
<span class="row-label">엑셀로 내보내기</span>
<span class="row-sub">가계부 전체 데이터를 .xlsx 파일로 저장</span>
</div>
<span class="row-value">{{ exporting ? '내보내는 중…' : '내보내기' }}</span>
</button>
<button type="button" class="row row-btn" :disabled="importing" @click="pickImportFile">
<div class="row-main">
<span class="row-label">엑셀에서 가져오기</span>
<span class="row-sub">백업 파일로 데이터 복구 (기존 데이터 덮어씀)</span>
</div>
<span class="row-value">{{ importing ? '복구 중…' : '가져오기' }}</span>
</button>
<input ref="fileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onImportFile" />
</template>
<RouterLink v-else to="/upgrade" class="row row-link">
<div class="row-main">
<span class="row-label">엑셀로 내보내기</span>
<span class="row-sub">가계부 전체 데이터를 .xlsx 파일로 저장</span>
<span class="row-label">데이터 백업 / 복구 👑</span>
<span class="row-sub">엑셀 내보내기·가져오기는 유료 멤버십 기능입니다</span>
</div>
<span class="row-value">{{ exporting ? '내보내는 중…' : '내보내기' }}</span>
</button>
<button type="button" class="row row-btn" :disabled="importing" @click="pickImportFile">
<div class="row-main">
<span class="row-label">엑셀에서 가져오기</span>
<span class="row-sub">백업 파일로 데이터 복구 (기존 데이터 덮어씀)</span>
</div>
<span class="row-value">{{ importing ? '복구 중…' : '가져오기' }}</span>
</button>
<input ref="fileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onImportFile" />
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</RouterLink>
</section>
<section class="card">