feat(frontend): restore account oauth and yearbook flows
This commit is contained in:
@@ -27,6 +27,7 @@ import DynastyListView from '../views/DynastyListView.vue';
|
||||
import DynastyDetailView from '../views/DynastyDetailView.vue';
|
||||
import SurveyView from '../views/SurveyView.vue';
|
||||
import TroopView from '../views/TroopView.vue';
|
||||
import YearbookView from '../views/YearbookView.vue';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
const routes = [
|
||||
@@ -211,6 +212,14 @@ const routes = [
|
||||
name: 'dynasty-detail',
|
||||
component: DynastyDetailView,
|
||||
},
|
||||
{
|
||||
path: '/yearbook',
|
||||
name: 'yearbook',
|
||||
component: YearbookView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/my-page',
|
||||
name: 'my-page',
|
||||
|
||||
@@ -102,6 +102,7 @@ watch(
|
||||
<RouterLink class="ghost" to="/best-general">명장일람</RouterLink>
|
||||
<RouterLink class="ghost" to="/hall-of-fame">명예의 전당</RouterLink>
|
||||
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
|
||||
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
|
||||
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import MapViewer from '../components/main/MapViewer.vue';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type YearbookRange = Awaited<ReturnType<typeof trpc.yearbook.getRange.query>>;
|
||||
type MapLayout = Awaited<ReturnType<typeof trpc.public.getMapLayout.query>>;
|
||||
type HistoryData = {
|
||||
year: number;
|
||||
month: number;
|
||||
map: Awaited<ReturnType<typeof trpc.public.getCachedMap.query>>;
|
||||
nations: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
level: number;
|
||||
power: number;
|
||||
cities: string[];
|
||||
}>;
|
||||
globalHistory: string[];
|
||||
globalAction: string[];
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const range = ref<YearbookRange | null>(null);
|
||||
const mapLayout = ref<MapLayout | null>(null);
|
||||
const history = ref<HistoryData | null>(null);
|
||||
const selectedYearMonth = ref<number | null>(null);
|
||||
|
||||
const parseYearMonth = (value: number): { year: number; month: number } => ({
|
||||
year: Math.floor(value / 12),
|
||||
month: (value % 12) + 1,
|
||||
});
|
||||
|
||||
const availableYearMonths = computed(() => {
|
||||
if (!range.value) {
|
||||
return [];
|
||||
}
|
||||
const values: Array<{ value: number; label: string }> = [];
|
||||
for (let value = range.value.firstYearMonth; value <= range.value.currentYearMonth; value += 1) {
|
||||
const { year, month } = parseYearMonth(value);
|
||||
const suffix = value === range.value.currentYearMonth ? ' (현재)' : '';
|
||||
values.push({ value, label: `${year}년 ${month}월${suffix}` });
|
||||
}
|
||||
return values;
|
||||
});
|
||||
|
||||
const closePage = async (): Promise<void> => {
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
await router.push('/');
|
||||
};
|
||||
|
||||
const loadHistory = async (): Promise<void> => {
|
||||
if (selectedYearMonth.value === null) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const { year, month } = parseYearMonth(selectedYearMonth.value);
|
||||
const result = await trpc.yearbook.getHistory.query({ year, month });
|
||||
if ('data' in result) {
|
||||
history.value = result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
history.value = null;
|
||||
errorMessage.value = error instanceof Error ? error.message : '연감 데이터를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const moveMonth = (delta: number): void => {
|
||||
if (!range.value || selectedYearMonth.value === null) {
|
||||
return;
|
||||
}
|
||||
selectedYearMonth.value = Math.min(
|
||||
range.value.currentYearMonth,
|
||||
Math.max(range.value.firstYearMonth, selectedYearMonth.value + delta)
|
||||
);
|
||||
};
|
||||
|
||||
watch(selectedYearMonth, () => {
|
||||
void loadHistory();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [loadedRange, loadedLayout] = await Promise.all([
|
||||
trpc.yearbook.getRange.query(),
|
||||
trpc.public.getMapLayout.query(),
|
||||
]);
|
||||
range.value = loadedRange;
|
||||
mapLayout.value = loadedLayout;
|
||||
selectedYearMonth.value = loadedRange.currentYearMonth;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '연감 범위를 불러오지 못했습니다.';
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main id="yearbook-container" class="yearbook-page legacy-bg0">
|
||||
<header class="yearbook-title legacy-bg2">
|
||||
<strong>연 감</strong>
|
||||
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
||||
</header>
|
||||
|
||||
<section class="year-selector legacy-border">
|
||||
<span>연월 선택:</span>
|
||||
<button
|
||||
class="legacy-button"
|
||||
type="button"
|
||||
:disabled="selectedYearMonth === range?.firstYearMonth"
|
||||
@click="moveMonth(-1)"
|
||||
>
|
||||
◀ 이전달
|
||||
</button>
|
||||
<select v-model="selectedYearMonth" aria-label="연월 선택">
|
||||
<option v-for="option in availableYearMonths" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="legacy-button"
|
||||
type="button"
|
||||
:disabled="selectedYearMonth === range?.currentYearMonth"
|
||||
@click="moveMonth(1)"
|
||||
>
|
||||
다음달 ▶
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div v-if="errorMessage" class="yearbook-message error" role="alert">{{ errorMessage }}</div>
|
||||
<div v-else-if="loading && !history" class="yearbook-message">불러오는 중...</div>
|
||||
|
||||
<section v-if="history" class="history-grid">
|
||||
<div class="map-position">
|
||||
<MapViewer :map-data="history.map" :map-layout="mapLayout" :loading="loading" />
|
||||
</div>
|
||||
<aside class="nation-position">
|
||||
<div class="section-heading legacy-bg1">세력 일람</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>국가</th>
|
||||
<th>국력</th>
|
||||
<th>도시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="nation in history.nations" :key="nation.id">
|
||||
<td :style="{ color: nation.color }">{{ nation.name }}</td>
|
||||
<td>{{ nation.power.toLocaleString() }}</td>
|
||||
<td>{{ nation.cities.length }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</aside>
|
||||
<article class="history-log">
|
||||
<div class="section-heading legacy-bg1">중원 정세</div>
|
||||
<div class="log-content">
|
||||
<!-- 레거시 색상 tag만 formatLog가 span으로 변환한다. -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-for="(item, index) in history.globalHistory" :key="index" v-html="formatLog(item)" />
|
||||
</div>
|
||||
</article>
|
||||
<article class="history-log">
|
||||
<div class="section-heading legacy-bg1">장수 동향</div>
|
||||
<div class="log-content">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-for="(item, index) in history.globalAction" :key="index" v-html="formatLog(item)" />
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<footer class="yearbook-footer">
|
||||
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
||||
</footer>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
.yearbook-page {
|
||||
width: 1000px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.yearbook-title {
|
||||
position: relative;
|
||||
min-height: 42px;
|
||||
border: 1px solid gray;
|
||||
text-align: center;
|
||||
line-height: 42px;
|
||||
}
|
||||
|
||||
.yearbook-title strong {
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.35em;
|
||||
}
|
||||
|
||||
.yearbook-title .legacy-button {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.legacy-border,
|
||||
.history-grid,
|
||||
.map-position,
|
||||
.nation-position,
|
||||
.history-log {
|
||||
border: 1px solid gray;
|
||||
}
|
||||
|
||||
.year-selector {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 110px minmax(220px, 1fr) 110px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 42px;
|
||||
padding: 3px 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.year-selector select {
|
||||
height: 32px;
|
||||
border: 1px solid gray;
|
||||
background: #191919;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 700px 300px;
|
||||
}
|
||||
|
||||
.map-position,
|
||||
.nation-position {
|
||||
min-width: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.nation-position table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nation-position th,
|
||||
.nation-position td {
|
||||
border: 1px solid #555;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
min-height: 28px;
|
||||
border-bottom: 1px solid gray;
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-log {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
min-height: 72px;
|
||||
padding: 7px 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.yearbook-message {
|
||||
min-height: 80px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff9c9c;
|
||||
}
|
||||
|
||||
.yearbook-footer {
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.yearbook-page {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.year-selector {
|
||||
grid-template-columns: 90px 100px 190px 100px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-position,
|
||||
.nation-position {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,8 @@ import HomeView from '../views/HomeView.vue';
|
||||
import LobbyView from '../views/LobbyView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import ServerOperationsView from '../views/ServerOperationsView.vue';
|
||||
import AccountView from '../views/AccountView.vue';
|
||||
import OAuthCallbackView from '../views/OAuthCallbackView.vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -27,6 +29,16 @@ const router = createRouter({
|
||||
name: 'server-operations',
|
||||
component: ServerOperationsView,
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
component: AccountView,
|
||||
},
|
||||
{
|
||||
path: '/oauth/callback',
|
||||
name: 'oauth-callback',
|
||||
component: OAuthCallbackView,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Account = Awaited<ReturnType<typeof trpc.account.get.query>>;
|
||||
|
||||
const router = useRouter();
|
||||
const account = ref<Account | null>(null);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
const currentPassword = ref('');
|
||||
const newPassword = ref('');
|
||||
const newPasswordConfirm = ref('');
|
||||
const deletePassword = ref('');
|
||||
const iconData = ref('');
|
||||
const iconFilename = ref('');
|
||||
|
||||
const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
|
||||
|
||||
const gradeLabel = computed(() => {
|
||||
if (!account.value) return '-';
|
||||
if (account.value.roles.some((role) => role.includes('admin') || role === 'superuser')) return '관리자';
|
||||
return '일반회원';
|
||||
});
|
||||
|
||||
const runAction = async (action: () => Promise<void>): Promise<void> => {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
errorMessage.value = '';
|
||||
successMessage.value = '';
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '요청을 처리하지 못했습니다.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAccount = async (): Promise<void> => {
|
||||
const token = sessionToken();
|
||||
if (!token) {
|
||||
await router.replace('/');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
account.value = await trpc.account.get.query({ sessionToken: token });
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '계정 정보를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (): Promise<void> => {
|
||||
if (newPassword.value !== newPasswordConfirm.value) {
|
||||
errorMessage.value = '새 비밀번호 확인이 일치하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
await trpc.account.changePassword.mutate({
|
||||
sessionToken: token,
|
||||
currentPassword: currentPassword.value,
|
||||
newPassword: newPassword.value,
|
||||
});
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
newPasswordConfirm.value = '';
|
||||
successMessage.value = '비밀번호를 변경했습니다.';
|
||||
});
|
||||
};
|
||||
|
||||
const disallowThirdPartyUse = async (): Promise<void> => {
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
await trpc.account.disallowThirdPartyUse.mutate({ sessionToken: token });
|
||||
if (account.value) account.value = { ...account.value, thirdPartyUse: false };
|
||||
successMessage.value = '개인정보 제3자 제공 동의를 철회했습니다.';
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleDeletion = async (): Promise<void> => {
|
||||
if (!window.confirm('탈퇴를 신청하면 현재 세션이 종료됩니다. 계속하시겠습니까?')) return;
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
const result = await trpc.account.scheduleDeletion.mutate({
|
||||
sessionToken: token,
|
||||
currentPassword: deletePassword.value,
|
||||
});
|
||||
window.localStorage.removeItem('sammo-session-token');
|
||||
successMessage.value = `${new Date(result.deleteAfter).toLocaleDateString('ko-KR')}까지 정보가 보존됩니다.`;
|
||||
await router.replace('/');
|
||||
});
|
||||
};
|
||||
|
||||
const selectIcon = async (event: Event): Promise<void> => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 50 * 1024) {
|
||||
errorMessage.value = '아이콘 파일은 50KB 이하여야 합니다.';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
iconFilename.value = file.name;
|
||||
iconData.value = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
||||
reader.onerror = () => reject(new Error('아이콘 파일을 읽지 못했습니다.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const changeIcon = async (): Promise<void> => {
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
if (!iconData.value) throw new Error('아이콘 파일을 선택해주세요.');
|
||||
const result = await trpc.account.changeIcon.mutate({ sessionToken: token, imageData: iconData.value });
|
||||
if (account.value) account.value = { ...account.value, iconUrl: result.iconUrl };
|
||||
iconData.value = '';
|
||||
iconFilename.value = '';
|
||||
successMessage.value = '전용 아이콘을 변경했습니다.';
|
||||
});
|
||||
};
|
||||
|
||||
const deleteIcon = async (): Promise<void> => {
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
await trpc.account.deleteIcon.mutate({ sessionToken: token });
|
||||
if (account.value) account.value = { ...account.value, iconUrl: null };
|
||||
successMessage.value = '전용 아이콘을 제거했습니다.';
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadAccount();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<div id="account-container">
|
||||
<table id="account-table" class="legacy-bg0">
|
||||
<caption class="section-title legacy-bg2">
|
||||
계 정 관 리
|
||||
<RouterLink class="skin-button back-button" to="/lobby">돌아가기</RouterLink>
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="label-column" />
|
||||
<col span="5" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="6" class="legacy-bg1">회 원 정 보</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="account">
|
||||
<tr>
|
||||
<th class="legacy-bg1">ID</th>
|
||||
<td colspan="5">{{ account.username }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1">닉네임</th>
|
||||
<td colspan="5">{{ account.displayName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1">등급</th>
|
||||
<td colspan="2">{{ gradeLabel }}</td>
|
||||
<td colspan="3">{{ account.roles.join(', ') || '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1">가입일시</th>
|
||||
<td colspan="2">{{ new Date(account.createdAt).toLocaleString('ko-KR') }}</td>
|
||||
<td colspan="3">
|
||||
개인정보 3자 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
|
||||
<button
|
||||
v-if="account.thirdPartyUse"
|
||||
class="skin-button compact"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="disallowThirdPartyUse"
|
||||
>
|
||||
철회
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1">인증 방식</th>
|
||||
<td colspan="5">{{ account.oauthType }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1"></th>
|
||||
<th class="legacy-bg1" colspan="2">회원 탈퇴</th>
|
||||
<th class="legacy-bg1" colspan="3">비밀번호 변경</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1">정보<br />수정</th>
|
||||
<td class="action-cell" colspan="2">
|
||||
<form @submit.prevent="scheduleDeletion">
|
||||
<label for="delete-password">현재 비밀번호</label>
|
||||
<input
|
||||
id="delete-password"
|
||||
v-model="deletePassword"
|
||||
class="skin-input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button class="skin-button full-button" type="submit" :disabled="busy">탈퇴신청</button>
|
||||
</form>
|
||||
</td>
|
||||
<td colspan="3">
|
||||
<form class="password-form" @submit.prevent="changePassword">
|
||||
<label for="current-password">현재 비밀번호</label>
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="currentPassword"
|
||||
class="skin-input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<label for="new-password">새 비밀번호</label>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
class="skin-input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<label for="confirm-password">비밀번호 확인</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
v-model="newPasswordConfirm"
|
||||
class="skin-input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button class="skin-button full-button" type="submit" :disabled="busy">
|
||||
비밀번호 변경
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1"></th>
|
||||
<th class="legacy-bg1" colspan="2">현재 / 신규</th>
|
||||
<th class="legacy-bg1" colspan="3">전용 아이콘 변경</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1">전용<br />아이콘</th>
|
||||
<td class="icon-preview" colspan="2">
|
||||
<img v-if="account.iconUrl" :src="account.iconUrl" width="64" height="64" alt="현재 아이콘" />
|
||||
<span v-else>기본 아이콘</span>
|
||||
<img v-if="iconData" :src="iconData" width="64" height="64" alt="새 아이콘 미리보기" />
|
||||
</td>
|
||||
<td class="icon-actions" colspan="3">
|
||||
<input class="skin-input filename" :value="iconFilename" readonly aria-label="선택한 아이콘" />
|
||||
<label class="skin-button file-button">
|
||||
찾아보기
|
||||
<input
|
||||
type="file"
|
||||
accept=".avif,.webp,.jpg,.jpeg,.png,.gif"
|
||||
@change="selectIcon"
|
||||
/>
|
||||
</label>
|
||||
<button class="skin-button half-button" type="button" :disabled="busy" @click="changeIcon">
|
||||
아이콘 변경
|
||||
</button>
|
||||
<button class="skin-button half-button" type="button" :disabled="busy" @click="deleteIcon">
|
||||
아이콘 제거
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else>
|
||||
<tr>
|
||||
<td colspan="6" class="status-cell">{{ loading ? '불러오는 중...' : '계정 정보 없음' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th class="legacy-bg1">도움말</th>
|
||||
<td colspan="5" class="help-cell">
|
||||
<p>아이콘은 64 x 64픽셀 ~ 128 x 128픽셀 사이, 50KB 이하 파일만 가능합니다.</p>
|
||||
<p class="warning">탈퇴시 1개월간 정보가 보존되며, 1개월간 재가입이 불가능합니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<p v-if="successMessage" class="feedback success" role="status">{{ successMessage }}</p>
|
||||
<p v-if="errorMessage" class="feedback error" role="alert">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#account-container {
|
||||
width: 550px;
|
||||
min-height: 575px;
|
||||
margin: 106px auto 30px;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#account-table {
|
||||
width: 100%;
|
||||
border: 1px solid gray;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legacy-bg0 {
|
||||
background-color: #302016;
|
||||
background-image: url('/image/game/back_walnut.jpg');
|
||||
}
|
||||
|
||||
.legacy-bg1 {
|
||||
background-color: #14241b;
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
}
|
||||
|
||||
.legacy-bg2 {
|
||||
background-color: #172a52;
|
||||
background-image: url('/image/game/back_blue.jpg');
|
||||
}
|
||||
|
||||
#account-table caption {
|
||||
caption-side: top;
|
||||
}
|
||||
|
||||
#account-table th,
|
||||
#account-table td {
|
||||
border: 1px solid;
|
||||
border-color: gray #000 #000 gray;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.label-column {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
position: relative;
|
||||
height: 50px;
|
||||
border: 1px solid gray;
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 38px;
|
||||
}
|
||||
|
||||
.skin-button,
|
||||
.skin-input {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid;
|
||||
border-color: gray #000 #000 gray;
|
||||
background: #191919;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.skin-button {
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skin-button:hover,
|
||||
.skin-button:focus {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.skin-button:focus-visible,
|
||||
.skin-input:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.skin-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.compact {
|
||||
width: 40px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.action-cell,
|
||||
.icon-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-cell form {
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
.action-cell label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.password-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px;
|
||||
justify-content: end;
|
||||
gap: 2px 6px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.full-button {
|
||||
width: 100%;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.password-form .full-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.icon-preview img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.icon-actions {
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.filename {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 10px;
|
||||
width: 130px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.file-button {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 10px;
|
||||
height: 22px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.half-button {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.half-button:first-of-type {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.half-button:last-of-type {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.help-cell {
|
||||
padding: 8px !important;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.help-cell p {
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.help-cell .warning {
|
||||
margin-top: 1em;
|
||||
color: #f0f;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
border: 1px solid gray;
|
||||
background: #191919;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #9cff9c;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff9c9c;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#account-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -105,6 +105,16 @@ const handleKakao = async (): Promise<void> => {
|
||||
loginError.value = error instanceof Error ? error.message : '카카오 로그인을 시작하지 못했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordReset = async (): Promise<void> => {
|
||||
loginError.value = '';
|
||||
try {
|
||||
const result = await trpc.auth.kakaoStart.query({ mode: 'change_pw' });
|
||||
window.location.assign(result.authUrl);
|
||||
} catch (error) {
|
||||
loginError.value = error instanceof Error ? error.message : '비밀번호 초기화를 시작하지 못했습니다.';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -140,6 +150,9 @@ const handleKakao = async (): Promise<void> => {
|
||||
<button class="login-button" type="submit" :disabled="loginLoading">
|
||||
{{ loginLoading ? '로그인 중…' : '로그인' }}
|
||||
</button>
|
||||
<button class="reset-button" type="button" @click="handlePasswordReset">
|
||||
비밀번호 초기화
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="loginError" class="login-error" role="alert">{{ loginError }}</p>
|
||||
</section>
|
||||
@@ -233,7 +246,8 @@ const handleKakao = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
.kakao-button,
|
||||
.login-button {
|
||||
.login-button,
|
||||
.reset-button {
|
||||
min-height: 40px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
@@ -241,6 +255,19 @@ const handleKakao = async (): Promise<void> => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 30px;
|
||||
border-color: #555;
|
||||
background: #191919;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.reset-button:hover,
|
||||
.reset-button:focus {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.kakao-button {
|
||||
background: #fee500;
|
||||
color: #191919;
|
||||
|
||||
@@ -352,11 +352,12 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
계 정 관 리
|
||||
</div>
|
||||
<div class="p-6 flex justify-center space-x-4">
|
||||
<button
|
||||
<RouterLink
|
||||
to="/account"
|
||||
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
>
|
||||
비밀번호 & 전콘 & 탈퇴
|
||||
</button>
|
||||
</RouterLink>
|
||||
<button
|
||||
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
@click="handleLogout"
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const infoMessage = ref('');
|
||||
const oauthSessionId = ref('');
|
||||
const email = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const displayName = ref('');
|
||||
const termsAgreed = ref(false);
|
||||
const privacyAgreed = ref(false);
|
||||
|
||||
const completeExchange = async (): Promise<void> => {
|
||||
const code = typeof route.query.code === 'string' ? route.query.code : '';
|
||||
const state = typeof route.query.state === 'string' ? route.query.state : '';
|
||||
if (!code || !state) {
|
||||
errorMessage.value = '카카오 인증 응답이 올바르지 않습니다.';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await trpc.auth.kakaoExchange.mutate({ code, state });
|
||||
if (result.status === 'login') {
|
||||
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||
await router.replace('/lobby');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'change_pw') {
|
||||
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
|
||||
return;
|
||||
}
|
||||
oauthSessionId.value = result.oauthSessionId;
|
||||
email.value = result.email;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '카카오 인증을 완료하지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
if (password.value !== confirmPassword.value) {
|
||||
errorMessage.value = '비밀번호 확인이 일치하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
if (!termsAgreed.value || !privacyAgreed.value) {
|
||||
errorMessage.value = '이용약관과 개인정보 처리방침에 동의해야 합니다.';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const result = await trpc.auth.register.mutate({
|
||||
oauthSessionId: oauthSessionId.value,
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
displayName: displayName.value || undefined,
|
||||
});
|
||||
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||
await router.replace('/lobby');
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '회원가입에 실패했습니다.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void completeExchange();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<main id="oauth-container">
|
||||
<h1>삼국지 모의전투 HiDCHe</h1>
|
||||
<section class="oauth-card">
|
||||
<h2>회원가입</h2>
|
||||
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
||||
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
|
||||
<form v-else-if="oauthSessionId" @submit.prevent="register">
|
||||
<div class="form-row">
|
||||
<label for="oauth-email">카카오 이메일</label>
|
||||
<input id="oauth-email" :value="email" readonly />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="oauth-username">계정명</label>
|
||||
<input id="oauth-username" v-model="username" minlength="4" maxlength="64" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="oauth-password">비밀번호</label>
|
||||
<input id="oauth-password" v-model="password" type="password" minlength="6" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="oauth-confirm">비밀번호 확인</label>
|
||||
<input id="oauth-confirm" v-model="confirmPassword" type="password" minlength="6" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="oauth-display-name">닉네임</label>
|
||||
<div>
|
||||
<input id="oauth-display-name" v-model="displayName" minlength="2" maxlength="40" required />
|
||||
<small>깃수가 종료될 때 공개됩니다. 계속 사용할 이름이므로 신중하게 정해주세요.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agreement-row">
|
||||
<span>이용 약관</span>
|
||||
<label>
|
||||
<input v-model="termsAgreed" type="checkbox" />
|
||||
<a href="./terms.1.html" target="_blank">내용 확인</a> 후 동의합니다.
|
||||
</label>
|
||||
</div>
|
||||
<div class="agreement-row">
|
||||
<span>개인정보 제공 및 이용</span>
|
||||
<label>
|
||||
<input v-model="privacyAgreed" type="checkbox" />
|
||||
<a href="./terms.2.html" target="_blank">내용 확인</a> 후 동의합니다.
|
||||
</label>
|
||||
</div>
|
||||
<button class="register-button" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '가입 중...' : '가입' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="errorMessage" class="oauth-error" role="alert">{{ errorMessage }}</p>
|
||||
<RouterLink v-if="!loading && !oauthSessionId" class="back-link" to="/">돌아가기</RouterLink>
|
||||
</section>
|
||||
</main>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#oauth-container {
|
||||
width: min(calc(100% - 24px), 700px);
|
||||
margin: 90px auto 40px;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
}
|
||||
|
||||
#oauth-container h1 {
|
||||
margin: 0 0 18px;
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.oauth-card {
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: #303030;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oauth-card h2 {
|
||||
margin: 0;
|
||||
border-bottom: 1px solid #555;
|
||||
background: #444;
|
||||
padding: 8px 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.oauth-card form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.form-row,
|
||||
.agreement-row {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-row > label,
|
||||
.agreement-row > span {
|
||||
padding-top: 7px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form-row input:not([type='checkbox']) {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
background: #ddd;
|
||||
color: #303030;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.form-row small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.agreement-row label {
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.agreement-row a {
|
||||
color: #6db9ff;
|
||||
}
|
||||
|
||||
.register-button {
|
||||
min-height: 42px;
|
||||
margin-left: 162px;
|
||||
border: 1px solid #2f4d6c;
|
||||
border-radius: 4px;
|
||||
background: #375a7f;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.register-button:hover,
|
||||
.register-button:focus {
|
||||
background: #2f4d6c;
|
||||
}
|
||||
|
||||
.register-button:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.oauth-message,
|
||||
.oauth-error,
|
||||
.back-link {
|
||||
display: block;
|
||||
margin: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.oauth-error {
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #6db9ff;
|
||||
}
|
||||
|
||||
@media (max-width: 519px) {
|
||||
#oauth-container {
|
||||
width: calc(100% - 16px);
|
||||
margin-top: 78px;
|
||||
}
|
||||
|
||||
#oauth-container h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-row,
|
||||
.agreement-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.form-row > label,
|
||||
.agreement-row > span {
|
||||
padding-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.register-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user