feat: 세력일람에 공개 장수 기본정보 hover 추가
장수일람 행을 공용 컴포넌트로 분리하고 세력일람 장수명 hover와 keyboard focus에서 공개 DTO 한 행만 표시한다. 국가 기밀 endpoint를 호출하지 않는 경계와 desktop/mobile Chromium geometry를 회귀 테스트로 고정한다.
This commit is contained in:
@@ -134,11 +134,10 @@ const parseSort = (route: Route): number => {
|
||||
try {
|
||||
const request = route.request();
|
||||
const queryInput = new URL(request.url()).searchParams.get('input');
|
||||
const input = (request.postData()
|
||||
? request.postDataJSON()
|
||||
: queryInput
|
||||
? JSON.parse(queryInput)
|
||||
: {}) as { 0?: { json?: { sort?: number }; sort?: number }; json?: { sort?: number } };
|
||||
const input = (request.postData() ? request.postDataJSON() : queryInput ? 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;
|
||||
} catch {
|
||||
return 9;
|
||||
@@ -148,7 +147,8 @@ const parseSort = (route: Route): number => {
|
||||
const install = async (
|
||||
page: Page,
|
||||
mode: 'general' | 'no-general' | 'error-after-load' = 'general',
|
||||
accessPages: string[] = []
|
||||
accessPages: string[] = [],
|
||||
requestedOperations: string[] = []
|
||||
) => {
|
||||
let generalDirectoryCalls = 0;
|
||||
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>',
|
||||
})
|
||||
);
|
||||
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) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||
);
|
||||
await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => {
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
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 === 'lobby.info') {
|
||||
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');
|
||||
const icon = page.locator('.general-icon').first();
|
||||
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(
|
||||
await icon.evaluate((element) => {
|
||||
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' });
|
||||
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
|
||||
.poll(() => nestedLegacyIcon.evaluate((element) => (element as HTMLImageElement).naturalWidth))
|
||||
.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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
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) =>
|
||||
route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' })
|
||||
);
|
||||
await page.goto('general-list');
|
||||
|
||||
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 icon.evaluate((element) => {
|
||||
(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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user