+5
-1
@@ -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
@@ -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())
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 일별 수입/지출 캘린더 -->
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user