Files
core2026/app/game-frontend/src/views/MySettingsView.vue
T

452 lines
13 KiB
Vue

<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>