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
+18 -1
View File
@@ -250,7 +250,7 @@ describe('legacy global nation/general directories', () => {
await expect(adminWithoutGeneral.world.getNationDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' }); await expect(adminWithoutGeneral.world.getNationDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' });
}); });
it('keeps the same read-only result for wandering, ordinary, chief, and possessed actors', async () => { it('keeps hover-safe public rows identical for wandering, ordinary, chief, and possessed actors', async () => {
const viewers = [ const viewers = [
actor({ nationId: 0, officerLevel: 0 }), actor({ nationId: 0, officerLevel: 0 }),
actor({ nationId: 1, officerLevel: 1 }), actor({ nationId: 1, officerLevel: 1 }),
@@ -270,6 +270,23 @@ describe('legacy global nation/general directories', () => {
}) })
); );
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]); expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
for (const result of results) {
const serializedGeneralRows = JSON.stringify(result.generals);
for (const privateField of [
'userId',
'meta',
'penalty',
'gold',
'rice',
'crew',
'train',
'atmos',
'turnTime',
'lastTurn',
]) {
expect(serializedGeneralRows).not.toContain(`"${privateField}"`);
}
}
}); });
it('preserves power/dedication order, synthesizes neutral, and applies target-general permission penalties', async () => { it('preserves power/dedication order, synthesizes neutral, and applies target-general permission penalties', async () => {
+118 -11
View File
@@ -134,11 +134,10 @@ const parseSort = (route: Route): number => {
try { try {
const request = route.request(); const request = route.request();
const queryInput = new URL(request.url()).searchParams.get('input'); const queryInput = new URL(request.url()).searchParams.get('input');
const input = (request.postData() const input = (request.postData() ? request.postDataJSON() : queryInput ? JSON.parse(queryInput) : {}) as {
? request.postDataJSON() 0?: { json?: { sort?: number }; sort?: number };
: queryInput json?: { sort?: number };
? JSON.parse(queryInput) };
: {}) as { 0?: { json?: { sort?: number }; sort?: number }; json?: { sort?: number } };
return input[0]?.json?.sort ?? input[0]?.sort ?? input.json?.sort ?? 9; return input[0]?.json?.sort ?? input[0]?.sort ?? input.json?.sort ?? 9;
} catch { } catch {
return 9; return 9;
@@ -148,7 +147,8 @@ const parseSort = (route: Route): number => {
const install = async ( const install = async (
page: Page, page: Page,
mode: 'general' | 'no-general' | 'error-after-load' = 'general', mode: 'general' | 'no-general' | 'error-after-load' = 'general',
accessPages: string[] = [] accessPages: string[] = [],
requestedOperations: string[] = []
) => { ) => {
let generalDirectoryCalls = 0; let generalDirectoryCalls = 0;
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
@@ -176,13 +176,22 @@ const install = async (
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>', body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
}) })
); );
await page.route('https://sam-image.hided.net/icons/**', (route) =>
route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
})
);
await page.route('**/image/game/**', (route) => await page.route('**/image/game/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
); );
await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => { await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => {
const requestBody = route.request().postDataJSON() as const requestBody = route.request().postDataJSON() as
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined; Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
const results = operationNames(route).map((operation, operationIndex) => { const operations = operationNames(route);
requestedOperations.push(...operations);
const results = operations.map((operation, operationIndex) => {
if (operation === 'auth.status') return response({ ok: true }); if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') { if (operation === 'lobby.info') {
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } }); return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
@@ -335,7 +344,7 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg'); expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
const icon = page.locator('.general-icon').first(); const icon = page.locator('.general-icon').first();
await expect(icon).toBeVisible(); await expect(icon).toBeVisible();
await expect(icon).toHaveAttribute('src', '/gateway/api/user-icons/%EA%B3%84%EC%A0%95%20icon.png'); await expect(icon).toHaveAttribute('src', 'https://sam-image.hided.net/icons/%EA%B3%84%EC%A0%95%20icon.png');
expect( expect(
await icon.evaluate((element) => { await icon.evaluate((element) => {
const image = element as HTMLImageElement; const image = element as HTMLImageElement;
@@ -350,7 +359,10 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
}) })
).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' }); ).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' });
const nestedLegacyIcon = page.locator('.general-icon').nth(1); const nestedLegacyIcon = page.locator('.general-icon').nth(1);
await expect(nestedLegacyIcon).toHaveAttribute('src', '/image/general/%EC%9E%A5%EC%88%98/%EC%9C%A0%EB%B9%84%201.png'); await expect(nestedLegacyIcon).toHaveAttribute(
'src',
'https://sam-image.hided.net/icons/%EC%9E%A5%EC%88%98/%EC%9C%A0%EB%B9%84%201.png'
);
await expect await expect
.poll(() => nestedLegacyIcon.evaluate((element) => (element as HTMLImageElement).naturalWidth)) .poll(() => nestedLegacyIcon.evaluate((element) => (element as HTMLImageElement).naturalWidth))
.toBe(64); .toBe(64);
@@ -380,20 +392,115 @@ test('general directory submits the legacy sort selector and keeps wounded/bonus
expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true); expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true);
}); });
test('nation directory reuses only the public general-directory row on hover and keyboard focus', async ({ page }) => {
const requestedOperations: string[] = [];
await install(page, 'general', [], requestedOperations);
const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR;
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 844 },
] as const) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('nation-list');
await page.waitForLoadState('networkidle');
await expect(page.getByRole('button', { name: '장수 일람 연동' })).toHaveCount(0);
await expect(page.locator('[data-general-preview-trigger]')).toHaveCount(4);
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(
viewport.name === 'desktop' ? 0 : 1
);
const documentHeightBefore = await page.evaluate(() => document.documentElement.scrollHeight);
const firstTrigger = page.locator('[data-general-preview-trigger="10"]');
await firstTrigger.hover();
const preview = page.locator('#nation-general-preview');
await expect(preview).toBeVisible();
await expect(preview.locator('tr[data-general-id]')).toHaveCount(1);
await expect(preview.locator('tr[data-general-id="10"]')).toContainText('조조');
await expect(preview.locator('tr[data-general-id="10"]')).toContainText('대담');
await expect(preview.locator('tr[data-general-id="10"]')).toContainText('상재 / 귀모');
await expect(preview).not.toContainText('user-');
await expect(preview).not.toContainText('secret');
const previewGeometry = await preview.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
const row = element.querySelector('tr[data-general-id]')!.getBoundingClientRect();
return {
x: rect.x,
width: rect.width,
bottom: window.innerHeight - rect.bottom,
position: style.position,
rowHeight: row.height,
documentWidth: document.documentElement.scrollWidth,
documentHeight: document.documentElement.scrollHeight,
};
});
expect(previewGeometry).toEqual({
x: viewport.name === 'desktop' ? 100 : 0,
width: 1000,
bottom: 12,
position: 'fixed',
rowHeight: 65,
documentWidth: Math.max(viewport.width, 1000),
documentHeight: documentHeightBefore,
});
await page.locator('.nation-title').first().hover();
await expect(preview).toHaveCount(0);
const foreignTrigger = page.locator('[data-general-preview-trigger="20"]');
await foreignTrigger.focus();
await expect(preview.locator('tr[data-general-id="20"]')).toContainText('유비');
expect(await foreignTrigger.getAttribute('aria-expanded')).toBe('true');
expect(await foreignTrigger.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('dashed');
await page.getByRole('button', { name: '창 닫기' }).first().focus();
await expect(preview).toHaveCount(0);
if (artifactRoot) {
const output = resolve(artifactRoot);
await mkdir(output, { recursive: true });
await firstTrigger.hover();
await expect(preview).toBeVisible();
await page.screenshot({
path: resolve(output, `nation-directory-hover-${viewport.name}.png`),
fullPage: true,
});
}
}
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(2);
expect(requestedOperations).not.toContain('nation.getSecretGeneralList');
expect(requestedOperations).not.toContain('nation.getPersonnelInfo');
expect(requestedOperations).not.toContain('general.me');
});
test('a reused image element falls back for each newly broken account icon', async ({ page }) => { test('a reused image element falls back for each newly broken account icon', async ({ page }) => {
await install(page); await install(page);
await page.route('https://sam-image.hided.net/icons/**', (route) => {
if (route.request().url().endsWith('/default.jpg')) {
return route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
});
}
return route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' });
});
await page.route('**/gateway/api/user-icons/**', (route) => await page.route('**/gateway/api/user-icons/**', (route) =>
route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' }) route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' })
); );
await page.goto('general-list'); await page.goto('general-list');
const icon = page.locator('.general-icon').first(); const icon = page.locator('.general-icon').first();
await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/); await expect(icon).toHaveAttribute('src', 'https://sam-image.hided.net/icons/default.jpg');
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
await icon.evaluate((element) => { await icon.evaluate((element) => {
(element as HTMLImageElement).src = '/gateway/api/user-icons/second-missing.png'; (element as HTMLImageElement).src = '/gateway/api/user-icons/second-missing.png';
}); });
await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/); await expect(icon).toHaveAttribute('src', 'https://sam-image.hided.net/icons/default.jpg');
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
}); });
@@ -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 { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
import { formatOfficerLevelText } from '../utils/nationFormat'; import type { GeneralDirectoryGeneral } from '../types/directory';
import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc'; 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; type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
const sortOptions: Array<{ value: SortKey; label: string }> = [ const sortOptions: Array<{ value: SortKey; label: string }> = [
@@ -30,7 +27,7 @@ const sortOptions: Array<{ value: SortKey; label: string }> = [
]; ];
const sort = ref<SortKey>(9); const sort = ref<SortKey>(9);
const generals = ref<General[]>([]); const generals = ref<GeneralDirectoryGeneral[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
const router = useRouter(); 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(() => { onMounted(() => {
void loadDirectory(); void loadDirectory();
}); });
@@ -84,114 +78,7 @@ onMounted(() => {
</table> </table>
<p v-if="error" class="directory-error" role="alert">{{ error }}</p> <p v-if="error" class="directory-error" role="alert">{{ error }}</p>
<table class="directory-table general-table legacy-bg0"> <GeneralDirectoryTable :generals="generals" :loading="loading" />
<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>
<table class="directory-table title-table legacy-bg0"> <table class="directory-table title-table legacy-bg0">
<tbody> <tbody>
@@ -241,34 +128,6 @@ onMounted(() => {
.sort-form button { .sort-form button {
font-size: 14px; 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 { .directory-error {
width: 998px; width: 998px;
margin: 0; margin: 0;
+138 -8
View File
@@ -1,6 +1,8 @@
<script setup lang="ts"> <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 { formatNationLevelText, formatOfficerLevelText } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -11,6 +13,19 @@ type Nation = Directory[number];
const nations = ref<Directory>([]); const nations = ref<Directory>([]);
const loading = ref(false); const loading = ref(false);
const error = ref(''); 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([ const whiteTextColors = new Set([
'', '',
@@ -62,6 +77,49 @@ const roamingCityName = (nation: Nation): string => {
}; };
const closeWindow = () => window.close(); 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(() => { onMounted(() => {
void loadDirectory(); void loadDirectory();
}); });
@@ -76,7 +134,6 @@ onMounted(() => {
<br /><button class="legacy-button" type="button" @click="closeWindow"> <br /><button class="legacy-button" type="button" @click="closeWindow">
닫기 닫기
</button> </button>
<input type="button" value="장수 일람 연동" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -155,9 +212,21 @@ onMounted(() => {
<td colspan="8"> <td colspan="8">
장수 일람 : 장수 일람 :
<template v-for="general in nation.generals" :key="general.id"> <template v-for="general in nation.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{ <button
displayGeneralName(general) type="button"
}}</span 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> </template>
</td> </td>
@@ -188,9 +257,21 @@ onMounted(() => {
<td colspan="5"> <td colspan="5">
장수 일람 : 장수 일람 :
<template v-for="general in nation.generals" :key="general.id"> <template v-for="general in nation.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{ <button
displayGeneralName(general) type="button"
}}</span 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> </template>
</td> </td>
@@ -199,6 +280,21 @@ onMounted(() => {
</table> </table>
</template> </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"> <div class="legacy-analysis-helper" aria-hidden="true">
<table> <table>
<thead> <thead>
@@ -306,6 +402,40 @@ onMounted(() => {
.center { .center {
text-align: 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 { .footer-table {
margin-top: 0; margin-top: 0;
} }