Files
sb-front/src/views/settings/SettingsView.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

327 lines
9.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
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() {
if (exporting.value) return
exporting.value = true
try {
await exportBackup()
} catch (e) {
window.alert(e?.message || '내보내기에 실패했습니다.')
} finally {
exporting.value = false
}
}
const importing = ref(false)
const fileInput = ref(null)
function pickImportFile() {
if (importing.value) return
fileInput.value?.click()
}
async function onImportFile(e) {
const file = e.target.files?.[0]
e.target.value = '' // 같은 파일 다시 선택 가능하게 초기화
if (!file) return
const ok = await dialog.confirm(
'가져오기를 하면 현재 가계부의 모든 데이터가 백업 파일 내용으로 교체됩니다.\n(투자 종목·매매내역은 백업에 없어 사라집니다)\n계속하시겠습니까?',
{ title: '데이터 가져오기', okText: '가져오기', danger: true },
)
if (!ok) return
importing.value = true
try {
await importBackup(file)
await dialog.alert('가져오기가 완료되었습니다. 화면을 새로고침합니다.')
window.location.reload()
} catch (err) {
window.alert(err?.response?.data?.message || err?.message || '가져오기에 실패했습니다.')
} finally {
importing.value = false
}
}
const THEMES = [
{ value: 'system', label: '시스템' },
{ value: 'light', label: '라이트' },
{ value: 'dark', label: '다크' },
]
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
const clearing = ref(false)
// PC(데스크톱) 전용: 창 크기(가로·세로 각각) 변경 (Electron preload 의 window.desktop 존재 시에만)
const isDesktop = typeof window !== 'undefined' && !!window.desktop
const WIDTHS = [900, 1024, 1280, 1366, 1600, 1920]
const HEIGHTS = [700, 800, 900, 1000, 1080, 1200]
// 한 축만 바꾸고 나머지 축은 현재 창 크기를 유지
async function setDim(dim, value) {
const v = Number(value)
if (!v) return
let cur = [1024, 768]
try {
cur = (await window.desktop?.getWindowSize?.()) || cur
} catch {
/* 무시 */
}
const w = dim === 'w' ? v : cur[0]
const h = dim === 'h' ? v : cur[1]
window.desktop?.setWindowSize(w, h)
}
async function clearAppData() {
if (clearing.value) return
const ok = await dialog.confirm(
'앱에 저장된 모든 데이터(로그인 정보·캐시)를 삭제합니다.\n삭제 후 로그아웃되며 첫 화면으로 이동합니다.\n계속하시겠습니까?',
{ title: 'App Data 삭제', okText: '삭제', danger: true },
)
if (!ok) return
clearing.value = true
try {
try { await Preferences.clear() } catch { /* 네이티브 저장소 없음 무시 */ }
try { localStorage.clear() } catch { /* 무시 */ }
try { sessionStorage.clear() } catch { /* 무시 */ }
try {
if (window.caches?.keys) {
const keys = await window.caches.keys()
await Promise.all(keys.map((k) => window.caches.delete(k)))
}
} catch { /* 무시 */ }
} finally {
// 초기화 상태로 첫 화면 재시작
window.location.replace('/')
}
}
</script>
<template>
<div class="settings">
<section class="card">
<div class="row">
<div class="row-main">
<span class="row-label">화면 테마</span>
<span class="row-sub">라이트 / 다크 모드 선택</span>
</div>
<div class="theme-seg">
<button
v-for="t in THEMES"
:key="t.value"
type="button"
class="seg-btn"
:class="{ active: ui.theme === t.value }"
@click="ui.setTheme(t.value)"
>{{ t.label }}</button>
</div>
</div>
</section>
<!-- PC(데스크톱) 전용: 크기 (가로·세로 각각) -->
<section v-if="isDesktop" class="card">
<div class="row">
<div class="row-main">
<span class="row-label"> 크기</span>
<span class="row-sub">가로·세로를 각각 선택</span>
</div>
<div class="size-selects">
<select class="res-select" title="가로(폭)" @change="setDim('w', $event.target.value)">
<option value="">가로</option>
<option v-for="w in WIDTHS" :key="w" :value="w">{{ w }}</option>
</select>
<span class="x">×</span>
<select class="res-select" title="세로(높이)" @change="setDim('h', $event.target.value)">
<option value="">세로</option>
<option v-for="h in HEIGHTS" :key="h" :value="h">{{ h }}</option>
</select>
</div>
</div>
</section>
<!-- 데이터 백업 (유료 전용) -->
<section class="card">
<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">엑셀 내보내기·가져오기는 유료 멤버십 기능입니다</span>
</div>
<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">
<RouterLink to="/settings/account" class="row row-link">
<div class="row-main">
<span class="row-label">계정정보</span>
<span class="row-sub">아이디·이름·이메일 확인 변경</span>
</div>
<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>
<div class="row">
<div class="row-main">
<span class="row-label"> 버전 정보</span>
</div>
<span class="row-value">v{{ appVersion }}</span>
</div>
<button type="button" class="row row-btn" :disabled="clearing" @click="clearAppData">
<div class="row-main">
<span class="row-label danger">App Data 삭제</span>
<span class="row-sub">저장된 로그인 정보·캐시를 모두 비웁니다</span>
</div>
<span class="row-value danger">{{ clearing ? '삭제 중…' : '삭제' }}</span>
</button>
</section>
<p class="copyright">© 2026 돈돼지 가계부. All rights reserved.</p>
</div>
</template>
<style scoped>
.settings {
max-width: 560px;
margin: 0 auto;
}
.page-title {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.card {
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.75rem;
padding: 1rem 1.1rem;
text-align: left;
background: transparent;
border: 0;
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
}
.row:last-child {
border-bottom: 0;
}
.row-link,
.row-btn {
cursor: pointer;
}
.row-link:hover,
.row-btn:hover:not(:disabled) {
background: var(--color-background-mute);
}
.row-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.row-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.row-label {
font-size: 0.98rem;
font-weight: 600;
}
.row-sub {
font-size: 0.78rem;
opacity: 0.6;
}
.row-value {
font-size: 0.9rem;
opacity: 0.8;
white-space: nowrap;
}
.danger {
color: #c0392b;
}
.theme-seg {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.size-selects {
display: flex;
align-items: center;
gap: 0.3rem;
flex: none;
}
.size-selects .x {
opacity: 0.5;
}
.res-select {
flex: none;
max-width: 90px;
}
.hidden-file {
display: none;
}
.seg-btn {
padding: 0.4rem 0.7rem;
border: 0;
border-left: 1px solid var(--color-border);
background: var(--color-background);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
}
.seg-btn:first-child {
border-left: 0;
}
.seg-btn.active {
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-weight: 600;
}
.chevron {
width: 18px;
height: 18px;
opacity: 0.5;
flex-shrink: 0;
}
.copyright {
margin-top: 1.5rem;
text-align: center;
font-size: 0.78rem;
opacity: 0.5;
}
</style>