세력도시 임명 toast와 접근성 이름, 아이템 파기 확인 문구를 JosaUtil로 조립한다. 받침 유무를 production Chromium 회귀로 고정하고 작업 지침에 병기 조사 금지 규칙을 추가한다.
1177 lines
41 KiB
Vue
1177 lines
41 KiB
Vue
<script setup lang="ts">
|
||
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||
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(`${JosaUtil.put(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>
|