feat: add dynasty management features including list and detail views

- Implemented `OldNation`, `OldGeneral`, and `Emperor` models in Prisma schema.
- Created API routes for fetching dynasty lists and details.
- Developed frontend views for displaying dynasty list and detail information.
- Enhanced in-memory world to track deleted nation snapshots during unification.
- Added functionality to settle dynasty information after unification events.
This commit is contained in:
2026-01-29 19:09:17 +00:00
parent c4a5361fc6
commit d3277f204a
12 changed files with 1095 additions and 1 deletions
+12
View File
@@ -22,6 +22,8 @@ import ScoutMessageView from '../views/ScoutMessageView.vue';
import DiplomacyView from '../views/DiplomacyView.vue';
import BestGeneralView from '../views/BestGeneralView.vue';
import HallOfFameView from '../views/HallOfFameView.vue';
import DynastyListView from '../views/DynastyListView.vue';
import DynastyDetailView from '../views/DynastyDetailView.vue';
import { useSessionStore } from '../stores/session';
const routes = [
@@ -178,6 +180,16 @@ const routes = [
name: 'hall-of-fame',
component: HallOfFameView,
},
{
path: '/dynasty',
name: 'dynasty-list',
component: DynastyListView,
},
{
path: '/dynasty/:id',
name: 'dynasty-detail',
component: DynastyDetailView,
},
{
path: '/my-page',
name: 'my-page',
@@ -0,0 +1,262 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
type EmperorDetail = {
id: number;
serverId: string;
winnerNationId: number | null;
phase: string;
nationCount: string;
nationName: string;
nationHist: string;
genCount: string;
personalHist: string;
specialHist: string;
name: string;
type: string;
color: string;
year: number;
month: number;
power: number;
gennum: number;
citynum: number;
pop: string;
poprate: string;
gold: number;
rice: number;
l12name: string;
l11name: string;
l10name: string;
l9name: string;
l8name: string;
l7name: string;
l6name: string;
l5name: string;
tiger: string;
eagle: string;
gen: string;
history: string[];
};
type NationEntry = {
nation: number;
isWinner: boolean;
name: string;
color: string;
type: string;
level: number | null;
tech: number | null;
maxPower: number | null;
maxCrew: number | null;
maxCities: number[];
generals: number[];
history: string[];
date: string;
generalsFull: Array<{ generalNo: number; name: string; lastYearMonth: number | null }>;
};
type DynastyDetailPayload = {
emperor: EmperorDetail;
nations: NationEntry[];
};
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const errorMessage = ref('');
const data = ref<DynastyDetailPayload | null>(null);
const emperorId = computed(() => {
const idParam = route.params.id;
const raw = Array.isArray(idParam) ? idParam[0] : idParam;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
});
const dynastyApi = trpc as unknown as {
dynasty: {
getDetail: {
query: (input: { emperorId: number }) => Promise<DynastyDetailPayload>;
};
};
};
const loadDetail = async () => {
if (emperorId.value === null) {
errorMessage.value = '잘못된 왕조 번호입니다.';
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await dynastyApi.dynasty.getDetail.query({
emperorId: emperorId.value,
});
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
watch(emperorId, () => {
void loadDetail();
});
onMounted(async () => {
await loadDetail();
});
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">왕조 상세</h1>
<p class="page-subtitle">통일 세력과 멸망한 국가의 기록입니다.</p>
</div>
<div class="header-actions">
<button class="ghost" @click="router.push('/dynasty')">목록으로</button>
<button class="ghost" @click="loadDetail">데이터 새로고침</button>
</div>
</header>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="loading" class="placeholder">불러오는 중...</div>
<div v-else-if="!data" class="placeholder">표시할 데이터가 없습니다.</div>
<section v-if="data" class="grid gap-4">
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<div class="flex items-center gap-3 mb-3">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: data.emperor.color }" />
<div>
<h2 class="text-lg font-semibold">{{ data.emperor.name }}</h2>
<p class="text-xs text-zinc-400">{{ data.emperor.phase }} · {{ data.emperor.year }} {{ data.emperor.month }}</p>
</div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 text-sm text-zinc-300">
<div>국력: {{ data.emperor.power }}</div>
<div>도시: {{ data.emperor.citynum }}</div>
<div>장수: {{ data.emperor.gennum }}</div>
<div>인구: {{ data.emperor.pop }}</div>
<div>인구율: {{ data.emperor.poprate }}</div>
<div>/: {{ data.emperor.gold }} / {{ data.emperor.rice }}</div>
</div>
</article>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">왕조 구성</h3>
<div class="grid sm:grid-cols-2 gap-2 text-sm text-zinc-300">
<div>세력 : {{ data.emperor.nationCount }}</div>
<div>장수 : {{ data.emperor.genCount }}</div>
<div>세력 목록: {{ data.emperor.nationName }}</div>
<div>세력 유형: {{ data.emperor.nationHist }}</div>
</div>
</article>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">관직</h3>
<ul class="grid sm:grid-cols-2 gap-2 text-sm text-zinc-300">
<li v-if="data.emperor.l12name">승상: {{ data.emperor.l12name }}</li>
<li v-if="data.emperor.l11name">태사: {{ data.emperor.l11name }}</li>
<li v-if="data.emperor.l10name">태부: {{ data.emperor.l10name }}</li>
<li v-if="data.emperor.l9name">태위: {{ data.emperor.l9name }}</li>
<li v-if="data.emperor.l8name">사도: {{ data.emperor.l8name }}</li>
<li v-if="data.emperor.l7name">사공: {{ data.emperor.l7name }}</li>
<li v-if="data.emperor.l6name">상서령: {{ data.emperor.l6name }}</li>
<li v-if="data.emperor.l5name">상서: {{ data.emperor.l5name }}</li>
</ul>
<div v-if="!data.emperor.l12name && !data.emperor.l11name" class="text-xs text-zinc-500">등록된 관직 정보가 없습니다.</div>
</article>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">장수 기록</h3>
<div class="text-sm text-zinc-300 space-y-2">
<div>
<span class="text-xs text-zinc-400">호랑이(전공):</span>
<div>{{ data.emperor.tiger || '기록 없음' }}</div>
</div>
<div>
<span class="text-xs text-zinc-400">독수리(방화):</span>
<div>{{ data.emperor.eagle || '기록 없음' }}</div>
</div>
<div>
<span class="text-xs text-zinc-400">헌납 순위:</span>
<div>{{ data.emperor.gen || '기록 없음' }}</div>
</div>
</div>
</article>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">통일 세력 연표</h3>
<div v-if="data.emperor.history.length === 0" class="text-xs text-zinc-500">연표가 없습니다.</div>
<ul v-else class="space-y-2 text-sm text-zinc-300">
<li v-for="(entry, index) in data.emperor.history" :key="`${data.emperor.id}-hist-${index}`">
{{ entry }}
</li>
</ul>
</article>
<section class="grid gap-4">
<header class="flex items-center justify-between">
<h3 class="text-base font-semibold">국가 기록</h3>
<span class="text-xs text-zinc-400"> {{ data.nations.length }}</span>
</header>
<article
v-for="nation in data.nations"
:key="nation.nation"
class="bg-zinc-900 border border-zinc-800 rounded p-4"
>
<div class="flex items-center gap-3 mb-2">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: nation.color }" />
<div>
<h4 class="text-base font-semibold">
{{ nation.name }}
<span
v-if="nation.isWinner"
class="ml-2 inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-200"
>
통일 세력
</span>
</h4>
<p class="text-xs text-zinc-400">{{ nation.type || '미상' }} · {{ new Date(nation.date).toLocaleString() }}</p>
</div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 text-sm text-zinc-300 mb-3">
<div>레벨: {{ nation.level ?? '-' }}</div>
<div>기술: {{ nation.tech ?? '-' }}</div>
<div>최대 국력: {{ nation.maxPower ?? '-' }}</div>
<div>최대 병력: {{ nation.maxCrew ?? '-' }}</div>
<div>최대 도시: {{ nation.maxCities.join(', ') || '-' }}</div>
<div>장수 : {{ nation.generals.length }}</div>
</div>
<div class="text-sm text-zinc-300">
<h5 class="text-xs text-zinc-400 mb-1">장수 목록</h5>
<div v-if="nation.generalsFull.length === 0" class="text-xs text-zinc-500">기록된 장수가 없습니다.</div>
<ul v-else class="flex flex-wrap gap-2">
<li
v-for="general in nation.generalsFull"
:key="general.generalNo"
class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs"
>
{{ general.name }}<span v-if="general.lastYearMonth"> ({{ general.lastYearMonth }})</span>
</li>
</ul>
</div>
<div class="text-sm text-zinc-300 mt-3">
<h5 class="text-xs text-zinc-400 mb-1">국가 연표</h5>
<div v-if="nation.history.length === 0" class="text-xs text-zinc-500">연표가 없습니다.</div>
<ul v-else class="space-y-1">
<li v-for="(entry, index) in nation.history" :key="`${nation.nation}-hist-${index}`">
{{ entry }}
</li>
</ul>
</div>
</article>
</section>
</section>
</main>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type DynastyEntry = {
id: number;
serverId: string;
phase: string;
name: string;
year: number;
month: number;
color: string;
type: string;
power: number;
gennum: number;
citynum: number;
};
type DynastyListPayload = {
entries: DynastyEntry[];
};
const loading = ref(false);
const errorMessage = ref('');
const entries = ref<DynastyEntry[]>([]);
const loadDynasty = async () => {
loading.value = true;
errorMessage.value = '';
try {
const result = (await trpc.dynasty.getList.query()) as DynastyListPayload;
entries.value = result.entries;
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
onMounted(async () => {
await loadDynasty();
});
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">왕조일람</h1>
<p class="page-subtitle">천하통일로 정리된 왕조 기록을 확인합니다.</p>
</div>
<div class="header-actions">
<button class="ghost" @click="loadDynasty">목록 새로고침</button>
</div>
</header>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="loading" class="placeholder">불러오는 중...</div>
<div v-else-if="entries.length === 0" class="placeholder">표시할 왕조 기록이 없습니다.</div>
<section v-else class="grid gap-4">
<RouterLink
v-for="entry in entries"
:key="entry.id"
class="block bg-zinc-900 border border-zinc-800 rounded p-4 hover:border-zinc-600 transition"
:to="`/dynasty/${entry.id}`"
>
<div class="flex items-center gap-3 mb-2">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: entry.color }" />
<div>
<h2 class="text-lg font-semibold">{{ entry.name }}</h2>
<p class="text-xs text-zinc-400">
{{ entry.phase }} · {{ entry.year }} {{ entry.month }}
</p>
</div>
</div>
<div class="grid sm:grid-cols-3 gap-2 text-sm text-zinc-300">
<div>세력 유형: {{ entry.type || '미상' }}</div>
<div>도시: {{ entry.citynum }}</div>
<div>장수: {{ entry.gennum }}</div>
<div class="sm:col-span-3">국력: {{ entry.power }}</div>
</div>
</RouterLink>
</section>
</main>
</template>
+1
View File
@@ -100,6 +100,7 @@ watch(
<RouterLink class="ghost" to="/battle-center">감찰부</RouterLink>
<RouterLink class="ghost" to="/best-general">명장일람</RouterLink>
<RouterLink class="ghost" to="/hall-of-fame">명예의 전당</RouterLink>
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>