feat: Refactor command selection and message handling components; add main dashboard store

This commit is contained in:
2026-01-16 16:33:44 +00:00
parent 5f664ddafc
commit 4f44e76ef1
6 changed files with 791 additions and 228 deletions
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import { ref } from 'vue';
import CommandSelectForm from './CommandSelectForm.vue';
interface TurnCommandAvailability {
key: string;
name: string;
status: string;
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean;
reason?: string;
}
interface TurnCommandGroup {
@@ -22,81 +25,39 @@ const props = defineProps<{
loading: boolean;
}>();
const generalGroups = computed(() => props.commandTable?.general ?? []);
const nationGroups = computed(() => props.commandTable?.nation ?? []);
const activeCategory = ref('');
const takePreview = (group: TurnCommandGroup): TurnCommandAvailability[] => group.values.slice(0, 6);
const handleSelect = (commandKey: string) => {
void commandKey;
};
</script>
<template>
<div class="command-list">
<div v-if="props.loading">
<SkeletonLines :lines="5" />
</div>
<div v-else-if="!props.commandTable" class="empty">
명령 목록을 불러오지 못했습니다.
</div>
<div v-else class="command-body">
<div class="group" v-for="group in generalGroups" :key="`g-${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 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 class="command-panel">
<CommandSelectForm
:command-table="props.commandTable"
:loading="props.loading"
:active-category="activeCategory"
@update:active-category="activeCategory = $event"
@select="handleSelect"
/>
<div class="command-placeholder">
선택한 명령의 상세/예약 UI는 추후 이식 예정
</div>
</div>
</template>
<style scoped>
.command-list {
.command-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.group {
display: flex;
flex-direction: column;
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);
.command-placeholder {
border: 1px dashed rgba(201, 164, 90, 0.3);
padding: 8px;
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);
}
</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">
import { computed, ref } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
interface MapSummary {
year: number;
month: number;
cityList: unknown[];
nationList: unknown[];
cityList: [number, number, number, number, number, number][];
nationList: [number, string, string, number][];
myCity?: number | null;
myNation?: number | null;
}
const props = defineProps<{
mapData: MapSummary | null;
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>
<template>
<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">
<SkeletonLines :lines="4" />
</div>
@@ -23,12 +79,27 @@ const props = defineProps<{
지도 데이터를 불러오지 못했습니다.
</div>
<div v-else class="map-body">
<div class="map-meta">
<span>연월: {{ props.mapData.year }} {{ props.mapData.month }}</span>
<span>도시 {{ props.mapData.cityList.length }}</span>
<span>세력 {{ props.mapData.nationList.length }}</span>
<div class="map-placeholder">
<div class="map-placeholder-text">레거시 지도 렌더러 이식 대기</div>
<div class="map-meta">
<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 class="map-placeholder">지도 뷰어는 추후 이식 예정</div>
</div>
</div>
</template>
@@ -37,25 +108,101 @@ const props = defineProps<{
.map-viewer {
display: flex;
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 {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.8);
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
}
.map-placeholder {
height: 240px;
border: 1px dashed rgba(201, 164, 90, 0.4);
.city-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.city-row {
display: grid;
grid-template-columns: 14px 1fr auto auto;
gap: 8px;
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);
background: rgba(16, 16, 16, 0.6);
}
.map-empty {
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import type { MessageType } from '@sammo-ts/logic';
interface MessageEntry {
id: number;
text: string;
time: string;
msgType: MessageType;
}
interface MessageBucket {
@@ -17,59 +20,96 @@ interface MessageBucket {
const props = defineProps<{
messages: MessageBucket | null;
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 clean = value.replace(/\s+/g, ' ').trim();
if (clean.length <= 60) {
return clean;
const messageTabs: Array<{ key: MessageType; label: string }> = [
{ key: 'public', label: '전체' },
{ key: 'national', label: '국가' },
{ 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>
<template>
<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">
<SkeletonLines :lines="5" />
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.messages" class="empty">
메시지를 불러오지 못했습니다.
</div>
<div v-else class="message-body">
<div class="bucket">
<div class="bucket-title">개인</div>
<div class="bucket-item" v-if="preview(props.messages.private)">
<div class="text">{{ trimText(preview(props.messages.private)?.text ?? '') }}</div>
<div class="time">{{ preview(props.messages.private)?.time }}</div>
<div v-else class="message-list">
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
<div v-else>
<div v-for="message in activeMessages" :key="message.id" class="message-item">
<div class="text">{{ message.text }}</div>
<div class="time">{{ message.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.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>
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
</div>
</div>
</div>
@@ -82,32 +122,72 @@ const trimText = (value: string): string => {
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;
flex-direction: column;
gap: 4px;
flex-wrap: wrap;
gap: 6px;
}
.bucket-title {
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.7);
}
.bucket-item {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.85rem;
}
.bucket-item .time {
.message-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 4px 8px;
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);
}
.bucket-empty {
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.5);
.load-older {
border: 1px dashed rgba(201, 164, 90, 0.3);
padding: 6px;
font-size: 0.7rem;
cursor: pointer;
}
.empty {