Merge remote-tracking branch 'origin/main' into refactor/shared-general-panel-20260813
# Conflicts: # app/game-frontend/src/views/MyPageView.vue
This commit is contained in:
@@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
|
||||
'src',
|
||||
'https://sam-image.hided.net/icons/22.jpg'
|
||||
);
|
||||
await expect(page.locator('.article-header .date')).toHaveText('07-26 19:20');
|
||||
await expect(page.locator('.comment-row .date')).toHaveText('07-26 19:25');
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, 'board-core-desktop.png'),
|
||||
@@ -392,7 +394,9 @@ test('uses the ref 500px responsive form widths', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }, testInfo) => {
|
||||
test('retains article and comment input after a failed mutation, then reloads after success', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: BoardFixture = {
|
||||
permission: 2,
|
||||
canMeeting: true,
|
||||
@@ -408,7 +412,9 @@ test('retains article and comment input after a failed mutation, then reloads af
|
||||
await page.locator('#board-title').fill('새 제목');
|
||||
await page.locator('#board-content').fill('새 내용');
|
||||
await page.locator('#submitArticle').click();
|
||||
const articleToast = page.getByTestId('game-toast').filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
|
||||
const articleToast = page
|
||||
.getByTestId('game-toast')
|
||||
.filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
|
||||
await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error');
|
||||
await expect(articleToast).toHaveAttribute('role', 'alert');
|
||||
await expect(page.locator('#board-title')).toHaveValue('새 제목');
|
||||
@@ -416,7 +422,13 @@ test('retains article and comment input after a failed mutation, then reloads af
|
||||
|
||||
const desktopToastGeometry = await articleToast.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth };
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
});
|
||||
expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth);
|
||||
@@ -435,10 +447,14 @@ test('retains article and comment input after a failed mutation, then reloads af
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
await commentInput.press('Enter');
|
||||
const commentToast = page.getByTestId('game-toast').filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
|
||||
const commentToast = page
|
||||
.getByTestId('game-toast')
|
||||
.filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
|
||||
await expect(commentToast).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom))
|
||||
.poll(async () =>
|
||||
commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)
|
||||
)
|
||||
.toBeGreaterThanOrEqual(0);
|
||||
const mobileToastGeometry = await commentToast.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
@@ -34,6 +34,8 @@ type NavigationFixture = {
|
||||
largeCommandTable?: boolean;
|
||||
currentYear?: number;
|
||||
currentMonth?: number;
|
||||
scenarioTitle?: string;
|
||||
latestVote?: { id: number; title: string; hasVoted: boolean } | null;
|
||||
globalRecords?: Array<{ id: number; text: string }>;
|
||||
generalRecords?: Array<{ id: number; text: string }>;
|
||||
worldHistory?: Array<{ id: number; text: string }>;
|
||||
@@ -309,6 +311,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
year: state.currentYear ?? 185,
|
||||
month: state.currentMonth ?? 1,
|
||||
turnTerm: 10,
|
||||
scenarioTitle: state.scenarioTitle ?? '',
|
||||
});
|
||||
}
|
||||
if (operation === 'dashboard.getContextBundleDelta') {
|
||||
@@ -421,7 +424,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
onlineGenerals: '메뉴검증장수',
|
||||
nationNotice: '<p>국가 방침</p>',
|
||||
lastExecuted: null,
|
||||
latestVote: { id: 9, title: '메뉴 설문', hasVoted: false },
|
||||
latestVote:
|
||||
state.latestVote === undefined
|
||||
? { id: 9, title: '메뉴 설문', hasVoted: false }
|
||||
: state.latestVote,
|
||||
});
|
||||
}
|
||||
if (operation === 'board.getAccess') {
|
||||
@@ -508,7 +514,7 @@ const installRealtimeHarness = async (page: Page) => {
|
||||
|
||||
const waitForMain = async (page: Page) => {
|
||||
await page.goto('./');
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await expect(page.locator('.game-shell__title')).toBeVisible();
|
||||
await expect(page.locator('.main-global-menu').first()).toBeVisible();
|
||||
await expect(page.locator('.main-nation-menu')).toBeVisible();
|
||||
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3);
|
||||
@@ -561,8 +567,24 @@ const persistArtifact = async (page: Page, name: string) => {
|
||||
globalPopup: describe('#mobile-global-menu'),
|
||||
nationPopup: describe('#mobile-nation-menu'),
|
||||
quickPopup: describe('#mobile-quick-menu'),
|
||||
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
|
||||
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
|
||||
(element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
borderTop: style.borderTop,
|
||||
margin: style.margin,
|
||||
};
|
||||
}
|
||||
),
|
||||
};
|
||||
});
|
||||
const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
|
||||
if (await commandMenu.isVisible()) {
|
||||
await commandMenu.screenshot({ path: resolve(target, `${name}-menu.png`) });
|
||||
}
|
||||
await Promise.all([
|
||||
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
|
||||
writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
|
||||
@@ -576,6 +598,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
nationLevel: 3,
|
||||
stage: 1,
|
||||
npcMode: 1,
|
||||
scenarioTitle: '메인 화면 검증 시나리오',
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
};
|
||||
@@ -589,6 +612,45 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await expect(page.locator('.main-mobile-bottom')).toBeHidden();
|
||||
await expect(page.locator('.layout-desktop')).toBeVisible();
|
||||
await expect(page.locator('.layout-mobile')).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
|
||||
await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오');
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
|
||||
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
|
||||
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
|
||||
const title = element.querySelector<HTMLElement>('.game-shell__title');
|
||||
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle');
|
||||
const activity = element.querySelector<HTMLElement>('.activity-status');
|
||||
const tournament = element.querySelector<HTMLElement>('.tournament-status');
|
||||
const survey = element.querySelector<HTMLElement>('.vote-status');
|
||||
if (!title || !subtitle || !activity || !tournament || !survey) {
|
||||
throw new Error('main header status geometry is incomplete');
|
||||
}
|
||||
return {
|
||||
title: title.getBoundingClientRect().toJSON(),
|
||||
subtitle: subtitle.getBoundingClientRect().toJSON(),
|
||||
activity: activity.getBoundingClientRect().toJSON(),
|
||||
tournament: tournament.getBoundingClientRect().toJSON(),
|
||||
survey: survey.getBoundingClientRect().toJSON(),
|
||||
activityColumns: getComputedStyle(activity).gridTemplateColumns,
|
||||
};
|
||||
});
|
||||
expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom);
|
||||
expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0);
|
||||
expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
|
||||
expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
|
||||
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2);
|
||||
const tournamentStatusLink = page.locator('.tournament-status a');
|
||||
const surveyStatusLink = page.locator('.vote-status a');
|
||||
await tournamentStatusLink.hover();
|
||||
await expect
|
||||
.poll(() => tournamentStatusLink.evaluate((element) => getComputedStyle(element).cursor))
|
||||
.toBe('pointer');
|
||||
await surveyStatusLink.focus();
|
||||
await expect(surveyStatusLink).toBeFocused();
|
||||
await expect
|
||||
.poll(() => surveyStatusLink.evaluate((element) => getComputedStyle(element).textDecorationLine))
|
||||
.toContain('underline');
|
||||
const contentOrder = await page
|
||||
.locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]')
|
||||
.evaluateAll((elements) =>
|
||||
@@ -642,7 +704,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await expect(gameInfoButton).toBeFocused();
|
||||
|
||||
await gameInfoButton.click();
|
||||
await page.getByRole('heading', { name: '전장 현황' }).click();
|
||||
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
|
||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
@@ -882,6 +944,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
await tenthTurnButton.click();
|
||||
const quickPicker = page.getByTestId('command-picker');
|
||||
await expect(quickPicker).toBeVisible();
|
||||
await tenthTurnButton.click();
|
||||
await expect(quickPicker).toBeHidden();
|
||||
await tenthTurnButton.click();
|
||||
await expect(quickPicker).toBeVisible();
|
||||
const quickPickerAlignment = await quickPicker.evaluate((element) => {
|
||||
const row = element
|
||||
.closest('.reserved-command-editor')
|
||||
@@ -923,6 +989,30 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
|
||||
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
|
||||
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
|
||||
const rangeMenu = page.locator('[data-main-target="commands"] .range-menu');
|
||||
await rangeMenu.locator('summary').click();
|
||||
const rangeDividers = rangeMenu.locator('.menu-divider');
|
||||
await expect(rangeDividers).toHaveCount(1);
|
||||
await expect(rangeDividers.first()).toBeVisible();
|
||||
expect(await rangeDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
|
||||
'1px solid rgb(68, 68, 68)'
|
||||
);
|
||||
await persistArtifact(page, `${basePath.slice(1)}-command-range-divider-desktop-1200`);
|
||||
await rangeMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
|
||||
await expect(rangeMenu).not.toHaveAttribute('open', '');
|
||||
|
||||
const selectedMenu = page.locator('[data-main-target="commands"] .selected-menu');
|
||||
await selectedMenu.locator('summary').click();
|
||||
const selectedMenuDividers = selectedMenu.locator('.menu-divider');
|
||||
await expect(selectedMenuDividers).toHaveCount(3);
|
||||
await expect(selectedMenuDividers.first()).toBeVisible();
|
||||
expect(await selectedMenuDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
|
||||
'1px solid rgb(68, 68, 68)'
|
||||
);
|
||||
await persistArtifact(page, `${basePath.slice(1)}-command-selected-dividers-desktop-1200`);
|
||||
await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
|
||||
await expect(selectedMenu).not.toHaveAttribute('open', '');
|
||||
|
||||
await page.locator('[data-main-target="commands"] .select-command').click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
@@ -1111,6 +1201,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
expect(mobileGeometry.controlBoxes).toHaveLength(3);
|
||||
expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
|
||||
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30);
|
||||
const mobileTurnButton = page.getByRole('button', { name: '10턴 명령 입력' });
|
||||
await mobileTurnButton.click();
|
||||
await expect(page.getByTestId('command-picker')).toBeVisible();
|
||||
await mobileTurnButton.click();
|
||||
await expect(page.getByTestId('command-picker')).toBeHidden();
|
||||
await captureProgress('mobile-500');
|
||||
});
|
||||
|
||||
@@ -1121,6 +1216,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
|
||||
nationLevel: 3,
|
||||
stage: 6,
|
||||
npcMode: 1,
|
||||
scenarioTitle: '모바일 검증 시나리오',
|
||||
latestVote: null,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
};
|
||||
@@ -1139,6 +1236,27 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
|
||||
await expect(page.locator('.main-mobile-bottom')).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
|
||||
await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
|
||||
const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
|
||||
const tournament = element.querySelector<HTMLElement>('.tournament-status');
|
||||
const survey = element.querySelector<HTMLElement>('.vote-status');
|
||||
if (!tournament || !survey) throw new Error('activity status is incomplete');
|
||||
return {
|
||||
width: element.getBoundingClientRect().width,
|
||||
tournamentWidth: tournament.getBoundingClientRect().width,
|
||||
surveyWidth: survey.getBoundingClientRect().width,
|
||||
columns: getComputedStyle(element).gridTemplateColumns,
|
||||
};
|
||||
});
|
||||
expect(activityGeometry).toMatchObject({
|
||||
width: 500,
|
||||
tournamentWidth: 250,
|
||||
surveyWidth: 250,
|
||||
columns: '250px 250px',
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
@@ -1175,6 +1293,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
|
||||
});
|
||||
|
||||
test('real mobile devices initially fit the complete 500px game canvas', async ({ browser }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||
if (typeof configuredBaseUrl !== 'string') {
|
||||
throw new Error('Playwright baseURL is required for the mobile viewport contract');
|
||||
}
|
||||
|
||||
const deviceWidths = [360, 390, 480];
|
||||
const measurements: Record<string, unknown> = {};
|
||||
|
||||
for (const deviceWidth of deviceWidths) {
|
||||
const context = await browser.newContext({
|
||||
baseURL: configuredBaseUrl,
|
||||
viewport: { width: deviceWidth, height: 844 },
|
||||
screen: { width: deviceWidth, height: 844 },
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const mobilePage = await context.newPage();
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 6,
|
||||
npcMode: 1,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
};
|
||||
await installFixture(mobilePage, state);
|
||||
await waitForMain(mobilePage);
|
||||
|
||||
const mainGeometry = await mobilePage.locator('.main-page').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
|
||||
screenWidth: screen.availWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
layoutViewportWidth: document.documentElement.clientWidth,
|
||||
visualViewportWidth: window.visualViewport?.width ?? null,
|
||||
visualViewportScale: window.visualViewport?.scale ?? null,
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
canvas: {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
width: rect.width,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(mainGeometry.viewportMeta).toBe('width=500');
|
||||
expect(mainGeometry.screenWidth).toBe(deviceWidth);
|
||||
expect(mainGeometry.layoutViewportWidth).toBe(500);
|
||||
expect(mainGeometry.visualViewportWidth).toBeCloseTo(500, 2);
|
||||
expect(mainGeometry.visualViewportScale).toBeCloseTo(deviceWidth / 500, 2);
|
||||
expect(mainGeometry.documentScrollWidth).toBeLessThanOrEqual(mainGeometry.innerWidth);
|
||||
expect(mainGeometry.canvas).toEqual({ left: 0, right: 500, width: 500 });
|
||||
expect(mainGeometry.canvas.right).toBeLessThanOrEqual((mainGeometry.visualViewportWidth ?? 0) + 0.01);
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await mobilePage.screenshot({
|
||||
path: resolve(artifactRoot, `initial-mobile-fit-${deviceWidth}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
const routeGeometry: Record<string, unknown> = {};
|
||||
if (deviceWidth === 390) {
|
||||
for (const target of [
|
||||
'chief-center',
|
||||
'battle-center',
|
||||
'inherit',
|
||||
'nation-betting',
|
||||
]) {
|
||||
await mobilePage.goto(target);
|
||||
await expect
|
||||
.poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth))
|
||||
.toBe('500px');
|
||||
routeGeometry[target] = await mobilePage.locator('#app').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
|
||||
layoutViewportWidth: document.documentElement.clientWidth,
|
||||
visualViewportWidth: window.visualViewport?.width ?? null,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
width: rect.width,
|
||||
};
|
||||
});
|
||||
const geometry = routeGeometry[target] as {
|
||||
viewportMeta: string;
|
||||
layoutViewportWidth: number;
|
||||
visualViewportWidth: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
};
|
||||
expect(geometry.viewportMeta).toBe('width=500');
|
||||
expect(geometry.layoutViewportWidth).toBe(500);
|
||||
expect(geometry.visualViewportWidth).toBeCloseTo(500, 2);
|
||||
expect(geometry.left).toBeCloseTo(0, 2);
|
||||
expect(geometry.right).toBeCloseTo(500, 2);
|
||||
expect(geometry.width).toBeCloseTo(500, 2);
|
||||
}
|
||||
}
|
||||
|
||||
measurements[String(deviceWidth)] = { main: mainGeometry, routes: routeGeometry };
|
||||
await context.close();
|
||||
}
|
||||
|
||||
if (artifactRoot) {
|
||||
await writeFile(
|
||||
resolve(artifactRoot, 'initial-mobile-fit-computed-dom.json'),
|
||||
`${JSON.stringify(measurements, null, 2)}\n`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 1,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=500" />
|
||||
<title>Sammo HiDCHe - Game</title>
|
||||
</head>
|
||||
<body class="bg-black text-white">
|
||||
|
||||
@@ -149,6 +149,14 @@ const closePicker = () => {
|
||||
quickTarget.value = null;
|
||||
selectedCommand.value = null;
|
||||
};
|
||||
const togglePicker = (turnIndex?: number) => {
|
||||
const target = turnIndex ?? null;
|
||||
if (pickerOpen.value && quickTarget.value === target) {
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
openPicker(turnIndex);
|
||||
};
|
||||
const selectCommand = (commandKey: string) => {
|
||||
const command = props.commandTable?.[props.scope]
|
||||
.flatMap((group) => group.values)
|
||||
@@ -305,6 +313,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
>
|
||||
짝수턴
|
||||
</button>
|
||||
<hr class="menu-divider" />
|
||||
<template v-for="step in [3, 4, 5, 6, 7]" :key="step">
|
||||
<small>{{ step }}턴 간격</small>
|
||||
<div class="step-buttons">
|
||||
@@ -429,6 +438,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
>
|
||||
붙여넣기
|
||||
</button>
|
||||
<hr class="menu-divider" />
|
||||
<button
|
||||
@click="
|
||||
textCopy();
|
||||
@@ -437,6 +447,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
>
|
||||
텍스트 복사
|
||||
</button>
|
||||
<hr class="menu-divider" />
|
||||
<button
|
||||
@click="
|
||||
saveTemplate();
|
||||
@@ -453,6 +464,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
>
|
||||
반복하기
|
||||
</button>
|
||||
<hr class="menu-divider" />
|
||||
<button
|
||||
@click="
|
||||
clearSelection();
|
||||
@@ -479,7 +491,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" class="select-command" @click="openPicker()">명령 선택 ▾</button>
|
||||
<button type="button" class="select-command" @click="togglePicker()">명령 선택 ▾</button>
|
||||
</div>
|
||||
|
||||
<div class="queue-area">
|
||||
@@ -542,7 +554,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
:key="row.index"
|
||||
type="button"
|
||||
:aria-label="`${row.index + 1}턴 명령 입력`"
|
||||
@click="openPicker(row.index)"
|
||||
@click="togglePicker(row.index)"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
@@ -708,6 +720,14 @@ const clickOutsideMenu = (event: Event) => {
|
||||
padding: 5px 8px;
|
||||
color: #bbb;
|
||||
}
|
||||
.menu-divider {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
margin: 4px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #444;
|
||||
opacity: 1;
|
||||
}
|
||||
.step-buttons,
|
||||
.template-row {
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { computed } from 'vue';
|
||||
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
|
||||
|
||||
const props = defineProps<{
|
||||
tournamentStage: number;
|
||||
status: {
|
||||
onlineUserCount: number;
|
||||
onlineNations: string;
|
||||
@@ -13,15 +17,24 @@ defineProps<{
|
||||
} | null;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="front-status" aria-label="접속 현황과 국가 방침">
|
||||
<div class="status-row vote-status">
|
||||
<RouterLink v-if="status?.latestVote" to="/survey">
|
||||
<span class="vote-label">설문 진행 중: </span>{{ status.latestVote.title }}
|
||||
</RouterLink>
|
||||
<span v-else class="vote-empty">진행중인 설문 없음</span>
|
||||
<div class="activity-status" aria-label="설문과 토너먼트 진행 현황">
|
||||
<div class="status-row tournament-status">
|
||||
<RouterLink to="/tournament">
|
||||
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="status-row vote-status">
|
||||
<RouterLink v-if="status?.latestVote" to="/survey">
|
||||
<span class="vote-label">설문: </span>{{ status.latestVote.title }}
|
||||
</RouterLink>
|
||||
<span v-else class="vote-empty">설문: 진행 중인 설문 없음</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
|
||||
<div class="status-row online-users">【 접속자 】 {{ status?.onlineGenerals ?? '' }}</div>
|
||||
@@ -71,19 +84,28 @@ defineProps<{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vote-status {
|
||||
width: 33.333333%;
|
||||
.activity-status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
width: 66.666667%;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.activity-status .status-row {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vote-status a {
|
||||
.activity-status a {
|
||||
color: #fff;
|
||||
text-decoration: gray underline;
|
||||
}
|
||||
|
||||
.tournament-label {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.vote-label {
|
||||
color: cyan;
|
||||
}
|
||||
@@ -93,8 +115,8 @@ defineProps<{
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.vote-status {
|
||||
width: 50%;
|
||||
.activity-status {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
const KOREA_TIME_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
export const formatSeoulDateTime = (value: string | Date): string => formatServerDateTime(value);
|
||||
|
||||
export const formatSeoulDateTime = (value: string | Date): string => {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())
|
||||
) {
|
||||
return value.trim().replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return typeof value === 'string' ? value.slice(0, 19) : '';
|
||||
}
|
||||
const koreaTime = new Date(date.getTime() + KOREA_TIME_OFFSET_MS);
|
||||
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
||||
koreaTime.getUTCDate()
|
||||
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
|
||||
koreaTime.getUTCSeconds()
|
||||
)}`;
|
||||
};
|
||||
|
||||
export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16);
|
||||
export const formatSeoulHourMinute = (value: string | Date): string =>
|
||||
formatServerDateTime(value, { format: 'hourMinute' });
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const tournamentStageNames = [
|
||||
'경기 없음',
|
||||
'참가 모집중',
|
||||
'예선 진행중',
|
||||
'본선 추첨중',
|
||||
'본선 진행중',
|
||||
'16강 배정중',
|
||||
'베팅 진행중',
|
||||
'16강 진행중',
|
||||
'8강 진행중',
|
||||
'4강 진행중',
|
||||
'결승 진행중',
|
||||
] as const;
|
||||
|
||||
export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중';
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
@@ -41,24 +42,10 @@ const formatNumber = (value: number | null | undefined): string => (value ?? 0).
|
||||
const displayCode = (value: string | null | undefined): string =>
|
||||
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
|
||||
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value.slice(5, showSecond ? 19 : 16);
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(showSecond ? { second: '2-digit' } : {}),
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((entry) => entry.type === type)?.value ?? '';
|
||||
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
|
||||
return formatServerDateTime(value, {
|
||||
format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
|
||||
fallback: '-',
|
||||
});
|
||||
};
|
||||
|
||||
const buyRice = computed(() =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
@@ -126,9 +127,9 @@ const selectedGeneral = computed(() => {
|
||||
|
||||
const formatGeneralLabel = (general: GeneralEntry): string => {
|
||||
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
|
||||
const time = general.turnTime ? general.turnTime.slice(-5) : '--:--';
|
||||
const time = formatServerDateTime(general.turnTime, { format: 'hourMinute', fallback: '--:--' });
|
||||
if (orderBy.value === 'recentWar') {
|
||||
return `${name} (${general.recentWar ? general.recentWar.slice(-5) : '--:--'})`;
|
||||
return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
|
||||
}
|
||||
if (orderBy.value === 'warnum') {
|
||||
return `${name} (${general.warnum}회)`;
|
||||
@@ -154,7 +155,10 @@ const loadLogs = async (generalId: number) => {
|
||||
}
|
||||
for (const response of responses) {
|
||||
const formatted = response.logs.map((entry) => {
|
||||
const eventTime = response.type === 'generalAction' ? ` ${entry.createdAt.slice(-8, -3)}` : '';
|
||||
const eventTime =
|
||||
response.type === 'generalAction'
|
||||
? ` ${formatServerDateTime(entry.createdAt, { format: 'hourMinute' })}`
|
||||
: '';
|
||||
return {
|
||||
id: entry.id,
|
||||
html: formatLog(`${entry.text}${eventTime}`),
|
||||
@@ -288,7 +292,12 @@ onMounted(() => {
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
<div v-if="selectedGeneral" class="general-meta">
|
||||
<div>최근 턴: {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
|
||||
<div>
|
||||
최근 턴:
|
||||
{{
|
||||
formatServerDateTime(selectedGeneral.turnTime, { format: 'hourMinute', fallback: '-' })
|
||||
}}
|
||||
</div>
|
||||
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
|
||||
<div>전투 횟수: {{ selectedGeneral.warnum }}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -68,7 +69,9 @@ const ratio = (id: number) => {
|
||||
const amount = totals?.[id] ?? 0;
|
||||
return amount ? (totalAmount.value / amount).toFixed(2) : '0';
|
||||
};
|
||||
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--');
|
||||
const openingTime = computed(() =>
|
||||
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||
);
|
||||
const expected = (id: number) => {
|
||||
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
|
||||
const current = myTotals?.[id] ?? 0;
|
||||
@@ -248,8 +251,7 @@ const placeBet = async (targetId: number) => {
|
||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||
</RouterLink>
|
||||
<small>
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) / Credit
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
|
||||
</small>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
@@ -40,7 +41,7 @@ const resizeTextArea = (element: HTMLTextAreaElement | null) => {
|
||||
element.style.height = `${Math.max(element.scrollHeight, 42)}px`;
|
||||
};
|
||||
|
||||
const formatDate = (value: string): string => value.slice(5, 16).replace('T', ' ');
|
||||
const formatDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
|
||||
|
||||
const iconPath = (article: BoardArticle): string =>
|
||||
resolveGeneralIconUrl({
|
||||
@@ -160,7 +161,14 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="article-submit-row">
|
||||
<div></div>
|
||||
<button id="submitArticle" class="legacy-button legacy-button--secondary" type="button" @click="submitArticle">등록</button>
|
||||
<button
|
||||
id="submitArticle"
|
||||
class="legacy-button legacy-button--secondary"
|
||||
type="button"
|
||||
@click="submitArticle"
|
||||
>
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -244,7 +252,9 @@ onMounted(() => {
|
||||
padding: 8px;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
font: 16px/normal 'Times New Roman', serif;
|
||||
font:
|
||||
16px/normal 'Times New Roman',
|
||||
serif;
|
||||
}
|
||||
|
||||
.legacy-board-page {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
@@ -47,12 +48,7 @@ const loadDetail = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatArchiveDate = (value: string): string =>
|
||||
new Intl.DateTimeFormat('sv-SE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date(value));
|
||||
const formatArchiveDate = (value: string): string => formatServerDateTime(value);
|
||||
|
||||
watch(emperorId, loadDetail);
|
||||
onMounted(loadDetail);
|
||||
@@ -67,7 +63,9 @@ onMounted(loadDetail);
|
||||
역 대 왕 조<br />
|
||||
<button class="native-button" type="button" @click="closePage">창 닫기</button>
|
||||
<span class="all-link">
|
||||
<RouterLink to="/dynasty"><button class="native-button" type="button">전체보기</button></RouterLink>
|
||||
<RouterLink to="/dynasty"
|
||||
><button class="native-button" type="button">전체보기</button></RouterLink
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -202,7 +200,11 @@ onMounted(loadDetail);
|
||||
<td colspan="5">
|
||||
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-for="(entry, index) in data.emperor.history" :key="index" v-html="formatLog(entry)" />
|
||||
<div
|
||||
v-for="(entry, index) in data.emperor.history"
|
||||
:key="index"
|
||||
v-html="formatLog(entry)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -283,14 +285,10 @@ 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="closePage">창 닫기</button><br /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
||||
</td>
|
||||
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -169,11 +170,7 @@ const turnTimeLabel = computed(() => {
|
||||
if (!turnTimeResult.value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(turnTimeResult.value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return turnTimeResult.value;
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
return formatServerDateTime(turnTimeResult.value);
|
||||
});
|
||||
|
||||
const isUnited = computed(() => status.value?.isUnited ?? false);
|
||||
@@ -735,7 +732,7 @@ onMounted(() => {
|
||||
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
|
||||
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
|
||||
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
|
||||
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
|
||||
<small>[{{ formatServerDateTime(entry.createdAt) }}]</small>
|
||||
<span>{{ entry.text }}</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
@@ -68,14 +69,8 @@ const nationColor = computed(() => nation.value?.color ?? '#000000');
|
||||
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
|
||||
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
|
||||
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
|
||||
const parsed = entry.createdAt ? new Date(entry.createdAt) : null;
|
||||
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text);
|
||||
const time = new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(parsed);
|
||||
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
|
||||
if (!time) return formatLog(entry.text);
|
||||
return formatLog(`${entry.text} ${time}`);
|
||||
};
|
||||
|
||||
@@ -148,13 +143,9 @@ watch(
|
||||
<header class="game-shell__header">
|
||||
<div>
|
||||
<h1 class="game-shell__title">
|
||||
{{ isMobile ? '전장 현황' : lobbyInfo?.scenarioTitle || '전장 현황' }}
|
||||
{{ lobbyInfo?.scenarioTitle || '전장 현황' }}
|
||||
</h1>
|
||||
<p class="game-shell__subtitle">
|
||||
{{
|
||||
!isMobile && lobbyInfo?.scenarioTitle ? `${lobbyInfo.scenarioTitle} ${statusLine}` : statusLine
|
||||
}}
|
||||
</p>
|
||||
<p class="game-shell__subtitle">{{ statusLine }}</p>
|
||||
</div>
|
||||
<div class="game-shell__actions desktop-action-controls">
|
||||
<button
|
||||
@@ -197,7 +188,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<div data-main-target="policy">
|
||||
<MainFrontStatus :status="frontStatus" />
|
||||
<MainFrontStatus :status="frontStatus" :tournament-stage="tournamentStage" />
|
||||
</div>
|
||||
|
||||
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
|
||||
@@ -147,7 +148,7 @@ onMounted(load);
|
||||
>
|
||||
</td>
|
||||
<td>{{ general.killTurn }}</td>
|
||||
<td>{{ general.turnTime.slice(14, 19) }}</td>
|
||||
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -159,8 +160,8 @@ onMounted(load);
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-banner">
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) /
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
|
||||
/
|
||||
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -67,16 +68,9 @@ const newVoteOptions = computed(() => newVoteOptionsText.value.split('\n').filte
|
||||
|
||||
const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1);
|
||||
|
||||
const formatStartDate = (value: string): string => value.slice(0, 10);
|
||||
const formatStartDate = (value: string): string => formatServerDateTime(value, { format: 'date' });
|
||||
|
||||
const formatCommentDate = (value: string): string => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
const pad = (part: number) => String(part).padStart(2, '0');
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
const formatCommentDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
|
||||
|
||||
const voteColor = (index: number): string =>
|
||||
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||
|
||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||
|
||||
@@ -14,20 +16,6 @@ const actionMessage = ref<string | null>(null);
|
||||
const adminEnabled = ref(false);
|
||||
|
||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||
const stageNames = [
|
||||
'경기 없음',
|
||||
'참가 모집중',
|
||||
'예선 진행중',
|
||||
'본선 추첨중',
|
||||
'본선 진행중',
|
||||
'16강 배정중',
|
||||
'베팅 진행중',
|
||||
'16강 진행중',
|
||||
'8강 진행중',
|
||||
'4강 진행중',
|
||||
'결승 진행중',
|
||||
];
|
||||
|
||||
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
|
||||
|
||||
const load = async () => {
|
||||
@@ -62,7 +50,9 @@ const matchesAt = (stage: number) =>
|
||||
.sort((a, b) => a.roundIndex - b.roundIndex);
|
||||
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
|
||||
const totalBet = computed(() => betting.value?.totalAmount ?? 0);
|
||||
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--');
|
||||
const openingTime = computed(() =>
|
||||
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||
);
|
||||
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
|
||||
const isParticipant = computed(() =>
|
||||
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
||||
@@ -152,7 +142,7 @@ const start = async () => {
|
||||
<section class="operator-row bg0">운영자 메세지 : <span></span></section>
|
||||
<section class="state-row bg0">
|
||||
<span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
|
||||
({{ stageNames[snapshot?.state?.stage ?? 0] ?? '상태 확인 중' }}, 개막시간 {{ openingTime }}, 경기당
|
||||
({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
|
||||
{{ snapshot?.state?.termSeconds ?? '-' }}초)
|
||||
</section>
|
||||
<section class="section-title bg2">16강 승자전</section>
|
||||
@@ -287,8 +277,7 @@ const start = async () => {
|
||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||
</RouterLink>
|
||||
<small>
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) / Credit
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
|
||||
</small>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -50,10 +51,7 @@ const onlineRows = computed(() =>
|
||||
}))
|
||||
);
|
||||
|
||||
const timeLabel = (value: string): string => {
|
||||
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
|
||||
return (timePart ?? '').slice(0, 5);
|
||||
};
|
||||
const timeLabel = (value: string): string => formatServerDateTime(value, { format: 'hourMinute' });
|
||||
|
||||
const trafficColor = (percentage: number): string => {
|
||||
const channel = (value: number): string =>
|
||||
@@ -204,8 +202,8 @@ onMounted(() => {
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) /
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
|
||||
/
|
||||
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -166,10 +167,7 @@ const hideMemberPopup = () => {
|
||||
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
|
||||
|
||||
const formatTurn = (turnTime: string | null): string => {
|
||||
if (!turnTime) {
|
||||
return '--:--';
|
||||
}
|
||||
return turnTime.slice(14, 19);
|
||||
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { resolveTournamentStageName } from '../src/utils/tournamentStatus.ts';
|
||||
|
||||
void describe('tournament status labels', () => {
|
||||
void it('describes inactive and active tournament stages', () => {
|
||||
assert.equal(resolveTournamentStageName(0), '경기 없음');
|
||||
assert.equal(resolveTournamentStageName(1), '참가 모집중');
|
||||
assert.equal(resolveTournamentStageName(6), '베팅 진행중');
|
||||
assert.equal(resolveTournamentStageName(10), '결승 진행중');
|
||||
});
|
||||
|
||||
void it('uses a safe fallback for an unknown stage', () => {
|
||||
assert.equal(resolveTournamentStageName(11), '상태 확인 중');
|
||||
assert.equal(resolveTournamentStageName(-1), '상태 확인 중');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user