feat: Implement session management and routing for general creation

- Added session initialization in main.ts to manage user sessions.
- Updated router to include a new route for joining or creating a general.
- Enhanced session store with methods for managing session tokens and user profiles.
- Introduced new components for displaying city, general, nation, and command information.
- Implemented loading states and error handling in the main view.
- Created a skeleton loader component for better user experience during data fetching.
- Updated TypeScript definitions for route metadata to include new authentication requirements.
This commit is contained in:
2026-01-16 15:57:56 +00:00
parent 15625f2c28
commit f8159728ca
20 changed files with 1342 additions and 18 deletions
@@ -0,0 +1,68 @@
<script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue';
interface CityInfo {
id: number;
name: string;
level: number;
nationId: number;
population: number;
agriculture: number;
commerce: number;
security: number;
defence: number;
wall: number;
supplyState: number;
frontState: number;
}
const props = defineProps<{
city: CityInfo | null;
loading: boolean;
}>();
</script>
<template>
<div class="city-card">
<div v-if="props.loading">
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div>
<div v-else class="city-body">
<div class="title">{{ props.city.name }} (Lv {{ props.city.level }})</div>
<div class="grid">
<div>인구 {{ props.city.population }}</div>
<div>농업 {{ props.city.agriculture }}</div>
<div>상업 {{ props.city.commerce }}</div>
<div>치안 {{ props.city.security }}</div>
<div>방어 {{ props.city.defence }}</div>
<div>성벽 {{ props.city.wall }}</div>
<div>보급 {{ props.city.supplyState }}</div>
<div>전방 {{ props.city.frontState }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.city-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.title {
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: 6px;
font-size: 0.85rem;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
</style>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
interface TurnCommandAvailability {
name: string;
status: string;
}
interface TurnCommandGroup {
category: string;
values: TurnCommandAvailability[];
}
interface TurnCommandTable {
general: TurnCommandGroup[];
nation: TurnCommandGroup[];
}
const props = defineProps<{
commandTable: TurnCommandTable | null;
loading: boolean;
}>();
const generalGroups = computed(() => props.commandTable?.general ?? []);
const nationGroups = computed(() => props.commandTable?.nation ?? []);
const takePreview = (group: TurnCommandGroup): TurnCommandAvailability[] => group.values.slice(0, 6);
</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>
</div>
</template>
<style scoped>
.command-list {
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);
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,99 @@
<script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue';
interface GeneralStats {
leadership: number;
strength: number;
intelligence: number;
}
interface GeneralInfo {
id: number;
name: string;
npcState: number;
officerLevel: number;
stats: GeneralStats;
gold: number;
rice: number;
crew: number;
train: number;
atmos: number;
injury: number;
experience: number;
dedication: number;
}
const props = defineProps<{
general: GeneralInfo | null;
loading: boolean;
}>();
</script>
<template>
<div class="general-card">
<div v-if="props.loading">
<SkeletonLines :lines="5" />
</div>
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body">
<div class="general-header">
<span class="name">{{ props.general.name }}</span>
<span class="meta">ID {{ props.general.id }} · 관직 {{ props.general.officerLevel }}</span>
</div>
<div class="stats">
<div>통솔 {{ props.general.stats.leadership }}</div>
<div>무력 {{ props.general.stats.strength }}</div>
<div>지력 {{ props.general.stats.intelligence }}</div>
</div>
<div class="resources">
<div> {{ props.general.gold }}</div>
<div> {{ props.general.rice }}</div>
<div> {{ props.general.crew }}</div>
</div>
<div class="status">
<div>훈련 {{ props.general.train }}</div>
<div>사기 {{ props.general.atmos }}</div>
<div>부상 {{ props.general.injury }}</div>
<div>경험 {{ props.general.experience }}</div>
<div>공헌 {{ props.general.dedication }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.general-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.general-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.general-header .name {
font-size: 1.1rem;
font-weight: 600;
}
.general-header .meta {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.stats,
.resources,
.status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: 6px;
font-size: 0.85rem;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
</style>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue';
interface MapSummary {
year: number;
month: number;
cityList: unknown[];
nationList: unknown[];
}
const props = defineProps<{
mapData: MapSummary | null;
loading: boolean;
}>();
</script>
<template>
<div class="map-viewer">
<div v-if="props.loading">
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.mapData" class="map-empty">
지도 데이터를 불러오지 못했습니다.
</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>
<div class="map-placeholder">지도 뷰어는 추후 이식 예정</div>
</div>
</div>
</template>
<style scoped>
.map-viewer {
display: flex;
flex-direction: column;
gap: 8px;
}
.map-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.8);
}
.map-placeholder {
height: 240px;
border: 1px dashed rgba(201, 164, 90, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: rgba(232, 221, 196, 0.6);
background: rgba(16, 16, 16, 0.6);
}
.map-empty {
color: rgba(232, 221, 196, 0.6);
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue';
interface MessageEntry {
id: number;
text: string;
time: string;
}
interface MessageBucket {
private: MessageEntry[];
public: MessageEntry[];
national: MessageEntry[];
diplomacy: MessageEntry[];
}
const props = defineProps<{
messages: MessageBucket | null;
loading: boolean;
}>();
const preview = (items: MessageEntry[]): MessageEntry | null => (items.length ? items[0] : null);
const trimText = (value: string): string => {
const clean = value.replace(/\s+/g, ' ').trim();
if (clean.length <= 60) {
return clean;
}
return `${clean.slice(0, 60)}...`;
};
</script>
<template>
<div class="message-panel">
<div v-if="props.loading">
<SkeletonLines :lines="5" />
</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>
<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>
</div>
</div>
</div>
</template>
<style scoped>
.message-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.bucket {
display: flex;
flex-direction: column;
gap: 4px;
}
.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 {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.bucket-empty {
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.5);
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
</style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue';
interface NationInfo {
id: number;
name: string;
color: string;
level: number;
gold: number;
rice: number;
tech: number;
typeCode: string;
capitalCityId: number | null;
}
const props = defineProps<{
nation: NationInfo | null;
loading: boolean;
}>();
</script>
<template>
<div class="nation-card">
<div v-if="props.loading">
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.nation" class="empty">국가 정보를 불러오지 못했습니다.</div>
<div v-else class="nation-body">
<div class="title">
<span class="color" :style="{ backgroundColor: props.nation.color }" />
{{ props.nation.name }} (Lv {{ props.nation.level }})
</div>
<div class="grid">
<div>국고 {{ props.nation.gold }}</div>
<div>국량 {{ props.nation.rice }}</div>
<div>기술 {{ props.nation.tech }}</div>
<div>체제 {{ props.nation.typeCode }}</div>
<div>수도 {{ props.nation.capitalCityId ?? '-' }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.nation-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.color {
width: 14px;
height: 14px;
border: 1px solid rgba(232, 221, 196, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: 6px;
font-size: 0.85rem;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
interface Props {
title: string;
subtitle?: string;
}
defineProps<Props>();
</script>
<template>
<section class="panel-card">
<header class="panel-header">
<div>
<h2 class="panel-title">{{ title }}</h2>
<p v-if="subtitle" class="panel-subtitle">{{ subtitle }}</p>
</div>
<div class="panel-actions">
<slot name="actions" />
</div>
</header>
<div class="panel-body">
<slot />
</div>
</section>
</template>
<style scoped>
.panel-card {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.8);
padding: 12px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.3);
padding-bottom: 8px;
margin-bottom: 10px;
}
.panel-title {
font-size: 1rem;
font-weight: 600;
color: #e8ddc4;
letter-spacing: 0.02em;
}
.panel-subtitle {
margin-top: 4px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.panel-body {
font-size: 0.9rem;
color: rgba(232, 221, 196, 0.9);
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
lines?: number;
}>(),
{
lines: 3,
}
);
</script>
<template>
<div class="skeleton-lines">
<div v-for="line in props.lines" :key="line" class="skeleton-line" />
</div>
</template>
<style scoped>
.skeleton-lines {
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-line {
height: 10px;
border-radius: 2px;
background: linear-gradient(90deg, rgba(201, 164, 90, 0.15), rgba(201, 164, 90, 0.3), rgba(201, 164, 90, 0.15));
background-size: 180% 100%;
animation: shimmer 1.4s infinite ease-in-out;
}
@keyframes shimmer {
0% {
background-position: 0% 0;
}
100% {
background-position: -180% 0;
}
}
</style>