feat: 세력일람에 공개 장수 기본정보 hover 추가

장수일람 행을 공용 컴포넌트로 분리하고 세력일람 장수명 hover와 keyboard focus에서 공개 DTO 한 행만 표시한다. 국가 기밀 endpoint를 호출하지 않는 경계와 desktop/mobile Chromium geometry를 회귀 테스트로 고정한다.
This commit is contained in:
2026-08-19 14:34:03 +00:00
parent 11e5e8d7c5
commit c4f70d0240
6 changed files with 454 additions and 165 deletions
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon';
import { formatOfficerLevelText } from '../../utils/nationFormat';
import { getNpcColor } from '../../utils/npcColor';
import type { GeneralDirectoryGeneral } from '../../types/directory';
withDefaults(
defineProps<{
generals: GeneralDirectoryGeneral[];
loading?: boolean;
}>(),
{
loading: false,
}
);
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
</script>
<template>
<table class="directory-table general-table legacy-bg0">
<colgroup>
<col style="width: 64px" />
<col style="width: 140px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 80px" />
<col style="width: 45px" />
<col style="width: 140px" />
<col style="width: 55px" />
<col style="width: 55px" />
<col style="width: 75px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 70px" />
</colgroup>
<thead>
<tr>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell">연령</td>
<td class="header-cell">성격</td>
<td class="header-cell">특기</td>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell">통솔</td>
<td class="header-cell">무력</td>
<td class="header-cell">지력</td>
<td class="header-cell">삭턴</td>
<td class="header-cell">벌점</td>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="15" class="loading-cell">불러오는 ...</td>
</tr>
<tr
v-for="general in generals"
v-else
:key="general.id"
:data-general-id="general.id"
:data-general-wounded="general.injury"
:data-general-leadership="general.leadership"
:data-general-leadership-bonus="general.leadershipBonus"
:data-general-strength="general.strength"
:data-general-intel="general.intelligence"
:data-is-npc="general.npcState >= 2"
:data-npc-type="general.npcState"
>
<td class="center">
<img
class="general-icon"
width="64"
height="64"
:src="resolveGeneralIconUrl(general)"
alt=""
@error="useDefaultGeneralIcon"
/>
</td>
<td class="center">
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span>
<template v-if="general.ownerName"
><br /><small>({{ general.ownerName }})</small></template
>
</td>
<td class="center">{{ general.age }}세</td>
<td class="center">
<span :title="general.personality.info">{{ general.personality.name }}</span>
</td>
<td class="center">
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> /
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span>
</td>
<td class="center">Lv {{ general.experienceLevel }}</td>
<td class="center">{{ general.nationName }}</td>
<td class="center">{{ general.honorText }}</td>
<td class="center">{{ general.dedicationText }}</td>
<td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
<td class="center">
<span :class="{ wounded: general.injury > 0 }">{{
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership
}}</span
><span v-if="general.leadershipBonus > 0" class="leadership-bonus"
>+{{ general.leadershipBonus }}</span
>
</td>
<td class="center">
<span :class="{ wounded: general.injury > 0 }">{{
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
}}</span>
</td>
<td class="center">
<span :class="{ wounded: general.injury > 0 }">{{
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence
}}</span>
</td>
<td class="center">{{ general.killturn }}</td>
<td class="center">{{ general.refreshScoreTotal }}<br />{{ general.refreshText }}</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
.directory-table {
width: 1000px;
border-collapse: collapse;
table-layout: auto;
padding: 0;
font-size: 14px;
line-height: 1.3;
word-break: break-all;
}
.directory-table td {
border: 1px solid gray;
padding: 0;
word-break: break-all;
}
.header-cell {
height: 18px;
text-align: center;
background-color: #14241b;
background-image: var(--sammo-texture-green);
}
.general-icon {
display: inline;
width: 64px;
min-width: 64px;
max-width: none;
height: 64px;
object-fit: fill;
vertical-align: middle;
}
.center {
text-align: center;
}
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.loading-cell {
height: 64px;
text-align: center;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
import type { trpc } from '../utils/trpc';
export type GeneralDirectory = Awaited<ReturnType<typeof trpc.world.getGeneralDirectory.query>>;
export type GeneralDirectoryGeneral = GeneralDirectory['generals'][number];
+4 -145
View File
@@ -2,13 +2,10 @@
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
import type { GeneralDirectoryGeneral } from '../types/directory';
import { trpc } from '../utils/trpc';
type Directory = Awaited<ReturnType<typeof trpc.world.getGeneralDirectory.query>>;
type General = Directory['generals'][number];
type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
const sortOptions: Array<{ value: SortKey; label: string }> = [
@@ -30,7 +27,7 @@ const sortOptions: Array<{ value: SortKey; label: string }> = [
];
const sort = ref<SortKey>(9);
const generals = ref<General[]>([]);
const generals = ref<GeneralDirectoryGeneral[]>([]);
const loading = ref(false);
const error = ref('');
const router = useRouter();
@@ -48,9 +45,6 @@ const loadDirectory = async () => {
}
};
const imageUrl = (general: General): string => resolveGeneralIconUrl(general);
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
onMounted(() => {
void loadDirectory();
});
@@ -84,114 +78,7 @@ onMounted(() => {
</table>
<p v-if="error" class="directory-error" role="alert">{{ error }}</p>
<table class="directory-table general-table legacy-bg0">
<colgroup>
<col style="width: 64px" />
<col style="width: 140px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 80px" />
<col style="width: 45px" />
<col style="width: 140px" />
<col style="width: 55px" />
<col style="width: 55px" />
<col style="width: 75px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 70px" />
</colgroup>
<thead>
<tr>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell">연령</td>
<td class="header-cell">성격</td>
<td class="header-cell">특기</td>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell"> </td>
<td class="header-cell">통솔</td>
<td class="header-cell">무력</td>
<td class="header-cell">지력</td>
<td class="header-cell">삭턴</td>
<td class="header-cell">벌점</td>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="15" class="loading-cell">불러오는 ...</td>
</tr>
<tr
v-for="general in generals"
v-else
:key="general.id"
:data-general-id="general.id"
:data-general-wounded="general.injury"
:data-general-leadership="general.leadership"
:data-general-leadership-bonus="general.leadershipBonus"
:data-general-strength="general.strength"
:data-general-intel="general.intelligence"
:data-is-npc="general.npcState >= 2"
:data-npc-type="general.npcState"
>
<td class="center">
<img
class="general-icon"
width="64"
height="64"
:src="imageUrl(general)"
alt=""
@error="useDefaultGeneralIcon"
/>
</td>
<td class="center">
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span>
<template v-if="general.ownerName"
><br /><small>({{ general.ownerName }})</small></template
>
</td>
<td class="center">{{ general.age }}세</td>
<td class="center">
<span :title="general.personality.info">{{ general.personality.name }}</span>
</td>
<td class="center">
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> /
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span>
</td>
<td class="center">Lv {{ general.experienceLevel }}</td>
<td class="center">{{ general.nationName }}</td>
<td class="center">{{ general.honorText }}</td>
<td class="center">{{ general.dedicationText }}</td>
<td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
<td class="center">
<span :class="{ wounded: general.injury > 0 }">{{
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership
}}</span
><span v-if="general.leadershipBonus > 0" class="leadership-bonus"
>+{{ general.leadershipBonus }}</span
>
</td>
<td class="center">
<span :class="{ wounded: general.injury > 0 }">{{
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
}}</span>
</td>
<td class="center">
<span :class="{ wounded: general.injury > 0 }">{{
general.injury > 0
? injuredStat(general.intelligence, general.injury)
: general.intelligence
}}</span>
</td>
<td class="center">{{ general.killturn }}</td>
<td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td>
</tr>
</tbody>
</table>
<GeneralDirectoryTable :generals="generals" :loading="loading" />
<table class="directory-table title-table legacy-bg0">
<tbody>
@@ -241,34 +128,6 @@ onMounted(() => {
.sort-form button {
font-size: 14px;
}
.header-cell {
height: 18px;
text-align: center;
background-color: #14241b;
background-image: var(--sammo-texture-green);
}
.general-icon {
display: inline;
width: 64px;
min-width: 64px;
max-width: none;
height: 64px;
object-fit: fill;
vertical-align: middle;
}
.center {
text-align: center;
}
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.loading-cell {
height: 64px;
text-align: center;
}
.directory-error {
width: 998px;
margin: 0;
+138 -8
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
import type { GeneralDirectoryGeneral } from '../types/directory';
import { formatNationLevelText, formatOfficerLevelText } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc';
@@ -11,6 +13,19 @@ type Nation = Directory[number];
const nations = ref<Directory>([]);
const loading = ref(false);
const error = ref('');
const generalDetails = ref<GeneralDirectoryGeneral[]>([]);
const generalDetailsLoaded = ref(false);
const generalDetailsLoading = ref(false);
const generalDetailsError = ref('');
const activeGeneralId = ref<number | null>(null);
let generalDetailsRequest: Promise<void> | null = null;
const generalDetailsById = computed(
() => new Map(generalDetails.value.map((general) => [general.id, general] as const))
);
const activeGeneral = computed(() =>
activeGeneralId.value === null ? null : (generalDetailsById.value.get(activeGeneralId.value) ?? null)
);
const whiteTextColors = new Set([
'',
@@ -62,6 +77,49 @@ const roamingCityName = (nation: Nation): string => {
};
const closeWindow = () => window.close();
const loadGeneralDetails = async (): Promise<void> => {
if (generalDetailsLoaded.value) {
return;
}
if (!generalDetailsRequest) {
generalDetailsLoading.value = true;
generalDetailsError.value = '';
generalDetailsRequest = trpc.world.getGeneralDirectory
.query({ sort: 9 })
.then((result) => {
generalDetails.value = result.generals;
generalDetailsLoaded.value = true;
})
.catch((cause: unknown) => {
generalDetailsError.value =
cause instanceof Error ? cause.message : '장수 기본 정보를 불러오지 못했습니다.';
})
.finally(() => {
generalDetailsLoading.value = false;
generalDetailsRequest = null;
});
}
await generalDetailsRequest;
};
const showGeneralDetails = (generalId: number): void => {
activeGeneralId.value = generalId;
void loadGeneralDetails();
};
const hideGeneralDetails = (generalId: number): void => {
if (activeGeneralId.value === generalId) {
activeGeneralId.value = null;
}
};
const hideGeneralDetailsFromPointer = (event: PointerEvent, generalId: number): void => {
if (document.activeElement === event.currentTarget) {
return;
}
hideGeneralDetails(generalId);
};
onMounted(() => {
void loadDirectory();
});
@@ -76,7 +134,6 @@ onMounted(() => {
<br /><button class="legacy-button" type="button" @click="closeWindow">
닫기
</button>
<input type="button" value="장수 일람 연동" />
</td>
</tr>
</tbody>
@@ -155,9 +212,21 @@ onMounted(() => {
<td colspan="8">
장수 일람 :
<template v-for="general in nation.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{
displayGeneralName(general)
}}</span
<button
type="button"
class="general-preview-trigger"
:data-general-preview-trigger="general.id"
:style="{ color: getNpcColor(general.npcState) }"
:aria-expanded="activeGeneralId === general.id"
:aria-describedby="
activeGeneralId === general.id ? 'nation-general-preview' : undefined
"
@pointerenter="showGeneralDetails(general.id)"
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
@focus="showGeneralDetails(general.id)"
@blur="hideGeneralDetails(general.id)"
>
{{ displayGeneralName(general) }}</button
>,
</template>
</td>
@@ -188,9 +257,21 @@ onMounted(() => {
<td colspan="5">
장수 일람 :
<template v-for="general in nation.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{
displayGeneralName(general)
}}</span
<button
type="button"
class="general-preview-trigger"
:data-general-preview-trigger="general.id"
:style="{ color: getNpcColor(general.npcState) }"
:aria-expanded="activeGeneralId === general.id"
:aria-describedby="
activeGeneralId === general.id ? 'nation-general-preview' : undefined
"
@pointerenter="showGeneralDetails(general.id)"
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
@focus="showGeneralDetails(general.id)"
@blur="hideGeneralDetails(general.id)"
>
{{ displayGeneralName(general) }}</button
>,
</template>
</td>
@@ -199,6 +280,21 @@ onMounted(() => {
</table>
</template>
<div
v-if="activeGeneralId !== null"
id="nation-general-preview"
class="general-hover-preview"
role="tooltip"
aria-live="polite"
>
<GeneralDirectoryTable v-if="activeGeneral" :generals="[activeGeneral]" />
<div v-else class="general-hover-status">
<template v-if="generalDetailsLoading">장수 기본 정보를 불러오는 중...</template>
<template v-else-if="generalDetailsError">{{ generalDetailsError }}</template>
<template v-else>표시할 있는 장수 기본 정보가 없습니다.</template>
</div>
</div>
<div class="legacy-analysis-helper" aria-hidden="true">
<table>
<thead>
@@ -306,6 +402,40 @@ onMounted(() => {
.center {
text-align: center;
}
.general-preview-trigger {
appearance: none;
border: 0;
padding: 0;
background: transparent;
font: inherit;
line-height: inherit;
cursor: help;
}
.general-preview-trigger:focus-visible {
outline: 1px dashed cyan;
outline-offset: 1px;
}
.general-hover-preview {
position: fixed;
z-index: 30;
bottom: 12px;
left: max(0px, calc(50% - 500px));
width: 1000px;
margin: 0;
padding: 0;
background: #000;
box-shadow: 0 0 7px 3px rgb(255 255 255 / 50%);
pointer-events: none;
}
.general-hover-status {
box-sizing: border-box;
width: 1000px;
min-height: 65px;
border: 1px solid gray;
padding: 22px 8px;
background: #000;
text-align: center;
}
.footer-table {
margin-top: 0;
}