Merge remote-tracking branch 'origin/main' into fix/turn-selector-divider-20260813
This commit is contained in:
@@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
|
|||||||
'src',
|
'src',
|
||||||
'https://sam-image.hided.net/icons/22.jpg'
|
'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) {
|
if (artifactRoot) {
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: resolve(artifactRoot, 'board-core-desktop.png'),
|
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();
|
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 = {
|
const state: BoardFixture = {
|
||||||
permission: 2,
|
permission: 2,
|
||||||
canMeeting: true,
|
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-title').fill('새 제목');
|
||||||
await page.locator('#board-content').fill('새 내용');
|
await page.locator('#board-content').fill('새 내용');
|
||||||
await page.locator('#submitArticle').click();
|
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('data-feedback-kind', 'error');
|
||||||
await expect(articleToast).toHaveAttribute('role', 'alert');
|
await expect(articleToast).toHaveAttribute('role', 'alert');
|
||||||
await expect(page.locator('#board-title')).toHaveValue('새 제목');
|
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 desktopToastGeometry = await articleToast.evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
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.left).toBeGreaterThanOrEqual(0);
|
||||||
expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth);
|
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 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
|
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||||
await commentInput.press('Enter');
|
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(commentToast).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom))
|
.poll(async () =>
|
||||||
|
commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)
|
||||||
|
)
|
||||||
.toBeGreaterThanOrEqual(0);
|
.toBeGreaterThanOrEqual(0);
|
||||||
const mobileToastGeometry = await commentToast.evaluate((element) => {
|
const mobileToastGeometry = await commentToast.evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
|
|||||||
@@ -1224,6 +1224,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
|
|||||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
|
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 }) => {
|
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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>
|
<title>Sammo HiDCHe - Game</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-black text-white">
|
<body class="bg-black text-white">
|
||||||
|
|||||||
@@ -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 => {
|
export const formatSeoulHourMinute = (value: string | Date): string =>
|
||||||
if (
|
formatServerDateTime(value, { format: 'hourMinute' });
|
||||||
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);
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
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 =>
|
const displayCode = (value: string | null | undefined): string =>
|
||||||
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
|
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
|
||||||
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
|
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
|
||||||
if (!value) {
|
return formatServerDateTime(value, {
|
||||||
return '-';
|
format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
|
||||||
}
|
fallback: '-',
|
||||||
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')}` : ''}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buyRice = computed(() =>
|
const buyRice = computed(() =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
@@ -126,9 +127,9 @@ const selectedGeneral = computed(() => {
|
|||||||
|
|
||||||
const formatGeneralLabel = (general: GeneralEntry): string => {
|
const formatGeneralLabel = (general: GeneralEntry): string => {
|
||||||
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
|
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') {
|
if (orderBy.value === 'recentWar') {
|
||||||
return `${name} (${general.recentWar ? general.recentWar.slice(-5) : '--:--'})`;
|
return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
|
||||||
}
|
}
|
||||||
if (orderBy.value === 'warnum') {
|
if (orderBy.value === 'warnum') {
|
||||||
return `${name} (${general.warnum}회)`;
|
return `${name} (${general.warnum}회)`;
|
||||||
@@ -156,7 +157,10 @@ const loadLogs = async (generalId: number) => {
|
|||||||
}
|
}
|
||||||
for (const response of responses) {
|
for (const response of responses) {
|
||||||
const formatted = response.logs.map((entry) => {
|
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 {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
html: formatLog(`${entry.text}${eventTime}`),
|
html: formatLog(`${entry.text}${eventTime}`),
|
||||||
@@ -308,7 +312,12 @@ onMounted(() => {
|
|||||||
<LegacyGeneralProgress :general="selectedGeneral" />
|
<LegacyGeneralProgress :general="selectedGeneral" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedGeneral" class="general-meta">
|
<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.recentWar || '-' }}</div>
|
||||||
<div>전투 횟수: {{ selectedGeneral.warnum }}</div>
|
<div>전투 횟수: {{ selectedGeneral.warnum }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -68,7 +69,9 @@ const ratio = (id: number) => {
|
|||||||
const amount = totals?.[id] ?? 0;
|
const amount = totals?.[id] ?? 0;
|
||||||
return amount ? (totalAmount.value / amount).toFixed(2) : '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 expected = (id: number) => {
|
||||||
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
|
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
|
||||||
const current = myTotals?.[id] ?? 0;
|
const current = myTotals?.[id] ?? 0;
|
||||||
@@ -248,8 +251,7 @@ const placeBet = async (targetId: number) => {
|
|||||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<small>
|
<small>
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
|
||||||
HideD(hided62@gmail.com) / Credit
|
|
||||||
</small>
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ const resizeTextArea = (element: HTMLTextAreaElement | null) => {
|
|||||||
element.style.height = `${Math.max(element.scrollHeight, 42)}px`;
|
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 =>
|
const iconPath = (article: BoardArticle): string =>
|
||||||
resolveGeneralIconUrl({
|
resolveGeneralIconUrl({
|
||||||
@@ -160,7 +161,14 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="article-submit-row">
|
<div class="article-submit-row">
|
||||||
<div></div>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -244,7 +252,9 @@ onMounted(() => {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
color: #000;
|
color: #000;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font: 16px/normal 'Times New Roman', serif;
|
font:
|
||||||
|
16px/normal 'Times New Roman',
|
||||||
|
serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legacy-board-page {
|
.legacy-board-page {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -47,12 +48,7 @@ const loadDetail = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatArchiveDate = (value: string): string =>
|
const formatArchiveDate = (value: string): string => formatServerDateTime(value);
|
||||||
new Intl.DateTimeFormat('sv-SE', {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'medium',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
}).format(new Date(value));
|
|
||||||
|
|
||||||
watch(emperorId, loadDetail);
|
watch(emperorId, loadDetail);
|
||||||
onMounted(loadDetail);
|
onMounted(loadDetail);
|
||||||
@@ -67,7 +63,9 @@ onMounted(loadDetail);
|
|||||||
역 대 왕 조<br />
|
역 대 왕 조<br />
|
||||||
<button class="native-button" type="button" @click="closePage">창 닫기</button>
|
<button class="native-button" type="button" @click="closePage">창 닫기</button>
|
||||||
<span class="all-link">
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -202,7 +200,11 @@ onMounted(loadDetail);
|
|||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
|
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -283,14 +285,10 @@ onMounted(loadDetail);
|
|||||||
<table class="legacy-table legacy-bg0 footer-table">
|
<table class="legacy-table legacy-bg0 footer-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td><button class="native-button" type="button" @click="closePage">창 닫기</button><br /></td>
|
||||||
<button class="native-button" type="button" @click="closePage">창 닫기</button><br />
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="banner">
|
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -169,11 +170,7 @@ const turnTimeLabel = computed(() => {
|
|||||||
if (!turnTimeResult.value) {
|
if (!turnTimeResult.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = new Date(turnTimeResult.value);
|
return formatServerDateTime(turnTimeResult.value);
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
|
||||||
return turnTimeResult.value;
|
|
||||||
}
|
|
||||||
return parsed.toLocaleString();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isUnited = computed(() => status.value?.isUnited ?? false);
|
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-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
|
||||||
<div v-else-if="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">
|
<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>
|
<span>{{ entry.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
import { useMediaQuery } from '@vueuse/core';
|
||||||
@@ -68,14 +69,8 @@ const nationColor = computed(() => nation.value?.color ?? '#000000');
|
|||||||
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
|
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
|
||||||
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
|
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);
|
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
|
||||||
const parsed = entry.createdAt ? new Date(entry.createdAt) : null;
|
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
|
||||||
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text);
|
if (!time) return formatLog(entry.text);
|
||||||
const time = new Intl.DateTimeFormat('ko-KR', {
|
|
||||||
timeZone: 'Asia/Seoul',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).format(parsed);
|
|
||||||
return formatLog(`${entry.text} ${time}`);
|
return formatLog(`${entry.text} ${time}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
import { formatSeoulDateTime, formatSeoulHourMinute } from '../utils/legacyDateTime';
|
||||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
@@ -430,7 +430,10 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>나이/다음턴</dt>
|
<dt>나이/다음턴</dt>
|
||||||
<dd>{{ data.general.age ?? '-' }}세 / {{ data.general.turnTime?.slice(11, 16) ?? '-' }}</dd>
|
<dd>
|
||||||
|
{{ data.general.age ?? '-' }}세 /
|
||||||
|
{{ data.general.turnTime ? formatSeoulHourMinute(data.general.turnTime) : '-' }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
|
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
|
||||||
@@ -147,7 +148,7 @@ onMounted(load);
|
|||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ general.killTurn }}</td>
|
<td>{{ general.killTurn }}</td>
|
||||||
<td>{{ general.turnTime.slice(14, 19) }}</td>
|
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -159,8 +160,8 @@ onMounted(load);
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="legacy-banner">
|
<td class="legacy-banner">
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
|
||||||
HideD(hided62@gmail.com) /
|
/
|
||||||
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
|
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
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 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 formatCommentDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
|
||||||
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 voteColor = (index: number): string =>
|
const voteColor = (index: number): string =>
|
||||||
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
|
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -62,7 +63,9 @@ const matchesAt = (stage: number) =>
|
|||||||
.sort((a, b) => a.roundIndex - b.roundIndex);
|
.sort((a, b) => a.roundIndex - b.roundIndex);
|
||||||
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
|
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
|
||||||
const totalBet = computed(() => betting.value?.totalAmount ?? 0);
|
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 betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
|
||||||
const isParticipant = computed(() =>
|
const isParticipant = computed(() =>
|
||||||
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
||||||
@@ -287,8 +290,7 @@ const start = async () => {
|
|||||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<small>
|
<small>
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
|
||||||
HideD(hided62@gmail.com) / Credit
|
|
||||||
</small>
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -50,10 +51,7 @@ const onlineRows = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const timeLabel = (value: string): string => {
|
const timeLabel = (value: string): string => formatServerDateTime(value, { format: 'hourMinute' });
|
||||||
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
|
|
||||||
return (timePart ?? '').slice(0, 5);
|
|
||||||
};
|
|
||||||
|
|
||||||
const trafficColor = (percentage: number): string => {
|
const trafficColor = (percentage: number): string => {
|
||||||
const channel = (value: number): string =>
|
const channel = (value: number): string =>
|
||||||
@@ -204,8 +202,8 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="banner">
|
<td class="banner">
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
|
||||||
HideD(hided62@gmail.com) /
|
/
|
||||||
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -166,10 +167,7 @@ const hideMemberPopup = () => {
|
|||||||
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
|
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
|
||||||
|
|
||||||
const formatTurn = (turnTime: string | null): string => {
|
const formatTurn = (turnTime: string | null): string => {
|
||||||
if (!turnTime) {
|
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
|
||||||
return '--:--';
|
|
||||||
}
|
|
||||||
return turnTime.slice(14, 19);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -490,6 +490,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
|
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
|
||||||
await page.getByTestId('load-scenarios').click();
|
await page.getByTestId('load-scenarios').click();
|
||||||
await page.getByTestId('scenario-select').selectOption('5');
|
await page.getByTestId('scenario-select').selectOption('5');
|
||||||
|
await page.getByLabel('작업 예약 (서버 시간 UTC+9)').fill('2026-08-13T09:30');
|
||||||
await page.getByTestId('request-reset').hover();
|
await page.getByTestId('request-reset').hover();
|
||||||
await page.getByTestId('request-reset').click();
|
await page.getByTestId('request-reset').click();
|
||||||
|
|
||||||
@@ -544,6 +545,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
||||||
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2026-08-13T00:30:00.000Z"');
|
||||||
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
@@ -588,9 +590,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
mobileOperationTableGeometry.scrollerWidth
|
mobileOperationTableGeometry.scrollerWidth
|
||||||
);
|
);
|
||||||
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
|
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
|
||||||
expect(
|
expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual(
|
||||||
mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth
|
mobileOperationTableGeometry.viewportWidth
|
||||||
).toBeLessThanOrEqual(mobileOperationTableGeometry.viewportWidth);
|
);
|
||||||
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
|
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
|
||||||
mobileOperationTableGeometry.viewportWidth
|
mobileOperationTableGeometry.viewportWidth
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ const scheduleDeletion = async (): Promise<void> => {
|
|||||||
currentCredential,
|
currentCredential,
|
||||||
});
|
});
|
||||||
window.localStorage.removeItem('sammo-session-token');
|
window.localStorage.removeItem('sammo-session-token');
|
||||||
successMessage.value = `${new Date(result.deleteAfter).toLocaleDateString('ko-KR')}까지 정보가 보존됩니다.`;
|
successMessage.value = `${formatServerDateTime(result.deleteAfter, { format: 'date' })}까지 정보가 보존됩니다.`;
|
||||||
await router.replace('/');
|
await router.replace('/');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -411,7 +412,7 @@ onBeforeUnmount(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="legacy-bg1">가입일시</th>
|
<th class="legacy-bg1">가입일시</th>
|
||||||
<td colspan="2">{{ new Date(account.createdAt).toLocaleString('ko-KR') }}</td>
|
<td colspan="2">{{ formatServerDateTime(account.createdAt) }}</td>
|
||||||
<td colspan="3">
|
<td colspan="3">
|
||||||
개인정보 3자 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
|
개인정보 3자 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime, serverDateTimeInputToIso, toServerDateTimeInputValue } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||||
@@ -435,8 +436,7 @@ const runtimeActionStatusClass = (status: AdminProfile['runtimeActions'][number]
|
|||||||
const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean =>
|
const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean =>
|
||||||
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
|
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
|
||||||
|
|
||||||
const formatRuntimeActionTime = (value: string | null): string =>
|
const formatRuntimeActionTime = (value: string | null): string => formatServerDateTime(value);
|
||||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
|
||||||
|
|
||||||
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
|
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
|
||||||
const userLookupValue = ref('');
|
const userLookupValue = ref('');
|
||||||
@@ -630,14 +630,7 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toLocalInputValue = (value: string): string => {
|
const toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '';
|
|
||||||
const pad = (part: number): string => String(part).padStart(2, '0');
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(
|
|
||||||
date.getMinutes()
|
|
||||||
)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadProfiles = async () => {
|
const loadProfiles = async () => {
|
||||||
profilesLoading.value = true;
|
profilesLoading.value = true;
|
||||||
@@ -795,7 +788,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
|
|||||||
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
|
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
|
||||||
const scheduledAt =
|
const scheduledAt =
|
||||||
action === 'RESET_SCHEDULED' && actionState?.scheduledAt
|
action === 'RESET_SCHEDULED' && actionState?.scheduledAt
|
||||||
? new Date(actionState.scheduledAt).toISOString()
|
? serverDateTimeInputToIso(actionState.scheduledAt)
|
||||||
: undefined;
|
: undefined;
|
||||||
const reason = actionState?.reason.trim() || undefined;
|
const reason = actionState?.reason.trim() || undefined;
|
||||||
let runtimeActionId: string | undefined;
|
let runtimeActionId: string | undefined;
|
||||||
@@ -952,7 +945,7 @@ const updateKakaoGrace = async (clear = false) => {
|
|||||||
try {
|
try {
|
||||||
const result = await adminClient.users.updateKakaoGrace.mutate({
|
const result = await adminClient.users.updateKakaoGrace.mutate({
|
||||||
userId: userResult.value.id,
|
userId: userResult.value.id,
|
||||||
until: clear || !kakaoGraceUntil.value ? null : new Date(kakaoGraceUntil.value).toISOString(),
|
until: clear || !kakaoGraceUntil.value ? null : (serverDateTimeInputToIso(kakaoGraceUntil.value) ?? null),
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
userResult.value = {
|
userResult.value = {
|
||||||
@@ -983,7 +976,9 @@ const grantSpecialAccess = async () => {
|
|||||||
.map((profile) => profile.trim())
|
.map((profile) => profile.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
|
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
|
||||||
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null,
|
expiresAt: specialAccessExpiresAt.value
|
||||||
|
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
|
||||||
|
: null,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
||||||
@@ -1071,7 +1066,7 @@ const applyBan = async () => {
|
|||||||
}
|
}
|
||||||
const reason = requireUserActionReason();
|
const reason = requireUserActionReason();
|
||||||
if (!reason) return;
|
if (!reason) return;
|
||||||
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
|
const until = banUntil.value ? (serverDateTimeInputToIso(banUntil.value) ?? null) : null;
|
||||||
const patch = {
|
const patch = {
|
||||||
bannedUntil: until,
|
bannedUntil: until,
|
||||||
notes: banReason.value.trim() || undefined,
|
notes: banReason.value.trim() || undefined,
|
||||||
@@ -1150,7 +1145,7 @@ const applyRestriction = async () => {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const restriction = {
|
const restriction = {
|
||||||
blockedFeatures: features.length ? features : undefined,
|
blockedFeatures: features.length ? features : undefined,
|
||||||
until: restrictionUntil.value ? new Date(restrictionUntil.value).toISOString() : undefined,
|
until: restrictionUntil.value ? serverDateTimeInputToIso(restrictionUntil.value) : undefined,
|
||||||
reason: restrictionReason.value.trim() || undefined,
|
reason: restrictionReason.value.trim() || undefined,
|
||||||
notes: restrictionNotes.value.trim() || undefined,
|
notes: restrictionNotes.value.trim() || undefined,
|
||||||
};
|
};
|
||||||
@@ -1213,7 +1208,7 @@ const scheduleDeleteUser = async () => {
|
|||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter };
|
userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter };
|
||||||
forceDeleteStatus.value = `탈퇴 예약 완료: ${new Date(result.deleteAfter).toLocaleString('ko-KR')}`;
|
forceDeleteStatus.value = `탈퇴 예약 완료: ${formatServerDateTime(result.deleteAfter)}`;
|
||||||
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
|
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
forceDeleteStatus.value = '탈퇴 예약 실패';
|
forceDeleteStatus.value = '탈퇴 예약 실패';
|
||||||
@@ -1357,7 +1352,7 @@ onMounted(() => {
|
|||||||
<span class="block truncate">{{ user.email || '이메일 없음' }}</span>
|
<span class="block truncate">{{ user.email || '이메일 없음' }}</span>
|
||||||
<span
|
<span
|
||||||
>{{ user.oauthType }} ·
|
>{{ user.oauthType }} ·
|
||||||
{{ new Date(user.createdAt).toLocaleDateString('ko-KR') }}</span
|
{{ formatServerDateTime(user.createdAt, { format: 'date' }) }}</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex flex-wrap gap-1 md:justify-end">
|
<span class="flex flex-wrap gap-1 md:justify-end">
|
||||||
@@ -1436,15 +1431,17 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-500">
|
<div class="text-xs text-zinc-500">
|
||||||
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
|
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
|
||||||
{{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }}
|
{{ formatServerDateTime(userResult.kakaoGraceStartedAt) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
|
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
|
||||||
관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지
|
관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userResult.deleteAfter" class="text-xs text-red-300">
|
<div v-if="userResult.deleteAfter" class="text-xs text-red-300">
|
||||||
탈퇴 예약: {{ new Date(userResult.deleteAfter).toLocaleString('ko-KR') }}
|
탈퇴 예약: {{ formatServerDateTime(userResult.deleteAfter) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
가입일: {{ formatServerDateTime(userResult.createdAt) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
|
|
||||||
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
|
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
|
||||||
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
|
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
|
||||||
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
|
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
|
||||||
@@ -1632,7 +1629,8 @@ onMounted(() => {
|
|||||||
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
|
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
|
||||||
<div class="text-xs text-zinc-400">
|
<div class="text-xs text-zinc-400">
|
||||||
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
|
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
|
||||||
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
|
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 시각 입력은 서버 시간
|
||||||
|
UTC+9 기준입니다.
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<select
|
<select
|
||||||
@@ -1696,11 +1694,11 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
|
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
|
||||||
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }}
|
{{ formatServerDateTime(grant.expiresAt, { fallback: '없음' }) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
|
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
|
||||||
<div v-if="grant.revokedAt" class="text-red-300">
|
<div v-if="grant.revokedAt" class="text-red-300">
|
||||||
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} ·
|
해제됨: {{ formatServerDateTime(grant.revokedAt) }} ·
|
||||||
{{ grant.revokedReason }}
|
{{ grant.revokedReason }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1713,7 +1711,8 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
|
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
|
||||||
<div class="text-xs text-zinc-500">
|
<div class="text-xs text-zinc-500">
|
||||||
기본·서버별 유예가 끝난 사용자를 예외적으로 더 허용할 때 사용합니다.
|
기본·서버별 유예가 끝난 사용자를 예외적으로 더 허용할 때 사용합니다. 시각 입력은 서버 시간
|
||||||
|
UTC+9 기준입니다.
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row gap-2">
|
<div class="flex flex-col md:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -1762,11 +1761,7 @@ onMounted(() => {
|
|||||||
<td class="text-center">{{ policy.accessGraceDays }}일</td>
|
<td class="text-center">{{ policy.accessGraceDays }}일</td>
|
||||||
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
|
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{{
|
{{ policy.graceEndsAt ? formatServerDateTime(policy.graceEndsAt) : '-' }}
|
||||||
policy.graceEndsAt
|
|
||||||
? new Date(policy.graceEndsAt).toLocaleString('ko-KR')
|
|
||||||
: '-'
|
|
||||||
}}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1778,7 +1773,7 @@ onMounted(() => {
|
|||||||
v-if="userWorkspaceSection === 'restrictions'"
|
v-if="userWorkspaceSection === 'restrictions'"
|
||||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||||
>
|
>
|
||||||
<h4 class="text-base font-semibold">유저 차단</h4>
|
<h4 class="text-base font-semibold">유저 차단 (서버 시간 UTC+9)</h4>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="banUntil"
|
v-model="banUntil"
|
||||||
@@ -1817,7 +1812,7 @@ onMounted(() => {
|
|||||||
v-if="userWorkspaceSection === 'restrictions'"
|
v-if="userWorkspaceSection === 'restrictions'"
|
||||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||||
>
|
>
|
||||||
<h4 class="text-base font-semibold">서버별 기능 제재</h4>
|
<h4 class="text-base font-semibold">서버별 기능 제재 (서버 시간 UTC+9)</h4>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="restrictionProfile"
|
v-model="restrictionProfile"
|
||||||
@@ -1936,9 +1931,7 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ event.outcome }} · {{ event.action }}
|
{{ event.outcome }} · {{ event.action }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-zinc-500">{{
|
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
|
||||||
new Date(event.createdAt).toLocaleString('ko-KR')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-zinc-400">
|
<div class="text-zinc-400">
|
||||||
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
|
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
|
||||||
@@ -1988,9 +1981,7 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ event.outcome }} · {{ event.action }}
|
{{ event.outcome }} · {{ event.action }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-zinc-500">{{
|
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
|
||||||
new Date(event.createdAt).toLocaleString('ko-KR')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-zinc-400">
|
<div class="text-zinc-400">
|
||||||
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
|
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, ref, onMounted, watch } from 'vue';
|
import { computed, ref, onMounted, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import type { inferRouterOutputs } from '@trpc/server';
|
import type { inferRouterOutputs } from '@trpc/server';
|
||||||
@@ -95,8 +96,7 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
|
|||||||
tabButtons?.[nextIndex]?.focus();
|
tabButtons?.[nextIndex]?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatGraceEndsAt = (value: string | null | undefined): string =>
|
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
|
||||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
|
||||||
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
||||||
const encodeLegacyIconPath = (value: string): string =>
|
const encodeLegacyIconPath = (value: string): string =>
|
||||||
value
|
value
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||||
@@ -213,21 +214,11 @@ const sourceHelp = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toIso = (value: string): string | undefined => {
|
const toIso = (value: string): string | undefined => {
|
||||||
if (!value) {
|
return serverDateTimeInputToIso(value);
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const parsed = new Date(value);
|
|
||||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
|
const formatTime = (value?: string): string => formatServerDateTime(value, { fallback: '-' });
|
||||||
const formatLogTime = (value: string): string =>
|
const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
|
||||||
new Date(value).toLocaleTimeString('ko-KR', {
|
|
||||||
hour12: false,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
});
|
|
||||||
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
|
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
|
||||||
|
|
||||||
const clearStatus = () => {
|
const clearStatus = () => {
|
||||||
@@ -959,7 +950,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
|
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
|
||||||
<label class="text-xs text-zinc-400"
|
<label class="text-xs text-zinc-400"
|
||||||
>작업 예약
|
>작업 예약 (서버 시간 UTC+9)
|
||||||
<input
|
<input
|
||||||
v-model="form.scheduledAt"
|
v-model="form.scheduledAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -967,7 +958,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="text-xs text-zinc-400"
|
<label class="text-xs text-zinc-400"
|
||||||
>가오픈
|
>가오픈 (서버 시간 UTC+9)
|
||||||
<input
|
<input
|
||||||
v-model="form.preopenAt"
|
v-model="form.preopenAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -975,7 +966,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="text-xs text-zinc-400"
|
<label class="text-xs text-zinc-400"
|
||||||
>정식 오픈
|
>정식 오픈 (서버 시간 UTC+9)
|
||||||
<input
|
<input
|
||||||
v-model="form.openAt"
|
v-model="form.openAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -1302,10 +1293,7 @@ onBeforeUnmount(() => {
|
|||||||
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
|
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table
|
<table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table">
|
||||||
class="w-full min-w-[1300px] table-fixed text-left text-sm"
|
|
||||||
data-testid="operations-table"
|
|
||||||
>
|
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style="width: 160px" />
|
<col style="width: 160px" />
|
||||||
<col style="width: 264px" />
|
<col style="width: 264px" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './rng.js';
|
export * from './rng.js';
|
||||||
export * from './time/Clock.js';
|
export * from './time/Clock.js';
|
||||||
export * from './time/GameClock.js';
|
export * from './time/GameClock.js';
|
||||||
|
export * from './time/ServerDateTime.js';
|
||||||
export * from './util/BytesLike.js';
|
export * from './util/BytesLike.js';
|
||||||
export * from './util/convertBytesLikeToArrayBuffer.js';
|
export * from './util/convertBytesLikeToArrayBuffer.js';
|
||||||
export * from './util/convertBytesLikeToUint8Array.js';
|
export * from './util/convertBytesLikeToUint8Array.js';
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const SERVER_UTC_OFFSET_MINUTES = 9 * 60;
|
||||||
|
const SERVER_UTC_OFFSET_MS = SERVER_UTC_OFFSET_MINUTES * 60_000;
|
||||||
|
|
||||||
|
export type ServerDateTimeFormat =
|
||||||
|
| 'dateTimeSeconds'
|
||||||
|
| 'dateTimeMinutes'
|
||||||
|
| 'date'
|
||||||
|
| 'timeSeconds'
|
||||||
|
| 'hourMinute'
|
||||||
|
| 'minuteSecond'
|
||||||
|
| 'monthDayTime'
|
||||||
|
| 'monthDayTimeSeconds';
|
||||||
|
|
||||||
|
export type ServerDateTimeOptions = {
|
||||||
|
format?: ServerDateTimeFormat;
|
||||||
|
fallback?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DateTimeParts = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
second: number;
|
||||||
|
millisecond: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVER_WALL_TIME_PATTERN = /^(\d{4,6})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/u;
|
||||||
|
|
||||||
|
const pad = (value: number, length = 2): string => String(value).padStart(length, '0');
|
||||||
|
|
||||||
|
const isValidParts = (parts: DateTimeParts): boolean => {
|
||||||
|
const candidate = new Date(0);
|
||||||
|
candidate.setUTCFullYear(parts.year, parts.month - 1, parts.day);
|
||||||
|
candidate.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
|
||||||
|
return (
|
||||||
|
candidate.getUTCFullYear() === parts.year &&
|
||||||
|
candidate.getUTCMonth() + 1 === parts.month &&
|
||||||
|
candidate.getUTCDate() === parts.day &&
|
||||||
|
candidate.getUTCHours() === parts.hour &&
|
||||||
|
candidate.getUTCMinutes() === parts.minute &&
|
||||||
|
candidate.getUTCSeconds() === parts.second &&
|
||||||
|
candidate.getUTCMilliseconds() === parts.millisecond
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseServerWallTime = (value: string): DateTimeParts | null => {
|
||||||
|
const match = SERVER_WALL_TIME_PATTERN.exec(value.trim());
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const millisecondText = match[7] ?? '';
|
||||||
|
const parts: DateTimeParts = {
|
||||||
|
year: Number(match[1]),
|
||||||
|
month: Number(match[2]),
|
||||||
|
day: Number(match[3]),
|
||||||
|
hour: Number(match[4] ?? 0),
|
||||||
|
minute: Number(match[5] ?? 0),
|
||||||
|
second: Number(match[6] ?? 0),
|
||||||
|
millisecond: Number(millisecondText.padEnd(3, '0')),
|
||||||
|
};
|
||||||
|
return isValidParts(parts) ? parts : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const partsFromInstant = (value: string | Date): DateTimeParts | null => {
|
||||||
|
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const shifted = new Date(date.getTime() + SERVER_UTC_OFFSET_MS);
|
||||||
|
return {
|
||||||
|
year: shifted.getUTCFullYear(),
|
||||||
|
month: shifted.getUTCMonth() + 1,
|
||||||
|
day: shifted.getUTCDate(),
|
||||||
|
hour: shifted.getUTCHours(),
|
||||||
|
minute: shifted.getUTCMinutes(),
|
||||||
|
second: shifted.getUTCSeconds(),
|
||||||
|
millisecond: shifted.getUTCMilliseconds(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveParts = (value: string | Date): DateTimeParts | null => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const wallTime = parseServerWallTime(value);
|
||||||
|
if (wallTime) {
|
||||||
|
return wallTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return partsFromInstant(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatParts = (parts: DateTimeParts, format: ServerDateTimeFormat): string => {
|
||||||
|
const year = pad(parts.year, 4);
|
||||||
|
const month = pad(parts.month);
|
||||||
|
const day = pad(parts.day);
|
||||||
|
const hour = pad(parts.hour);
|
||||||
|
const minute = pad(parts.minute);
|
||||||
|
const second = pad(parts.second);
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'dateTimeMinutes':
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||||
|
case 'date':
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
case 'timeSeconds':
|
||||||
|
return `${hour}:${minute}:${second}`;
|
||||||
|
case 'hourMinute':
|
||||||
|
return `${hour}:${minute}`;
|
||||||
|
case 'minuteSecond':
|
||||||
|
return `${minute}:${second}`;
|
||||||
|
case 'monthDayTime':
|
||||||
|
return `${month}-${day} ${hour}:${minute}`;
|
||||||
|
case 'monthDayTimeSeconds':
|
||||||
|
return `${month}-${day} ${hour}:${minute}:${second}`;
|
||||||
|
case 'dateTimeSeconds':
|
||||||
|
default:
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an instant in the service's fixed UTC+9 wall clock.
|
||||||
|
*
|
||||||
|
* Timezone-less legacy DATETIME strings are already server wall-clock values and
|
||||||
|
* therefore keep their components. This deliberate fixed offset also avoids
|
||||||
|
* historical IANA timezone rules changing ancient in-game years.
|
||||||
|
*/
|
||||||
|
export const formatServerDateTime = (
|
||||||
|
value: string | Date | null | undefined,
|
||||||
|
options: ServerDateTimeOptions = {}
|
||||||
|
): string => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return options.fallback ?? '';
|
||||||
|
}
|
||||||
|
const parts = resolveParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return options.fallback ?? String(value);
|
||||||
|
}
|
||||||
|
return formatParts(parts, options.format ?? 'dateTimeSeconds');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toServerDateTimeInputValue = (value: string | Date | null | undefined): string => {
|
||||||
|
const formatted = formatServerDateTime(value, { format: 'dateTimeMinutes', fallback: '' });
|
||||||
|
return formatted ? formatted.replace(' ', 'T') : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Converts an HTML datetime-local value, interpreted as UTC+9 server wall time, to ISO UTC. */
|
||||||
|
export const serverDateTimeInputToIso = (value: string): string | undefined => {
|
||||||
|
const parts = parseServerWallTime(value);
|
||||||
|
if (!parts) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const wallTime = new Date(0);
|
||||||
|
wallTime.setUTCFullYear(parts.year, parts.month - 1, parts.day);
|
||||||
|
wallTime.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
|
||||||
|
return new Date(wallTime.getTime() - SERVER_UTC_OFFSET_MS).toISOString();
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatServerDateTime,
|
||||||
|
serverDateTimeInputToIso,
|
||||||
|
toServerDateTimeInputValue,
|
||||||
|
} from '../src/time/ServerDateTime.js';
|
||||||
|
|
||||||
|
describe('formatServerDateTime', () => {
|
||||||
|
it('formats ISO instants with the fixed UTC+9 service offset', () => {
|
||||||
|
expect(formatServerDateTime('2026-08-13T00:05:06.000Z')).toBe('2026-08-13 09:05:06');
|
||||||
|
expect(formatServerDateTime('0185-01-02T00:04:05.000Z')).toBe('0185-01-02 09:04:05');
|
||||||
|
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'date' })).toBe('2026-08-14');
|
||||||
|
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'hourMinute' })).toBe('03:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves timezone-less legacy wall-clock values', () => {
|
||||||
|
expect(formatServerDateTime('0185-01-02 03:04:05')).toBe('0185-01-02 03:04:05');
|
||||||
|
expect(formatServerDateTime('0185-01-02T03:04:05', { format: 'monthDayTime' })).toBe('01-02 03:04');
|
||||||
|
expect(formatServerDateTime('2026-08-13 09:05:06', { format: 'minuteSecond' })).toBe('05:06');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers explicit shapes and predictable fallbacks', () => {
|
||||||
|
const value = '2026-08-13T00:05:06.000Z';
|
||||||
|
expect(formatServerDateTime(value, { format: 'dateTimeMinutes' })).toBe('2026-08-13 09:05');
|
||||||
|
expect(formatServerDateTime(value, { format: 'timeSeconds' })).toBe('09:05:06');
|
||||||
|
expect(formatServerDateTime(value, { format: 'monthDayTimeSeconds' })).toBe('08-13 09:05:06');
|
||||||
|
expect(formatServerDateTime(undefined, { fallback: '-' })).toBe('-');
|
||||||
|
expect(formatServerDateTime('not-a-date')).toBe('not-a-date');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server datetime-local conversion', () => {
|
||||||
|
it('does not depend on the browser or process timezone', () => {
|
||||||
|
expect(serverDateTimeInputToIso('2026-08-13T09:05')).toBe('2026-08-13T00:05:00.000Z');
|
||||||
|
expect(toServerDateTimeInputValue('2026-08-13T00:05:00.000Z')).toBe('2026-08-13T09:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid local input', () => {
|
||||||
|
expect(serverDateTimeInputToIso('2026-02-30T09:05')).toBeUndefined();
|
||||||
|
expect(serverDateTimeInputToIso('')).toBeUndefined();
|
||||||
|
expect(toServerDateTimeInputValue('not-a-date')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user