Files
sb-front/src/views/settings/SettingsView.vue
T
ByungCheol 127551da12
CI / build (push) Failing after 14m29s
feat: 로컬 알림 — 가계부 기록 리마인더 + 구독 만료 임박 (앱 전용)
- @capacitor/local-notifications 추가, src/native/reminders.js
- 매일 설정 시각(기본 21시) 가계부 미기록 시 알림(오늘 기록하면 오늘자 제외, 7일 롤링)
- 구독 해지 상태 만료 3일 전 알림
- 설정에 알림 받기 토글 + 시간 선택(앱에서만), 권한 요청
- 앱 시작/홈/내역에서 스케줄 보정

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

450 lines
14 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, onMounted, 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'
import { reminders } from '@/native/reminders'
const dialog = useDialog()
const ui = useUiStore()
const auth = useAuthStore()
const isPremium = computed(() => auth.isPremium) // 데이터 백업/복구는 유료 전용
// ===== 알림(로컬) 설정 — 앱에서만 =====
const notifSupported = reminders.isNative()
const notifEnabled = ref(false)
const notifHour = ref(21)
const notifSaving = ref(false)
function subFromUser() {
return { premium: auth.isPremium, autoRenew: !!auth.user?.planAutoRenew, expiresAt: auth.user?.planExpiresAt }
}
onMounted(async () => {
if (!notifSupported) return
const s = await reminders.getSettings()
notifEnabled.value = s.enabled
notifHour.value = s.hour
})
async function toggleNotif() {
if (notifSaving.value) return
const next = !notifEnabled.value
notifSaving.value = true
try {
if (next) {
const ok = await reminders.ensurePermission()
if (!ok) {
await dialog.alert('알림 권한이 꺼져 있어요. 기기 설정에서 알림을 허용해 주세요.')
return
}
}
notifEnabled.value = next
await reminders.setSettings({ enabled: next, hour: notifHour.value })
if (next) {
await reminders.scheduleEntryReminder(false)
await reminders.scheduleExpiryReminder(subFromUser())
} else {
await reminders.cancelAll()
}
} finally {
notifSaving.value = false
}
}
async function changeNotifHour(v) {
notifHour.value = Number(v)
await reminders.setSettings({ enabled: notifEnabled.value, hour: notifHour.value })
if (notifEnabled.value) await reminders.scheduleEntryReminder(false)
}
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>
<!-- 알림 ( 전용 로컬 알림) -->
<section v-if="notifSupported" class="card">
<div class="row">
<div class="row-main">
<span class="row-label">알림 받기</span>
<span class="row-sub">가계부 기록 리마인더 · 구독 만료 안내</span>
</div>
<button
type="button" class="switch" :class="{ on: notifEnabled }"
:disabled="notifSaving" role="switch" :aria-checked="notifEnabled"
@click="toggleNotif"
><span class="knob"></span></button>
</div>
<div v-if="notifEnabled" class="row">
<div class="row-main">
<span class="row-label">리마인더 시간</span>
<span class="row-sub">오늘 기록이 없으면 시각에 알려드려요</span>
</div>
<select class="res-select" :value="notifHour" @change="changeNotifHour($event.target.value)">
<option v-for="h in 24" :key="h - 1" :value="h - 1">{{ String(h - 1).padStart(2, '0') }}:00</option>
</select>
</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>
<RouterLink to="/privacy" class="row row-link">
<div class="row-main">
<span class="row-label">개인정보처리방침</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>
<RouterLink to="/terms" class="row row-link">
<div class="row-main">
<span class="row-label">이용약관</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;
}
.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);
}
.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>