오류 정지 중 가입 턴을 고정하고 화면 이동 실패 복구 지원

This commit is contained in:
2026-09-12 02:02:31 +00:00
parent 5041fc365e
commit 21d10d5dfc
15 changed files with 448 additions and 26 deletions
+2
View File
@@ -3,6 +3,7 @@ import { useClockDisplayRefresh } from './composables/useClockDisplayRefresh';
import { RouterView } from 'vue-router';
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
import GameNavigationNotice from './components/ui/GameNavigationNotice.vue';
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
useDeploymentVersionNotice();
@@ -13,6 +14,7 @@ useClockDisplayRefresh();
<RouterView />
<GameServerConnectionNotice />
<GameFeedbackLayer />
<GameNavigationNotice />
</template>
<style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { routeNavigation } from '../../utils/routeNavigation';
</script>
<template>
<aside
v-if="routeNavigation.state !== 'idle'"
class="game-navigation-notice"
role="status"
aria-live="polite"
data-testid="game-navigation-notice"
>
<span v-if="routeNavigation.state === 'loading'">화면을 여는 중입니다.</span>
<template v-else>
<span v-if="routeNavigation.state === 'failed'">화면을 불러오지 못했습니다.</span>
<span v-else>화면을 여는 시간이 걸리고 있습니다.</span>
<a :href="routeNavigation.href"> 페이지 다시 열기</a>
</template>
</aside>
</template>
<style scoped>
.game-navigation-notice {
position: fixed;
z-index: 2050;
bottom: max(16px, env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
width: max-content;
max-width: calc(100vw - 24px);
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px 16px;
padding: 12px 16px;
border: 1px solid #8a7765;
border-radius: 6px;
background: #251b15;
color: #fff;
}
.game-navigation-notice a {
color: #ffd59a;
text-decoration: underline;
}
</style>
+3
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
import { useSessionStore } from '../stores/session';
import { trpc } from '../utils/trpc';
import { installRouteNavigation } from '../utils/routeNavigation';
const MainView = () => import('../views/MainView.vue');
const PublicView = () => import('../views/PublicView.vue');
@@ -389,6 +390,8 @@ const router = createRouter({
routes,
});
installRouteNavigation(router);
router.beforeEach(async (to) => {
const session = useSessionStore();
@@ -0,0 +1,35 @@
import { reactive } from 'vue';
import type { Router } from 'vue-router';
export const routeNavigation = reactive({ href: '', state: 'idle' as 'idle' | 'loading' | 'slow' | 'failed' });
export const installRouteNavigation = (router: Router): void => {
let pendingPath = '';
let visibleTimer: ReturnType<typeof setTimeout> | undefined;
let slowTimer: ReturnType<typeof setTimeout> | undefined;
const clearTimers = () => {
clearTimeout(visibleTimer);
clearTimeout(slowTimer);
};
router.beforeEach((to) => {
clearTimers();
pendingPath = to.fullPath;
routeNavigation.href = router.resolve(to).href;
routeNavigation.state = 'idle';
visibleTimer = setTimeout(() => (routeNavigation.state = 'loading'), 350);
slowTimer = setTimeout(() => (routeNavigation.state = 'slow'), 10_000);
});
router.afterEach((to) => {
if (to.fullPath !== pendingPath) return;
clearTimers();
routeNavigation.state = 'idle';
pendingPath = '';
});
router.onError((_error, to) => {
if (to.fullPath !== pendingPath) return;
clearTimers();
// 실패한 dynamic import는 같은 탭에서 캐시된다. RouterLink 재클릭 대신
// 원래 목적지의 문서를 새로 받아 모듈 캐시와 세션 초기화를 다시 시작한다.
routeNavigation.state = 'failed';
});
};