merge: add Gateway action result toasts

This commit is contained in:
2026-08-11 12:04:36 +00:00
12 changed files with 388 additions and 25 deletions
@@ -224,7 +224,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
await page.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che');
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구');
await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click();
await expect(page.getByText('특수 접근 자격을 부여했습니다.')).toBeVisible();
await expect(page.getByText('특수 접근 자격을 부여했습니다.').first()).toBeVisible();
await expect(page.getByText(/RECOVERY · che/)).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true });
@@ -232,7 +232,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
await gracePanel.locator('input[type="datetime-local"]').fill('2026-08-20T00:00');
await page.getByRole('button', { name: '유예 연장', exact: true }).click();
await expect(page.getByText('OAuth 유예 연장 완료')).toBeVisible();
await expect(page.getByText('OAuth 유예 연장 완료').first()).toBeVisible();
await page.getByRole('button', { name: /탈퇴 · 이력/ }).click();
await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible();
@@ -246,7 +246,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('탈퇴 요청 접수');
await page.getByLabel('탈퇴 전 보존 일수').fill('30');
await deletionButton.click();
await expect(page.getByText(/탈퇴 예약 완료/)).toBeVisible();
await expect(page.getByText(/탈퇴 예약 완료/).first()).toBeVisible();
expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true);
expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true);
@@ -311,7 +311,7 @@ test('renders a failed terminal outcome without calling it applied', async ({ pa
const failed = page.getByText('FAILED · ACCELERATE 15분');
await expect(failed).toBeVisible();
await expect(page.getByText('DB 시간 조정 실패')).toBeVisible();
await expect(page.getByText('DB 시간 조정 실패').first()).toBeVisible();
expect(await failed.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)');
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
});
@@ -98,7 +98,7 @@ test('admin resets and opens hwe, then two users create generals and reach main'
const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first();
const previousLatestOperation = await latestOperation.textContent();
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
await expect
.poll(() => latestOperation.textContent(), {
+1 -1
View File
@@ -118,7 +118,7 @@ test('keeps the lobby and every token when server logout fails', async ({ page }
await page.locator('#btn_logout').click();
await expect(page).toHaveURL(/\/gateway\/lobby$/);
await expect(page.getByRole('alert')).toContainText('로그아웃 서버가 응답하지 않습니다.');
await expect(page.getByTestId('action-toast')).toContainText('로그아웃 서버가 응답하지 않습니다.');
await expect
.poll(() =>
page.evaluate(() => ({
@@ -42,6 +42,7 @@ type FixtureState = {
profileNavigationResolved?: boolean;
scenarioFailuresRemaining?: number;
resetDefaults?: Record<string, unknown>;
updateMetaFails?: boolean;
};
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
@@ -131,6 +132,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
await route.abort('failed');
return;
}
if (names.includes('admin.profiles.updateMeta') && state.updateMetaFails) {
await route.abort('failed');
return;
}
const results = names.map((name) => {
if (route.request().method() === 'POST') {
state.requestBodies.push({ operation: name, body });
@@ -408,7 +413,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.getByTestId('request-reset').hover();
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('RESET');
const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
@@ -457,7 +462,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
);
await page.getByTestId('request-deploy').click();
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.')).toBeVisible();
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
@@ -505,7 +510,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
expect(request).toContain('"options":["develop","train"]');
});
test('edits server reset defaults through profile metadata settings', async ({ page }) => {
test('edits server reset defaults through profile metadata settings', async ({ page }, testInfo) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
@@ -513,10 +518,47 @@ test('edits server reset defaults through profile metadata settings', async ({ p
await page.getByText('서버 리셋 기본 옵션').click();
await page.getByTestId('meta-reset-turn-term').selectOption('10');
await page.getByTestId('meta-reset-npc-mode').selectOption('2');
await page.getByRole('button', { name: '메타 저장' }).click();
const validationToast = page.getByTestId('action-toast').filter({ hasText: '변경 사유를 입력하세요.' });
await expect(validationToast).toHaveAttribute('data-toast-kind', 'error');
await expect(validationToast).toHaveAttribute('role', 'alert');
await page.getByPlaceholder('변경 사유 (필수)').fill('set reset defaults');
await page.getByRole('button', { name: '메타 저장' }).click();
await expect(page.getByText('메타 저장 완료')).toBeVisible();
await expect(page.getByText('메타 저장 완료').first()).toBeVisible();
const successToast = page.getByTestId('action-toast').filter({ hasText: '메타 저장 완료' });
await expect(successToast).toHaveAttribute('data-toast-kind', 'success');
await expect(successToast).toHaveAttribute('role', 'status');
const toastGeometry = await successToast.evaluate((element) => {
const rect = element.getBoundingClientRect();
const viewport = element.parentElement?.parentElement;
return {
right: Math.round(window.innerWidth - rect.right),
width: Math.round(rect.width),
viewportPosition: viewport ? getComputedStyle(viewport).position : '',
};
});
expect(toastGeometry.right).toBeGreaterThanOrEqual(0);
expect(toastGeometry.width).toBeGreaterThan(250);
expect(toastGeometry.viewportPosition).toBe('fixed');
await page.screenshot({ path: testInfo.outputPath('meta-save-toast-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
const mobileToastGeometry = await successToast.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
left: Math.round(rect.left),
right: Math.round(window.innerWidth - rect.right),
bottom: Math.round(window.innerHeight - rect.bottom),
};
});
expect(mobileToastGeometry.left).toBeGreaterThanOrEqual(0);
expect(mobileToastGeometry.right).toBeGreaterThanOrEqual(0);
expect(mobileToastGeometry.bottom).toBeGreaterThanOrEqual(0);
expect(mobileToastGeometry.bottom).toBeLessThanOrEqual(20);
await page.screenshot({ path: testInfo.outputPath('meta-save-toast-mobile.png'), fullPage: true });
const request = JSON.stringify(
state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body
);
@@ -525,13 +567,35 @@ test('edits server reset defaults through profile metadata settings', async ({ p
expect(request).toContain('"npcMode":2');
});
test('shows a dismissible error toast when profile metadata persistence fails', async ({ page }, testInfo) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
updateMetaFails: true,
};
await installFixture(page, state);
await page.goto('admin/servers/che%3A2');
await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
await page.getByRole('button', { name: '메타 저장' }).click();
const errorToast = page.getByTestId('action-toast').filter({ hasText: '메타 저장 실패' });
await expect(errorToast).toBeVisible();
await expect(errorToast).toHaveAttribute('data-toast-kind', 'error');
await page.screenshot({ path: testInfo.outputPath('meta-save-toast-error.png'), fullPage: true });
await errorToast.getByRole('button', { name: '알림 닫기' }).click();
await expect(errorToast).toHaveCount(0);
});
test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
profileNavigationDelayMs: 1500,
profileNavigationDelayMs: 3000,
profileNavigationResolved: false,
};
await installFixture(page, state);
@@ -595,7 +659,7 @@ test('scenario-only operator resets the current version without Git or Gateway c
await expect(page.getByTestId('source-commit')).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
await page.getByTestId('request-reset').click();
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
await expect
.poll(() => state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset'))
.toBe(true);
@@ -618,7 +682,7 @@ test('controls gateway deployment and rollback through the external controller q
await page.getByTestId('gateway-source-ref').fill('release/2026-08');
await page.getByTestId('request-gateway-deploy').click();
await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/)).toBeVisible();
await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/).first()).toBeVisible();
await expect(page.getByTestId('gateway-release-table')).toContainText('DEPLOY');
await expect(page.getByTestId('gateway-release-log-panel')).toBeVisible();
await expect(page.getByTestId('gateway-release-log')).toContainText('Gateway 구성 요소를 빌드합니다.');
@@ -640,7 +704,7 @@ test('controls gateway deployment and rollback through the external controller q
state.gatewayOperations = [];
await page.getByTestId('refresh-operations').click();
await page.getByTestId('request-gateway-rollback').click();
await expect(page.getByText('Gateway rollback 작업을 등록했습니다.')).toBeVisible();
await expect(page.getByText('Gateway rollback 작업을 등록했습니다.').first()).toBeVisible();
expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayRollback')).toBe(true);
});
@@ -709,7 +773,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)');
await page.getByRole('button', { name: '재시도' }).click();
await expect(page.getByText('재시도 작업을 등록했습니다.')).toBeVisible();
await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
await expect(page.getByText('QUEUED', { exact: true })).toBeVisible();
await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2);
+2
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import ToastViewport from './components/ToastViewport.vue';
</script>
<template>
<RouterView />
<ToastViewport />
</template>
<style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { useToast, type ToastKind } from '../composables/useToast';
const { toasts, dismiss } = useToast();
const titleFor = (kind: ToastKind): string => {
if (kind === 'success') return '완료';
if (kind === 'error') return '처리 실패';
return '안내';
};
const iconFor = (kind: ToastKind): string => {
if (kind === 'success') return '✓';
if (kind === 'error') return '!';
return 'i';
};
</script>
<template>
<Teleport to="body">
<div class="toast-viewport" aria-label="작업 알림">
<TransitionGroup name="toast" tag="div" class="toast-stack">
<article
v-for="toast in toasts"
:key="toast.id"
class="toast-card"
:class="`toast-card--${toast.kind}`"
:role="toast.kind === 'error' ? 'alert' : 'status'"
:aria-live="toast.kind === 'error' ? 'assertive' : 'polite'"
data-testid="action-toast"
:data-toast-kind="toast.kind"
>
<span class="toast-icon" aria-hidden="true">{{ iconFor(toast.kind) }}</span>
<span class="toast-copy">
<strong>{{ titleFor(toast.kind) }}</strong>
<span>{{ toast.message }}</span>
</span>
<button type="button" class="toast-close" aria-label="알림 닫기" @click="dismiss(toast.id)">
×
</button>
</article>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-viewport {
position: fixed;
z-index: 1000;
top: max(1rem, env(safe-area-inset-top));
right: max(1rem, env(safe-area-inset-right));
width: min(25rem, calc(100vw - 2rem));
pointer-events: none;
}
.toast-stack {
display: grid;
gap: 0.625rem;
}
.toast-card {
display: grid;
grid-template-columns: 1.75rem minmax(0, 1fr) 2rem;
gap: 0.75rem;
align-items: start;
padding: 0.875rem;
color: #f4f4f5;
background: rgb(24 24 27 / 96%);
border: 1px solid #52525b;
border-left-width: 4px;
border-radius: 0.625rem;
box-shadow: 0 14px 38px rgb(0 0 0 / 45%);
pointer-events: auto;
backdrop-filter: blur(8px);
}
.toast-card--success {
border-left-color: #34d399;
}
.toast-card--error {
border-left-color: #fb7185;
}
.toast-card--info {
border-left-color: #60a5fa;
}
.toast-icon {
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
font-weight: 800;
color: #09090b;
background: #a1a1aa;
border-radius: 999px;
}
.toast-card--success .toast-icon {
background: #34d399;
}
.toast-card--error .toast-icon {
background: #fb7185;
}
.toast-card--info .toast-icon {
background: #60a5fa;
}
.toast-copy {
display: grid;
gap: 0.15rem;
min-width: 0;
font-size: 0.875rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.toast-copy strong {
color: #fff;
font-size: 0.75rem;
letter-spacing: 0.04em;
}
.toast-close {
width: 2rem;
height: 2rem;
margin: -0.35rem -0.35rem 0 0;
color: #d4d4d8;
font-size: 1.35rem;
line-height: 1;
border-radius: 0.35rem;
cursor: pointer;
}
.toast-close:hover,
.toast-close:focus-visible {
color: #fff;
background: #3f3f46;
outline: 2px solid #a1a1aa;
outline-offset: 1px;
}
.toast-enter-active,
.toast-leave-active,
.toast-move {
transition:
transform 180ms ease,
opacity 180ms ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(1rem);
}
@media (max-width: 640px) {
.toast-viewport {
top: auto;
right: max(0.75rem, env(safe-area-inset-right));
bottom: max(0.75rem, env(safe-area-inset-bottom));
left: max(0.75rem, env(safe-area-inset-left));
width: auto;
}
.toast-enter-from,
.toast-leave-to {
transform: translateY(0.75rem);
}
}
@media (prefers-reduced-motion: reduce) {
.toast-enter-active,
.toast-leave-active,
.toast-move {
transition: none;
}
}
</style>
@@ -0,0 +1,59 @@
import { readonly, ref } from 'vue';
export type ToastKind = 'success' | 'error' | 'info';
export type Toast = {
id: number;
kind: ToastKind;
message: string;
};
const visibleToasts = ref<Toast[]>([]);
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
let nextToastId = 1;
const dismiss = (id: number): void => {
const timer = dismissTimers.get(id);
if (timer) clearTimeout(timer);
dismissTimers.delete(id);
visibleToasts.value = visibleToasts.value.filter((toast) => toast.id !== id);
};
const show = (message: string, kind: ToastKind = 'info', durationMs = 5_000): number => {
const normalizedMessage = message.trim();
if (!normalizedMessage) return -1;
const duplicate = visibleToasts.value.find(
(toast) => toast.message === normalizedMessage && toast.kind === kind
);
if (duplicate) {
dismiss(duplicate.id);
}
const id = nextToastId++;
visibleToasts.value = [...visibleToasts.value.slice(-3), { id, kind, message: normalizedMessage }];
if (durationMs > 0) {
dismissTimers.set(id, setTimeout(() => dismiss(id), durationMs));
}
return id;
};
const feedback = (message: string): number => {
if (/실패|오류|못했|필요|입력|선택|유효|일치하지|비활성화|없습니다|해야 합니다/.test(message)) {
return show(message, 'error');
}
if (/완료|성공|저장|등록|적용|변경|해제|부여|생성|철회|예약/.test(message)) {
return show(message, 'success');
}
return show(message, 'info');
};
export const useToast = () => ({
toasts: readonly(visibleToasts),
show,
success: (message: string, durationMs?: number) => show(message, 'success', durationMs),
error: (message: string, durationMs?: number) => show(message, 'error', durationMs),
info: (message: string, durationMs?: number) => show(message, 'info', durationMs),
feedback,
dismiss,
});
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from '../composables/useToast';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
@@ -22,6 +23,10 @@ const loading = ref(true);
const busy = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const { success: showSuccessToast, error: showErrorToast } = useToast();
watch(successMessage, (value) => value && showSuccessToast(value), { flush: 'sync' });
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
const currentPassword = ref('');
const newPassword = ref('');
const newPasswordConfirm = ref('');
+47 -6
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { useToast } from '../composables/useToast';
import {
normalizeProfileResetDefaults,
type ProfileResetDefaults,
@@ -513,6 +514,45 @@ const userHistory = ref<AdminAuditEvent[]>([]);
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
const globalAuditStatus = ref('');
const { feedback: showFeedbackToast } = useToast();
const actionFeedback = [
noticeStatus,
userError,
kakaoGraceStatus,
specialAccessStatus,
passwordStatus,
rolesStatus,
banStatus,
profileIconStatus,
restrictionStatus,
forceDeleteStatus,
];
watch(
actionFeedback,
(current, previous) => {
current.forEach((message, index) => {
if (message && message !== previous[index]) showFeedbackToast(message);
});
},
{ flush: 'sync' }
);
const setLocalAccountFeedback = (message: string): void => {
localAccountStatus.value = message;
showFeedbackToast(message);
};
watch(
profileActionStatus,
(current, previous) => {
Object.entries(current).forEach(([profileName, message]) => {
if (message && message !== previous[profileName]) showFeedbackToast(message);
});
},
{ flush: 'sync' }
);
const hasUser = computed(() => Boolean(userResult.value));
const loadLocalAccountStatus = async () => {
@@ -723,9 +763,10 @@ const updateProfileMeta = async (profileName: string) => {
);
}
} catch (error) {
const detail = error instanceof Error ? error.message : '';
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '메타 저장 실패',
[profileName]: detail ? `메타 저장 실패: ${detail}` : '메타 저장 실패',
};
}
};
@@ -1183,14 +1224,14 @@ const createLocalAccount = async () => {
localAccountStatus.value = '';
localAccountResult.value = '';
if (!localAccountEnabled.value) {
localAccountStatus.value = 'ENV 설정이 비활성화 상태입니다.';
setLocalAccountFeedback('ENV 설정이 비활성화 상태입니다.');
return;
}
const username = localAccountForm.value.username.trim();
const password = localAccountForm.value.password.trim();
const displayName = localAccountForm.value.displayName.trim();
if (!username || !password) {
localAccountStatus.value = '아이디와 비밀번호를 입력하세요.';
setLocalAccountFeedback('아이디와 비밀번호를 입력하세요.');
return;
}
localAccountLoading.value = true;
@@ -1201,7 +1242,7 @@ const createLocalAccount = async () => {
displayName: displayName || undefined,
});
localAccountResult.value = `생성됨: ${result.user.username} (${result.user.id})`;
localAccountStatus.value = '로컬 계정 생성 완료';
setLocalAccountFeedback('로컬 계정 생성 완료');
localAccountForm.value = {
username: result.user.username,
password: '',
@@ -1211,7 +1252,7 @@ const createLocalAccount = async () => {
userLookupValue.value = result.user.username;
await Promise.all([lookupUser(), loadUserDirectory()]);
} catch (error) {
localAccountStatus.value = '로컬 계정 생성 실패';
setLocalAccountFeedback('로컬 계정 생성 실패');
} finally {
localAccountLoading.value = false;
}
+7 -3
View File
@@ -5,6 +5,7 @@ import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@sammo-ts/gateway-api';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import MapPreview from '../components/MapPreview.vue';
import { useToast } from '../composables/useToast';
import { trpc } from '../utils/trpc';
import { createGameTrpc } from '../utils/gameTrpc';
import type { GameRouter } from '../utils/gameTrpc';
@@ -34,6 +35,9 @@ const selectedMapProfileName = ref<string | null>(null);
const entryLoading = ref<Record<string, boolean>>({});
const logoutLoading = ref(false);
const logoutError = ref('');
const { error: showErrorToast } = useToast();
watch(logoutError, (value) => value && showErrorToast(value), { flush: 'sync' });
const canAccessAdmin = computed(
() =>
me.value?.roles.some(
@@ -207,7 +211,7 @@ const handleKakaoVerification = async (): Promise<void> => {
});
window.location.assign(result.authUrl);
} catch (error) {
alert(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.');
showErrorToast(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.');
}
};
@@ -245,13 +249,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
});
const url = resolveGameUrl(targetPath, issued.profile, issued.gameToken);
if (!url) {
alert('게임 프론트엔드 주소가 설정되지 않았습니다.');
showErrorToast('게임 프론트엔드 주소가 설정되지 않았습니다.');
return;
}
window.location.href = url;
} catch (e) {
console.error('Failed to issue game session', e);
alert(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.');
showErrorToast(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.');
} finally {
entryLoading.value[profile.profileName] = false;
}
@@ -2,6 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import { useToast } from '../composables/useToast';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
@@ -95,6 +96,10 @@ const catalogAttempted = ref(false);
const submitting = ref(false);
const message = ref('');
const errorMessage = ref('');
const { success: showSuccessToast, error: showErrorToast } = useToast();
watch(message, (value) => value && showSuccessToast(value), { flush: 'sync' });
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
let pollTimer: ReturnType<typeof setInterval> | undefined;
let stateRequestInFlight = false;