580 lines
20 KiB
Vue
580 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||
import { storeToRefs } from 'pinia';
|
||
import { useMediaQuery } from '@vueuse/core';
|
||
import PanelCard from '../components/ui/PanelCard.vue';
|
||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||
import MapViewer from '../components/main/MapViewer.vue';
|
||
import CommandListPanel from '../components/main/CommandListPanel.vue';
|
||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||
import CityBasicCard from '../components/main/CityBasicCard.vue';
|
||
import NationBasicCard from '../components/main/NationBasicCard.vue';
|
||
import MessagePanel from '../components/main/MessagePanel.vue';
|
||
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
|
||
import RecordPanel from '../components/main/RecordPanel.vue';
|
||
import MainFrontStatus from '../components/main/MainFrontStatus.vue';
|
||
import MainGlobalMenu from '../components/main/MainGlobalMenu.vue';
|
||
import MainNationMenu from '../components/main/MainNationMenu.vue';
|
||
import { formatLog } from '../utils/formatLog';
|
||
import { useSessionStore } from '../stores/session';
|
||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||
import { trpc } from '../utils/trpc';
|
||
|
||
const session = useSessionStore();
|
||
const dashboard = useMainDashboardStore();
|
||
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
||
|
||
const tournamentStage = ref(0);
|
||
const npcMode = ref(0);
|
||
|
||
const {
|
||
loading,
|
||
error,
|
||
recordsError,
|
||
frontStatusError,
|
||
realtimeEnabled,
|
||
general,
|
||
city,
|
||
nation,
|
||
worldMap,
|
||
mapLayout,
|
||
selectedCity,
|
||
commandTable,
|
||
messages,
|
||
boardAccess,
|
||
reservedGeneralTurns,
|
||
reservedNationTurns,
|
||
globalRecords,
|
||
generalRecords,
|
||
worldHistory,
|
||
frontStatus,
|
||
surveyNotice,
|
||
messageDraftText,
|
||
targetMailbox,
|
||
mailboxGroups,
|
||
statusLine,
|
||
realtimeLabel,
|
||
} = storeToRefs(dashboard);
|
||
|
||
const nationAccess = computed(() => ({
|
||
permission: boardAccess.value?.permission ?? -1,
|
||
officerLevel: general.value?.officerLevel ?? 0,
|
||
nationLevel: nation.value?.level ?? 0,
|
||
}));
|
||
const nationColor = computed(() => nation.value?.color ?? '#000000');
|
||
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
|
||
|
||
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||
watch(surveyNotice, (notice) => {
|
||
if (surveyNoticeTimer) {
|
||
clearTimeout(surveyNoticeTimer);
|
||
surveyNoticeTimer = null;
|
||
}
|
||
if (notice) {
|
||
surveyNoticeTimer = setTimeout(() => dashboard.dismissSurveyNotice(), 60_000);
|
||
}
|
||
});
|
||
onUnmounted(() => {
|
||
if (surveyNoticeTimer) {
|
||
clearTimeout(surveyNoticeTimer);
|
||
}
|
||
});
|
||
|
||
const reserveGeneralTurn = (payload: { index: number; action: string; args: Record<string, unknown> }) => {
|
||
void dashboard.setGeneralTurn(payload.index, payload.action, payload.args);
|
||
};
|
||
|
||
const shiftGeneralTurns = (amount: number) => {
|
||
void dashboard.shiftGeneralTurns(amount);
|
||
};
|
||
|
||
const reserveNationTurn = (payload: { index: number; action: string; args: Record<string, unknown> }) => {
|
||
void dashboard.setNationTurn(payload.index, payload.action, payload.args);
|
||
};
|
||
|
||
const shiftNationTurns = (amount: number) => {
|
||
void dashboard.shiftNationTurns(amount);
|
||
};
|
||
|
||
const loadMainData = async () => {
|
||
const [, state, worldState] = await Promise.all([
|
||
dashboard.loadMainData(),
|
||
trpc.tournament.getState.query().catch(() => null),
|
||
trpc.world.getState.query().catch(() => null),
|
||
]);
|
||
tournamentStage.value = state?.stage ?? 0;
|
||
npcMode.value = worldState?.config.npcMode ?? 0;
|
||
};
|
||
|
||
const moveLobby = () => {
|
||
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
||
};
|
||
|
||
watch(
|
||
() => [session.isReady, session.hasGeneral],
|
||
([ready, hasGeneral]) => {
|
||
if (ready && hasGeneral) {
|
||
void loadMainData();
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<main class="game-shell main-page">
|
||
<MainGlobalMenu data-menu-position="top" :npc-mode="npcMode" :vote-active="voteActive" />
|
||
|
||
<header class="game-shell__header">
|
||
<div>
|
||
<h1 class="game-shell__title">전장 현황</h1>
|
||
<p class="game-shell__subtitle">{{ statusLine }}</p>
|
||
</div>
|
||
<div class="game-shell__actions desktop-action-controls">
|
||
<button
|
||
class="game-shell__action toggle"
|
||
:class="{ active: realtimeEnabled }"
|
||
type="button"
|
||
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
||
>
|
||
실시간 동기화: {{ realtimeLabel }}
|
||
</button>
|
||
<button class="game-shell__action" type="button" @click="loadMainData">갱 신</button>
|
||
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button>
|
||
</div>
|
||
</header>
|
||
|
||
<MainNationMenu :access="nationAccess" :tournament-stage="tournamentStage" :nation-color="nationColor" />
|
||
|
||
<div v-if="error" class="game-feedback game-feedback--error" role="alert">{{ error }}</div>
|
||
<div v-if="frontStatusError" class="front-status-error" role="alert">{{ frontStatusError }}</div>
|
||
|
||
<div v-if="session.needsGeneral" class="warning">
|
||
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
|
||
</div>
|
||
|
||
<div data-main-target="policy">
|
||
<MainFrontStatus :status="frontStatus" />
|
||
</div>
|
||
|
||
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
|
||
<div class="survey-notice-title">
|
||
<strong>설문조사 안내</strong>
|
||
<button type="button" aria-label="설문조사 알림 닫기" @click="dashboard.dismissSurveyNotice">×</button>
|
||
</div>
|
||
<RouterLink to="/survey">새로운 설문조사가 있습니다.</RouterLink>
|
||
</aside>
|
||
|
||
<section v-if="isMobile" class="layout-mobile">
|
||
<div class="mobile-panel">
|
||
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역" data-main-target="commands">
|
||
<CommandListPanel
|
||
:command-table="commandTable"
|
||
:loading="loading"
|
||
:selected-city="selectedCity"
|
||
:reserved-general-turns="reservedGeneralTurns"
|
||
:reserved-nation-turns="reservedNationTurns"
|
||
:general="general"
|
||
@set-general-turn="reserveGeneralTurn"
|
||
@shift-general-turns="shiftGeneralTurns"
|
||
@set-nation-turn="reserveNationTurn"
|
||
@shift-nation-turns="shiftNationTurns"
|
||
/>
|
||
</PanelCard>
|
||
</div>
|
||
|
||
<div class="mobile-panel">
|
||
<PanelCard title="장수 스탯" data-main-target="general">
|
||
<GeneralBasicCard :general="general" :loading="loading" />
|
||
</PanelCard>
|
||
<PanelCard title="도시 정보" data-main-target="city">
|
||
<CityBasicCard :city="city" :loading="loading" />
|
||
</PanelCard>
|
||
<PanelCard title="국가 정보" data-main-target="nation">
|
||
<NationBasicCard :nation="nation" :loading="loading" />
|
||
</PanelCard>
|
||
</div>
|
||
|
||
<div class="mobile-panel">
|
||
<PanelCard title="지도" data-main-target="map">
|
||
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
|
||
</PanelCard>
|
||
<PanelCard title="선택 도시">
|
||
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
||
</PanelCard>
|
||
</div>
|
||
|
||
<div class="mobile-panel record-zone-mobile">
|
||
<RecordPanel title="장수 동향" data-main-target="global-records">
|
||
<SkeletonLines v-if="loading" :lines="4" />
|
||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||
<div v-else class="record-list" data-record-bucket="global">
|
||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||
<div
|
||
v-for="entry in globalRecords"
|
||
:key="entry.id"
|
||
class="record-line"
|
||
v-html="formatLog(entry.text)"
|
||
/>
|
||
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||
</div>
|
||
</RecordPanel>
|
||
<RecordPanel title="개인 기록" data-main-target="general-records">
|
||
<SkeletonLines v-if="loading" :lines="4" />
|
||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||
<div v-else class="record-list" data-record-bucket="general">
|
||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||
<div
|
||
v-for="entry in generalRecords"
|
||
:key="entry.id"
|
||
class="record-line"
|
||
v-html="formatLog(entry.text)"
|
||
/>
|
||
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||
</div>
|
||
</RecordPanel>
|
||
<RecordPanel title="중원 정세" data-main-target="world-history">
|
||
<SkeletonLines v-if="loading" :lines="4" />
|
||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||
<div v-else class="record-list" data-record-bucket="history">
|
||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||
<div
|
||
v-for="entry in worldHistory"
|
||
:key="entry.id"
|
||
class="record-line"
|
||
v-html="formatLog(entry.text)"
|
||
/>
|
||
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
|
||
</div>
|
||
</RecordPanel>
|
||
</div>
|
||
|
||
<MainGlobalMenu
|
||
class="common-menu-middle"
|
||
data-menu-position="middle"
|
||
:npc-mode="npcMode"
|
||
:vote-active="voteActive"
|
||
/>
|
||
|
||
<div class="mobile-panel">
|
||
<MessagePanel
|
||
class="mobile-message-panel"
|
||
:messages="messages"
|
||
:loading="loading"
|
||
:target-mailbox="targetMailbox"
|
||
:draft-text="messageDraftText"
|
||
:mailbox-groups="mailboxGroups"
|
||
:general-id="general?.id ?? 0"
|
||
:general-name="general?.name ?? ''"
|
||
:nation-id="general?.nationId ?? 0"
|
||
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||
@update:target-mailbox="targetMailbox = $event"
|
||
@update:draft-text="messageDraftText = $event"
|
||
@send="dashboard.sendMessage"
|
||
@load-older="dashboard.loadOlderMessages"
|
||
@refresh="dashboard.refreshMessages"
|
||
@respond="dashboard.respondToMessage"
|
||
@read-latest="dashboard.readLatestMessage"
|
||
@delete="dashboard.deleteMessage"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-else class="layout-desktop">
|
||
<div class="stack">
|
||
<PanelCard title="지도" subtitle="실시간 지도 + 도시 상황" data-main-target="map">
|
||
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
|
||
</PanelCard>
|
||
<PanelCard title="선택 도시">
|
||
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
||
</PanelCard>
|
||
</div>
|
||
|
||
<div class="stack">
|
||
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역" data-main-target="commands">
|
||
<CommandListPanel
|
||
:command-table="commandTable"
|
||
:loading="loading"
|
||
:selected-city="selectedCity"
|
||
:reserved-general-turns="reservedGeneralTurns"
|
||
:reserved-nation-turns="reservedNationTurns"
|
||
:general="general"
|
||
@set-general-turn="reserveGeneralTurn"
|
||
@shift-general-turns="shiftGeneralTurns"
|
||
@set-nation-turn="reserveNationTurn"
|
||
@shift-nation-turns="shiftNationTurns"
|
||
/>
|
||
</PanelCard>
|
||
<PanelCard title="장수 스탯" data-main-target="general">
|
||
<GeneralBasicCard :general="general" :loading="loading" />
|
||
</PanelCard>
|
||
<PanelCard title="도시 정보" data-main-target="city">
|
||
<CityBasicCard :city="city" :loading="loading" />
|
||
</PanelCard>
|
||
<PanelCard title="국가 정보" data-main-target="nation">
|
||
<NationBasicCard :nation="nation" :loading="loading" />
|
||
</PanelCard>
|
||
</div>
|
||
<section class="record-zone">
|
||
<RecordPanel title="장수 동향" data-main-target="global-records">
|
||
<SkeletonLines v-if="loading" :lines="4" />
|
||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||
<div v-else class="record-list" data-record-bucket="global">
|
||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||
<div
|
||
v-for="entry in globalRecords"
|
||
:key="entry.id"
|
||
class="record-line"
|
||
v-html="formatLog(entry.text)"
|
||
/>
|
||
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||
</div>
|
||
</RecordPanel>
|
||
<RecordPanel title="개인 기록" data-main-target="general-records">
|
||
<SkeletonLines v-if="loading" :lines="4" />
|
||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||
<div v-else class="record-list" data-record-bucket="general">
|
||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||
<div
|
||
v-for="entry in generalRecords"
|
||
:key="entry.id"
|
||
class="record-line"
|
||
v-html="formatLog(entry.text)"
|
||
/>
|
||
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||
</div>
|
||
</RecordPanel>
|
||
<RecordPanel class="world-history-panel" title="중원 정세" data-main-target="world-history">
|
||
<SkeletonLines v-if="loading" :lines="4" />
|
||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||
<div v-else class="record-list" data-record-bucket="history">
|
||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||
<div
|
||
v-for="entry in worldHistory"
|
||
:key="entry.id"
|
||
class="record-line"
|
||
v-html="formatLog(entry.text)"
|
||
/>
|
||
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
|
||
</div>
|
||
</RecordPanel>
|
||
</section>
|
||
<MainGlobalMenu
|
||
class="common-menu-middle"
|
||
data-menu-position="middle"
|
||
:npc-mode="npcMode"
|
||
:vote-active="voteActive"
|
||
/>
|
||
<MessagePanel
|
||
class="desktop-message-panel"
|
||
:messages="messages"
|
||
:loading="loading"
|
||
:target-mailbox="targetMailbox"
|
||
:draft-text="messageDraftText"
|
||
:mailbox-groups="mailboxGroups"
|
||
:general-id="general?.id ?? 0"
|
||
:general-name="general?.name ?? ''"
|
||
:nation-id="general?.nationId ?? 0"
|
||
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||
@update:target-mailbox="targetMailbox = $event"
|
||
@update:draft-text="messageDraftText = $event"
|
||
@send="dashboard.sendMessage"
|
||
@load-older="dashboard.loadOlderMessages"
|
||
@refresh="dashboard.refreshMessages"
|
||
@respond="dashboard.respondToMessage"
|
||
@read-latest="dashboard.readLatestMessage"
|
||
@delete="dashboard.deleteMessage"
|
||
/>
|
||
</section>
|
||
|
||
<MainGlobalMenu
|
||
class="common-menu-repeat"
|
||
data-menu-position="bottom"
|
||
:npc-mode="npcMode"
|
||
:vote-active="voteActive"
|
||
/>
|
||
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped>
|
||
button {
|
||
font-family: inherit;
|
||
background: none;
|
||
border: none;
|
||
color: inherit;
|
||
}
|
||
|
||
.main-page {
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
min-width: 500px;
|
||
max-width: 1000px;
|
||
margin: 0 auto;
|
||
padding: 0;
|
||
gap: 10px;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.toggle.active {
|
||
background: rgba(201, 164, 90, 0.2);
|
||
}
|
||
|
||
.game-shell__action.highlight {
|
||
border-color: #f39c12;
|
||
background: #8a5b13;
|
||
color: #fff;
|
||
}
|
||
|
||
.game-shell__action.disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.front-status-error {
|
||
color: #ff8a80;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.warning {
|
||
color: #f5d08a;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.survey-notice {
|
||
position: fixed;
|
||
z-index: 1080;
|
||
right: 16px;
|
||
bottom: 16px;
|
||
box-sizing: border-box;
|
||
width: min(350px, calc(100vw - 32px));
|
||
border: 1px solid rgba(255, 193, 7, 0.75);
|
||
border-radius: 4px;
|
||
background: rgba(32, 28, 16, 0.96);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
|
||
color: #fff;
|
||
font-size: 14px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.survey-notice-title {
|
||
display: flex;
|
||
min-height: 35px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 1px solid rgba(255, 193, 7, 0.45);
|
||
padding: 8px 12px;
|
||
color: #ffc107;
|
||
}
|
||
|
||
.survey-notice-title button {
|
||
padding: 0 4px;
|
||
cursor: pointer;
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.survey-notice > a {
|
||
display: block;
|
||
padding: 12px;
|
||
color: #fff;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.survey-notice > a:hover,
|
||
.survey-notice > a:focus-visible {
|
||
background: rgba(255, 193, 7, 0.12);
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.layout-desktop {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 2.35fr) minmax(0, 1fr);
|
||
gap: 10px;
|
||
}
|
||
|
||
.stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.desktop-message-panel {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.common-menu-middle {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.record-zone {
|
||
grid-column: 1 / -1;
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.world-history-panel {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.record-list {
|
||
min-height: 21px;
|
||
line-height: 21px;
|
||
}
|
||
|
||
.record-line {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.record-empty {
|
||
color: #aaa;
|
||
}
|
||
|
||
.record-error {
|
||
color: #ff8a80;
|
||
}
|
||
|
||
.record-zone-mobile {
|
||
width: 500px;
|
||
gap: 0;
|
||
}
|
||
|
||
.mobile-message-panel {
|
||
width: 500px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.layout-mobile {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.mobile-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.placeholder {
|
||
font-size: 0.85rem;
|
||
color: rgba(232, 221, 196, 0.7);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
@media (max-width: 939.98px) {
|
||
.main-page {
|
||
width: 500px;
|
||
}
|
||
|
||
.survey-notice {
|
||
z-index: 90;
|
||
bottom: 16px;
|
||
}
|
||
}
|
||
</style>
|