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:
@@ -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>
|
||||
Reference in New Issue
Block a user