fix(frontend): standardize server time display

This commit is contained in:
2026-08-13 15:26:53 +00:00
parent 2c23c1458a
commit dcba974aa4
23 changed files with 352 additions and 175 deletions
@@ -470,6 +470,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
await page.getByTestId('load-scenarios').click();
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').click();
@@ -524,6 +525,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('0123456789abcdef0123456789abcdef01234567');
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.setViewportSize({ width: 390, height: 844 });
@@ -568,9 +570,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
mobileOperationTableGeometry.scrollerWidth
);
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(
mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth
).toBeLessThanOrEqual(mobileOperationTableGeometry.viewportWidth);
expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth
);
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth
);
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
@@ -130,7 +131,7 @@ const scheduleDeletion = async (): Promise<void> => {
currentCredential,
});
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('/');
});
};
@@ -411,7 +412,7 @@ onBeforeUnmount(() => {
</tr>
<tr>
<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">
개인정보 3 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
<button
+29 -38
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso, toServerDateTimeInputValue } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.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 =>
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
const formatRuntimeActionTime = (value: string | null): string =>
value ? new Date(value).toLocaleString('ko-KR') : '';
const formatRuntimeActionTime = (value: string | null): string => formatServerDateTime(value);
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
const userLookupValue = ref('');
@@ -630,14 +630,7 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
}
};
const toLocalInputValue = (value: string): string => {
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 toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
const loadProfiles = async () => {
profilesLoading.value = true;
@@ -795,7 +788,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
const scheduledAt =
action === 'RESET_SCHEDULED' && actionState?.scheduledAt
? new Date(actionState.scheduledAt).toISOString()
? serverDateTimeInputToIso(actionState.scheduledAt)
: undefined;
const reason = actionState?.reason.trim() || undefined;
let runtimeActionId: string | undefined;
@@ -952,7 +945,7 @@ const updateKakaoGrace = async (clear = false) => {
try {
const result = await adminClient.users.updateKakaoGrace.mutate({
userId: userResult.value.id,
until: clear || !kakaoGraceUntil.value ? null : new Date(kakaoGraceUntil.value).toISOString(),
until: clear || !kakaoGraceUntil.value ? null : (serverDateTimeInputToIso(kakaoGraceUntil.value) ?? null),
reason,
});
userResult.value = {
@@ -983,7 +976,9 @@ const grantSpecialAccess = async () => {
.map((profile) => profile.trim())
.filter(Boolean),
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null,
expiresAt: specialAccessExpiresAt.value
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
: null,
reason,
});
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
@@ -1071,7 +1066,7 @@ const applyBan = async () => {
}
const reason = requireUserActionReason();
if (!reason) return;
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
const until = banUntil.value ? (serverDateTimeInputToIso(banUntil.value) ?? null) : null;
const patch = {
bannedUntil: until,
notes: banReason.value.trim() || undefined,
@@ -1150,7 +1145,7 @@ const applyRestriction = async () => {
.filter(Boolean);
const restriction = {
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,
notes: restrictionNotes.value.trim() || undefined,
};
@@ -1213,7 +1208,7 @@ const scheduleDeleteUser = async () => {
reason,
});
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()]);
} catch (error) {
forceDeleteStatus.value = '탈퇴 예약 실패';
@@ -1357,7 +1352,7 @@ onMounted(() => {
<span class="block truncate">{{ user.email || '이메일 없음' }}</span>
<span
>{{ user.oauthType }} ·
{{ new Date(user.createdAt).toLocaleDateString('ko-KR') }}</span
{{ formatServerDateTime(user.createdAt, { format: 'date' }) }}</span
>
</span>
<span class="flex flex-wrap gap-1 md:justify-end">
@@ -1436,15 +1431,17 @@ onMounted(() => {
</div>
<div class="text-xs text-zinc-500">
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
{{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }}
{{ formatServerDateTime(userResult.kakaoGraceStartedAt) }}
</div>
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지
관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
</div>
<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 class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</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"
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
@@ -1632,7 +1629,8 @@ onMounted(() => {
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
<div class="text-xs text-zinc-400">
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<select
@@ -1696,11 +1694,11 @@ onMounted(() => {
</div>
<div>
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }}
{{ formatServerDateTime(grant.expiresAt, { fallback: '없음' }) }}
</div>
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
<div v-if="grant.revokedAt" class="text-red-300">
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} ·
해제됨: {{ formatServerDateTime(grant.revokedAt) }} ·
{{ grant.revokedReason }}
</div>
</div>
@@ -1713,7 +1711,8 @@ onMounted(() => {
>
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
<div class="text-xs text-zinc-500">
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다.
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div>
<div class="flex flex-col md:flex-row gap-2">
<input
@@ -1762,11 +1761,7 @@ onMounted(() => {
<td class="text-center">{{ policy.accessGraceDays }}</td>
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
<td class="text-center">
{{
policy.graceEndsAt
? new Date(policy.graceEndsAt).toLocaleString('ko-KR')
: '-'
}}
{{ policy.graceEndsAt ? formatServerDateTime(policy.graceEndsAt) : '-' }}
</td>
</tr>
</tbody>
@@ -1778,7 +1773,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'"
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">
<input
v-model="banUntil"
@@ -1817,7 +1812,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'"
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">
<input
v-model="restrictionProfile"
@@ -1936,9 +1931,7 @@ onMounted(() => {
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
@@ -1988,9 +1981,7 @@ onMounted(() => {
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
+2 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server';
@@ -95,8 +96,7 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
tabButtons?.[nextIndex]?.focus();
};
const formatGraceEndsAt = (value: string | null | undefined): string =>
value ? new Date(value).toLocaleString('ko-KR') : '';
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const encodeLegacyIconPath = (value: string): string =>
value
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
@@ -211,21 +212,11 @@ const sourceHelp = computed(() =>
);
const toIso = (value: string): string | undefined => {
if (!value) {
return undefined;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
return serverDateTimeInputToIso(value);
};
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
const formatLogTime = (value: string): string =>
new Date(value).toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const formatTime = (value?: string): string => formatServerDateTime(value, { fallback: '-' });
const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => {
@@ -945,7 +936,7 @@ onBeforeUnmount(() => {
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
<label class="text-xs text-zinc-400"
>작업 예약
>작업 예약 (서버 시간 UTC+9)
<input
v-model="form.scheduledAt"
type="datetime-local"
@@ -953,7 +944,7 @@ onBeforeUnmount(() => {
/>
</label>
<label class="text-xs text-zinc-400"
>가오픈
>가오픈 (서버 시간 UTC+9)
<input
v-model="form.preopenAt"
type="datetime-local"
@@ -961,7 +952,7 @@ onBeforeUnmount(() => {
/>
</label>
<label class="text-xs text-zinc-400"
>정식 오픈
>정식 오픈 (서버 시간 UTC+9)
<input
v-model="form.openAt"
type="datetime-local"
@@ -1236,10 +1227,7 @@ onBeforeUnmount(() => {
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
</div>
<div class="overflow-x-auto">
<table
class="w-full min-w-[1300px] table-fixed text-left text-sm"
data-testid="operations-table"
>
<table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table">
<colgroup>
<col style="width: 160px" />
<col style="width: 264px" />