feat: 오프라인·네트워크 오류 안내 배너 추가
CI / build (push) Failing after 13m9s

- 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:
ByungCheol
2026-06-29 08:33:20 +09:00
parent cbe469552d
commit 3f9b0daf53
2 changed files with 88 additions and 1 deletions
+85 -1
View File
@@ -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;
+3
View File
@@ -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)
}, },