- 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>
|
<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 { 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'
|
||||||
@@ -47,10 +47,37 @@ function onUnauthorized() {
|
|||||||
function onPremiumRequired() {
|
function onPremiumRequired() {
|
||||||
if (route.name !== 'upgrade') router.push({ name: 'upgrade' })
|
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(() => {
|
onMounted(() => {
|
||||||
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
||||||
window.addEventListener('auth:unauthorized', onUnauthorized)
|
window.addEventListener('auth:unauthorized', onUnauthorized)
|
||||||
window.addEventListener('premium:required', onPremiumRequired)
|
window.addEventListener('premium:required', onPremiumRequired)
|
||||||
|
window.addEventListener('online', onOnline)
|
||||||
|
window.addEventListener('offline', onOffline)
|
||||||
|
window.addEventListener('net:error', onNetError)
|
||||||
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
||||||
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
|
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
@@ -69,6 +96,10 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('auth:unauthorized', onUnauthorized)
|
window.removeEventListener('auth:unauthorized', onUnauthorized)
|
||||||
window.removeEventListener('premium:required', onPremiumRequired)
|
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): 전체 기능 -->
|
<!-- 앱(Capacitor): 전체 기능 -->
|
||||||
<template v-else>
|
<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 }">
|
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed, 'has-ad': showAds }">
|
||||||
<AppHeader class="layout-top" />
|
<AppHeader class="layout-top" />
|
||||||
<!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김(빈 칸 제거·중앙 정렬) -->
|
<!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김(빈 칸 제거·중앙 정렬) -->
|
||||||
@@ -147,6 +186,51 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
|||||||
background: hsla(160, 100%, 37%, 0.08);
|
background: hsla(160, 100%, 37%, 0.08);
|
||||||
font-size: 0.88rem;
|
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 {
|
.demo-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ http.interceptors.response.use(
|
|||||||
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
|
} else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') {
|
||||||
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
|
// 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내
|
||||||
window.dispatchEvent(new CustomEvent('premium:required'))
|
window.dispatchEvent(new CustomEvent('premium:required'))
|
||||||
|
} else if (!error.response && error.code !== 'ERR_CANCELED') {
|
||||||
|
// 응답 자체가 없음 = 네트워크 오류(오프라인/타임아웃/서버 연결 불가). 취소 요청은 제외.
|
||||||
|
window.dispatchEvent(new CustomEvent('net:error'))
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user