459 lines
17 KiB
Vue
459 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { 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 { 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: 1024px)');
|
|
|
|
const mobileTabs = [
|
|
{ key: 'map', label: '지도' },
|
|
{ key: 'commands', label: '명령' },
|
|
{ key: 'status', label: '상태' },
|
|
{ key: 'world', label: '동향' },
|
|
{ key: 'messages', label: '메시지' },
|
|
] as const;
|
|
|
|
type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
|
|
|
const mobileTab = ref<MobileTabKey>('map');
|
|
const tournamentStage = ref(0);
|
|
|
|
const {
|
|
loading,
|
|
error,
|
|
realtimeEnabled,
|
|
general,
|
|
city,
|
|
nation,
|
|
lobbyInfo,
|
|
worldMap,
|
|
mapLayout,
|
|
selectedCity,
|
|
commandTable,
|
|
messages,
|
|
boardAccess,
|
|
reservedGeneralTurns,
|
|
reservedNationTurns,
|
|
messageDraftText,
|
|
targetMailbox,
|
|
mailboxGroups,
|
|
statusLine,
|
|
realtimeLabel,
|
|
} = storeToRefs(dashboard);
|
|
|
|
const reserveGeneralTurn = (payload: { index: number; action: string }) => {
|
|
void dashboard.setGeneralTurn(payload.index, payload.action);
|
|
};
|
|
|
|
const shiftGeneralTurns = (amount: number) => {
|
|
void dashboard.shiftGeneralTurns(amount);
|
|
};
|
|
|
|
const reserveNationTurn = (payload: { index: number; action: string }) => {
|
|
void dashboard.setNationTurn(payload.index, payload.action);
|
|
};
|
|
|
|
const shiftNationTurns = (amount: number) => {
|
|
void dashboard.shiftNationTurns(amount);
|
|
};
|
|
|
|
const loadMainData = async () => {
|
|
const [, state] = await Promise.all([dashboard.loadMainData(), trpc.tournament.getState.query().catch(() => null)]);
|
|
tournamentStage.value = state?.stage ?? 0;
|
|
};
|
|
|
|
watch(
|
|
() => [session.isReady, session.hasGeneral],
|
|
([ready, hasGeneral]) => {
|
|
if (ready && hasGeneral) {
|
|
void loadMainData();
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<main class="main-page">
|
|
<header class="page-header">
|
|
<div>
|
|
<h1 class="page-title">전장 현황</h1>
|
|
<p class="page-subtitle">{{ statusLine }}</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<RouterLink v-if="boardAccess?.canMeeting" class="ghost" to="/board">회의실</RouterLink>
|
|
<span v-else class="ghost disabled" aria-disabled="true">회의실</span>
|
|
<RouterLink v-if="boardAccess?.canSecret" class="ghost" to="/board/secret">기밀실</RouterLink>
|
|
<span v-else class="ghost disabled" aria-disabled="true">기밀실</span>
|
|
<RouterLink class="ghost" to="/nation/info">세력 정보</RouterLink>
|
|
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
|
|
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
|
|
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
|
|
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
|
<RouterLink v-if="(boardAccess?.permission ?? -1) >= 1" class="ghost" to="/nation/secret"
|
|
>암행부</RouterLink
|
|
>
|
|
<span v-else class="ghost disabled" aria-disabled="true">암행부</span>
|
|
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
|
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
|
|
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
|
|
<RouterLink class="ghost" to="/diplomacy">외교부</RouterLink>
|
|
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
|
|
<RouterLink class="ghost" to="/battle-center">감찰부</RouterLink>
|
|
<RouterLink class="ghost" to="/best-general">명장일람</RouterLink>
|
|
<RouterLink class="ghost" to="/hall-of-fame">명예의 전당</RouterLink>
|
|
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
|
|
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
|
|
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
|
|
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
|
|
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
|
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
|
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
|
<RouterLink class="ghost" to="/my-page">내 정보&설정</RouterLink>
|
|
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
|
|
>토너먼트</RouterLink
|
|
>
|
|
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 6 }" to="/betting"
|
|
>베팅장</RouterLink
|
|
>
|
|
<RouterLink class="ghost" to="/auction">거래장</RouterLink>
|
|
<RouterLink class="ghost" to="/survey">설문조사</RouterLink>
|
|
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
|
|
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
|
|
<button
|
|
class="toggle"
|
|
:class="{ active: realtimeEnabled }"
|
|
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
|
>
|
|
실시간 동기화: {{ realtimeLabel }}
|
|
</button>
|
|
<button class="ghost" @click="loadMainData">새로고침</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div v-if="error" class="error">{{ error }}</div>
|
|
|
|
<div v-if="session.needsGeneral" class="warning">
|
|
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
|
|
</div>
|
|
|
|
<section v-if="isMobile" class="layout-mobile">
|
|
<div class="mobile-tabs">
|
|
<button
|
|
v-for="tab in mobileTabs"
|
|
:key="tab.key"
|
|
:class="{ active: mobileTab === tab.key }"
|
|
@click="mobileTab = tab.key"
|
|
>
|
|
{{ tab.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="mobileTab === 'map'" class="mobile-panel">
|
|
<PanelCard title="지도">
|
|
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
|
|
</PanelCard>
|
|
<PanelCard title="선택 도시">
|
|
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
|
</PanelCard>
|
|
</div>
|
|
|
|
<div v-if="mobileTab === 'commands'" class="mobile-panel">
|
|
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
|
|
<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 v-if="mobileTab === 'status'" class="mobile-panel">
|
|
<PanelCard title="장수 스탯">
|
|
<GeneralBasicCard :general="general" :loading="loading" />
|
|
</PanelCard>
|
|
<PanelCard title="도시 정보">
|
|
<CityBasicCard :city="city" :loading="loading" />
|
|
</PanelCard>
|
|
<PanelCard title="국가 정보">
|
|
<NationBasicCard :nation="nation" :loading="loading" />
|
|
</PanelCard>
|
|
</div>
|
|
|
|
<div v-if="mobileTab === 'world'" class="mobile-panel">
|
|
<PanelCard title="장수 동향">
|
|
<SkeletonLines v-if="loading" :lines="4" />
|
|
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
|
</PanelCard>
|
|
<PanelCard title="개인 기록">
|
|
<SkeletonLines v-if="loading" :lines="4" />
|
|
<div v-else class="placeholder">개인 기록 영역</div>
|
|
</PanelCard>
|
|
<PanelCard title="중원 정세">
|
|
<SkeletonLines v-if="loading" :lines="4" />
|
|
<div v-else class="placeholder">
|
|
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
|
|
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
|
|
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
|
</div>
|
|
</PanelCard>
|
|
</div>
|
|
|
|
<div v-if="mobileTab === 'messages'" 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="실시간 지도 + 도시 상황">
|
|
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
|
|
</PanelCard>
|
|
<PanelCard title="선택 도시">
|
|
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
|
</PanelCard>
|
|
<PanelCard title="중원 정세">
|
|
<SkeletonLines v-if="loading" :lines="3" />
|
|
<div v-else class="placeholder">
|
|
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
|
|
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
|
|
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
|
</div>
|
|
</PanelCard>
|
|
</div>
|
|
|
|
<div class="stack">
|
|
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
|
|
<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="장수 스탯">
|
|
<GeneralBasicCard :general="general" :loading="loading" />
|
|
</PanelCard>
|
|
<PanelCard title="장수 동향">
|
|
<SkeletonLines v-if="loading" :lines="4" />
|
|
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
|
</PanelCard>
|
|
<PanelCard title="도시 정보">
|
|
<CityBasicCard :city="city" :loading="loading" />
|
|
</PanelCard>
|
|
<PanelCard title="국가 정보">
|
|
<NationBasicCard :nation="nation" :loading="loading" />
|
|
</PanelCard>
|
|
<PanelCard title="개인 기록">
|
|
<SkeletonLines v-if="loading" :lines="4" />
|
|
<div v-else class="placeholder">개인 기록 영역</div>
|
|
</PanelCard>
|
|
</div>
|
|
<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>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.main-page {
|
|
min-height: 100vh;
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
|
padding-bottom: 12px;
|
|
}
|
|
|
|
.page-title {
|
|
font-size: 1.6rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.page-subtitle {
|
|
font-size: 0.85rem;
|
|
color: rgba(232, 221, 196, 0.7);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
button {
|
|
font-family: inherit;
|
|
background: none;
|
|
border: none;
|
|
color: inherit;
|
|
}
|
|
|
|
.toggle,
|
|
.ghost {
|
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
padding: 6px 12px;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.toggle.active {
|
|
background: rgba(201, 164, 90, 0.2);
|
|
}
|
|
|
|
.ghost {
|
|
background: rgba(16, 16, 16, 0.6);
|
|
}
|
|
|
|
.ghost.highlight {
|
|
border-color: #f39c12;
|
|
background: #8a5b13;
|
|
color: #fff;
|
|
}
|
|
|
|
.ghost.disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.error {
|
|
color: #f5b7b1;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.warning {
|
|
color: #f5d08a;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.layout-desktop {
|
|
display: grid;
|
|
grid-template-columns: minmax(320px, 1.4fr) minmax(320px, 1fr);
|
|
gap: 16px;
|
|
}
|
|
|
|
.stack {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.desktop-message-panel {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.mobile-message-panel {
|
|
width: 100vw;
|
|
min-width: 0;
|
|
margin-left: -24px;
|
|
}
|
|
|
|
.layout-mobile {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.mobile-tabs {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 6px;
|
|
}
|
|
|
|
.mobile-tabs button {
|
|
padding: 6px 4px;
|
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.mobile-tabs button.active {
|
|
background: rgba(201, 164, 90, 0.2);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
</style>
|