Merge branch 'dev'
Deploy / deploy (push) Successful in 36s

This commit is contained in:
ByungCheol
2026-06-29 09:09:03 +09:00
4 changed files with 155 additions and 7 deletions
+108 -1
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onErrorCaptured, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router' import { RouterView, useRoute, useRouter } from 'vue-router'
import AppHeader from '@/components/layout/AppHeader.vue' import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue' import AppSidebar from '@/components/layout/AppSidebar.vue'
@@ -11,6 +11,7 @@ import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
import AppDialog from '@/components/ui/AppDialog.vue' import AppDialog from '@/components/ui/AppDialog.vue'
import AppLockScreen from '@/components/AppLockScreen.vue' import AppLockScreen from '@/components/AppLockScreen.vue'
import { appLock } from '@/composables/appLock' import { appLock } from '@/composables/appLock'
import { appError, reportFatal } from '@/composables/appError'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { reminders } from '@/native/reminders' import { reminders } from '@/native/reminders'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -79,6 +80,15 @@ function onVisibility() {
if (document.hidden) appLock.lock() if (document.hidden) appLock.lock()
} }
// ===== 토스트 (뒤로가기 종료 안내 등) =====
const toastMsg = ref('')
let toastTimer = null
function onToast(e) {
toastMsg.value = e.detail || ''
clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toastMsg.value = '' }, 1800)
}
onMounted(() => { onMounted(() => {
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략 if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
window.addEventListener('auth:unauthorized', onUnauthorized) window.addEventListener('auth:unauthorized', onUnauthorized)
@@ -86,6 +96,7 @@ onMounted(() => {
window.addEventListener('online', onOnline) window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline) window.addEventListener('offline', onOffline)
window.addEventListener('net:error', onNetError) window.addEventListener('net:error', onNetError)
window.addEventListener('app:toast', onToast)
// 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음) // 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음)
if (appLock.state.enabled && appLock.hasSession()) appLock.lock() if (appLock.state.enabled && appLock.hasSession()) appLock.lock()
document.addEventListener('visibilitychange', onVisibility) document.addEventListener('visibilitychange', onVisibility)
@@ -110,8 +121,16 @@ onUnmounted(() => {
window.removeEventListener('online', onOnline) window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline) window.removeEventListener('offline', onOffline)
window.removeEventListener('net:error', onNetError) window.removeEventListener('net:error', onNetError)
window.removeEventListener('app:toast', onToast)
document.removeEventListener('visibilitychange', onVisibility) document.removeEventListener('visibilitychange', onVisibility)
clearTimeout(netTimer) clearTimeout(netTimer)
clearTimeout(toastTimer)
})
// 자식 컴포넌트의 렌더/라이프사이클 오류를 잡아 흰 화면 대신 폴백 표시
onErrorCaptured((err, _instance, info) => {
reportFatal({ err, info })
return false // 상위로 전파 중단
}) })
// 페이지 이동 시 모바일 사이드바 자동 닫힘 // 페이지 이동 시 모바일 사이드바 자동 닫힘
@@ -119,6 +138,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
</script> </script>
<template> <template>
<!-- 치명적 오류 폴백 ( 화면 방지) -->
<div v-if="appError.hasError" class="fatal-screen">
<div class="fatal-inner">
<div class="fatal-emoji">😵</div>
<h1 class="fatal-title">문제가 발생했어요</h1>
<p class="fatal-sub">일시적인 오류일 있어요. 새로고침 다시 시도해 주세요.</p>
<button type="button" class="fatal-btn" @click="retryReload">새로고침</button>
</div>
</div>
<template v-else>
<!-- (브라우저): 공개 법적 고지는 노출, 외엔 안내 페이지 --> <!-- (브라우저): 공개 법적 고지는 노출, 외엔 안내 페이지 -->
<template v-if="!isApp"> <template v-if="!isApp">
<RouterView v-if="isPublicLegal" /> <RouterView v-if="isPublicLegal" />
@@ -162,11 +192,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
<LoginModal /> <LoginModal />
<SignupModal /> <SignupModal />
<!-- 토스트 (뒤로가기 종료 안내 ) -->
<Transition name="toast">
<div v-if="toastMsg" class="app-toast" role="status">{{ toastMsg }}</div>
</Transition>
</template> </template>
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) --> <!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
<AppDialog /> <AppDialog />
</template> </template>
</template>
<style scoped> <style scoped>
.layout { .layout {
@@ -246,6 +282,77 @@ watch(() => route.fullPath, () => ui.closeSidebar())
transform: translateY(-100%); transform: translateY(-100%);
opacity: 0; opacity: 0;
} }
/* 토스트 */
.app-toast {
position: fixed;
left: 50%;
bottom: calc(72px + env(safe-area-inset-bottom, 0));
transform: translateX(-50%);
z-index: 2600;
max-width: 80vw;
padding: 0.6rem 1.1rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.82);
color: #fff;
font-size: 0.85rem;
white-space: nowrap;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
/* 치명적 오류 폴백 화면 */
.fatal-screen {
position: fixed;
inset: 0;
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: var(--color-background);
}
.fatal-inner {
text-align: center;
max-width: 360px;
}
.fatal-emoji {
font-size: 2.8rem;
}
.fatal-title {
margin-top: 0.6rem;
font-size: 1.2rem;
color: var(--color-heading);
}
.fatal-sub {
margin-top: 0.5rem;
font-size: 0.9rem;
opacity: 0.7;
line-height: 1.6;
word-break: keep-all;
}
.fatal-btn {
margin-top: 1.4rem;
padding: 0.7rem 1.8rem;
border: 0;
border-radius: 10px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
}
.fatal-btn:hover {
background: hsla(160, 100%, 32%, 1);
}
.demo-actions { .demo-actions {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
+25 -6
View File
@@ -1,17 +1,36 @@
// Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op. // Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op.
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { App as CapApp } from '@capacitor/app' import { App as CapApp } from '@capacitor/app'
import { useUiStore } from '@/stores/ui'
import { appLock } from '@/composables/appLock'
export function setupCapacitor(router) { export function setupCapacitor(router) {
if (!Capacitor.isNativePlatform()) return if (!Capacitor.isNativePlatform()) return
// 안드로이드 하드웨어 뒤로가기 → 라우터 뒤로, 더 못 가면 앱 종료 const ui = useUiStore()
let exitArmed = false
let exitTimer = null
// 안드로이드 하드웨어 뒤로가기 우선순위:
// 1) 잠금 화면이면 우회 불가 → 종료
// 2) 열린 전역 오버레이(회원가입/로그인/비번/사이드바) 닫기
// 3) 화면 뒤로
// 4) 최상위에서 한 번 더 누르면 종료(실수 종료 방지)
CapApp.addListener('backButton', ({ canGoBack }) => { CapApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack && window.history.length > 1) { if (appLock.state.locked) { CapApp.exitApp(); return }
router.back()
} else { if (ui.signupOpen) { ui.closeSignup(); return }
CapApp.exitApp() if (ui.loginOpen) { ui.closeLogin(); return }
} if (ui.passwordOpen) { ui.closePassword(); return }
if (ui.sidebarOpen) { ui.closeSidebar(); return }
if (canGoBack && window.history.length > 1) { router.back(); return }
if (exitArmed) { CapApp.exitApp(); return }
exitArmed = true
window.dispatchEvent(new CustomEvent('app:toast', { detail: '한 번 더 누르면 종료됩니다' }))
clearTimeout(exitTimer)
exitTimer = setTimeout(() => { exitArmed = false }, 2000)
}) })
// 네이티브 표시용 클래스 (safe-area 등 스타일 분기) // 네이티브 표시용 클래스 (safe-area 등 스타일 분기)
+16
View File
@@ -0,0 +1,16 @@
import { reactive } from 'vue'
// 전역 치명적 오류 상태. 컴포넌트 트리 밖에서 관리해, 렌더 오류로 화면이
// 깨져도(흰 화면) App.vue 가 폴백 UI 를 대신 렌더할 수 있게 한다.
export const appError = reactive({ hasError: false })
export function reportFatal(err) {
// 콘솔에는 원본을 남기고, 사용자에겐 폴백 화면을 보여준다.
// eslint-disable-next-line no-console
console.error('[fatal]', err)
appError.hasError = true
}
export function clearFatal() {
appError.hasError = false
}
+6
View File
@@ -8,6 +8,7 @@ import router from './router'
import { setupCapacitor } from './capacitor' import { setupCapacitor } from './capacitor'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { useDialog } from './composables/dialog' import { useDialog } from './composables/dialog'
import { reportFatal } from './composables/appError'
import { initTheme } from './theme' import { initTheme } from './theme'
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지 // 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
@@ -22,6 +23,11 @@ window.alert = (message) => {
const app = createApp(App) const app = createApp(App)
// 렌더/라이프사이클 중 처리되지 않은 오류 → 흰 화면 대신 폴백 UI 표시
app.config.errorHandler = (err, instance, info) => {
reportFatal({ err, info })
}
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)