메인 연결 페이지의 탭 종료와 복귀 동작 통일

This commit is contained in:
2026-09-14 23:49:30 +00:00
parent 62f4ca3058
commit 854a91c397
45 changed files with 720 additions and 237 deletions
+18 -1
View File
@@ -209,6 +209,22 @@ const installApi = async (page: Page, state: BoardFixture) => {
});
return response({ id: 22 });
}
if (operation === 'dashboard.getContextBundleDelta') {
const snapshot = (data: unknown, revision: string) => ({
kind: 'snapshot',
revision,
sourceRevision: revision,
data,
});
return response({
context: snapshot(generalContext, 'board-context'),
commandTable: snapshot({ general: [], nation: [] }, 'board-commands'),
boardAccess: snapshot(
{ permission: state.permission, canMeeting: state.canMeeting, canSecret: state.canSecret },
'board-access'
),
});
}
if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMapLayout') {
return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} });
@@ -498,7 +514,8 @@ test('denies direct secret-room rendering and disables its in-game menu for an o
expect(state.requests).not.toContain('board.getArticles');
state.requests.length = 0;
await page.goto('');
await page.getByRole('button', { name: '돌아가기', exact: true }).click();
await expect(page).toHaveURL(new URL(gamePath('/'), page.url()).href);
const nationMenu = page.locator('.main-nation-menu').first();
await expect(nationMenu.locator('[data-navigation-id="meeting"]')).toHaveAttribute('href', gamePath('/board'));
await expect(nationMenu.locator('[data-navigation-id="secret-board"]')).toHaveAttribute('aria-disabled', 'true');
+1 -1
View File
@@ -807,7 +807,7 @@ test('nation directory reuses only the public general-directory row on hover and
expect(await foreignTrigger.getAttribute('aria-expanded')).toBe('true');
expect(await foreignTrigger.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('dashed');
await page.getByRole('button', { name: '창 닫기' }).first().focus();
await page.getByRole('button', { name: '돌아가기' }).first().focus();
await expect(preview).toHaveCount(0);
if (artifactRoot) {
@@ -273,6 +273,8 @@ test('왕조 일람과 상세는 현재 profile의 이전 서버 기록만 조
expect(state.dynastyRequests.some((request) => request.includes('legacy'))).toBe(true);
expect(state.dynastyRequests.every((request) => !request.includes('sourceProfile'))).toBe(true);
await page.screenshot({ path: testInfo.outputPath('dynasty-detail-profile-scope-desktop.png'), fullPage: true });
await page.getByRole('button', { name: '돌아가기', exact: true }).last().click();
await expect(page).toHaveURL(new URL('./', testInfo.project.use.baseURL as string).href);
});
test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰 글자를 사용한다', async ({ page }, testInfo) => {
@@ -6,6 +6,9 @@ import { buildEquipmentTradeItemOptions, loadEquipmentTradeItemOrder } from '../
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic/items/index.js';
// 브라우저 보조키로 생성한 탭까지 검증하므로 경량 headless shell 대신 전체 Chromium을 사용한다.
test.use({ channel: 'chromium' });
const response = (data: unknown) => ({ result: { data } });
const runtimeNavigation = JSON.parse(
await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8')
@@ -29,6 +32,7 @@ const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
type NavigationFixture = {
pageExitAudit?: boolean;
decoratedNpcChiefs?: boolean;
officerLevel: number;
permission: number;
@@ -567,6 +571,7 @@ const generalContext = (state: NavigationFixture) => ({
const installFixture = async (page: Page | BrowserContext, state: NavigationFixture) => {
await page.addInitScript(
({ profile }) => {
if (!window.location.protocol.startsWith('http')) return;
localStorage.setItem('sammo-game-token', 'ga_navigation');
localStorage.setItem('sammo-game-profile', profile);
},
@@ -838,6 +843,12 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
winnerId: state.tournamentWinnerId,
});
}
if (state.pageExitAudit) return errorResponse(operation, 'Navigation audit fixture: data unavailable');
if (operation === 'tournament.getRankings') return response([]);
if (operation === 'tournament.getSnapshot') return response({ state: null, participants: [], matches: [] });
if (operation === 'tournament.getBettingSummary')
return response({ totals: {}, myTotals: {}, totalAmount: 0, myAmount: 0 });
if (operation === 'tournament.getAdminStatus') return response({ ok: false });
return response({ ok: true });
});
operations.forEach((operation, index) => {
@@ -6881,3 +6892,246 @@ test('NPC prefixes stay single in the main nation card', async ({ page }, testIn
await card.screenshot({ path: testInfo.outputPath(`npc-main-prefix-${width}.png`) });
}
});
test.describe('page exit navigation', () => {
for (const width of [1000, 500]) {
test(`page exit policy distinguishes ordinary, modifier, copied and same-tab links at ${width}`, async ({
page,
context,
}, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await installFixture(context, {
officerLevel: 12,
permission: 4,
nationLevel: 1,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
});
await waitForMain(page);
const mainUrl = page.url();
const survey = page.locator('[data-menu-position="top"] [data-navigation-id="survey"]');
const href = await survey.getAttribute('href');
for (const method of ['control', 'middle', 'copy'] as const) {
const created = context.waitForEvent('page');
if (method === 'copy') {
const copied = await context.newPage();
await copied.goto(href!);
} else {
await survey.click(method === 'control' ? { modifiers: ['Control'] } : { button: 'middle' });
}
const tab = await created;
await tab.bringToFront();
await tab.waitForLoadState('domcontentloaded');
await expect(tab).toHaveURL(`${basePath}/survey`);
await expect(tab.locator('.back_bar .back_btn')).toHaveText('돌아가기');
await tab.reload();
await expect(tab.locator('.bottom_bar .back_btn')).toHaveText('돌아가기');
expect(context.pages()).toHaveLength(2);
await tab.locator('.bottom_bar .back_btn').click();
await expect(tab).toHaveURL(mainUrl);
expect(context.pages()).toHaveLength(2);
await tab.close();
}
// The status title intentionally uses the current tab, unlike its menu entry.
await page.locator('.tournament-status a').click();
await expect(page).toHaveURL(`${basePath}/tournament`);
expect(context.pages()).toHaveLength(1);
await expect(page.getByRole('button', { name: '돌아가기', exact: true }).first()).toBeVisible();
await page.screenshot({ path: testInfo.outputPath(`same-tab-${width}.png`), fullPage: true });
await page.getByRole('button', { name: '돌아가기', exact: true }).last().click();
await expect(page).toHaveURL(mainUrl);
expect(context.pages()).toHaveLength(1);
// Retain the auxiliary identity through SPA navigation and a reload.
const opened = page.waitForEvent('popup');
await page.locator('[data-navigation-id="tournament"]').click();
const tab = await opened;
await expect(tab.getByRole('button', { name: '창 닫기', exact: true }).first()).toBeVisible();
await tab.getByRole('tab', { name: '베팅장', exact: true }).click();
await expect(tab).toHaveURL(`${basePath}/betting`);
await tab.reload();
await expect(tab.getByRole('button', { name: '창 닫기', exact: true }).last()).toBeVisible();
const closed = tab.waitForEvent('close');
await tab
.getByRole('button', { name: '창 닫기', exact: true })
.last()
.click()
.catch((error: unknown) => {
if (!tab.isClosed()) throw error;
});
await closed;
expect(context.pages()).toHaveLength(1);
expect(page.url()).toBe(mainUrl);
});
}
const exitAuditEntries = [
'nation-betting',
'nation-list',
'general-list',
'best-general',
'yearbook',
'battle-simulator',
'hall-of-fame',
'dynasty',
'traffic',
'npc-list',
'survey',
'meeting',
'secret-board',
'troop',
'diplomacy',
'personnel',
'finance',
'chief-center',
'npc-control',
'nation-secret',
'tournament',
'tournament-menu',
'betting',
'nation-info',
'nation-cities',
'nation-generals',
'global-info',
'current-city',
'battle-center',
'inherit',
'my-page',
'auction-resource',
'auction-resource-menu',
'auction-unique',
'my-settings',
];
for (const width of [1000, 500]) {
test(`all main page exits obey tab policy even when page data fails at ${width}`, async ({
page,
context,
}, testInfo) => {
test.setTimeout(180_000);
await page.setViewportSize({ width, height: 900 });
await installFixture(context, {
pageExitAudit: true,
officerLevel: 12,
permission: 4,
nationLevel: 1,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
});
await waitForMain(page);
const mainUrl = page.url();
const evidence: unknown[] = [];
for (const id of exitAuditEntries) {
let link = page.locator(`[data-navigation-id="${id}"]`).first();
if (!(await link.isVisible())) {
const group = ['tournament-menu', 'betting'].includes(id)
? 'tournament-betting'
: ['auction-resource-menu', 'auction-unique'].includes(id)
? 'auction'
: 'game-info';
await page.locator(`[data-menu-id="${group}"]`).first().click();
}
link = page.locator(`[data-navigation-id="${id}"]`).first();
const href = await link.getAttribute('href');
const newTab = (await link.getAttribute('target')) === '_blank';
const opened = newTab ? page.waitForEvent('popup') : null;
await link.click();
const target = opened ? await opened : page;
await target.setViewportSize({ width, height: 900 });
await target.waitForURL(new URL(href!, mainUrl).href);
const exit = target.getByRole('button', { name: newTab ? '창 닫기' : '돌아가기', exact: true }).first();
await expect(exit, id).toBeVisible();
expect(context.pages(), id).toHaveLength(newTab ? 2 : 1);
await target.evaluate(() => document.fonts.ready);
await exit.focus();
await exit.hover();
evidence.push({
id,
href,
newTab,
width,
...(await exit.evaluate((el) => ({
viewportWidth: window.innerWidth,
rect: el.getBoundingClientRect().toJSON(),
font: getComputedStyle(el).font,
border: getComputedStyle(el).border,
text: el.textContent,
}))),
});
await target.screenshot({ path: testInfo.outputPath(`${id}-${width}.png`), fullPage: true });
await writeFile(
testInfo.outputPath(`${id}-${width}.html`),
await target.locator('#app').evaluate((el) => el.outerHTML)
);
if (newTab) {
const closed = target.waitForEvent('close');
await exit.click().catch((error: unknown) => {
if (!target.isClosed()) throw error;
});
await closed;
// Opening the original href from the browser does not inherit auxiliary identity.
const direct = await context.newPage();
await direct.setViewportSize({ width, height: 900 });
await direct.goto(href!);
const back = direct.getByRole('button', { name: '돌아가기', exact: true }).first();
await expect(back, id).toBeVisible();
await back.click();
await expect(direct).toHaveURL(mainUrl);
expect(context.pages()).toHaveLength(2);
await direct.close();
} else {
await exit.click();
await expect(page).toHaveURL(mainUrl);
}
expect(context.pages(), id).toHaveLength(1);
expect(page.url(), id).toBe(mainUrl);
}
await writeFile(testInfo.outputPath(`exit-audit-${width}.json`), JSON.stringify(evidence, null, 2));
});
}
test('keyboard activation opens a closable tab and blocked popups use the current tab', async ({
page,
context,
}) => {
await installFixture(context, {
officerLevel: 0,
permission: 0,
nationLevel: 0,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
});
await waitForMain(page);
const mainUrl = page.url();
const link = page.locator('[data-menu-position="top"] [data-navigation-id="survey"]');
await link.focus();
const opened = page.waitForEvent('popup');
await link.press('Enter');
const tab = await opened;
await expect(tab.getByRole('button', { name: '창 닫기', exact: true }).first()).toBeVisible();
expect(await tab.evaluate(() => document.referrer)).toBe('');
const closed = tab.waitForEvent('close');
await tab
.getByRole('button', { name: '창 닫기', exact: true })
.first()
.click()
.catch((error: unknown) => {
if (!tab.isClosed()) throw error;
});
await closed;
expect(context.pages()).toHaveLength(1);
await page.evaluate(() => {
window.open = () => null;
});
await link.click();
await expect(page).toHaveURL(`${basePath}/survey`);
await page.getByRole('button', { name: '돌아가기', exact: true }).first().click();
await expect(page).toHaveURL(mainUrl);
expect(context.pages()).toHaveLength(1);
});
});
@@ -536,7 +536,7 @@ test('join refresh shows the assigned preliminary group immediately with accessi
const refresh = page.getByRole('button', { name: '갱신' });
const join = page.getByRole('button', { name: '참가' });
const close = page.getByRole('button', { name: '창 닫기' }).first();
const close = page.getByRole('button', { name: '돌아가기' }).first();
await expect(join).toBeEnabled();
await expect(page.getByText('조별 예선 순위')).toBeVisible();
await expect(page.getByText('조별 본선 순위')).toHaveCount(0);
@@ -797,7 +797,7 @@ test('tournament and betting pages expose same-row navigation tabs beside close'
const navigation = page.getByRole('tablist', { name: '토너먼트와 베팅장 이동' });
const tournamentTab = navigation.getByRole('tab', { name: '토너먼트' });
const bettingTab = navigation.getByRole('tab', { name: '베팅장' });
const close = page.getByRole('button', { name: '창 닫기' }).first();
const close = page.getByRole('button', { name: '돌아가기' }).first();
await expect(tournamentTab).toHaveAttribute('aria-selected', 'true');
const headerCenters = await Promise.all(
@@ -945,29 +945,7 @@ test('betting realtime refresh is shared across tabs and preserves local interac
await expect(follower.locator('.mobile-bracket').getByLabel('관우 베팅 금액', { exact: true })).toHaveValue('50');
});
test('tournament and betting close only their script-opened popup window', async ({ page }, testInfo) => {
const baseURL = testInfo.project.use.baseURL;
expect(typeof baseURL).toBe('string');
await page.goto('about:blank');
for (const route of ['tournament', 'betting'] as const) {
const popupPromise = page.waitForEvent('popup');
await page.evaluate(() => window.open('about:blank', '_blank', 'noopener'));
const popup = await popupPromise;
await installFixture(popup, { tournamentStage: route === 'betting' ? 6 : 1 });
await popup.goto(new URL(route, baseURL as string).href);
await expect(popup.getByRole('button', { name: '창 닫기' }).first()).toBeVisible();
expect(await popup.evaluate(() => window.opener)).toBeNull();
const closed = popup.waitForEvent('close');
await popup.getByRole('button', { name: '창 닫기' }).first().click();
await closed;
expect(page.isClosed()).toBe(false);
expect(page.url()).toBe('about:blank');
}
});
// Actual main-link popup closure and tab counts are covered in mainNavigation.spec.ts.
test('betting bracket shows intelligence for debate tournament candidates', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { usePageExit } from '../../composables/usePageExit';
const { pageExitLabel, exitPage } = usePageExit();
defineProps<{
activePage: 'tournament' | 'betting';
title: string;
}>();
const closeWindow = (): void => window.close();
</script>
<template>
@@ -40,9 +42,9 @@ const closeWindow = (): void => window.close();
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="closeWindow"
@click="exitPage"
>
닫기
{{ pageExitLabel }}
</button>
</div>
</header>
@@ -0,0 +1,16 @@
import { useRouter } from 'vue-router';
import { isAuxiliaryWindow } from '../utils/auxiliaryNavigation';
export const usePageExit = (): { pageExitLabel: '창 닫기' | '돌아가기'; exitPage: () => void } => {
const router = useRouter();
const canClose = isAuxiliaryWindow();
const pageExitLabel = canClose ? '창 닫기' : '돌아가기';
const exitPage = (): void => {
if (canClose) {
window.close();
return;
}
void router.push('/');
};
return { pageExitLabel, exitPage };
};
+2
View File
@@ -1,3 +1,4 @@
import { installAuxiliaryNavigation } from './utils/auxiliaryNavigation';
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
@@ -7,6 +8,7 @@ import { applyStoredCustomCss } from './utils/customCss';
import { installImageAssetCssVariables } from './utils/imageAssets';
import { installScreenModeViewport } from './utils/screenModeViewport';
installAuxiliaryNavigation();
installImageAssetCssVariables();
installScreenModeViewport();
applyStoredCustomCss();
@@ -0,0 +1,47 @@
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
// 이름은 앱이 직접 연 탭에만 부여한다. URL/sessionStorage 표식은 링크 복사나
// 브라우저의 새 탭 열기에도 전달될 수 있으므로 닫기 권한 판정에 사용하지 않는다.
const auxiliaryWindowPrefix = 'sammo-auxiliary:';
export const isAuxiliaryWindow = (): boolean => window.name.startsWith(auxiliaryWindowPrefix);
export const installAuxiliaryNavigation = (): void => {
document.addEventListener(
'click',
(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link = event.target instanceof Element ? event.target.closest('a') : null;
if (!link || link.target !== '_blank' || link.hasAttribute('download')) return;
const url = new URL(link.href, window.location.href);
const basePath = gameFrontendRuntimeConfig.appBasePath;
if (url.origin !== window.location.origin || !url.pathname.startsWith(basePath)) return;
event.preventDefault();
const child = window.open('about:blank', '_blank');
if (!child) {
// 팝업 차단 시 현재 탭에서 열어 돌아가기 동작을 제공한다.
window.location.assign(url.href);
return;
}
child.name = `${auxiliaryWindowPrefix}${crypto.randomUUID()}`;
child.opener = null;
// 새 문서 소유의 링크로 이동해야 원본 링크의 referrer 정책도 적용된다.
const destination = child.document.createElement('a');
destination.href = url.href;
destination.rel = link.rel;
destination.referrerPolicy = link.referrerPolicy;
child.document.body.append(destination);
destination.click();
},
true
);
};
+8 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
@@ -9,6 +11,8 @@ import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
import { useGameFeedback } from '../composables/useGameFeedback';
const { pageExitLabel, exitPage } = usePageExit();
type AuctionOverview = Awaited<ReturnType<typeof trpc.auction.getOverview.query>>;
type ResourceAuction = AuctionOverview['resourceAuctions'][number];
type UniqueAuction = AuctionOverview['uniqueAuctions'][number];
@@ -177,8 +181,6 @@ const bidUniqueAuction = (): Promise<void> =>
showSuccessToast('입찰이 완료되었습니다.');
});
const closeWindow = (): void => window.close();
watch(activeTab, (tab) => {
error.value = null;
if (tab === 'unique' && !selectedUnique.value && ongoingUnique.value[0]) {
@@ -201,9 +203,9 @@ onMounted(() => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="closeWindow"
@click="exitPage"
>
닫기
{{ pageExitLabel }}
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height reload-button"
@@ -499,9 +501,9 @@ onMounted(() => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="closeWindow"
@click="exitPage"
>
닫기
{{ pageExitLabel }}
</button>
</footer>
</main>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
@@ -16,6 +18,8 @@ import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
const { pageExitLabel, exitPage } = usePageExit();
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
type GeneralEntry = BattleCenterResponse['generals'][number];
@@ -237,15 +241,13 @@ onMounted(() => {
<template>
<main class="ref-shell battle-page">
<header class="battle-top legacy-bg0">
<RouterLink v-slot="{ navigate }" custom to="/">
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height battle-nav"
type="button"
@click="navigate"
>
닫기
</button>
</RouterLink>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height battle-nav"
type="button"
@click="exitPage"
>
{{ pageExitLabel }}
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height battle-nav"
@click="loadBattleCenter"
@@ -318,15 +320,13 @@ onMounted(() => {
</div>
</section>
<footer class="battle-footer legacy-bg0">
<RouterLink v-slot="{ navigate }" custom to="/">
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height battle-nav"
type="button"
@click="navigate"
>
닫기
</button>
</RouterLink>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height battle-nav"
type="button"
@click="exitPage"
>
{{ pageExitLabel }}
</button>
</footer>
</main>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef } from 'vue';
import type { BattleSimRequestPayload, BattleSimResultPayload } from '@sammo-ts/game-api';
import PanelCard from '../components/ui/PanelCard.vue';
@@ -12,6 +13,8 @@ import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battl
import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
const { pageExitLabel, exitPage } = usePageExit();
type GeneralExport = Omit<GeneralDraft, 'id'>;
type BattleExport = {
@@ -1015,6 +1018,9 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<template>
<main class="battle-simulator">
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
<!-- Ref renders several equivalent controls as input/button tags. Keep
that DOM signature without duplicating the visible Vue controls. -->
<div class="legacy-control-signature" hidden>
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import RichTooltip from '../components/ui/RichTooltip.vue';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type RankEntry = {
id: number;
name: string;
@@ -43,7 +46,6 @@ type BestGeneralPayload = {
uniqueItems: UniqueItemSection[];
};
const router = useRouter();
const viewMode = ref<'user' | 'npc'>('user');
const loading = ref(false);
const errorMessage = ref('');
@@ -51,14 +53,6 @@ const data = ref<BestGeneralPayload | null>(null);
const imageUrl = (entry: { picture: string | null; imageServer: number }): string => resolveGeneralIconUrl(entry);
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const refresh = async (): Promise<void> => {
loading.value = true;
errorMessage.value = '';
@@ -84,7 +78,7 @@ watch(viewMode, () => {
<main id="best-general-container" class="legacy-ranking-page legacy-bg0">
<div class="legacy-ranking-title">
<br />
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
<button class="legacy-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</div>
<div class="view-selector" role="group" aria-label="장수 유형">
@@ -175,7 +169,7 @@ watch(viewMode, () => {
</section>
<div class="legacy-ranking-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
<button class="legacy-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
+7 -5
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { storeToRefs } from 'pinia';
@@ -10,6 +12,8 @@ import { useTournamentPagesStore } from '../stores/tournamentPages';
import type { TournamentBracketSlot } from '../utils/tournamentBracket';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
const tournamentPages = useTournamentPagesStore();
const { snapshot, betting: summary, rankings, loading, error } = storeToRefs(tournamentPages);
const amounts = ref<Record<number, number | string>>({});
@@ -266,11 +270,9 @@ const placeBet = async (target: TournamentBracketSlot) => {
ㆍ소지금 500 이하일땐 베팅이 불가능합니다.
</section>
<footer class="betting-footer bg0">
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="legacy-button legacy-button--navigation close-button" type="button" @click="navigate">
닫기
</button>
</RouterLink>
<button class="legacy-button legacy-button--navigation close-button" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
</small>
+15 -8
View File
@@ -1,20 +1,22 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { useGameFeedback } from '../composables/useGameFeedback';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type BoardArticle = Awaited<ReturnType<typeof trpc.board.getArticles.query>>[number];
const route = useRoute();
const router = useRouter();
const { error: showErrorToast } = useGameFeedback();
const isSecretBoard = computed(() => route.name === 'board-secret');
const title = computed(() => (isSecretBoard.value ? '기밀실' : '회의실'));
const closeBoard = () => router.push('/');
const loading = ref(false);
const accessChecked = ref(false);
@@ -123,15 +125,20 @@ onMounted(() => {
</script>
<template>
<div v-if="accessChecked && !canAccess" class="legacy-raw-access-error" role="alert">{{ errorMessage }}</div>
<div v-if="accessChecked && !canAccess" class="legacy-raw-access-error">
<span role="alert">{{ errorMessage }}</span>
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</div>
<main v-else id="container" class="legacy-board-page">
<header class="top-back-bar bg0">
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button"
type="button"
@click="closeBoard"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
<div></div>
<h1>{{ title }}</h1>
@@ -244,9 +251,9 @@ onMounted(() => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button"
type="button"
@click="closeBoard"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</footer>
</template>
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { computed, onMounted, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import { addMinutes } from 'date-fns';
import { useRouter } from 'vue-router';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
@@ -13,6 +14,8 @@ import { formatOfficerLevelText } from '../utils/nationFormat';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandMapData, CommandMapLayout, CommandPatternEntry, CommandTable } from '../components/command/types';
const { pageExitLabel, exitPage } = usePageExit();
type ReservationCompletion = (success: boolean) => void;
type ChiefTurn = {
@@ -91,7 +94,6 @@ const worldMap = ref<CommandMapData | null>(null);
const mapLayout = ref<CommandMapLayout | null>(null);
const selectedChiefLevel = ref<number | null>(null);
const router = useRouter();
const isMobile = useMediaQuery('(max-width: 1024px)');
@@ -330,9 +332,9 @@ const repeatTurns = async (amount: number) => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height chief-nav"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height chief-nav"
@@ -465,9 +467,9 @@ const repeatTurns = async (amount: number) => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height chief-nav"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</footer>
</main>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
@@ -9,6 +11,8 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
type General = Result['generals'][number];
type ReservedCommand = General['turns'][number];
@@ -95,9 +99,9 @@ const commandBrief = (command: ReservedCommand): string =>
<br /><button
class="legacy-button legacy-button--navigation back-link"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -313,9 +317,9 @@ const commandBrief = (command: ReservedCommand): string =>
<button
class="legacy-button legacy-button--navigation back-link"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { EditorContent, useEditor } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
@@ -6,12 +8,13 @@ import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
const { pageExitLabel, exitPage } = usePageExit();
type DiplomacyResponse = Awaited<ReturnType<typeof trpc.diplomacy.getLetters.query>>;
type DiplomacyLetter = DiplomacyResponse['letters'][number];
@@ -19,7 +22,6 @@ const loading = ref(false);
const errorMessage = ref<string | null>(null);
const data = ref<DiplomacyResponse | null>(null);
const historyOpen = ref<Record<number, boolean>>({});
const router = useRouter();
const editable = computed(() => (data.value?.permission ?? 0) >= 4);
@@ -263,9 +265,9 @@ onBeforeUnmount(() => {
<br /><button
class="legacy-button legacy-button--navigation"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -634,8 +636,8 @@ onBeforeUnmount(() => {
<tbody>
<tr>
<td>
<button class="legacy-button legacy-button--navigation" type="button" @click="router.push('/')">
돌아가기</button
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}</button
><br /><br />
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
/
@@ -1,16 +1,19 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { formatLog } from '../utils/formatLog';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type DynastyDetailPayload = Awaited<ReturnType<typeof trpc.dynasty.getDetail.query>>;
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const errorMessage = ref('');
const data = ref<DynastyDetailPayload | null>(null);
@@ -23,14 +26,6 @@ const emperorId = computed(() => {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const loadDetail = async (): Promise<void> => {
if (emperorId.value === null) {
data.value = null;
@@ -62,7 +57,7 @@ onMounted(loadDetail);
<tr>
<td>
<br />
<button class="native-button" type="button" @click="closePage"> 닫기</button>
<button class="native-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
<span class="all-link">
<RouterLink
:to="{ path: '/dynasty', query: source === 'legacy' ? { source: 'legacy' } : {} }"
@@ -290,7 +285,9 @@ onMounted(loadDetail);
<table class="legacy-table legacy-bg0 footer-table">
<tbody>
<tr>
<td><button class="native-button" type="button" @click="closePage"> 닫기</button><br /></td>
<td>
<button class="native-button" type="button" @click="exitPage">{{ pageExitLabel }}</button><br />
</td>
</tr>
<tr>
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
@@ -1,27 +1,22 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>;
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const errorMessage = ref('');
const data = ref<DynastyListPayload | null>(null);
const selectedSource = ref<'current' | 'legacy'>(route.query.source === 'legacy' ? 'legacy' : 'current');
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const loadDynasty = async (): Promise<void> => {
loading.value = true;
errorMessage.value = '';
@@ -45,7 +40,7 @@ watch(selectedSource, loadDynasty);
<tr>
<td>
<br />
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
<button class="native-button" type="button" @click="exitPage">{{ pageExitLabel }}</button><br />
</td>
</tr>
</tbody>
@@ -162,7 +157,9 @@ watch(selectedSource, loadDynasty);
<table class="legacy-table legacy-bg0 footer-table spaced-table">
<tbody>
<tr>
<td><button class="native-button" type="button" @click="closePage"> 닫기</button><br /></td>
<td>
<button class="native-button" type="button" @click="exitPage">{{ pageExitLabel }}</button><br />
</td>
</tr>
<tr>
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
import LegacySortControls from '../components/ui/LegacySortControls.vue';
@@ -14,6 +15,8 @@ import {
} from '../utils/generalDirectorySort';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
const sortOptions: Array<{ value: GeneralDirectorySortKey; label: string }> = [
{ value: 0, label: '이름' },
{ value: 1, label: '국가' },
@@ -38,7 +41,6 @@ const sourceGenerals = ref<GeneralDirectoryGeneral[]>([]);
const sortCriteria = ref<GeneralDirectorySortCriterion[]>([]);
const loading = ref(false);
const error = ref('');
const router = useRouter();
const { info: showInfoToast } = useGameFeedback();
const generals = computed(() => sortGeneralDirectory(sourceGenerals.value, sortCriteria.value));
@@ -81,7 +83,7 @@ onMounted(() => {
<tr>
<td>
<br />
<button class="legacy-button" type="button" @click="router.push('/')"> 닫기</button>
<button class="legacy-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
<button
class="legacy-button"
type="button"
@@ -118,7 +120,9 @@ onMounted(() => {
<table class="directory-table title-table legacy-bg0">
<tbody>
<tr>
<td><button class="legacy-button" type="button" @click="router.push('/')"> 닫기</button></td>
<td>
<button class="legacy-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</td>
</tr>
<tr>
<td><small>삼국지 모의전투 HiDCHe</small></td>
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import MapViewer from '../components/main/MapViewer.vue';
import { trpc } from '../utils/trpc';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
const { pageExitLabel, exitPage } = usePageExit();
type Result = Awaited<ReturnType<typeof trpc.world.getGlobalInfo.query>>;
type Layout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
const data = ref<Result | null>(null);
@@ -12,8 +15,6 @@ const layout = ref<Layout | null>(null);
const error = ref('');
const matrixElement = ref<HTMLTableElement | null>(null);
const matrixHeight = ref<number | null>(null);
const router = useRouter();
const goBack = () => router.push('/');
const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ';
const stateClass = (value: number) => `state-${value}`;
const nationMap = computed(() => new Map(data.value?.nations.map((nation) => [nation.id, nation]) ?? []));
@@ -56,9 +57,9 @@ onMounted(async () => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="goBack"
@click="exitPage"
>
돌아가기</button
{{ pageExitLabel }}</button
><strong>중원 정보</strong>
</header>
<p v-if="error" class="error">{{ error }}</p>
@@ -157,9 +158,9 @@ onMounted(async () => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="goBack"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</footer>
<button class="legacy-compat-button" type="button" tabindex="-1" aria-hidden="true" />
+6 -12
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type HallOption = {
sourceProfile: string;
season: number;
@@ -39,7 +42,6 @@ type HallPayload = {
sections: HallSection[];
};
const router = useRouter();
const loading = ref(false);
const errorMessage = ref('');
const options = ref<HallOption[]>([]);
@@ -64,14 +66,6 @@ const selection = computed({
const imageUrl = (entry: HallEntry): string => resolveGeneralIconUrl(entry);
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const loadOptions = async (): Promise<void> => {
try {
options.value = await trpc.ranking.getHallOfFameOptions.query({ source: selectedSource.value });
@@ -121,7 +115,7 @@ onMounted(loadOptions);
<main id="container" class="legacy-hall-page legacy-bg0">
<div class="legacy-hall-title">
<br />
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
<button class="legacy-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</div>
<label class="archive-source">
@@ -189,7 +183,7 @@ onMounted(loadOptions);
</section>
<div class="legacy-hall-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
<button class="legacy-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
+10 -2
View File
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
import { useGameFeedback } from '../composables/useGameFeedback';
const { pageExitLabel, exitPage } = usePageExit();
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
type InheritLog = Awaited<ReturnType<typeof trpc.inherit.getLogs.query>>[number];
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
@@ -452,9 +456,13 @@ onMounted(() => {
<template>
<header class="top-back-bar legacy-bg0">
<RouterLink class="top-button legacy-button legacy-button--navigation legacy-button--fixed-height" to="/"
>돌아가기</RouterLink
<button
class="top-button legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="exitPage"
>
{{ pageExitLabel }}
</button>
<strong>유산 관리</strong>
<button
class="top-button legacy-button legacy-button--navigation legacy-button--fixed-height"
+7 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
@@ -11,6 +13,8 @@ import GeneralInformationPanel from '../components/main/GeneralInformationPanel.
import { useGameFeedback } from '../composables/useGameFeedback';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
const { pageExitLabel, exitPage } = usePageExit();
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
const { error: showErrorToast, showDialog } = useGameFeedback();
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
@@ -340,7 +344,9 @@ onMounted(() => {
<span> </span>
<div class="title-actions">
<div class="navigation-actions">
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
<button class="legacy-button legacy-button--navigation" type="button" @click="() => loadPage()">
새로고침
</button>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { mode: clockDisplayMode } = useClockDisplay();
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
@@ -23,6 +25,8 @@ import {
type ScreenMode,
} from '../utils/screenModeViewport';
const { pageExitLabel, exitPage } = usePageExit();
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const data = ref<MyGeneralResponse | null>(null);
@@ -134,7 +138,9 @@ onBeforeUnmount(() => {
<main id="interface-settings" class="legacy-page bg0 interface-settings-page">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</div>
<section class="settings-grid">
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref } from 'vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type BettingListItem = {
id: number;
type: string;
@@ -333,15 +337,13 @@ onMounted(() => {
<template>
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar">
<RouterLink v-slot="{ navigate }" custom to="/">
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="navigate"
>
돌아가기
</button>
</RouterLink>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="exitPage"
>
{{ pageExitLabel }}
</button>
<div></div>
<h1>국가 베팅장</h1>
<div></div>
@@ -441,7 +443,9 @@ onMounted(() => {
</section>
<footer class="betting-footer">
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</footer>
</main>
</template>
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandTable } from '../components/command/types';
import LegacySortControls from '../components/ui/LegacySortControls.vue';
@@ -14,6 +15,8 @@ import { legacyNationTextColor } from '../utils/legacyNationColor';
import { cityLevelMap, regionMap } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type Result = Awaited<ReturnType<typeof trpc.nation.getCityOverview.query>>;
type SecretResult = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
type PersonnelResult = Awaited<ReturnType<typeof trpc.nation.getPersonnelInfo.query>>;
@@ -45,7 +48,6 @@ const extraSort = ref<
| 'generalCount'
| null
>(null);
const router = useRouter();
const { error: showErrorToast, info: showInfoToast, success: showSuccessToast } = useGameFeedback();
const sortOptions = [
'기본',
@@ -303,9 +305,9 @@ onMounted(async () => {
<br /><button
class="legacy-button legacy-button--navigation back-button"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -716,9 +718,9 @@ onMounted(async () => {
<button
class="legacy-button legacy-button--navigation back-button"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandTable } from '../components/command/types';
import { formatOfficerLevelText } from '../utils/nationFormat';
@@ -33,6 +34,8 @@ import {
} from '../utils/nationGeneralGrid';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['generals'][number];
type CellValue = string | number | null;
@@ -218,7 +221,6 @@ const layout: LayoutItem[] = [
const columnById = new Map(columns.map((column) => [column.id, column]));
const data = ref<Result | null>(null);
const commandTable = ref<CommandTable | null>(null);
const router = useRouter();
const error = ref('');
const loading = ref(false);
const viewMenuOpen = ref(false);
@@ -709,9 +711,9 @@ onBeforeUnmount(() => {
<span class="left-actions">
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height top-button nation-button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height top-button nation-button"
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { formatLog } from '../utils/formatLog';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { formatNationLevelText } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
const data = ref<Result | null>(null);
const router = useRouter();
const error = ref('');
const number = (value: number) => value.toLocaleString('ko-KR');
const diff = (value: number) => `${value > 0 ? '+' : ''}${number(value)}`;
@@ -31,9 +33,9 @@ onMounted(async () => {
<br /><button
class="legacy-button legacy-button--navigation"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -119,8 +121,8 @@ onMounted(async () => {
<tbody>
<tr>
<td>
<button class="legacy-button legacy-button--navigation" type="button" @click="router.push('/')">
돌아가기
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
@@ -9,6 +11,8 @@ import { getNpcColor } from '../utils/npcColor';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type Directory = Awaited<ReturnType<typeof trpc.world.getNationDirectory.query>>;
type Nation = Directory[number];
@@ -58,7 +62,6 @@ const displayAmbassadorName = (nation: Nation, name: string) => {
return general ? displayGeneralName(general) : name;
};
const closeWindow = () => window.close();
const officerLevelAt = (row: number, column: number, columns: number) => 13 - ((row - 1) * columns + column);
const positionGeneralPreview = async (): Promise<void> => {
@@ -165,8 +168,8 @@ onBeforeUnmount(() => {
<tbody>
<tr>
<td>
<br /><button class="legacy-button" type="button" @click="closeWindow">
닫기
<br /><button class="legacy-button" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -400,7 +403,9 @@ onBeforeUnmount(() => {
<table class="directory-table title-table footer-table legacy-bg0">
<tbody>
<tr>
<td><button class="legacy-button" type="button" @click="closeWindow"> 닫기</button></td>
<td>
<button class="legacy-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</td>
</tr>
<tr>
<td>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
@@ -13,6 +14,8 @@ import { trpc } from '../utils/trpc';
import { cityLevelMap, formatOfficerLevelText, getNationChiefLevel, regionMap } from '../utils/nationFormat';
import { legacyNationTextColor } from '../utils/legacyNationColor';
const { pageExitLabel, exitPage } = usePageExit();
type PersonnelResponse = Awaited<ReturnType<typeof trpc.nation.getPersonnelInfo.query>>;
type GeneralEntry = PersonnelResponse['generals'][number];
type OfficerLevel = 2 | 3 | 4;
@@ -41,7 +44,6 @@ const selectionContext = ref<SelectionContext | null>(null);
const kickTargetId = ref(0);
const ambassadorSelection = ref<number[]>([]);
const auditorSelection = ref<number[]>([]);
const router = useRouter();
const { success: showSuccessToast, error: showErrorToast, confirm: showConfirm } = useGameFeedback();
const resolveErrorMessage = (value: unknown): string =>
@@ -311,9 +313,9 @@ onMounted(() => void loadPersonnel());
<br /><button
class="legacy-button legacy-button--navigation"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -569,12 +571,8 @@ onMounted(() => void loadPersonnel());
<tbody>
<tr>
<td>
<button
class="legacy-button legacy-button--navigation"
type="button"
@click="router.push('/')"
>
돌아가기
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</td>
</tr>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { computed, onMounted, ref } from 'vue';
@@ -9,6 +11,8 @@ import DirectoryTooltip from '../components/directory/DirectoryTooltip.vue';
import { getNpcColor } from '../utils/npcColor';
import { generalInjuryPresentation } from '../utils/generalInjury';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
type ReservedCommand = Result['generals'][number]['reservedCommands'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
@@ -48,7 +52,6 @@ const generals = computed(() =>
return b.troopId - a.troopId || a.id - b.id;
})
);
const closeWindow = () => window.close();
const displayName = (general: { name: string; npcState: number }) =>
general.npcState > 0 && !/^[ⓜⓝⓖ㉥ⓤⓞⓧ]/u.test(general.name) ? `${general.name}` : general.name;
const injuryInfo = (injury: number) => generalInjuryPresentation(injury);
@@ -86,7 +89,9 @@ onMounted(load);
<tbody>
<tr>
<td>
<br /><button class="close-button" type="button" @click="closeWindow"> 닫기</button>
<br /><button class="close-button" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</td>
</tr>
<tr>
@@ -343,7 +348,9 @@ onMounted(load);
<table class="layout legacy-bg0 footer">
<tbody>
<tr>
<td><button class="close-button" type="button" @click="closeWindow"> 닫기</button></td>
<td>
<button class="close-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</td>
</tr>
<tr>
<td class="legacy-banner">
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
@@ -7,6 +9,8 @@ import { legacyNationTextColor } from '../utils/legacyNationColor';
import LegacyHtmlEditor from '../components/ui/LegacyHtmlEditor.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
const { pageExitLabel, exitPage } = usePageExit();
type StratFinanResponse = Awaited<ReturnType<typeof trpc.nation.getStratFinan.query>>;
type NationEntry = StratFinanResponse['nationsList'][number];
@@ -172,9 +176,13 @@ onMounted(() => void loadStratFinan());
<template>
<main id="finance-container" class="page-finance">
<nav class="top-back-bar">
<RouterLink class="legacy-button legacy-button--navigation legacy-button--fixed-height" to="/"
>돌아가기</RouterLink
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="exitPage"
>
{{ pageExitLabel }}
</button>
<span />
<strong>내무부</strong>
<span />
@@ -451,7 +459,9 @@ onMounted(() => void loadStratFinan());
<input v-for="index in 4" :key="`compat-input-${index}`" type="hidden" />
</div>
<footer class="bottom-bar">
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</footer>
</template>
</main>
+10 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
@@ -6,6 +8,8 @@ import { useGameFeedback } from '../composables/useGameFeedback';
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
@@ -363,9 +367,13 @@ const submitPriority = async (section: PrioritySectionKey) => {
<template>
<main id="npc-policy-page" class="npc-page">
<nav class="top-back-bar legacy-bg0">
<RouterLink class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button" to="/"
>돌아가기</RouterLink
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button"
type="button"
@click="exitPage"
>
{{ pageExitLabel }}
</button>
<strong>NPC 정책</strong>
</nav>
+8 -3
View File
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { onMounted, ref } from 'vue';
import LegacySortControls from '../components/ui/LegacySortControls.vue';
import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type NpcList = Awaited<ReturnType<typeof trpc.public.getNpcList.query>>;
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
@@ -40,7 +44,6 @@ const load = async () => {
}
};
const closeWindow = () => window.close();
const updateSort = (value: number): void => {
sort.value = value as NpcListSort;
};
@@ -63,7 +66,7 @@ onMounted(() => {
<tr>
<td>
<br />
<button class="legacy-close" type="button" @click="closeWindow">창닫기</button>
<button class="legacy-close" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</td>
</tr>
<tr>
@@ -226,7 +229,9 @@ onMounted(() => {
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr>
<td><button class="legacy-close" type="button" @click="closeWindow">창닫기</button></td>
<td>
<button class="legacy-close" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</td>
</tr>
<tr>
<td class="banner">
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref } from 'vue';
import {
@@ -20,6 +22,8 @@ import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
type ArchiveSeason = Archive['seasons'][number] & {
sourceProfile?: string;
@@ -233,7 +237,7 @@ onMounted(() => {
<header class="title-row legacy-bg1">
<h1> 지난 플레이 보기</h1>
<nav>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="exitPage">{{ pageExitLabel }}</button>
<button class="legacy-button" type="button" :disabled="loading" @click="loadArchive">새로고침</button>
</nav>
</header>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -10,6 +11,8 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
type Reservation = Awaited<ReturnType<typeof trpc.join.getSelectionPool.mutate>>;
type Candidate = Reservation['candidates'][number];
@@ -235,6 +238,10 @@ async function loadPage(): Promise<void> {
}
const goBack = (): void => {
if (hasGeneral.value) {
exitPage();
return;
}
if (window.history.length > 1) {
router.back();
return;
@@ -260,7 +267,9 @@ onBeforeUnmount(() => {
<main class="select-pool-page legacy-bg0">
<header class="page-title with-border">
<br />
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
<button class="legacy-button" type="button" @click="goBack">
{{ hasGeneral ? pageExitLabel : '돌아가기' }}
</button>
</header>
<table v-if="serverInfo" class="server-info-table legacy-bg0">
@@ -479,7 +488,9 @@ onBeforeUnmount(() => {
<footer class="page-footer">
<div class="footer-back with-border">
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
<button class="legacy-button" type="button" @click="goBack">
{{ hasGeneral ? pageExitLabel : '돌아가기' }}
</button>
</div>
<div class="footer-banner with-border">
<small>
+8 -5
View File
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type VoteListResponse = Awaited<ReturnType<typeof trpc.vote.getVoteList.query>>;
type VoteDetail = Awaited<ReturnType<typeof trpc.vote.getVoteDetail.query>>;
type PollSummary = VoteListResponse['polls'][number];
@@ -24,7 +28,6 @@ const myComment = ref('');
const newVoteTitle = ref('');
const newVoteOptionsText = ref('');
const newVoteMultipleOptions = ref(1);
const closeWindow = (): void => window.close();
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
const getErrorMessage = (error: unknown): string => {
@@ -208,9 +211,9 @@ onMounted(() => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn"
type="button"
@click="closeWindow"
@click="exitPage"
>
닫기
{{ pageExitLabel }}
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height reload_btn"
@@ -408,9 +411,9 @@ onMounted(() => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn"
type="button"
@click="closeWindow"
@click="exitPage"
>
닫기
{{ pageExitLabel }}
</button>
</footer>
</main>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { storeToRefs } from 'pinia';
@@ -12,6 +14,8 @@ import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus';
const { pageExitLabel, exitPage } = usePageExit();
const tournamentPages = useTournamentPagesStore();
const { snapshot, betting, loading, error } = storeToRefs(tournamentPages);
type Snapshot = NonNullable<typeof snapshot.value>;
@@ -354,11 +358,9 @@ const start = async () => {
</section>
<input type="hidden" name="tournamentAction" value="join" />
<footer class="tournament-footer bg0">
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="legacy-button legacy-button--navigation close-button" type="button" @click="navigate">
닫기
</button>
</RouterLink>
<button class="legacy-button legacy-button--navigation close-button" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
</small>
+8 -4
View File
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type TrafficData = Awaited<ReturnType<typeof trpc.public.getTraffic.query>>;
const data = ref<TrafficData | null>(null);
const loading = ref(false);
const errorMessage = ref('');
const router = useRouter();
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
@@ -73,7 +75,7 @@ onMounted(() => {
<tr>
<td>
<br />
<button class="legacy-close" type="button" @click="router.push('/')">돌아가기</button>
<button class="legacy-close" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</td>
</tr>
</tbody>
@@ -198,7 +200,9 @@ onMounted(() => {
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr>
<td><button class="legacy-close" type="button" @click="router.push('/')">돌아가기</button></td>
<td>
<button class="legacy-close" type="button" @click="exitPage">{{ pageExitLabel }}</button>
</td>
</tr>
<tr>
<td class="banner">
+8 -6
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { trpc } from '../utils/trpc';
@@ -10,6 +11,8 @@ import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
const { pageExitLabel, exitPage } = usePageExit();
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
type Troop = TroopList['troops'][number];
type Member = Troop['members'][number];
@@ -25,7 +28,6 @@ const dialogKind = ref<DialogKind>(null);
const dialogTroopId = ref(0);
const popupMember = ref<Member | null>(null);
const popupTop = ref(0);
const router = useRouter();
const { success: showSuccessToast, error: showErrorToast, confirm: showConfirm } = useGameFeedback();
const me = computed(() => data.value?.me ?? null);
@@ -185,9 +187,9 @@ onMounted(() => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height legacyNavButton backLink"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height legacyNavButton reloadButton"
@@ -335,9 +337,9 @@ onMounted(() => {
<button
class="legacy-button legacy-button--navigation legacyNavButton backLink"
type="button"
@click="router.push('/')"
@click="exitPage"
>
돌아가기
{{ pageExitLabel }}
</button>
<div></div>
</footer>
+10 -13
View File
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import MapViewer from '../components/main/MapViewer.vue';
import { formatLog } from '../utils/formatLog';
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
type YearbookRange = Awaited<ReturnType<typeof trpc.yearbook.getRange.query>>;
type MapLayout = Awaited<ReturnType<typeof trpc.public.getMapLayout.query>>;
type HistoryData = {
@@ -26,7 +30,6 @@ type HistoryData = {
globalAction: string[];
};
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const errorMessage = ref('');
@@ -61,14 +64,6 @@ const availableYearMonths = computed(() => {
return values;
});
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const loadHistory = async (): Promise<void> => {
if (selectedYearMonth.value === null) {
return;
@@ -140,9 +135,9 @@ onMounted(async () => {
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="closePage"
@click="exitPage"
>
닫기
{{ pageExitLabel }}
</button>
<span class="settings-menu">
<button
@@ -242,7 +237,9 @@ onMounted(async () => {
</section>
<footer class="yearbook-footer">
<button class="legacy-button legacy-button--navigation" type="button" @click="closePage">창 닫기</button>
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</footer>
<div class="dropdown-compat-buttons" aria-hidden="true">
<button type="button" tabindex="-1" /><button type="button" tabindex="-1" />
@@ -33,13 +33,13 @@ void describe('shared Lumen button family', () => {
nationGenerals,
/legacy-button legacy-button--info legacy-button--fixed-height top-button columns-button/u
);
for (const label of ['돌아가기', '갱신', '보기 모드⌄', '열 선택⌄']) {
for (const label of ['pageExitLabel', '갱신', '보기 모드⌄', '열 선택⌄']) {
assert.match(nationGenerals, new RegExp(label, 'u'));
}
for (const label of ['토너먼트', '베팅장', '창 닫기']) {
for (const label of ['토너먼트', '베팅장', 'pageExitLabel']) {
assert.match(tournamentHeader, new RegExp(`legacy-button[\\s\\S]{0,260}${label}`, 'u'));
}
assert.match(tournamentHeader, /const closeWindow = \(\): void => window\.close\(\)/u);
assert.match(tournamentHeader, /@click="exitPage"/u);
assert.doesNotMatch(tournamentHeader, /custom to="\/"/u);
assert.match(reservedEditor, /legacy-button legacy-button--info legacy-button--fixed-height select-command/u);
assert.match(reservedEditor, / /u);
+67
View File
@@ -0,0 +1,67 @@
# 게임 페이지의 돌아가기와 창 닫기
## 제품 계약
2026-09-14부터 게임 메인에서 연결되는 내부 페이지는 페이지 이름이나 `opener`
아니라 **탭을 연 방식**으로 종료 동작을 결정합니다. 기존 Ref의 메인 복귀와 별도
창 종료 개념을 계승하면서, 직접 접속과 브라우저 새 탭 동작을 구분하는 Core UX 정책입니다.
| 진입 방식 | 표시 | 버튼 결과 |
| --------------------------------------------------------------------- | -------- | ------------------------------------------------- |
| 현재 탭에서 페이지 이동 | 돌아가기 | 같은 탭의 게임 메인으로 이동 |
| `newTab` 링크를 일반 클릭 또는 Enter로 실행 | 창 닫기 | 새로 열린 탭 종료, 원래 탭 유지 |
| Ctrl/Meta/Shift 클릭, 가운데 클릭, 브라우저 새 탭 열기, URL 직접 접속 | 돌아가기 | 해당 탭을 유지하고 게임 메인으로 이동 |
| 앱이 연 탭 안에서 페이지 이동 또는 새로고침 | 창 닫기 | 같은 보조 탭 종료 |
| 브라우저가 앱의 새 창 열기를 차단 | 돌아가기 | 현재 탭에서 목적지를 열고 같은 탭의 메인으로 복귀 |
‘창 닫기’는 `window.close()`만 호출하며 메인 이동으로 대체하지 않습니다.
‘돌아가기’는 브라우저 history의 직전 페이지가 아닌 현재 게임 prefix의 `/`로 이동합니다.
버튼의 원래 위치·스타일·상하단 구성은 유지합니다. 전투 시뮬레이터에는 빠져 있던
페이지 종료 버튼을 상단에 추가했습니다. 게시판 조회·접근 실패 화면에도 복귀 버튼을 제공합니다.
## 구현
- `app/game-frontend/src/main.ts`에서 `installAuxiliaryNavigation()`을 설치합니다.
- `app/game-frontend/src/utils/auxiliaryNavigation.ts`는 게임 prefix 안의 `target="_blank"` 링크에
대한 일반 클릭만 처리합니다. 따라서 공통·국가 메뉴, 모바일 메뉴, 설문 상태 제목과
새 설문 알림도 동일한 진입 처리를 사용합니다.
- 앱이 실제로 연 빈 탭에 고유한 `window.name`을 지정하고 `opener`를 분리한 뒤
목적지로 이동합니다. `noreferrer`도 새 문서의 referrer 정책으로 보존합니다.
- `app/game-frontend/src/composables/usePageExit.ts`가 표식 유무로 문구와 동작을 함께 결정합니다.
이 표식은 UI 상태이며 인증·권한 근거가 아닙니다. URL, localStorage 또는
sessionStorage를 표식으로 사용하지 않아 복사한 링크와 사용자가 연 탭에 전파하지 않습니다.
- 외부 웹사이트 및 다른 앱 prefix의 링크는 해당 앱의 탐색 정책을 따릅니다.
게임 안의 대화상자·선택창 ‘닫기’는 해당 대화상자를 닫는 기존 동작입니다.
- 장수 선택의 경우 이미 장수가 있는 메인 진입 흐름에는 이 정책을 사용하고,
아직 장수가 없는 가입 흐름의 복귀 경로는 유지합니다.
## 적용 화면
공통 메뉴: 천통국 베팅, 세력일람, 장수일람, 명장일람, 연감, 전투 시뮬레이터,
명예의전당, 왕조일람·상세, 접속량정보, 빙의일람, 설문조사.
국가 메뉴: 회의실·기밀실, 부대 편성, 외교부, 인사부, 내무부, 사령부, NPC 정책,
암행부, 토너먼트·베팅장, 세력 정보·도시·장수, 중원 정보, 현재 도시, 감찰부,
유산 관리, 내 정보, 금/쌀·유니크 경매장, 환경 설정.
후속 화면인 과거 플레이 기록 및 기존 장수의 장수 선택에도 같은 정책을 적용합니다.
## 검증
`e2e/mainNavigation.spec.ts`에서 메인의 실제 링크 클릭으로 팝업을 얻고,
상하단 종료, 원래 탭 URL 유지와 `context.pages()` 수를 검증합니다.
직접 접속·Ctrl 클릭·가운데 클릭, 같은 탭 이동, 보조 탭 내부 이동·새로고침을
구분합니다. 전체 메뉴 순회는 API 조회 실패 시에도 종료 동작을 제공하는지 확인합니다.
정상 데이터 렌더링은 기존 페이지별 fixture suite와 함께 검증합니다.
실행 예:
```sh
PLAYWRIGHT_FRONTEND_PORT=15261 PLAYWRIGHT_FRONTEND_MODE=production \
pnpm --filter @sammo-ts/game-frontend exec playwright test mainNavigation.spec.ts \
--config e2e/playwright.config.mjs \
--grep 'page exit policy|all main page exits|closes the survey popup'
```
브라우저의 종료 제한 근거:
[MDN Window.close](https://developer.mozilla.org/en-US/docs/Web/API/Window/close).