Compare commits
2 Commits
48c3664136
...
b39190baa2
| Author | SHA1 | Date | |
|---|---|---|---|
| b39190baa2 | |||
| bd5f1afd47 |
+108
-1
@@ -1,5 +1,5 @@
|
||||
<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 AppHeader from '@/components/layout/AppHeader.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 AppLockScreen from '@/components/AppLockScreen.vue'
|
||||
import { appLock } from '@/composables/appLock'
|
||||
import { appError, reportFatal } from '@/composables/appError'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { reminders } from '@/native/reminders'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -79,6 +80,15 @@ function onVisibility() {
|
||||
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(() => {
|
||||
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized)
|
||||
@@ -86,6 +96,7 @@ onMounted(() => {
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
window.addEventListener('net:error', onNetError)
|
||||
window.addEventListener('app:toast', onToast)
|
||||
// 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음)
|
||||
if (appLock.state.enabled && appLock.hasSession()) appLock.lock()
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
@@ -110,8 +121,16 @@ onUnmounted(() => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
window.removeEventListener('net:error', onNetError)
|
||||
window.removeEventListener('app:toast', onToast)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
clearTimeout(netTimer)
|
||||
clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
// 자식 컴포넌트의 렌더/라이프사이클 오류를 잡아 흰 화면 대신 폴백 표시
|
||||
onErrorCaptured((err, _instance, info) => {
|
||||
reportFatal({ err, info })
|
||||
return false // 상위로 전파 중단
|
||||
})
|
||||
|
||||
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
||||
@@ -119,6 +138,17 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<RouterView v-if="isPublicLegal" />
|
||||
@@ -162,10 +192,16 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
|
||||
<LoginModal />
|
||||
<SignupModal />
|
||||
|
||||
<!-- 토스트 (뒤로가기 종료 안내 등) -->
|
||||
<Transition name="toast">
|
||||
<div v-if="toastMsg" class="app-toast" role="status">{{ toastMsg }}</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
|
||||
<AppDialog />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -246,6 +282,77 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
transform: translateY(-100%);
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
||||
+25
-6
@@ -1,17 +1,36 @@
|
||||
// Capacitor 네이티브(안드로이드) 통합. 웹에서는 no-op.
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { App as CapApp } from '@capacitor/app'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { appLock } from '@/composables/appLock'
|
||||
|
||||
export function setupCapacitor(router) {
|
||||
if (!Capacitor.isNativePlatform()) return
|
||||
|
||||
// 안드로이드 하드웨어 뒤로가기 → 라우터 뒤로, 더 못 가면 앱 종료
|
||||
const ui = useUiStore()
|
||||
let exitArmed = false
|
||||
let exitTimer = null
|
||||
|
||||
// 안드로이드 하드웨어 뒤로가기 우선순위:
|
||||
// 1) 잠금 화면이면 우회 불가 → 종료
|
||||
// 2) 열린 전역 오버레이(회원가입/로그인/비번/사이드바) 닫기
|
||||
// 3) 화면 뒤로
|
||||
// 4) 최상위에서 한 번 더 누르면 종료(실수 종료 방지)
|
||||
CapApp.addListener('backButton', ({ canGoBack }) => {
|
||||
if (canGoBack && window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
CapApp.exitApp()
|
||||
}
|
||||
if (appLock.state.locked) { CapApp.exitApp(); return }
|
||||
|
||||
if (ui.signupOpen) { ui.closeSignup(); return }
|
||||
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 등 스타일 분기)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import router from './router'
|
||||
import { setupCapacitor } from './capacitor'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useDialog } from './composables/dialog'
|
||||
import { reportFatal } from './composables/appError'
|
||||
import { initTheme } from './theme'
|
||||
|
||||
// 저장된 테마(라이트/다크/시스템) 즉시 적용 — 첫 렌더 깜빡임 방지
|
||||
@@ -22,6 +23,11 @@ window.alert = (message) => {
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 렌더/라이프사이클 중 처리되지 않은 오류 → 흰 화면 대신 폴백 UI 표시
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
reportFatal({ err, info })
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user