feat: 이전 서버 기록 조회 화면을 통합

지난 플레이를 현재 장수 카드와 전투·숙련도·기록 컴포넌트로 표시하고, 중앙 archive의 소유자 기반 조회를 연결한다. 명예의 전당과 왕조에 현재/이전 서버 source 분리를 추가한다.
This commit is contained in:
2026-08-17 15:55:43 +00:00
parent fc7de05017
commit d6a25c63b0
18 changed files with 2594 additions and 777 deletions
@@ -0,0 +1,10 @@
export const GENERAL_RECORD_TYPES = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'] as const;
export type GeneralRecordType = (typeof GENERAL_RECORD_TYPES)[number];
export type GeneralRecordEntry = {
id: number | string;
content: string;
};
export type GeneralRecordCollection = Partial<Record<GeneralRecordType, GeneralRecordEntry[]>>;
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatServerDateTime } from '@sammo-ts/common';
export type GeneralBattleSummaryData = {
available?: boolean;
experience?: number | null;
dedicationText?: string | null;
warnum?: number | null;
wins?: number | null;
losses?: number | null;
strategies?: number | null;
killCrew?: number | null;
deathCrew?: number | null;
winRate?: number | null;
killRate?: number | null;
recentWar?: string | null;
};
const props = withDefaults(
defineProps<{
summary: GeneralBattleSummaryData;
showWinRate?: boolean;
rateScale?: 'ratio' | 'percent';
}>(),
{ showWinRate: false, rateScale: 'ratio' }
);
const numberText = (value: number | null | undefined): string =>
typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('ko-KR') : '-';
const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(1)}%`;
const winRate = computed(() => {
if (typeof props.summary.winRate === 'number' && Number.isFinite(props.summary.winRate)) {
return rateText(props.summary.winRate);
}
const battles = props.summary.warnum;
const wins = props.summary.wins;
if (typeof battles !== 'number' || battles <= 0 || typeof wins !== 'number') return '-';
return `${((wins / battles) * 100).toFixed(1)}%`;
});
const killRate = computed(() => {
if (typeof props.summary.killRate === 'number' && Number.isFinite(props.summary.killRate)) {
return rateText(props.summary.killRate);
}
const killed = props.summary.killCrew;
const lost = props.summary.deathCrew;
if (typeof killed !== 'number' || typeof lost !== 'number' || lost <= 0) return '-';
return `${((killed / lost) * 100).toFixed(1)}%`;
});
</script>
<template>
<div class="battle-general-extra" data-general-battle-summary>
<div v-if="summary.available === false" class="battle-summary-unavailable">
전투 집계가 보존되지 않았습니다.
</div>
<template v-else>
<span>명성</span><strong>{{ numberText(summary.experience) }}</strong> <span>계급</span
><strong>{{ summary.dedicationText || '-' }}</strong> <span>전투</span
><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null"></template></strong>
<span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span
><strong>{{ numberText(summary.losses) }}</strong> <span>계략</span
><strong>{{ numberText(summary.strategies) }}</strong> <span>사살</span
><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
><strong>{{ numberText(summary.deathCrew) }}</strong>
<template v-if="showWinRate">
<span>승률</span><strong>{{ winRate }}</strong> <span>살상률</span><strong>{{ killRate }}</strong>
</template>
<span class="battle-general-extra__recent-label">최근 전투</span>
<strong class="battle-general-extra__recent-value">
{{ formatServerDateTime(summary.recentWar, { format: 'monthDayTime', fallback: '-' }) }}
</strong>
</template>
</div>
</template>
<style scoped>
.battle-general-extra {
display: grid;
grid-template-columns: repeat(6, 1fr);
}
.battle-general-extra > * {
min-height: 24px;
box-sizing: border-box;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
padding: 2px 5px;
}
.battle-general-extra > span {
background-color: rgb(20 75 42 / 70%);
text-align: center;
}
.battle-general-extra > strong {
overflow: hidden;
font-weight: 500;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.battle-general-extra > .battle-general-extra__recent-label {
grid-column: 1;
}
.battle-general-extra > .battle-general-extra__recent-value {
grid-column: 2 / -1;
text-align: left;
}
.battle-summary-unavailable {
grid-column: 1 / -1;
color: #bbb;
text-align: center;
}
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue';
import { GENERAL_RECORD_TYPES, type GeneralRecordCollection, type GeneralRecordType } from '../generalRecords';
const props = withDefaults(
defineProps<{
records: GeneralRecordCollection;
loading?: boolean;
trustedHtml?: boolean;
unavailable?: GeneralRecordType[];
}>(),
{
loading: false,
trustedHtml: false,
unavailable: () => [],
}
);
const labels: Record<GeneralRecordType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
};
const unavailableText: Record<GeneralRecordType, string> = {
generalHistory: '이 기수에는 장수 열전이 보존되지 않았습니다.',
battleDetail: '이 기수에는 전투 기록이 보존되지 않았습니다.',
battleResult: '이 기수에는 전투 결과가 보존되지 않았습니다.',
generalAction: '이 기수에는 개인 기록이 보존되지 않았습니다.',
};
</script>
<template>
<div class="log-grid" data-general-record-panels>
<div v-for="type in GENERAL_RECORD_TYPES" :key="type" class="log-block" :data-log-type="type">
<div class="log-title">{{ labels[type] }}</div>
<SkeletonLines v-if="loading" :lines="3" />
<template v-else-if="props.unavailable.includes(type)">
<div class="empty unavailable">{{ unavailableText[type] }}</div>
</template>
<template v-else>
<div v-if="(records[type]?.length ?? 0) === 0" class="empty">기록이 없습니다.</div>
<template v-for="entry in records[type] ?? []" :key="entry.id">
<!-- Current-season logs have already passed the trusted formatter boundary. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="trustedHtml" class="log-line" v-html="entry.content" />
<div v-else class="log-line">{{ entry.content }}</div>
</template>
</template>
</div>
</div>
</template>
<style scoped>
.log-grid {
display: contents;
}
.log-block {
min-height: 0;
border: 1px solid #666;
padding: 0;
background-color: #302016;
background-image: var(--sammo-texture-walnut);
}
.log-title {
display: flex;
min-height: 34px;
align-items: center;
justify-content: center;
margin: 0;
border-bottom: 1px solid #666;
color: orange;
background-color: #000;
background-image: var(--sammo-texture-green);
font-size: 1.3em;
font-weight: 500;
}
.log-line {
padding: 2px 8px;
border-bottom: 0;
}
.log-line :deep(.hidden_but_copyable) {
color: transparent !important;
font-size: 0;
}
.empty {
padding: 2px 8px;
color: #999;
}
.unavailable {
color: #bbb;
}
</style>