feat: 내 정보 및 설정 페이지 추가, 관련 라우트 및 UI 구성
This commit is contained in:
@@ -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,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user