feat: Refactor command selection and message handling components; add main dashboard store
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { ref } from 'vue';
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import CommandSelectForm from './CommandSelectForm.vue';
|
||||||
|
|
||||||
interface TurnCommandAvailability {
|
interface TurnCommandAvailability {
|
||||||
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
|
||||||
|
possible: boolean;
|
||||||
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TurnCommandGroup {
|
interface TurnCommandGroup {
|
||||||
@@ -22,81 +25,39 @@ const props = defineProps<{
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const generalGroups = computed(() => props.commandTable?.general ?? []);
|
const activeCategory = ref('');
|
||||||
const nationGroups = computed(() => props.commandTable?.nation ?? []);
|
|
||||||
|
|
||||||
const takePreview = (group: TurnCommandGroup): TurnCommandAvailability[] => group.values.slice(0, 6);
|
const handleSelect = (commandKey: string) => {
|
||||||
|
void commandKey;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="command-list">
|
<div class="command-panel">
|
||||||
<div v-if="props.loading">
|
<CommandSelectForm
|
||||||
<SkeletonLines :lines="5" />
|
:command-table="props.commandTable"
|
||||||
</div>
|
:loading="props.loading"
|
||||||
<div v-else-if="!props.commandTable" class="empty">
|
:active-category="activeCategory"
|
||||||
명령 목록을 불러오지 못했습니다.
|
@update:active-category="activeCategory = $event"
|
||||||
</div>
|
@select="handleSelect"
|
||||||
<div v-else class="command-body">
|
/>
|
||||||
<div class="group" v-for="group in generalGroups" :key="`g-${group.category}`">
|
<div class="command-placeholder">
|
||||||
<div class="group-title">{{ group.category }}</div>
|
선택한 명령의 상세/예약 UI는 추후 이식 예정
|
||||||
<div class="commands">
|
|
||||||
<span v-for="command in takePreview(group)" :key="command.name" class="command">
|
|
||||||
{{ command.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="nationGroups.length" class="divider">국가 명령</div>
|
|
||||||
<div class="group" v-for="group in nationGroups" :key="`n-${group.category}`">
|
|
||||||
<div class="group-title">{{ group.category }}</div>
|
|
||||||
<div class="commands">
|
|
||||||
<span v-for="command in takePreview(group)" :key="command.name" class="command">
|
|
||||||
{{ command.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.command-list {
|
.command-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.command-placeholder {
|
||||||
display: flex;
|
border: 1px dashed rgba(201, 164, 90, 0.3);
|
||||||
flex-direction: column;
|
padding: 8px;
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-title {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(232, 221, 196, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.commands {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command {
|
|
||||||
padding: 2px 6px;
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.35);
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px dashed rgba(201, 164, 90, 0.3);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: rgba(232, 221, 196, 0.6);
|
color: rgba(232, 221, 196, 0.6);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
|
|
||||||
|
type CommandAvailability = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
|
||||||
|
possible: boolean;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommandGroup = {
|
||||||
|
category: string;
|
||||||
|
values: CommandAvailability[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommandTable = {
|
||||||
|
general: CommandGroup[];
|
||||||
|
nation: CommandGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
commandTable: CommandTable | null;
|
||||||
|
loading: boolean;
|
||||||
|
activeCategory?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'select', commandKey: string): void;
|
||||||
|
(event: 'update:activeCategory', category: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const categories = computed(() => {
|
||||||
|
if (!props.commandTable) {
|
||||||
|
return [] as Array<{ label: string; category: string; groupType: 'general' | 'nation' }>;
|
||||||
|
}
|
||||||
|
const general = props.commandTable.general.map((group) => ({
|
||||||
|
label: group.category,
|
||||||
|
category: group.category,
|
||||||
|
groupType: 'general' as const,
|
||||||
|
}));
|
||||||
|
const nation = props.commandTable.nation.map((group) => ({
|
||||||
|
label: `국가:${group.category}`,
|
||||||
|
category: group.category,
|
||||||
|
groupType: 'nation' as const,
|
||||||
|
}));
|
||||||
|
return [...general, ...nation];
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCategory = ref('');
|
||||||
|
const selectedGroup = computed(() => {
|
||||||
|
if (!props.commandTable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const base = props.commandTable.general.find((group) => group.category === selectedCategory.value);
|
||||||
|
if (base) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return props.commandTable.nation.find((group) => group.category === selectedCategory.value) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.activeCategory,
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
selectedCategory.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
categories,
|
||||||
|
(list) => {
|
||||||
|
if (!list.length) {
|
||||||
|
selectedCategory.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!list.some((item) => item.category === selectedCategory.value)) {
|
||||||
|
selectedCategory.value = list[0].category;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(selectedCategory, (value) => {
|
||||||
|
if (value) {
|
||||||
|
emit('update:activeCategory', value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = (command: CommandAvailability) => {
|
||||||
|
if (command.status === 'available') {
|
||||||
|
return '가능';
|
||||||
|
}
|
||||||
|
if (command.status === 'needsInput') {
|
||||||
|
return '입력 필요';
|
||||||
|
}
|
||||||
|
if (command.status === 'blocked') {
|
||||||
|
return '불가';
|
||||||
|
}
|
||||||
|
return '확인 필요';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="command-form">
|
||||||
|
<div v-if="props.loading">
|
||||||
|
<SkeletonLines :lines="4" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!props.commandTable" class="empty">
|
||||||
|
명령 목록을 불러오지 못했습니다.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="category-list">
|
||||||
|
<button
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.label"
|
||||||
|
:class="['category-btn', { active: selectedCategory === category.category }]"
|
||||||
|
@click="selectedCategory = category.category"
|
||||||
|
>
|
||||||
|
{{ category.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!selectedGroup" class="empty">명령이 없습니다.</div>
|
||||||
|
<div v-else class="command-grid">
|
||||||
|
<button
|
||||||
|
v-for="command in selectedGroup.values"
|
||||||
|
:key="command.key"
|
||||||
|
:class="[
|
||||||
|
'command-item',
|
||||||
|
command.status === 'available' ? 'ok' : '',
|
||||||
|
command.status === 'blocked' ? 'blocked' : '',
|
||||||
|
]"
|
||||||
|
:disabled="!command.possible"
|
||||||
|
@click="emit('select', command.key)"
|
||||||
|
>
|
||||||
|
<span class="command-name">{{ command.name }}</span>
|
||||||
|
<span class="command-status">{{ statusLabel(command) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.command-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-btn.active {
|
||||||
|
background: rgba(201, 164, 90, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item.ok {
|
||||||
|
border-color: rgba(201, 164, 90, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item.blocked {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-status {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(232, 221, 196, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: rgba(232, 221, 196, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,21 +1,77 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
|
|
||||||
interface MapSummary {
|
interface MapSummary {
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
cityList: unknown[];
|
cityList: [number, number, number, number, number, number][];
|
||||||
nationList: unknown[];
|
nationList: [number, string, string, number][];
|
||||||
|
myCity?: number | null;
|
||||||
|
myNation?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
mapData: MapSummary | null;
|
mapData: MapSummary | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const showCityName = ref(true);
|
||||||
|
const detailMode = ref(false);
|
||||||
|
|
||||||
|
const nationById = computed(() => {
|
||||||
|
const map = new Map<number, { name: string; color: string }>();
|
||||||
|
if (!props.mapData) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
for (const nation of props.mapData.nationList) {
|
||||||
|
const [id, name, color] = nation;
|
||||||
|
map.set(id, { name, color });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cityEntries = computed(() => {
|
||||||
|
if (!props.mapData) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return props.mapData.cityList.map((entry) => {
|
||||||
|
const [id, level, state, nationId, region, supplyFlag] = entry;
|
||||||
|
const nation = nationById.value.get(nationId);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
level,
|
||||||
|
state,
|
||||||
|
nationId,
|
||||||
|
region,
|
||||||
|
supply: supplyFlag > 0,
|
||||||
|
nationName: nation?.name ?? '무주',
|
||||||
|
color: nation?.color ?? '#444444',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapSummary = computed(() => {
|
||||||
|
if (!props.mapData) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `${props.mapData.year}년 ${props.mapData.month}월`;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="map-viewer">
|
<div class="map-viewer">
|
||||||
|
<div class="map-top">
|
||||||
|
<div class="map-title">{{ mapSummary }}</div>
|
||||||
|
<div class="map-controls">
|
||||||
|
<button class="map-toggle" :class="{ active: showCityName }" @click="showCityName = !showCityName">
|
||||||
|
도시명
|
||||||
|
</button>
|
||||||
|
<button class="map-toggle" :class="{ active: detailMode }" @click="detailMode = !detailMode">
|
||||||
|
상세
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="props.loading">
|
<div v-if="props.loading">
|
||||||
<SkeletonLines :lines="4" />
|
<SkeletonLines :lines="4" />
|
||||||
</div>
|
</div>
|
||||||
@@ -23,12 +79,27 @@ const props = defineProps<{
|
|||||||
지도 데이터를 불러오지 못했습니다.
|
지도 데이터를 불러오지 못했습니다.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="map-body">
|
<div v-else class="map-body">
|
||||||
<div class="map-meta">
|
<div class="map-placeholder">
|
||||||
<span>연월: {{ props.mapData.year }}년 {{ props.mapData.month }}월</span>
|
<div class="map-placeholder-text">레거시 지도 렌더러 이식 대기</div>
|
||||||
<span>도시 {{ props.mapData.cityList.length }}</span>
|
<div class="map-meta">
|
||||||
<span>세력 {{ props.mapData.nationList.length }}</span>
|
<span>도시 {{ props.mapData.cityList.length }}</span>
|
||||||
|
<span>세력 {{ props.mapData.nationList.length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="city-list">
|
||||||
|
<div
|
||||||
|
v-for="city in cityEntries.slice(0, detailMode ? 20 : 10)"
|
||||||
|
:key="city.id"
|
||||||
|
class="city-row"
|
||||||
|
:class="{ mine: props.mapData.myCity === city.id }"
|
||||||
|
>
|
||||||
|
<span class="nation" :style="{ backgroundColor: city.color }" />
|
||||||
|
<span class="name">{{ showCityName ? `도시 ${city.id}` : `#${city.id}` }}</span>
|
||||||
|
<span class="meta">Lv {{ city.level }} · 지역 {{ city.region }}</span>
|
||||||
|
<span class="state">보급 {{ city.supply ? 'O' : 'X' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="cityEntries.length === 0" class="city-empty">표시할 도시가 없습니다.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="map-placeholder">지도 뷰어는 추후 이식 예정</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -37,25 +108,101 @@ const props = defineProps<{
|
|||||||
.map-viewer {
|
.map-viewer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-toggle {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-toggle.active {
|
||||||
|
background: rgba(201, 164, 90, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-placeholder {
|
||||||
|
border: 1px dashed rgba(201, 164, 90, 0.4);
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(16, 16, 16, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-placeholder-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(232, 221, 196, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-meta {
|
.map-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
color: rgba(232, 221, 196, 0.8);
|
color: rgba(232, 221, 196, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-placeholder {
|
.city-list {
|
||||||
height: 240px;
|
|
||||||
border: 1px dashed rgba(201, 164, 90, 0.4);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14px 1fr auto auto;
|
||||||
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
padding: 4px 6px;
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-row.mine {
|
||||||
|
background: rgba(201, 164, 90, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-row .nation {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1px solid rgba(232, 221, 196, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-row .name {
|
||||||
|
color: rgba(232, 221, 196, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-row .meta,
|
||||||
|
.city-row .state {
|
||||||
|
color: rgba(232, 221, 196, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-empty {
|
||||||
|
font-size: 0.8rem;
|
||||||
color: rgba(232, 221, 196, 0.6);
|
color: rgba(232, 221, 196, 0.6);
|
||||||
background: rgba(16, 16, 16, 0.6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-empty {
|
.map-empty {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
|
import type { MessageType } from '@sammo-ts/logic';
|
||||||
|
|
||||||
interface MessageEntry {
|
interface MessageEntry {
|
||||||
id: number;
|
id: number;
|
||||||
text: string;
|
text: string;
|
||||||
time: string;
|
time: string;
|
||||||
|
msgType: MessageType;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageBucket {
|
interface MessageBucket {
|
||||||
@@ -17,59 +20,96 @@ interface MessageBucket {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
messages: MessageBucket | null;
|
messages: MessageBucket | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
targetMailbox: number;
|
||||||
|
draftText: string;
|
||||||
|
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const preview = (items: MessageEntry[]): MessageEntry | null => (items.length ? items[0] : null);
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:targetMailbox', value: number): void;
|
||||||
|
(event: 'update:draftText', value: string): void;
|
||||||
|
(event: 'send'): void;
|
||||||
|
(event: 'refresh'): void;
|
||||||
|
(event: 'load-older', type: MessageType): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const trimText = (value: string): string => {
|
const messageTabs: Array<{ key: MessageType; label: string }> = [
|
||||||
const clean = value.replace(/\s+/g, ' ').trim();
|
{ key: 'public', label: '전체' },
|
||||||
if (clean.length <= 60) {
|
{ key: 'national', label: '국가' },
|
||||||
return clean;
|
{ key: 'private', label: '개인' },
|
||||||
|
{ key: 'diplomacy', label: '외교' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeTab = ref<MessageType>('public');
|
||||||
|
|
||||||
|
const activeMessages = computed(() => {
|
||||||
|
if (!props.messages) {
|
||||||
|
return [] as MessageEntry[];
|
||||||
}
|
}
|
||||||
return `${clean.slice(0, 60)}...`;
|
return props.messages[activeTab.value] ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const setMailbox = (value: string) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message-panel">
|
<div class="message-panel">
|
||||||
|
<div class="message-input">
|
||||||
|
<select
|
||||||
|
class="message-select"
|
||||||
|
:value="targetMailbox"
|
||||||
|
@change="setMailbox(($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in mailboxOptions"
|
||||||
|
:key="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
:disabled="option.disabled"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
class="message-text"
|
||||||
|
type="text"
|
||||||
|
maxlength="99"
|
||||||
|
:value="draftText"
|
||||||
|
placeholder="메시지 입력"
|
||||||
|
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
|
||||||
|
@keydown.enter="emit('send')"
|
||||||
|
/>
|
||||||
|
<button class="message-send" @click="emit('send')">전송</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in messageTabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:class="{ active: activeTab === tab.key }"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
<button class="refresh" @click="emit('refresh')">갱신</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="props.loading">
|
<div v-if="props.loading">
|
||||||
<SkeletonLines :lines="5" />
|
<SkeletonLines :lines="4" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!props.messages" class="empty">
|
<div v-else-if="!props.messages" class="empty">
|
||||||
메시지를 불러오지 못했습니다.
|
메시지를 불러오지 못했습니다.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="message-body">
|
<div v-else class="message-list">
|
||||||
<div class="bucket">
|
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
|
||||||
<div class="bucket-title">개인</div>
|
<div v-else>
|
||||||
<div class="bucket-item" v-if="preview(props.messages.private)">
|
<div v-for="message in activeMessages" :key="message.id" class="message-item">
|
||||||
<div class="text">{{ trimText(preview(props.messages.private)?.text ?? '') }}</div>
|
<div class="text">{{ message.text }}</div>
|
||||||
<div class="time">{{ preview(props.messages.private)?.time }}</div>
|
<div class="time">{{ message.time }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="bucket-empty">메시지 없음</div>
|
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
|
||||||
</div>
|
|
||||||
<div class="bucket">
|
|
||||||
<div class="bucket-title">공공</div>
|
|
||||||
<div class="bucket-item" v-if="preview(props.messages.public)">
|
|
||||||
<div class="text">{{ trimText(preview(props.messages.public)?.text ?? '') }}</div>
|
|
||||||
<div class="time">{{ preview(props.messages.public)?.time }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="bucket-empty">메시지 없음</div>
|
|
||||||
</div>
|
|
||||||
<div class="bucket">
|
|
||||||
<div class="bucket-title">국가</div>
|
|
||||||
<div class="bucket-item" v-if="preview(props.messages.national)">
|
|
||||||
<div class="text">{{ trimText(preview(props.messages.national)?.text ?? '') }}</div>
|
|
||||||
<div class="time">{{ preview(props.messages.national)?.time }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="bucket-empty">메시지 없음</div>
|
|
||||||
</div>
|
|
||||||
<div class="bucket">
|
|
||||||
<div class="bucket-title">외교</div>
|
|
||||||
<div class="bucket-item" v-if="preview(props.messages.diplomacy)">
|
|
||||||
<div class="text">{{ trimText(preview(props.messages.diplomacy)?.text ?? '') }}</div>
|
|
||||||
<div class="time">{{ preview(props.messages.diplomacy)?.time }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="bucket-empty">메시지 없음</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,32 +122,72 @@ const trimText = (value: string): string => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bucket {
|
.message-input {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(90px, 120px) 1fr auto;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-select,
|
||||||
|
.message-text {
|
||||||
|
background: rgba(16, 16, 16, 0.8);
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
color: inherit;
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-send {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bucket-title {
|
.message-tabs button {
|
||||||
font-size: 0.8rem;
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
color: rgba(232, 221, 196, 0.7);
|
padding: 4px 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bucket-item .time {
|
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tabs button.active {
|
||||||
|
background: rgba(201, 164, 90, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tabs .refresh {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item .time {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.65rem;
|
||||||
color: rgba(232, 221, 196, 0.6);
|
color: rgba(232, 221, 196, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bucket-empty {
|
.load-older {
|
||||||
font-size: 0.8rem;
|
border: 1px dashed rgba(201, 164, 90, 0.3);
|
||||||
color: rgba(232, 221, 196, 0.5);
|
padding: 6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return value.message;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'unknown_error';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||||
|
type GeneralContext = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||||
|
type LobbyInfo = Awaited<ReturnType<typeof trpc.lobby.info.query>>;
|
||||||
|
type WorldMapResult = Awaited<ReturnType<typeof trpc.world.getMap.query>>;
|
||||||
|
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
|
||||||
|
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const realtimeEnabled = ref(true);
|
||||||
|
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('connected');
|
||||||
|
|
||||||
|
const generalContext = ref<GeneralContext | null>(null);
|
||||||
|
const lobbyInfo = ref<LobbyInfo | null>(null);
|
||||||
|
const worldMap = ref<WorldMapResult | null>(null);
|
||||||
|
const commandTable = ref<CommandTable | null>(null);
|
||||||
|
const messages = ref<MessageBundle | null>(null);
|
||||||
|
|
||||||
|
const messageDraftText = ref('');
|
||||||
|
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
||||||
|
|
||||||
|
const general = computed(() => generalContext.value?.general ?? null);
|
||||||
|
const city = computed(() => generalContext.value?.city ?? null);
|
||||||
|
const nation = computed(() => generalContext.value?.nation ?? null);
|
||||||
|
const generalId = computed(() => general.value?.id ?? null);
|
||||||
|
const nationId = computed(() => nation.value?.id ?? null);
|
||||||
|
|
||||||
|
const mailboxOptions = computed(() => {
|
||||||
|
const options: Array<{ label: string; value: number; disabled?: boolean }> = [
|
||||||
|
{ label: '공공', value: MESSAGE_MAILBOX_PUBLIC },
|
||||||
|
];
|
||||||
|
if (nationId.value) {
|
||||||
|
options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value });
|
||||||
|
} else {
|
||||||
|
options.push({ label: '국가', value: -1, disabled: true });
|
||||||
|
}
|
||||||
|
options.push({ label: '외교', value: -2, disabled: true });
|
||||||
|
options.push({ label: '개인', value: -3, disabled: true });
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLine = computed(() => {
|
||||||
|
if (!lobbyInfo.value) {
|
||||||
|
return '상태 정보를 불러오는 중';
|
||||||
|
}
|
||||||
|
return `${lobbyInfo.value.year}년 ${lobbyInfo.value.month}월 · 턴 ${lobbyInfo.value.turnTerm}분`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const realtimeLabel = computed(() => {
|
||||||
|
if (!realtimeEnabled.value) {
|
||||||
|
return '끔';
|
||||||
|
}
|
||||||
|
return realtimeStatus.value === 'connected' ? '연결됨' : '대기중';
|
||||||
|
});
|
||||||
|
|
||||||
|
const setRealtimeEnabled = (enabled: boolean) => {
|
||||||
|
realtimeEnabled.value = enabled;
|
||||||
|
realtimeStatus.value = enabled ? 'connected' : 'paused';
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMainData = async () => {
|
||||||
|
if (loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await trpc.general.me.query();
|
||||||
|
generalContext.value = context;
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = context.general.id;
|
||||||
|
const [lobby, map, commands, messageData] = await Promise.all([
|
||||||
|
trpc.lobby.info.query(),
|
||||||
|
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
||||||
|
trpc.turns.getCommandTable.query({ generalId: id }),
|
||||||
|
trpc.messages.getRecent.query({ generalId: id }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
lobbyInfo.value = lobby;
|
||||||
|
worldMap.value = map;
|
||||||
|
commandTable.value = commands;
|
||||||
|
messages.value = messageData;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshMessages = async () => {
|
||||||
|
const id = generalId.value;
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
messages.value = await trpc.messages.getRecent.query({ generalId: id });
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
const id = generalId.value;
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mailbox = targetMailbox.value;
|
||||||
|
const text = messageDraftText.value.trim();
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mailbox <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await trpc.messages.send.mutate({
|
||||||
|
generalId: id,
|
||||||
|
mailbox,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
messageDraftText.value = '';
|
||||||
|
await refreshMessages();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOlderMessages = async (type: MessageType) => {
|
||||||
|
const id = generalId.value;
|
||||||
|
if (!id || !messages.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = messages.value[type] ?? [];
|
||||||
|
const oldest = bucket[bucket.length - 1];
|
||||||
|
if (!oldest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const older = await trpc.messages.getOld.query({
|
||||||
|
generalId: id,
|
||||||
|
type,
|
||||||
|
to: oldest.id,
|
||||||
|
});
|
||||||
|
const merged = {
|
||||||
|
...messages.value,
|
||||||
|
[type]: [...bucket, ...older[type]],
|
||||||
|
} as MessageBundle;
|
||||||
|
messages.value = merged;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
realtimeEnabled,
|
||||||
|
realtimeStatus,
|
||||||
|
generalContext,
|
||||||
|
general,
|
||||||
|
city,
|
||||||
|
nation,
|
||||||
|
lobbyInfo,
|
||||||
|
worldMap,
|
||||||
|
commandTable,
|
||||||
|
messages,
|
||||||
|
messageDraftText,
|
||||||
|
targetMailbox,
|
||||||
|
mailboxOptions,
|
||||||
|
statusLine,
|
||||||
|
realtimeLabel,
|
||||||
|
setRealtimeEnabled,
|
||||||
|
loadMainData,
|
||||||
|
refreshMessages,
|
||||||
|
sendMessage,
|
||||||
|
loadOlderMessages,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
import { useMediaQuery } from '@vueuse/core';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||||
@@ -9,20 +10,11 @@ import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
|||||||
import CityBasicCard from '../components/main/CityBasicCard.vue';
|
import CityBasicCard from '../components/main/CityBasicCard.vue';
|
||||||
import NationBasicCard from '../components/main/NationBasicCard.vue';
|
import NationBasicCard from '../components/main/NationBasicCard.vue';
|
||||||
import MessagePanel from '../components/main/MessagePanel.vue';
|
import MessagePanel from '../components/main/MessagePanel.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||||
type GeneralContext = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
|
||||||
type LobbyInfo = Awaited<ReturnType<typeof trpc.lobby.info.query>>;
|
|
||||||
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
|
|
||||||
type WorldMapResult = Awaited<ReturnType<typeof trpc.world.getMap.query>>;
|
|
||||||
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
|
||||||
|
|
||||||
type GeneralInfo = NonNullable<GeneralContext>['general'];
|
|
||||||
type CityInfo = NonNullable<GeneralContext>['city'];
|
|
||||||
type NationInfo = NonNullable<GeneralContext>['nation'];
|
|
||||||
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
|
const dashboard = useMainDashboardStore();
|
||||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||||
|
|
||||||
const mobileTabs = [
|
const mobileTabs = [
|
||||||
@@ -36,76 +28,27 @@ const mobileTabs = [
|
|||||||
type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
||||||
|
|
||||||
const mobileTab = ref<MobileTabKey>('map');
|
const mobileTab = ref<MobileTabKey>('map');
|
||||||
const realtimeEnabled = ref(true);
|
|
||||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('connected');
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const {
|
||||||
const error = ref<string | null>(null);
|
loading,
|
||||||
|
error,
|
||||||
const generalInfo = ref<GeneralInfo | null>(null);
|
realtimeEnabled,
|
||||||
const cityInfo = ref<CityInfo | null>(null);
|
general,
|
||||||
const nationInfo = ref<NationInfo | null>(null);
|
city,
|
||||||
const lobbyInfo = ref<LobbyInfo | null>(null);
|
nation,
|
||||||
const worldMap = ref<WorldMapResult | null>(null);
|
lobbyInfo,
|
||||||
const commandTable = ref<CommandTable | null>(null);
|
worldMap,
|
||||||
const messages = ref<MessageBundle | null>(null);
|
commandTable,
|
||||||
|
messages,
|
||||||
const statusLine = computed(() => {
|
messageDraftText,
|
||||||
if (!lobbyInfo.value) {
|
targetMailbox,
|
||||||
return '상태 정보를 불러오는 중';
|
mailboxOptions,
|
||||||
}
|
statusLine,
|
||||||
return `${lobbyInfo.value.year}년 ${lobbyInfo.value.month}월 · 턴 ${lobbyInfo.value.turnTerm}분`;
|
realtimeLabel,
|
||||||
});
|
} = storeToRefs(dashboard);
|
||||||
|
|
||||||
const realtimeLabel = computed(() => {
|
|
||||||
if (!realtimeEnabled.value) {
|
|
||||||
return '끔';
|
|
||||||
}
|
|
||||||
if (realtimeStatus.value === 'connected') {
|
|
||||||
return '연결됨';
|
|
||||||
}
|
|
||||||
return '대기중';
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadMainData = async () => {
|
const loadMainData = async () => {
|
||||||
if (loading.value) {
|
await dashboard.loadMainData();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const generalContext = await trpc.general.me.query();
|
|
||||||
if (!generalContext) {
|
|
||||||
generalInfo.value = null;
|
|
||||||
cityInfo.value = null;
|
|
||||||
nationInfo.value = null;
|
|
||||||
loading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
generalInfo.value = generalContext.general;
|
|
||||||
cityInfo.value = generalContext.city;
|
|
||||||
nationInfo.value = generalContext.nation;
|
|
||||||
|
|
||||||
const generalId = generalContext.general.id;
|
|
||||||
const [lobby, map, commands, messageData] = await Promise.all([
|
|
||||||
trpc.lobby.info.query(),
|
|
||||||
trpc.world.getMap.query({ generalId, showMe: true, useCache: true }),
|
|
||||||
trpc.turns.getCommandTable.query({ generalId }),
|
|
||||||
trpc.messages.getRecent.query({ generalId }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
lobbyInfo.value = lobby;
|
|
||||||
worldMap.value = map;
|
|
||||||
commandTable.value = commands;
|
|
||||||
messages.value = messageData;
|
|
||||||
} catch (err) {
|
|
||||||
error.value = '메인 정보를 불러오지 못했습니다.';
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -117,10 +60,6 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(realtimeEnabled, (enabled) => {
|
|
||||||
realtimeStatus.value = enabled ? 'connected' : 'paused';
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -131,7 +70,11 @@ watch(realtimeEnabled, (enabled) => {
|
|||||||
<p class="page-subtitle">{{ statusLine }}</p>
|
<p class="page-subtitle">{{ statusLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="toggle" :class="{ active: realtimeEnabled }" @click="realtimeEnabled = !realtimeEnabled">
|
<button
|
||||||
|
class="toggle"
|
||||||
|
:class="{ active: realtimeEnabled }"
|
||||||
|
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
||||||
|
>
|
||||||
실시간 동기화: {{ realtimeLabel }}
|
실시간 동기화: {{ realtimeLabel }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ghost" @click="loadMainData">새로고침</button>
|
<button class="ghost" @click="loadMainData">새로고침</button>
|
||||||
@@ -170,13 +113,13 @@ watch(realtimeEnabled, (enabled) => {
|
|||||||
|
|
||||||
<div class="mobile-panel" v-if="mobileTab === 'status'">
|
<div class="mobile-panel" v-if="mobileTab === 'status'">
|
||||||
<PanelCard title="장수 스탯">
|
<PanelCard title="장수 스탯">
|
||||||
<GeneralBasicCard :general="generalInfo" :loading="loading" />
|
<GeneralBasicCard :general="general" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="도시 정보">
|
<PanelCard title="도시 정보">
|
||||||
<CityBasicCard :city="cityInfo" :loading="loading" />
|
<CityBasicCard :city="city" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="국가 정보">
|
<PanelCard title="국가 정보">
|
||||||
<NationBasicCard :nation="nationInfo" :loading="loading" />
|
<NationBasicCard :nation="nation" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -201,7 +144,18 @@ watch(realtimeEnabled, (enabled) => {
|
|||||||
|
|
||||||
<div class="mobile-panel" v-if="mobileTab === 'messages'">
|
<div class="mobile-panel" v-if="mobileTab === 'messages'">
|
||||||
<PanelCard title="메시지함">
|
<PanelCard title="메시지함">
|
||||||
<MessagePanel :messages="messages" :loading="loading" />
|
<MessagePanel
|
||||||
|
:messages="messages"
|
||||||
|
:loading="loading"
|
||||||
|
:target-mailbox="targetMailbox"
|
||||||
|
:draft-text="messageDraftText"
|
||||||
|
:mailbox-options="mailboxOptions"
|
||||||
|
@update:target-mailbox="targetMailbox = $event"
|
||||||
|
@update:draft-text="messageDraftText = $event"
|
||||||
|
@send="dashboard.sendMessage"
|
||||||
|
@load-older="dashboard.loadOlderMessages"
|
||||||
|
@refresh="dashboard.refreshMessages"
|
||||||
|
/>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -220,7 +174,18 @@ watch(realtimeEnabled, (enabled) => {
|
|||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="메시지함">
|
<PanelCard title="메시지함">
|
||||||
<MessagePanel :messages="messages" :loading="loading" />
|
<MessagePanel
|
||||||
|
:messages="messages"
|
||||||
|
:loading="loading"
|
||||||
|
:target-mailbox="targetMailbox"
|
||||||
|
:draft-text="messageDraftText"
|
||||||
|
:mailbox-options="mailboxOptions"
|
||||||
|
@update:target-mailbox="targetMailbox = $event"
|
||||||
|
@update:draft-text="messageDraftText = $event"
|
||||||
|
@send="dashboard.sendMessage"
|
||||||
|
@load-older="dashboard.loadOlderMessages"
|
||||||
|
@refresh="dashboard.refreshMessages"
|
||||||
|
/>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,17 +194,17 @@ watch(realtimeEnabled, (enabled) => {
|
|||||||
<CommandListPanel :command-table="commandTable" :loading="loading" />
|
<CommandListPanel :command-table="commandTable" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="장수 스탯">
|
<PanelCard title="장수 스탯">
|
||||||
<GeneralBasicCard :general="generalInfo" :loading="loading" />
|
<GeneralBasicCard :general="general" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="장수 동향">
|
<PanelCard title="장수 동향">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="도시 정보">
|
<PanelCard title="도시 정보">
|
||||||
<CityBasicCard :city="cityInfo" :loading="loading" />
|
<CityBasicCard :city="city" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="국가 정보">
|
<PanelCard title="국가 정보">
|
||||||
<NationBasicCard :nation="nationInfo" :loading="loading" />
|
<NationBasicCard :nation="nation" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="개인 기록">
|
<PanelCard title="개인 기록">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
|
|||||||
Reference in New Issue
Block a user