feat: 내 정보 및 설정 페이지 추가, 관련 라우트 및 UI 구성

This commit is contained in:
2026-01-28 13:35:49 +00:00
parent 21d8507fc5
commit 87fe1edae1
5 changed files with 1200 additions and 0 deletions
+137
View File
@@ -1,6 +1,9 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { LogCategory, LogScope } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -11,6 +14,62 @@ const zGeneralSettings = z.object({
use_auto_nation_turn: z.number().int().optional(),
});
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
const readNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const normalizeItemCode = (value: string | null): string | null => {
if (!value || value === 'None') {
return null;
}
return value;
};
const resolveUserSettings = (meta: Record<string, unknown>) => {
const settings = asRecord(meta.userSettings);
const mysetRaw = settings.myset;
const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null;
return {
tnmt: readNumber(settings.tnmt, 1),
defence_train: readNumber(settings.defence_train, 80),
use_treatment: readNumber(settings.use_treatment, 10),
use_auto_nation_turn: readNumber(settings.use_auto_nation_turn, 1),
myset,
};
};
const resolvePenalty = (penalty: unknown): Record<string, number> => {
const penaltyRecord = asRecord(penalty);
const result: Record<string, number> = {};
for (const [key, value] of Object.entries(penaltyRecord)) {
if (typeof value === 'number' && Number.isFinite(value)) {
result[key] = value;
continue;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
result[key] = parsed;
}
}
}
return result;
};
export const generalRouter = router({
me: authedProcedure.query(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
@@ -41,6 +100,12 @@ export const generalRouter = router({
injury: true,
experience: true,
dedication: true,
weaponCode: true,
horseCode: true,
bookCode: true,
itemCode: true,
meta: true,
penalty: true,
},
});
@@ -86,6 +151,10 @@ export const generalRouter = router({
: null,
]);
const metaRecord = asRecord(general.meta);
const settings = resolveUserSettings(metaRecord);
const penalties = resolvePenalty(general.penalty);
return {
general: {
id: general.id,
@@ -110,9 +179,17 @@ export const generalRouter = router({
injury: general.injury,
experience: general.experience,
dedication: general.dedication,
items: {
horse: normalizeItemCode(general.horseCode),
weapon: normalizeItemCode(general.weaponCode),
book: normalizeItemCode(general.bookCode),
item: normalizeItemCode(general.itemCode),
},
},
city,
nation,
settings,
penalties,
};
}),
dieOnPrestart: authedProcedure.mutation(async ({ ctx }) => {
@@ -184,6 +261,30 @@ export const generalRouter = router({
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
const metaRecord = asRecord(general.meta);
const prevSettings = asRecord(metaRecord.userSettings);
const prevMyset = typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset)
? prevSettings.myset
: null;
const nextSettings = {
...prevSettings,
...input,
} as Record<string, unknown>;
if (typeof prevMyset === 'number') {
nextSettings.myset = Math.max(0, prevMyset - 1);
}
await ctx.db.general.update({
where: { id: general.id },
data: {
meta: {
...metaRecord,
userSettings: nextSettings,
},
} as any,
});
return { ok: true };
}),
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
@@ -201,4 +302,40 @@ export const generalRouter = router({
}
return { ok: true };
}),
getMyLog: authedProcedure
.input(
z.object({
type: zGeneralLogType,
beforeId: z.number().int().positive().optional(),
})
)
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const categoryMap: Record<z.infer<typeof zGeneralLogType>, LogCategory> = {
generalHistory: LogCategory.HISTORY,
generalAction: LogCategory.ACTION,
battleResult: LogCategory.BATTLE_BRIEF,
battleDetail: LogCategory.BATTLE_DETAIL,
};
const logs = await ctx.db.logEntry.findMany({
where: {
generalId: me.id,
scope: LogScope.GENERAL,
category: categoryMap[input.type],
...(input.beforeId ? { id: { lt: input.beforeId } } : {}),
},
orderBy: { id: 'desc' },
take: 24,
});
return {
type: input.type,
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
})),
};
}),
});
+20
View File
@@ -14,6 +14,8 @@ import BattleSimulatorView from '../views/BattleSimulatorView.vue';
import NpcControlView from '../views/NpcControlView.vue';
import NotFoundView from '../views/NotFoundView.vue';
import TournamentView from '../views/TournamentView.vue';
import MyPageView from '../views/MyPageView.vue';
import MySettingsView from '../views/MySettingsView.vue';
import { useSessionStore } from '../stores/session';
const routes = [
@@ -112,6 +114,24 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/my-page',
name: 'my-page',
component: MyPageView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
path: '/my-settings',
name: 'my-settings',
component: MySettingsView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
path: '/npc-control',
name: 'npc-control',
+1
View File
@@ -98,6 +98,7 @@ watch(
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
<RouterLink class="ghost" to="/battle-center">감찰부</RouterLink>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<RouterLink class="ghost" to="/tournament">토너먼트</RouterLink>
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
+591
View File
@@ -0,0 +1,591 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
const SCREEN_MODE_KEY = 'sammo-screen-mode';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = {
currentYear: number;
currentMonth: number;
tickSeconds: number;
config: Record<string, unknown>;
meta: Record<string, unknown>;
} | null;
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type LogLine = {
id: number;
html: string;
};
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type ItemSlot = {
key: ItemSlotKey;
label: string;
code: string | null;
};
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
const logLabels: Record<LogType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
};
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const logs = reactive<Record<LogType, LogLine[]>>({
generalHistory: [],
battleDetail: [],
battleResult: [],
generalAction: [],
});
const logLoading = reactive<Record<LogType, boolean>>({
generalHistory: false,
battleDetail: false,
battleResult: false,
generalAction: false,
});
const logHasMore = reactive<Record<LogType, boolean>>({
generalHistory: true,
battleDetail: true,
battleResult: true,
generalAction: true,
});
const activeLogTab = ref<LogType>('generalAction');
const isMobile = useMediaQuery('(max-width: 1024px)');
const screenMode = ref<'auto' | '500px' | '1000px'>('auto');
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const resolveNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const statusLine = computed(() => {
if (!worldState.value) {
return '내 정보를 불러오는 중';
}
const turnTerm = resolveNumber((worldState.value.config as Record<string, unknown>)?.turnTermMinutes, 0);
const termLabel = turnTerm > 0 ? ` · 턴 ${turnTerm}` : '';
return `${worldState.value.currentYear}${worldState.value.currentMonth}${termLabel}`;
});
const itemSlots = computed<ItemSlot[]>(() => {
const items = data.value?.general?.items;
return [
{ key: 'horse', label: '말', code: items?.horse ?? null },
{ key: 'weapon', label: '무기', code: items?.weapon ?? null },
{ key: 'book', label: '서적', code: items?.book ?? null },
{ key: 'item', label: '아이템', code: items?.item ?? null },
];
});
const actionAvailability = computed(() => {
const general = data.value?.general;
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const config = (worldState.value?.config ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const turntime = meta.turntime ? new Date(String(meta.turntime)) : null;
const opentime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turntime && opentime && turntime.getTime() <= opentime.getTime());
const npcMode = resolveNumber(config.npcMode, 0);
return {
canDieOnPrestart: Boolean(preopen && general && general.npcState === 0 && general.nationId === 0),
canBuildNationCandidate: Boolean(preopen && general && general.nationId === 0),
canVacation: !(autorunUser.limit_minutes ?? false),
canInstantRetreat: Boolean(general && general.nationId > 0),
canSelectOtherGeneral: Boolean(npcMode === 2 && general && general.npcState === 0),
};
});
const loadLogs = async () => {
if (!data.value?.general?.id) {
return;
}
await Promise.all(
logTypes.map((type) => loadLog(type))
);
};
const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) {
return;
}
logLoading[type] = true;
try {
const response = await trpc.general.getMyLog.query({ type, beforeId });
const formatted = response.logs.map((entry) => ({
id: entry.id,
html: formatLog(entry.text),
}));
if (beforeId) {
logs[type].push(...formatted);
} else {
logs[type] = formatted;
}
logHasMore[type] = formatted.length >= 24;
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
logLoading[type] = false;
}
};
const loadMyPage = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await (trpc.world.getState.query as unknown as () => Promise<WorldStateSnapshot>)();
data.value = general;
worldState.value = world ?? null;
await loadLogs();
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const confirmAction = async (message: string, action: () => Promise<void>) => {
if (!confirm(message)) {
return;
}
try {
await action();
await loadMyPage();
} catch (err) {
alert(`실패했습니다: ${resolveErrorMessage(err)}`);
}
};
const handleDieOnPrestart = () =>
confirmAction('정말로 삭제하시겠습니까?', async () => {
await trpc.general.dieOnPrestart.mutate();
window.location.reload();
});
const handleBuildNationCandidate = () =>
confirmAction('거병 이후 장수를 삭제할 수 없습니다. 거병하시겠습니까?', async () => {
await trpc.general.buildNationCandidate.mutate();
});
const handleInstantRetreat = () =>
confirmAction('아군 접경으로 이동할까요?', async () => {
await trpc.general.instantRetreat.mutate();
});
const handleVacation = () =>
confirmAction('휴가 기능을 신청할까요?', async () => {
await trpc.general.vacation.mutate();
});
const handleDropItem = (slot: ItemSlot) =>
confirmAction(`${slot.label}(${slot.code ?? '-'})을(를) 파기하시겠습니까?`, async () => {
await trpc.general.dropItem.mutate({ itemType: slot.key });
});
const refreshScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const mode = window.localStorage.getItem(SCREEN_MODE_KEY);
if (mode === '500px' || mode === '1000px') {
screenMode.value = mode;
} else {
screenMode.value = 'auto';
}
};
watch(
() => isMobile.value,
(value) => {
if (value && !logTypes.includes(activeLogTab.value)) {
activeLogTab.value = 'generalAction';
}
}
);
onMounted(() => {
refreshScreenMode();
void loadMyPage();
});
</script>
<template>
<main class="my-page" :class="`screen-${screenMode}`">
<header class="page-header">
<div>
<h1 class="page-title"> 정보</h1>
<p class="page-subtitle">{{ statusLine }}</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/my-settings">게임 설정</RouterLink>
<button class="ghost" @click="loadMyPage">새로고침</button>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<section class="layout-grid">
<div class="stack">
<PanelCard title="장수 상태">
<GeneralBasicCard :general="data?.general ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="도시 상태">
<CityBasicCard :city="data?.city ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="세력 상태">
<NationBasicCard :nation="data?.nation ?? null" :loading="loading" />
</PanelCard>
</div>
<div class="stack">
<PanelCard title="장수 상태 변경" subtitle="중요 액션은 확인 후 실행됩니다.">
<div class="action-grid">
<button
v-if="actionAvailability.canVacation"
class="action-btn"
type="button"
@click="handleVacation"
>
휴가 신청
</button>
<button
v-if="actionAvailability.canDieOnPrestart"
class="action-btn"
type="button"
@click="handleDieOnPrestart"
>
장수 삭제
</button>
<button
v-if="actionAvailability.canBuildNationCandidate"
class="action-btn"
type="button"
@click="handleBuildNationCandidate"
>
사전 거병
</button>
<button
v-if="actionAvailability.canInstantRetreat"
class="action-btn"
type="button"
@click="handleInstantRetreat"
>
접경 귀환
</button>
<RouterLink
v-if="actionAvailability.canSelectOtherGeneral"
class="action-btn link"
to="/join"
>
다른 장수 선택
</RouterLink>
</div>
</PanelCard>
<PanelCard title="아이템 파기" subtitle="소지 중인 장비를 선택합니다.">
<div class="item-grid">
<button
v-for="slot in itemSlots"
:key="slot.key"
class="item-btn"
type="button"
:disabled="!slot.code"
@click="handleDropItem(slot)"
>
{{ slot.label }}: {{ slot.code ?? '-' }}
</button>
</div>
</PanelCard>
<PanelCard v-if="isMobile" title="장수 기록">
<div class="log-tabs">
<button
v-for="type in logTypes"
:key="type"
:class="{ active: activeLogTab === type }"
@click="activeLogTab = type"
>
{{ logLabels[type] }}
</button>
</div>
<div class="log-block">
<div class="log-title">{{ logLabels[activeLogTab] }}</div>
<SkeletonLines v-if="loading || logLoading[activeLogTab]" :lines="4" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[activeLogTab].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[activeLogTab]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<button
v-if="logHasMore[activeLogTab]"
class="ghost log-more"
@click="loadLog(activeLogTab, logs[activeLogTab].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
</div>
</PanelCard>
<PanelCard v-else title="장수 기록" subtitle="개인 기록 및 전투 로그">
<div class="log-grid">
<div v-for="type in logTypes" :key="type" class="log-block">
<div class="log-title">{{ logLabels[type] }}</div>
<SkeletonLines v-if="loading || logLoading[type]" :lines="3" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<button
v-if="logHasMore[type]"
class="ghost log-more"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</PanelCard>
</div>
</section>
</main>
</template>
<style scoped>
.my-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: width 0.2s ease;
margin: 0 auto;
}
.my-page.screen-500px {
max-width: 500px;
}
.my-page.screen-1000px {
max-width: 1000px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.action-btn {
border: 1px solid rgba(201, 164, 90, 0.5);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
cursor: pointer;
text-align: center;
}
.action-btn.link {
display: inline-flex;
align-items: center;
justify-content: center;
}
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.item-btn {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 10px;
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.item-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.log-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
}
.log-line:last-child {
border-bottom: none;
}
.log-more {
margin-top: 8px;
}
.log-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.log-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.log-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
@media (max-width: 1024px) {
.layout-grid {
grid-template-columns: 1fr;
}
.log-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,451 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
const CUSTOM_CSS_KEY = 'sammo-custom-css';
const SCREEN_MODE_KEY = 'sammo-screen-mode';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = {
config: Record<string, unknown>;
meta: Record<string, unknown>;
} | null;
type ScreenMode = 'auto' | '500px' | '1000px';
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
let cssTimer: number | null = null;
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const resolveNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const canSave = computed(() => {
const remaining = data.value?.settings?.myset ?? null;
if (remaining === null) {
return true;
}
return remaining > 0;
});
const remainingLabel = computed(() => {
const remaining = data.value?.settings?.myset ?? null;
if (remaining === null) {
return '설정 저장 제한 없음';
}
return `설정저장은 이달중 ${remaining}회 남았습니다.`;
});
const showAutoNationTurn = computed(() => {
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const options = (autorunUser.options ?? {}) as Record<string, unknown>;
return Boolean(options.chief);
});
const penalties = computed(() => {
const list = data.value?.penalties ?? {};
return Object.entries(list);
});
const applyCustomCss = (text: string) => {
if (typeof window === 'undefined') {
return;
}
const id = 'sammo-custom-css';
let style = document.getElementById(id) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = id;
document.head.appendChild(style);
}
style.textContent = text;
};
const saveCustomCss = () => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(CUSTOM_CSS_KEY, customCss.value);
applyCustomCss(customCss.value);
};
const loadScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const value = window.localStorage.getItem(SCREEN_MODE_KEY);
if (value === '500px' || value === '1000px') {
screenMode.value = value;
} else {
screenMode.value = 'auto';
}
};
const saveScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(SCREEN_MODE_KEY, screenMode.value);
};
const loadSettings = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await trpc.world.getState.query() as
| {
config?: Record<string, unknown>;
meta?: Record<string, unknown>;
}
| null;
data.value = general;
worldState.value = world
? {
config: world.config ?? {},
meta: world.meta ?? {},
}
: null;
if (general?.settings) {
form.tnmt = general.settings.tnmt;
form.defence_train = general.settings.defence_train;
form.use_treatment = general.settings.use_treatment;
form.use_auto_nation_turn = general.settings.use_auto_nation_turn;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const saveSettings = async () => {
if (!canSave.value) {
alert('설정 저장 가능 횟수가 없습니다.');
return;
}
try {
await trpc.general.setMySetting.mutate({
tnmt: resolveNumber(form.tnmt, 1),
defence_train: resolveNumber(form.defence_train, 80),
use_treatment: resolveNumber(form.use_treatment, 10),
use_auto_nation_turn: resolveNumber(form.use_auto_nation_turn, 1),
});
await loadSettings();
alert('설정을 저장했습니다.');
} catch (err) {
alert(`실패했습니다: ${resolveErrorMessage(err)}`);
}
};
watch(
() => screenMode.value,
() => {
saveScreenMode();
}
);
watch(
() => customCss.value,
() => {
if (cssTimer) {
window.clearTimeout(cssTimer);
}
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
saveCustomCss();
cssSaving.value = false;
}, 400);
}
);
onMounted(() => {
if (typeof window !== 'undefined') {
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
}
loadScreenMode();
void loadSettings();
});
</script>
<template>
<main class="settings-page">
<header class="page-header">
<div>
<h1 class="page-title">게임 설정</h1>
<p class="page-subtitle"> 정보 설정을 관리합니다.</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<button class="ghost" @click="loadSettings">새로고침</button>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<section class="layout-grid">
<div class="stack">
<PanelCard title="게임 옵션" subtitle="설정 저장 시 즉시 반영됩니다.">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="form-grid">
<label class="form-field">
<span>토너먼트 참가</span>
<select v-model.number="form.tnmt">
<option :value="0">수동 참여</option>
<option :value="1">자동 참여</option>
</select>
</label>
<label class="form-field">
<span>환약 사용 기준</span>
<select v-model.number="form.use_treatment">
<option :value="10">경상</option>
<option :value="21">중상</option>
<option :value="41">심각</option>
<option :value="61">위독</option>
<option :value="100">사용 안함</option>
</select>
</label>
<label v-if="showAutoNationTurn" class="form-field">
<span>자동 사령턴 허용</span>
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<label class="form-field">
<span>수비 설정</span>
<select v-model.number="form.defence_train">
<option :value="90">훈련 90</option>
<option :value="80">훈련 80</option>
<option :value="60">훈련 60</option>
<option :value="40">훈련 40</option>
<option :value="999">절대 수비</option>
</select>
</label>
<div class="hint">{{ remainingLabel }}</div>
<button class="primary" type="button" :disabled="!canSave" @click="saveSettings">
설정 저장
</button>
</div>
</PanelCard>
<PanelCard title="징계 목록" subtitle="설정 저장 시 갱신됩니다.">
<SkeletonLines v-if="loading" :lines="3" />
<div v-else class="penalty-list">
<div v-if="penalties.length === 0" class="empty">징계가 없습니다.</div>
<div v-for="[key, value] in penalties" :key="key" class="penalty-item">
{{ key }}: {{ value }}
</div>
</div>
</PanelCard>
</div>
<div class="stack">
<PanelCard title="화면 모드" subtitle="모바일 전용 설정입니다.">
<div class="screen-mode">
<label>
<input v-model="screenMode" type="radio" value="auto" />
자동
</label>
<label>
<input v-model="screenMode" type="radio" value="500px" />
500px
</label>
<label>
<input v-model="screenMode" type="radio" value="1000px" />
1000px
</label>
</div>
</PanelCard>
<PanelCard title="개인용 CSS" subtitle="변경 사항은 자동 저장됩니다.">
<textarea v-model="customCss" class="css-input" rows="8" />
<div class="hint">{{ cssSaving ? '저장 중...' : '자동 저장 완료' }}</div>
</PanelCard>
</div>
</section>
</main>
</template>
<style scoped>
.settings-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.form-grid {
display: grid;
gap: 12px;
}
.form-field {
display: grid;
gap: 6px;
font-size: 0.9rem;
}
.form-field select {
padding: 6px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.7);
color: inherit;
}
.primary {
border: 1px solid rgba(201, 164, 90, 0.6);
background: rgba(201, 164, 90, 0.2);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
cursor: pointer;
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.screen-mode {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.85rem;
}
.css-input {
width: 100%;
background: rgba(12, 12, 12, 0.7);
color: inherit;
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
}
.penalty-list {
display: grid;
gap: 6px;
font-size: 0.85rem;
}
.penalty-item {
color: #f08a5d;
}
.hint {
color: rgba(232, 221, 196, 0.7);
font-size: 0.8rem;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
@media (max-width: 1024px) {
.layout-grid {
grid-template-columns: 1fr;
}
}
</style>