- http 인터셉터: 응답 없는 네트워크 오류(오프라인/타임아웃/서버다운) 시 net:error 이벤트 발생 (취소 요청 제외) - App.vue: navigator.onLine + online/offline + net:error 구독 → 상단 고정 배너로 안내(+새로고침). 서버 응답 없음은 5초 후 자동 숨김 - 폐쇄 테스트(다양한 네트워크 환경)에서 무반응 대신 명확한 피드백 제공 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+85
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { computed, 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'
|
||||
@@ -47,10 +47,37 @@ function onUnauthorized() {
|
||||
function onPremiumRequired() {
|
||||
if (route.name !== 'upgrade') router.push({ name: 'upgrade' })
|
||||
}
|
||||
|
||||
// ===== 네트워크 상태 배너 =====
|
||||
// 오프라인이거나(서버 연결 끊김) 서버 응답이 없을 때 상단에 안내 배너를 띄운다.
|
||||
const online = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||
const netTrouble = ref(false) // 온라인인데 서버 응답이 없는 경우(타임아웃/서버다운) 잠깐 표시
|
||||
let netTimer = null
|
||||
function onOnline() { online.value = true }
|
||||
function onOffline() { online.value = false }
|
||||
function onNetError() {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) { online.value = false; return }
|
||||
netTrouble.value = true
|
||||
clearTimeout(netTimer)
|
||||
netTimer = setTimeout(() => { netTrouble.value = false }, 5000)
|
||||
}
|
||||
const showNetBanner = computed(() => !online.value || netTrouble.value)
|
||||
const netBannerText = computed(() =>
|
||||
!online.value
|
||||
? '📡 인터넷 연결이 끊겼어요. 연결 상태를 확인해 주세요.'
|
||||
: '⚠️ 서버에 연결할 수 없어요. 잠시 후 다시 시도해 주세요.',
|
||||
)
|
||||
function retryReload() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized)
|
||||
window.addEventListener('premium:required', onPremiumRequired)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
window.addEventListener('net:error', onNetError)
|
||||
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
||||
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
|
||||
if (auth.isAuthenticated) {
|
||||
@@ -69,6 +96,10 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth:unauthorized', onUnauthorized)
|
||||
window.removeEventListener('premium:required', onPremiumRequired)
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
window.removeEventListener('net:error', onNetError)
|
||||
clearTimeout(netTimer)
|
||||
})
|
||||
|
||||
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
||||
@@ -84,6 +115,14 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
|
||||
<!-- 앱(Capacitor): 전체 기능 -->
|
||||
<template v-else>
|
||||
<!-- 네트워크 오류/오프라인 안내 배너 -->
|
||||
<Transition name="netbar">
|
||||
<div v-if="showNetBanner" class="net-banner" role="alert">
|
||||
<span class="net-text">{{ netBannerText }}</span>
|
||||
<button type="button" class="net-retry" @click="retryReload">새로고침</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed, 'has-ad': showAds }">
|
||||
<AppHeader class="layout-top" />
|
||||
<!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김(빈 칸 제거·중앙 정렬) -->
|
||||
@@ -147,6 +186,51 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
||||
background: hsla(160, 100%, 37%, 0.08);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* 네트워크 오류/오프라인 배너 — 화면 최상단 고정 */
|
||||
.net-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: calc(env(safe-area-inset-top, 0) + 0.5rem) 0.9rem 0.5rem;
|
||||
background: #c0392b;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.net-text {
|
||||
word-break: keep-all;
|
||||
}
|
||||
.net-retry {
|
||||
flex: none;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.net-retry:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.netbar-enter-active,
|
||||
.netbar-leave-active {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.netbar-enter-from,
|
||||
.netbar-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.demo-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
||||
@@ -34,6 +34,9 @@ http.interceptors.response.use(
|
||||
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
|
||||
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
|
||||
window.dispatchEvent(new CustomEvent('premium:required'))
|
||||
} else if (!error.response && error.code !== 'ERR_CANCELED') {
|
||||
// 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외.
|
||||
window.dispatchEvent(new CustomEvent('net:error'))
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user