Files
core2026/app/game-frontend/src/views/MyPageView.vue
T
Hide_D 71ce057318 fix: 정보 화면 버튼을 공통 Lumen 계열로 통일
세력도시·세력정보·현재도시·내 정보·인사부·외교부의 raised control을 공통 semantic button family에 연결한다. 화면별 크기와 색상은 유지하고 실제 Chromium 상태 geometry 회귀를 추가한다.
2026-08-21 17:34:45 +00:00

1176 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
import {
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
loadMobileMainPanelOrder,
MOBILE_MAIN_PANEL_DEFINITIONS,
moveMobileMainPanel,
saveMobileMainPanelOrder,
type MobileMainPanelId,
} from '../utils/mobileMainPanelOrder';
const CUSTOM_CSS_KEY = 'sam_customCSS';
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback();
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type SelectionPoolStatus = Awaited<ReturnType<typeof trpc.join.getConfig.query>>['selectionPool'];
type DieOnPrestartStatus = Awaited<ReturnType<typeof trpc.general.ensureDieOnPrestartStatus.mutate>>;
type WorldSnapshot = {
currentYear: number;
currentMonth: number;
tickSeconds: number;
config: Record<string, unknown>;
meta: Record<string, unknown>;
} | null;
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const selectionPoolStatus = ref<SelectionPoolStatus | null>(null);
const dieOnPrestartStatus = ref<DieOnPrestartStatus | null>(null);
const dieOnPrestartStatusLoading = ref(false);
const dieOnPrestartStatusError = ref<string | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const selectedIconId = ref('');
const cssSaving = ref(false);
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
const session = useSessionStore();
let cssTimer: number | null = null;
const readPendingDieOnPrestartId = (): string => {
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
return stored && /^[0-9a-f-]{36}$/iu.test(stored) ? stored : crypto.randomUUID();
};
const immediateActionRequestIds = reactive({
dieOnPrestart: readPendingDieOnPrestartId(),
buildNationCandidate: crypto.randomUUID(),
instantRetreat: crypto.randomUUID(),
});
const resetImmediateActionRequestIds = () => {
immediateActionRequestIds.buildNationCandidate = crypto.randomUUID();
immediateActionRequestIds.instantRetreat = crypto.randomUUID();
};
const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
const logTypes: LogType[] = ['generalAction', 'battleDetail', 'generalHistory', 'battleResult'];
const logLabels: Record<LogType, string> = {
generalAction: '개인 기록',
battleDetail: '전투 기록',
generalHistory: '장수 열전',
battleResult: '전투 결과',
};
const logColors: Record<LogType, string> = {
generalAction: 'skyblue',
battleDetail: 'orange',
generalHistory: 'skyblue',
battleResult: 'orange',
};
const logs = reactive<Record<LogType, Array<{ id: number; html: string }>>>({
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 errorText = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const asRecord = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const numberValue = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const statusLine = computed(() =>
world.value
? `${world.value.currentYear}${world.value.currentMonth}월 · ${Math.max(
1,
Math.round(world.value.tickSeconds / 60)
)}분 턴`
: '내 정보를 불러오는 중'
);
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const noDefencePenaltyWaived = computed(() => {
const environment = asRecord(world.value?.config.environment);
return isDefenceTrainPenaltyWaivedByScenarioEffect(
typeof environment.scenarioEffect === 'string' ? environment.scenarioEffect : null
);
});
const noDefenceLabel = computed(() => (noDefencePenaltyWaived.value ? '×' : '× [훈련 -3,사기 -6]'));
const fallbackDisplayCode = (value: string | null | undefined): string | null =>
value && !/^\d+$/u.test(value) ? value.replace(/^che_(?:event_)?/u, '') : null;
const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }>>(
() => [
{
key: 'horse',
slotName: '말',
displayName:
data.value?.general.itemNames?.horse ?? fallbackDisplayCode(data.value?.general.items.horse) ?? null,
code: data.value?.general.items.horse ?? null,
},
{
key: 'weapon',
slotName: '무기',
displayName:
data.value?.general.itemNames?.weapon ?? fallbackDisplayCode(data.value?.general.items.weapon) ?? null,
code: data.value?.general.items.weapon ?? null,
},
{
key: 'book',
slotName: '서적',
displayName:
data.value?.general.itemNames?.book ?? fallbackDisplayCode(data.value?.general.items.book) ?? null,
code: data.value?.general.items.book ?? null,
},
{
key: 'item',
slotName: '도구',
displayName:
data.value?.general.itemNames?.item ?? fallbackDisplayCode(data.value?.general.items.item) ?? null,
code: data.value?.general.items.item ?? null,
},
]
);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
);
const openMobileLayoutDialog = () => {
mobileLayoutOrder.value = loadMobileMainPanelOrder();
mobileLayoutDialog.value?.showModal();
window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector<HTMLButtonElement>('button')?.focus());
};
const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
};
const resetMobileLayoutOrder = () => {
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
};
const applyMobileLayoutOrder = () => {
mobileLayoutOrder.value = saveMobileMainPanelOrder(mobileLayoutOrder.value);
mobileLayoutDialog.value?.close();
showSuccessToast('모바일 메인 레이아웃 순서를 저장했습니다.');
};
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
const showVacation = computed(() => autorunUser.value.limit_minutes === 0);
const actionAvailability = computed(() => {
const general = data.value?.general;
const meta = world.value?.meta ?? {};
const config = world.value?.config ?? {};
const constConfig = asRecord(config.const);
const availableInstantAction = asRecord(constConfig.availableInstantAction ?? config.availableInstantAction);
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 = numberValue(config.npcMode ?? config.npcmode, 0);
return {
dieOnPrestart: Boolean(dieOnPrestartStatus.value?.show),
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
instantRetreat: Boolean(availableInstantAction.instantRetreat),
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
};
});
const formatDieOnPrestartAvailableAt = computed(() => {
const value = dieOnPrestartStatus.value?.availableAt;
return value ? formatSeoulDateTime(value) : '';
});
const formatSelectionAvailableAt = computed(() => {
const value = selectionPoolStatus.value?.nextChangeAt;
if (!value) return '';
return formatSeoulDateTime(value);
});
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
}
style.textContent = text;
};
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 next = response.logs.map((entry) => ({ id: entry.id, html: formatLog(entry.text) }));
logs[type] = beforeId ? [...logs[type], ...next] : next;
logHasMore[type] = next.length >= 24;
} catch (cause) {
error.value = errorText(cause);
} finally {
logLoading[type] = false;
}
};
const loadDieOnPrestartStatus = async () => {
if (dieOnPrestartStatusLoading.value) return;
dieOnPrestartStatusLoading.value = true;
dieOnPrestartStatusError.value = null;
try {
dieOnPrestartStatus.value = await trpc.general.ensureDieOnPrestartStatus.mutate();
} catch (cause) {
dieOnPrestartStatus.value = null;
dieOnPrestartStatusError.value = errorText(cause);
} finally {
dieOnPrestartStatusLoading.value = false;
}
};
const loadPage = async (resetImmediateActionIds = true) => {
if (loading.value) return;
loading.value = true;
error.value = null;
void loadDieOnPrestartStatus();
try {
const [general, state, joinConfig] = await Promise.all([
trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
trpc.join.getConfig.query(),
]);
data.value = general;
world.value = state;
selectionPoolStatus.value = joinConfig.selectionPool;
if (general) {
Object.assign(form, general.settings);
selectedIconId.value =
iconChoices.value.find((icon) => icon.picture === general.general.picture)?.id ??
iconChoices.value[0]?.id ??
'';
}
await Promise.all(logTypes.map((type) => loadLog(type)));
if (resetImmediateActionIds) {
resetImmediateActionRequestIds();
}
} catch (cause) {
error.value = errorText(cause);
} finally {
loading.value = false;
}
};
const changeGeneralIcon = async () => {
if (!selectedIconId.value) return;
if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return;
try {
await trpc.general.adjustIcon.mutate({
iconId: selectedIconId.value,
clientRequestId: crypto.randomUUID(),
});
await loadPage();
} catch (cause) {
showErrorToast(`전용 아이콘 변경에 실패했습니다: ${errorText(cause)}`);
}
};
const saveSettings = async () => {
if (!canSave.value) return;
try {
await trpc.general.setMySetting.mutate({ ...form });
await loadPage();
} catch (cause) {
showErrorToast(`설정 저장에 실패했습니다: ${errorText(cause)}`);
}
};
const confirmMutation = async (message: string, mutation: () => Promise<unknown>, reloadAfterFailure = false) => {
if (!confirm(message)) return;
try {
await mutation();
await loadPage();
} catch (cause) {
showErrorToast(`요청 처리에 실패했습니다: ${errorText(cause)}`);
if (reloadAfterFailure) {
const code = asRecord(asRecord(cause).data).code;
await loadPage(code !== 'TIMEOUT');
}
}
};
const dieOnPrestart = async () => {
if (!confirm('정말로 삭제하시겠습니까?')) return;
const clientRequestId = immediateActionRequestIds.dieOnPrestart;
window.sessionStorage.setItem(PENDING_DIE_ON_PRESTART_KEY, clientRequestId);
try {
await trpc.general.dieOnPrestart.mutate({ clientRequestId });
window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY);
session.leaveGame();
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
} catch (cause) {
const code = asRecord(asRecord(cause).data).code;
if (code !== 'TIMEOUT') {
window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY);
}
await showDialog({
kind: 'error',
title: '장수 삭제 실패',
message: `요청 처리에 실패했습니다: ${errorText(cause)}\n확인 후 페이지를 새로고침합니다.`,
});
window.location.reload();
}
};
const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }) =>
confirmMutation(`${item.displayName ?? item.slotName}을(를) 버리시겠습니까?`, () =>
trpc.general.dropItem.mutate({ itemType: item.key })
);
watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
}, 500);
});
onMounted(() => {
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
});
</script>
<template>
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<div class="title-actions">
<div class="navigation-actions">
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
<button class="legacy-button legacy-button--navigation" type="button" @click="() => loadPage()">
새로고침
</button>
</div>
<RouterLink class="legacy-button legacy-button--secondary past-plays-link" to="/past-plays">
지난 플레이
</RouterLink>
</div>
</div>
<div v-if="error" class="error-row">{{ error }}</div>
<div class="status-row">{{ statusLine }}</div>
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<GeneralInformationPanel
class="general-table"
:general="data?.general ?? null"
:summary="
data
? {
available: true,
experience: data.general.experience,
dedicationText: data.general.progression?.dedicationText,
bill: data.general.bill,
warnum: data.general.records.battles,
wins: data.general.records.wins,
losses: data.general.records.losses,
strategies: data.general.records.strategies,
serviceYears: data.general.records.serviceYears,
killCrew: data.general.records.killedCrew,
deathCrew: data.general.records.lostCrew,
recentWar: data.general.recentWar,
}
: null
"
:loading="loading"
:nation-color="data?.nation?.color"
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`"
:penalty-text="penalties.length || '-'"
/>
</div>
<div class="settings-column">
<div class="setting-line">
토너먼트
<label><input v-model.number="form.tnmt" type="radio" :value="0" />수동참여</label>
<label><input v-model.number="form.tnmt" type="radio" :value="1" />자동참여</label>
</div>
<div class="hint"> 개막직전 남는자리가 있을경우 랜덤하게 참여합니다.</div>
<label class="setting-line">
환약 사용
<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>
<div class="hint"> 부상을 입었을 환약을 사용하는 기준입니다.</div>
<label v-if="showAutoNationTurn" class="setting-line">
자동 사령턴 허용
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<div v-if="showAutoNationTurn" class="hint">
수뇌가 되었을 휴식 턴이어도 적당한 턴을 알아서 넣는 것을 허용합니다.
</div>
<label class="setting-line">
수비
<select
id="defence_train"
v-model.number="form.defence_train"
:class="{ 'penalty-waived': noDefencePenaltyWaived }"
>
<option :value="90">(훈사90)</option>
<option :value="80">(훈사80)</option>
<option :value="60">(훈사60)</option>
<option :value="40">(훈사40)</option>
<option :value="999">{{ noDefenceLabel }}</option>
</select>
</label>
<button
id="set_my_setting"
class="legacy-button legacy-button--lumen legacy-button--fixed-height action-button"
type="button"
:hidden="!canSave"
@click="saveSettings"
>
설정저장
</button>
<div class="hint"> 설정저장은 이달중 {{ data?.settings.myset ?? 0 }} 남았습니다.</div>
<div v-if="penalties.length" class="penalties">
징계 목록(저장 갱신)
<div v-for="[key, value] in penalties" :key="key">{{ key }} : {{ value }}</div>
</div>
<div v-if="showVacation" class="action-line">
<br />
<button
class="legacy-button legacy-button--lumen legacy-button--fixed-height action-button"
type="button"
@click="confirmMutation('휴가 기능을 신청할까요?', () => trpc.general.vacation.mutate())"
>
휴가 신청
</button>
</div>
<div v-if="data?.canChangeIcon && iconChoices.length" class="action-line general-icon-action">
전용 아이콘 변경 (24시간에 1회)<br />
<span v-if="data.iconChangeAvailableAt" class="hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</span>
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
<img
:src="resolveGeneralIconUrl(selectedIcon)"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
<strong>{{ data.general.name }}</strong>
</div>
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
<img
:src="resolveGeneralIconUrl(icon)"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
</label>
</div>
<button
class="legacy-button legacy-button--lumen legacy-button--fixed-height action-button"
type="button"
@click="changeGeneralIcon"
>
아이콘 변경
</button>
</div>
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
가오픈 기간 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
<button
class="legacy-button legacy-button--danger legacy-button--fixed-height action-button"
@click="dieOnPrestart"
>
장수 삭제
</button>
</div>
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
서버 개시 이전 거병(2턴부터 건국 가능)<br />
<button
class="legacy-button legacy-button--lumen legacy-button--fixed-height action-button"
@click="
confirmMutation(
'거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?',
() =>
trpc.general.buildNationCandidate.mutate({
clientRequestId: immediateActionRequestIds.buildNationCandidate,
}),
true
)
"
>
사전 거병
</button>
</div>
<div v-if="actionAvailability.instantRetreat" class="action-line">
거리 3칸 이내 아국 도시로 즉시 이동<br />
<button
class="legacy-button legacy-button--lumen legacy-button--fixed-height action-button"
@click="
confirmMutation(
'아군 접경으로 이동할까요?',
() =>
trpc.general.instantRetreat.mutate({
clientRequestId: immediateActionRequestIds.instantRetreat,
}),
true
)
"
>
접경 귀환
</button>
</div>
<div v-if="actionAvailability.selectOtherGeneral && selectionPoolStatus?.enabled" class="action-line">
다른 장수 선택
<template v-if="formatSelectionAvailableAt"> ({{ formatSelectionAvailableAt }} 부터) </template>
<br />
<RouterLink
class="legacy-button legacy-button--lumen legacy-button--fixed-height action-button select-general-link"
to="/select-general"
>
다른 장수 선택
</RouterLink>
<br /><br />
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
<div class="button-group">
<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>
</div>
<div class="mobile-layout-setting-row">
<span>
모바일 레이아웃 순서 바꾸기<br />
<small>500px 메인 화면의 패널 순서를 기기에 저장합니다.</small>
</span>
<button
class="legacy-button legacy-button--primary mobile-layout-open"
type="button"
@click="openMobileLayoutDialog"
>
순서 바꾸기
</button>
</div>
<div class="item-title">아이템 파기</div>
<div class="item-group">
<button
v-for="item in items"
:key="item.key"
class="legacy-button legacy-button--secondary item-button"
type="button"
:disabled="!item.code"
@click="dropItem(item)"
>
{{ item.displayName ?? '-' }}
</button>
</div>
<label class="custom-css">
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" />
</label>
</div>
</section>
<div class="legacy-general-info-compat" aria-hidden="true">
<table v-for="tableIndex in 3" :key="tableIndex">
<tbody>
<tr v-for="rowIndex in tableIndex < 3 ? 7 : 6" :key="rowIndex">
<td></td>
</tr>
</tbody>
</table>
<button type="button" tabindex="-1"></button><button type="button" tabindex="-1"></button>
<input tabindex="-1" />
</div>
<section class="log-grid">
<article v-for="type in logTypes" :key="type" class="log-panel">
<h2 :style="{ color: logColors[type] }">{{ logLabels[type] }}</h2>
<div v-if="logLoading[type]" class="loading">불러오는 중...</div>
<div v-else>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<button
v-if="logHasMore[type]"
class="legacy-button legacy-button--secondary load-old"
type="button"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</div>
</article>
</section>
<footer class="legacy-credit">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
</footer>
</main>
<dialog ref="mobileLayoutDialog" class="mobile-layout-dialog" aria-labelledby="mobile-layout-dialog-title">
<div class="mobile-layout-dialog__header">
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
<form method="dialog">
<button
class="legacy-button legacy-button--secondary"
type="submit"
aria-label="모바일 레이아웃 순서 닫기"
>
×
</button>
</form>
</div>
<p>항목을 끌어 놓거나 ·아래 버튼으로 상대 순서를 바꿉니다.</p>
<SortableStringList v-model:list="mobileLayoutOrder" tag="ol" class="mobile-layout-list">
<template #item="{ element: panelId, index }">
<li :data-mobile-layout-id="panelId">
<span class="mobile-layout-handle" aria-hidden="true"></span>
<span class="mobile-layout-label">
<span class="mobile-layout-position">{{ index + 1 }}</span>
{{ mobileLayoutLabels[panelId] }}
</span>
<span class="mobile-layout-move-buttons">
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
:disabled="index === 0"
@click="moveMobileLayoutItem(index, index - 1)"
>
</button>
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
:disabled="index === mobileLayoutOrder.length - 1"
@click="moveMobileLayoutItem(index, index + 1)"
>
</button>
</span>
</li>
</template>
</SortableStringList>
<div class="mobile-layout-dialog__actions">
<button class="legacy-button legacy-button--secondary" type="button" @click="resetMobileLayoutOrder">
기본값
</button>
<form method="dialog">
<button class="legacy-button legacy-button--secondary" type="submit">취소</button>
</form>
<button
class="legacy-button legacy-button--primary mobile-layout-apply"
type="button"
@click="applyMobileLayoutOrder"
>
적용
</button>
</div>
</dialog>
<div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div>
</template>
<style scoped>
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 0;
min-height: 0;
margin: 0 auto;
padding: 0;
color: #fff;
background-color: transparent;
background-image: var(--sammo-texture-walnut);
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.3;
}
.my-page-mobile-scroll-spacer {
display: none;
}
.legacy-page.screen-500px {
max-width: 500px;
}
.legacy-page.screen-1000px {
max-width: 1000px;
}
.title-row {
height: 54px;
display: flex;
align-content: flex-start;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0 4px;
border: 1px solid #666;
background: transparent;
font-size: 14px;
}
.title-row > span {
flex-basis: 100%;
height: 18px;
letter-spacing: 0;
}
.title-actions {
display: flex;
width: 100%;
align-items: flex-start;
justify-content: space-between;
}
.navigation-actions {
display: flex;
gap: 4px;
}
button:not(.legacy-button),
select,
textarea {
border: 1px solid #777;
border-radius: 0;
color: #fff;
background: #6b6b6b;
font: inherit;
}
.legacy-page .legacy-button,
.mobile-layout-dialog .legacy-button {
letter-spacing: 0;
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.45;
}
.status-row,
.error-row {
padding: 4px 8px;
text-align: center;
}
.status-row {
display: none;
}
.error-row {
color: #ff7777;
border: 1px solid #a33;
}
.top-grid,
.log-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.general-column,
.settings-column,
.log-panel {
border: 1px solid #666;
background-image: var(--sammo-texture-walnut);
}
.section-title,
.log-panel h2 {
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: var(--sammo-texture-green);
font-size: 1.25em;
font-weight: 500;
}
.section-title {
display: none;
}
.log-panel h2 {
min-height: 32px;
}
.sky {
color: skyblue;
}
.legacy-general-info-compat {
display: none;
}
.legacy-credit {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.settings-column {
padding: 10px 18px;
}
.setting-line {
display: block;
margin-top: 5px;
}
#defence_train {
width: 134px;
}
#defence_train.penalty-waived {
width: 86px;
}
.hint {
margin: 0 0 13px;
color: orange;
}
.action-button {
--legacy-button-height: 30px;
--legacy-button-bg: #225500;
--legacy-button-border: #1f4d00;
position: relative;
top: 4px;
width: 160px;
margin-bottom: 8px;
}
.action-button.legacy-button--danger {
--legacy-button-bg: var(--sammo-button-danger-bg);
--legacy-button-border: var(--sammo-button-danger-border);
}
.select-general-link {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
color: #fff;
text-decoration: none;
}
.action-line {
margin: 12px 0;
}
.penalties {
margin: 12px 0;
color: #f66;
}
.screen-mode-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
margin: 14px 0;
}
.mobile-layout-setting-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
align-items: center;
gap: 8px;
margin: 14px 0;
}
.mobile-layout-setting-row small {
color: orange;
}
.mobile-layout-open {
min-height: 34px;
font-weight: 700;
}
.mobile-layout-dialog {
box-sizing: border-box;
width: min(460px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
margin: auto;
overflow: auto;
border: 1px solid #777;
border-radius: 4px;
padding: 12px;
background: #171717 var(--sammo-texture-walnut);
color: #fff;
font: 14px/1.3 var(--sammo-font-sans);
}
.mobile-layout-dialog::backdrop {
background: rgb(0 0 0 / 72%);
}
.mobile-layout-dialog__header,
.mobile-layout-dialog__actions,
.mobile-layout-move-buttons {
display: flex;
align-items: center;
}
.mobile-layout-dialog__header {
justify-content: space-between;
gap: 12px;
}
.mobile-layout-dialog__header h2,
.mobile-layout-dialog p {
margin: 0 0 10px;
}
.mobile-layout-dialog__header h2 {
color: skyblue;
font-size: 18px;
}
.mobile-layout-dialog__header form,
.mobile-layout-dialog__actions form {
margin: 0;
}
.mobile-layout-dialog__header button {
min-width: 32px;
min-height: 32px;
font-size: 20px;
}
.mobile-layout-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.mobile-layout-list > li {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
min-height: 44px;
align-items: center;
border: 1px solid #777;
background: #172a52 var(--sammo-texture-blue);
cursor: grab;
}
.mobile-layout-list > li:active {
cursor: grabbing;
}
.mobile-layout-handle {
color: #aaa;
text-align: center;
font-size: 20px;
}
.mobile-layout-label {
min-width: 0;
font-weight: 700;
}
.mobile-layout-position {
display: inline-grid;
width: 22px;
height: 22px;
place-items: center;
margin-right: 4px;
border: 1px solid #7186a7;
border-radius: 50%;
font-size: 12px;
}
.mobile-layout-move-buttons {
gap: 4px;
padding-right: 5px;
}
.mobile-layout-move-buttons button {
width: 36px;
min-height: 34px;
font-weight: 700;
}
.mobile-layout-dialog__actions {
justify-content: flex-end;
gap: 6px;
margin-top: 12px;
}
.mobile-layout-dialog__actions button {
min-height: 34px;
padding: 4px 10px;
}
.mobile-layout-dialog__actions .mobile-layout-apply {
font-weight: 700;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.item-title {
margin-top: 12px;
}
.item-group {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin: 5px 0 14px;
}
.item-group button {
min-height: 30px;
}
.custom-css {
display: block;
}
.custom-css textarea {
display: block;
width: 420px;
max-width: 100%;
height: 150px;
color: #fff;
background: #000;
}
.log-panel {
min-height: 180px;
}
.log-panel h2 {
color: orange;
}
.log-line,
.empty,
.loading {
padding: 0 8px;
line-height: 18px;
}
.load-old {
width: 100%;
min-height: 32px;
margin-top: 8px;
}
.general-icon-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin: 6px 0;
}
.selected-general-icon {
display: flex;
max-width: 260px;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px auto;
padding: 6px 10px;
border: 1px solid #666;
background: rgb(23 42 82 / 70%);
}
.selected-general-icon img {
flex: 0 0 var(--sammo-general-icon-size);
width: var(--sammo-general-icon-size);
height: var(--sammo-general-icon-size);
object-fit: cover;
}
.general-icon-choice {
display: flex;
align-items: center;
gap: 2px;
}
@media (max-width: 991px) {
.legacy-page {
width: 100%;
max-width: 100%;
}
.my-page-mobile-scroll-spacer {
display: none;
}
.top-grid,
.log-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.title-row {
height: auto;
min-height: 54px;
}
dl > div {
grid-template-columns: 62px minmax(0, 1fr);
}
dt,
dd {
padding: 2px 3px;
}
.settings-column {
padding: 10px 12px;
}
.screen-mode-row {
grid-template-columns: 1fr;
gap: 6px;
}
.mobile-layout-setting-row {
grid-template-columns: 1fr;
}
.mobile-layout-open {
width: 100%;
}
.button-group {
overflow-x: auto;
}
.item-group {
grid-template-columns: repeat(2, 1fr);
}
.custom-css textarea {
width: 100%;
}
}
</style>