Merge branch 'feature/nation-general-controls-20260813'
This commit is contained in:
@@ -18,6 +18,8 @@ const general = {
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
experienceLevel: 9,
|
||||
dedicationLevel: 1,
|
||||
dedicationText: '30품관',
|
||||
bill: 600,
|
||||
injury: 0,
|
||||
gold: 1000,
|
||||
rice: 2000,
|
||||
@@ -28,6 +30,24 @@ const general = {
|
||||
refreshScoreTotal: 10,
|
||||
permission: 'normal',
|
||||
};
|
||||
const otherGeneral = {
|
||||
...general,
|
||||
id: 2,
|
||||
name: '다른장수',
|
||||
npcState: 1,
|
||||
stats: { leadership: 40, strength: 80, intelligence: 65 },
|
||||
experienceLevel: 12,
|
||||
dedicationLevel: 3,
|
||||
dedicationText: '28품관',
|
||||
bill: 1000,
|
||||
gold: 3000,
|
||||
rice: 500,
|
||||
personality: { key: '용장', name: '용장', info: '공격적인 성격' },
|
||||
specialDomestic: { key: '상재', name: '상재', info: '상업 특기' },
|
||||
specialWar: { key: '돌격', name: '돌격', info: '전투 특기' },
|
||||
belong: 4,
|
||||
refreshScoreTotal: 20,
|
||||
};
|
||||
const install = async (page: Page, secretAllowed = true) => {
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_general');
|
||||
@@ -42,7 +62,7 @@ const install = async (page: Page, secretAllowed = true) => {
|
||||
return response({
|
||||
nation: { id: 1, name: '위', color: '#008000', level: 3 },
|
||||
viewer: { generalId: 1, permission: 0 },
|
||||
generals: [general],
|
||||
generals: [general, otherGeneral],
|
||||
});
|
||||
if (operation === 'nation.getSecretGeneralList') {
|
||||
if (!secretAllowed)
|
||||
@@ -108,19 +128,91 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns',
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('nation/generals');
|
||||
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
|
||||
await expect(page.locator('#nation-general-list')).toContainText('?');
|
||||
const computed = await page.locator('.general-page').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
|
||||
});
|
||||
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
|
||||
expect(computed.fontFamily).toContain('Times New Roman');
|
||||
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '14px' });
|
||||
expect(computed.fontFamily).toContain('Pretendard');
|
||||
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
|
||||
'separate'
|
||||
);
|
||||
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
|
||||
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
|
||||
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1000);
|
||||
expect((await page.locator('#nation-general-list tbody tr').first().boundingBox())?.height).toBe(68);
|
||||
await page.getByRole('button', { name: '보기 모드⌄' }).click();
|
||||
await page.getByRole('button', { name: '전투', exact: true }).click();
|
||||
await expect(page.locator('#nation-general-list')).toContainText('?');
|
||||
});
|
||||
|
||||
test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('nation/generals');
|
||||
const table = page.locator('#nation-general-list');
|
||||
await page.screenshot({ path: testInfo.outputPath('core-initial.png'), fullPage: true });
|
||||
|
||||
const statGroupButton = page.getByRole('button', { name: '능력치 접기' });
|
||||
await expect(statGroupButton).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
|
||||
await statGroupButton.hover();
|
||||
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(48, 54, 56)');
|
||||
await statGroupButton.focus();
|
||||
await expect(statGroupButton).toBeFocused();
|
||||
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid');
|
||||
await statGroupButton.click();
|
||||
await page.screenshot({ path: testInfo.outputPath('core-stat-collapsed.png'), fullPage: true });
|
||||
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(table.locator('thead')).toContainText('통|무|지');
|
||||
await expect(table.locator('tr[data-general-id="1"]')).toContainText('70|60|50');
|
||||
|
||||
await page.getByRole('button', { name: '능력치 펼치기' }).click();
|
||||
await page.getByLabel('장수명 필터').fill('ㅌㅅㅌㅈㅅ');
|
||||
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
|
||||
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
|
||||
await page.getByLabel('장수명 필터').fill('');
|
||||
await page.getByLabel('통솔 필터').fill('>= 60');
|
||||
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
|
||||
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
|
||||
await page.getByLabel('통솔 필터').fill('');
|
||||
|
||||
await page.getByRole('button', { name: '통솔 정렬' }).click();
|
||||
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '1');
|
||||
await page.getByRole('button', { name: '통솔 정렬' }).click();
|
||||
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '2');
|
||||
|
||||
await page.getByRole('button', { name: '능력치 접기' }).click();
|
||||
await page.getByRole('button', { name: '열 선택⌄' }).click();
|
||||
await page.getByLabel('쌀', { exact: true }).uncheck();
|
||||
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '보기 모드⌄' }).click();
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.type()).toBe('prompt');
|
||||
await dialog.accept('내 보기');
|
||||
});
|
||||
await page.getByRole('button', { name: /보관하기/ }).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
settings: localStorage.getItem('GeneralListDisplaySetting'),
|
||||
last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'),
|
||||
}))
|
||||
)
|
||||
.toMatchObject({ settings: expect.stringContaining('내 보기'), last: '[false,"내 보기"]' });
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '보기 모드⌄' }).click();
|
||||
await expect(page.getByRole('button', { name: '내 보기', exact: true })).toBeVisible();
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.type()).toBe('confirm');
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '내 보기 설정 삭제' }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting')))
|
||||
.not.toContain('내 보기');
|
||||
});
|
||||
|
||||
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
export type NationGeneralColumnId =
|
||||
| 'icon'
|
||||
| 'name'
|
||||
| 'officerLevel'
|
||||
| 'expDedLv_1'
|
||||
| 'dedlevel'
|
||||
| 'explevel'
|
||||
| 'stat_1'
|
||||
| 'leadership'
|
||||
| 'strength'
|
||||
| 'intel'
|
||||
| 'troop'
|
||||
| 'goldRice_1'
|
||||
| 'gold'
|
||||
| 'rice'
|
||||
| 'city'
|
||||
| 'crew'
|
||||
| 'specials_1'
|
||||
| 'personal'
|
||||
| 'specialDomestic'
|
||||
| 'specialWar'
|
||||
| 'years_1'
|
||||
| 'belong'
|
||||
| 'killturnAndRefresh_1'
|
||||
| 'refreshScoreTotal';
|
||||
|
||||
export type NationGeneralGroupId = 'expDedLv' | 'stat' | 'goldRice' | 'specials' | 'years' | 'killturnAndRefresh';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
export type NationGeneralViewMode = 'normal' | 'war';
|
||||
|
||||
export type NationGeneralColumnState = {
|
||||
colId: NationGeneralColumnId;
|
||||
width: number;
|
||||
hide: boolean;
|
||||
sort: SortDirection | null;
|
||||
sortIndex?: number;
|
||||
};
|
||||
|
||||
export type NationGeneralGroupState = {
|
||||
groupId: NationGeneralGroupId;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export type NationGeneralDisplaySetting = {
|
||||
column: NationGeneralColumnState[];
|
||||
columnGroup: NationGeneralGroupState[];
|
||||
};
|
||||
|
||||
export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string];
|
||||
|
||||
export const DISPLAY_SETTINGS_KEY = 'GeneralListDisplaySetting';
|
||||
export const DISPLAY_SETTINGS_VERSION = 1;
|
||||
export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`;
|
||||
|
||||
const baseColumns = (): NationGeneralColumnState[] => [
|
||||
{ colId: 'icon', width: 80, hide: false, sort: null },
|
||||
{ colId: 'name', width: 126, hide: false, sort: null },
|
||||
{ colId: 'officerLevel', width: 70, hide: false, sort: null },
|
||||
{ colId: 'expDedLv_1', width: 60, hide: false, sort: null },
|
||||
{ colId: 'dedlevel', width: 70, hide: false, sort: null },
|
||||
{ colId: 'explevel', width: 60, hide: false, sort: null },
|
||||
{ colId: 'stat_1', width: 88, hide: false, sort: null },
|
||||
{ colId: 'leadership', width: 60, hide: false, sort: null },
|
||||
{ colId: 'strength', width: 60, hide: false, sort: null },
|
||||
{ colId: 'intel', width: 60, hide: false, sort: null },
|
||||
{ colId: 'troop', width: 90, hide: true, sort: null },
|
||||
{ colId: 'goldRice_1', width: 80, hide: false, sort: null },
|
||||
{ colId: 'gold', width: 70, hide: false, sort: null },
|
||||
{ colId: 'rice', width: 70, hide: false, sort: null },
|
||||
{ colId: 'city', width: 60, hide: true, sort: null },
|
||||
{ colId: 'crew', width: 70, hide: true, sort: null },
|
||||
{ colId: 'specials_1', width: 80, hide: false, sort: null },
|
||||
{ colId: 'personal', width: 60, hide: false, sort: null },
|
||||
{ colId: 'specialDomestic', width: 60, hide: false, sort: null },
|
||||
{ colId: 'specialWar', width: 60, hide: false, sort: null },
|
||||
{ colId: 'years_1', width: 60, hide: false, sort: null },
|
||||
{ colId: 'belong', width: 60, hide: false, sort: null },
|
||||
{ colId: 'killturnAndRefresh_1', width: 70, hide: false, sort: null },
|
||||
{ colId: 'refreshScoreTotal', width: 70, hide: false, sort: null },
|
||||
];
|
||||
|
||||
const groupState = (overrides: Partial<Record<NationGeneralGroupId, boolean>>): NationGeneralGroupState[] =>
|
||||
(['expDedLv', 'stat', 'goldRice', 'specials', 'years', 'killturnAndRefresh'] as const).map((groupId) => ({
|
||||
groupId,
|
||||
open: overrides[groupId] ?? false,
|
||||
}));
|
||||
|
||||
const withColumnOverrides = (
|
||||
columns: NationGeneralColumnState[],
|
||||
overrides: Partial<Record<NationGeneralColumnId, Partial<NationGeneralColumnState>>>
|
||||
): NationGeneralColumnState[] =>
|
||||
columns.map((column) => ({
|
||||
...column,
|
||||
...overrides[column.colId],
|
||||
}));
|
||||
|
||||
export const defaultNationGeneralDisplaySettings: Record<NationGeneralViewMode, NationGeneralDisplaySetting> = {
|
||||
normal: {
|
||||
column: withColumnOverrides(baseColumns(), {
|
||||
troop: { hide: true },
|
||||
city: { hide: true },
|
||||
crew: { hide: true },
|
||||
refreshScoreTotal: { sort: 'desc', sortIndex: 0 },
|
||||
}),
|
||||
columnGroup: groupState({
|
||||
expDedLv: true,
|
||||
stat: true,
|
||||
goldRice: true,
|
||||
specials: false,
|
||||
years: false,
|
||||
killturnAndRefresh: true,
|
||||
}),
|
||||
},
|
||||
war: {
|
||||
column: withColumnOverrides(baseColumns(), {
|
||||
icon: { hide: true },
|
||||
officerLevel: { hide: true },
|
||||
expDedLv_1: { hide: true },
|
||||
dedlevel: { hide: true },
|
||||
explevel: { hide: true },
|
||||
troop: { hide: false },
|
||||
city: { hide: false },
|
||||
crew: { hide: false },
|
||||
specials_1: { hide: true },
|
||||
personal: { hide: true },
|
||||
specialDomestic: { hide: true },
|
||||
specialWar: { hide: true },
|
||||
years_1: { hide: true },
|
||||
belong: { hide: true },
|
||||
killturnAndRefresh_1: { hide: true },
|
||||
refreshScoreTotal: { hide: true },
|
||||
}),
|
||||
columnGroup: groupState({
|
||||
expDedLv: false,
|
||||
stat: false,
|
||||
goldRice: true,
|
||||
specials: false,
|
||||
years: false,
|
||||
killturnAndRefresh: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const cloneNationGeneralDisplaySetting = (
|
||||
setting: NationGeneralDisplaySetting
|
||||
): NationGeneralDisplaySetting => ({
|
||||
column: setting.column.map((column) => ({ ...column })),
|
||||
columnGroup: setting.columnGroup.map((group) => ({ ...group })),
|
||||
});
|
||||
|
||||
const validColumnIds = new Set<NationGeneralColumnId>(baseColumns().map((column) => column.colId));
|
||||
const validGroupIds = new Set<NationGeneralGroupId>(groupState({}).map((group) => group.groupId));
|
||||
|
||||
const isSortDirection = (value: unknown): value is SortDirection => value === 'asc' || value === 'desc';
|
||||
|
||||
export const normalizeNationGeneralDisplaySetting = (raw: unknown): NationGeneralDisplaySetting | null => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const candidate = raw as { column?: unknown; columnGroup?: unknown };
|
||||
if (!Array.isArray(candidate.column) || !Array.isArray(candidate.columnGroup)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallback = cloneNationGeneralDisplaySetting(defaultNationGeneralDisplaySettings.normal);
|
||||
const rawColumns = new Map<string, Record<string, unknown>>();
|
||||
for (const value of candidate.column) {
|
||||
if (!value || typeof value !== 'object') continue;
|
||||
const column = value as Record<string, unknown>;
|
||||
if (typeof column.colId === 'string' && validColumnIds.has(column.colId as NationGeneralColumnId)) {
|
||||
rawColumns.set(column.colId, column);
|
||||
}
|
||||
}
|
||||
fallback.column = fallback.column.map((column) => {
|
||||
const saved = rawColumns.get(column.colId);
|
||||
if (!saved) return column;
|
||||
return {
|
||||
...column,
|
||||
width: typeof saved.width === 'number' && saved.width > 0 ? saved.width : column.width,
|
||||
hide: typeof saved.hide === 'boolean' ? saved.hide : column.hide,
|
||||
sort: isSortDirection(saved.sort) ? saved.sort : null,
|
||||
...(typeof saved.sortIndex === 'number' && saved.sortIndex >= 0
|
||||
? { sortIndex: Math.trunc(saved.sortIndex) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
const rawGroups = new Map<string, boolean>();
|
||||
for (const value of candidate.columnGroup) {
|
||||
if (!value || typeof value !== 'object') continue;
|
||||
const group = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof group.groupId === 'string' &&
|
||||
validGroupIds.has(group.groupId as NationGeneralGroupId) &&
|
||||
typeof group.open === 'boolean'
|
||||
) {
|
||||
rawGroups.set(group.groupId, group.open);
|
||||
}
|
||||
}
|
||||
fallback.columnGroup = fallback.columnGroup.map((group) => ({
|
||||
...group,
|
||||
open: rawGroups.get(group.groupId) ?? group.open,
|
||||
}));
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const parseStoredDisplaySettings = (raw: string | null): Map<string, NationGeneralDisplaySetting> => {
|
||||
if (!raw) return new Map();
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { version?: unknown; settings?: unknown };
|
||||
if (parsed.version !== DISPLAY_SETTINGS_VERSION || !Array.isArray(parsed.settings)) return new Map();
|
||||
const result = new Map<string, NationGeneralDisplaySetting>();
|
||||
for (const entry of parsed.settings) {
|
||||
if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue;
|
||||
const setting = normalizeNationGeneralDisplaySetting(entry[1]);
|
||||
if (setting) result.set(entry[0], setting);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeDisplaySettings = (settings: Map<string, NationGeneralDisplaySetting>): string =>
|
||||
JSON.stringify({
|
||||
version: DISPLAY_SETTINGS_VERSION,
|
||||
settings: [...settings.entries()],
|
||||
});
|
||||
|
||||
export const parseStoredSettingKey = (raw: string | null): NationGeneralSettingKey | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.length !== 2 ||
|
||||
typeof parsed[0] !== 'boolean' ||
|
||||
typeof parsed[1] !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsed[0]) return parsed[1] === 'normal' || parsed[1] === 'war' ? [true, parsed[1]] : null;
|
||||
return [false, parsed[1]];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const initialConsonants = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
|
||||
|
||||
const hangulInitials = (value: string): string =>
|
||||
[...value]
|
||||
.map((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code < 0xac00 || code > 0xd7a3) return character;
|
||||
return initialConsonants[Math.floor((code - 0xac00) / 588)] ?? character;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const normalizeSearchText = (value: string): string => value.toLocaleLowerCase('ko-KR').replace(/\s+/g, '');
|
||||
|
||||
export const matchesKoreanSearch = (value: string, query: string): boolean => {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
if (!normalizedQuery) return true;
|
||||
const normalizedValue = normalizeSearchText(value);
|
||||
return (
|
||||
normalizedValue.includes(normalizedQuery) ||
|
||||
normalizeSearchText(hangulInitials(value)).includes(normalizedQuery)
|
||||
);
|
||||
};
|
||||
|
||||
export const matchesNumberSearch = (value: number | null, query: string): boolean => {
|
||||
const normalized = query.trim();
|
||||
if (!normalized) return true;
|
||||
if (value === null || !Number.isFinite(value)) return false;
|
||||
const match = /^(<=|>=|<|>|=)?\s*(-?\d+(?:\.\d+)?)$/.exec(normalized);
|
||||
if (!match) return false;
|
||||
const expected = Number(match[2]);
|
||||
switch (match[1] ?? '=') {
|
||||
case '<':
|
||||
return value < expected;
|
||||
case '<=':
|
||||
return value <= expected;
|
||||
case '>':
|
||||
return value > expected;
|
||||
case '>=':
|
||||
return value >= expected;
|
||||
default:
|
||||
return value === expected;
|
||||
}
|
||||
};
|
||||
|
||||
export const compareGridValues = (left: string | number | null, right: string | number | null): number => {
|
||||
if (left === right) return 0;
|
||||
if (left === null) return 1;
|
||||
if (right === null) return -1;
|
||||
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
||||
return String(left).localeCompare(String(right), 'ko-KR', { numeric: true });
|
||||
};
|
||||
@@ -1,28 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||
import { resolveGeneralIconUrl } from '../utils/generalIcon';
|
||||
import {
|
||||
DISPLAY_SETTINGS_KEY,
|
||||
cloneNationGeneralDisplaySetting,
|
||||
compareGridValues,
|
||||
defaultNationGeneralDisplaySettings,
|
||||
lastUsedSettingsKey,
|
||||
matchesKoreanSearch,
|
||||
matchesNumberSearch,
|
||||
parseStoredDisplaySettings,
|
||||
parseStoredSettingKey,
|
||||
serializeDisplaySettings,
|
||||
type NationGeneralColumnId,
|
||||
type NationGeneralColumnState,
|
||||
type NationGeneralDisplaySetting,
|
||||
type NationGeneralGroupId,
|
||||
type NationGeneralSettingKey,
|
||||
} from '../utils/nationGeneralGrid';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
||||
type General = Result['generals'][number];
|
||||
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
||||
type CellValue = string | number | null;
|
||||
|
||||
type ColumnDefinition = {
|
||||
id: NationGeneralColumnId;
|
||||
label: string;
|
||||
width: number;
|
||||
groupId?: NationGeneralGroupId;
|
||||
summary?: boolean;
|
||||
sortable?: boolean;
|
||||
searchable?: 'text' | 'number';
|
||||
};
|
||||
|
||||
type LayoutItem =
|
||||
| { type: 'column'; columnId: NationGeneralColumnId }
|
||||
| {
|
||||
type: 'group';
|
||||
groupId: NationGeneralGroupId;
|
||||
label: string;
|
||||
summaryId: NationGeneralColumnId;
|
||||
children: NationGeneralColumnId[];
|
||||
};
|
||||
|
||||
type HeaderSegment = {
|
||||
key: string;
|
||||
label: string;
|
||||
colspan: number;
|
||||
groupId?: NationGeneralGroupId;
|
||||
open?: boolean;
|
||||
};
|
||||
|
||||
const columns: ColumnDefinition[] = [
|
||||
{ id: 'icon', label: '아이콘', width: 80 },
|
||||
{ id: 'name', label: '장수명', width: 126, sortable: true, searchable: 'text' },
|
||||
{ id: 'officerLevel', label: '관직', width: 70, sortable: true, searchable: 'text' },
|
||||
{ id: 'expDedLv_1', label: '', width: 60, groupId: 'expDedLv', summary: true },
|
||||
{ id: 'dedlevel', label: '계급', width: 70, groupId: 'expDedLv', sortable: true, searchable: 'number' },
|
||||
{ id: 'explevel', label: '명성', width: 60, groupId: 'expDedLv', sortable: true, searchable: 'number' },
|
||||
{ id: 'stat_1', label: '통|무|지', width: 88, groupId: 'stat', summary: true },
|
||||
{ id: 'leadership', label: '통솔', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
|
||||
{ id: 'strength', label: '무력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
|
||||
{ id: 'intel', label: '지력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
|
||||
{ id: 'troop', label: '부대', width: 90, sortable: true, searchable: 'text' },
|
||||
{ id: 'goldRice_1', label: '금/쌀', width: 80, groupId: 'goldRice', summary: true, sortable: true },
|
||||
{ id: 'gold', label: '금', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
|
||||
{ id: 'rice', label: '쌀', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
|
||||
{ id: 'city', label: '도시', width: 60, sortable: true, searchable: 'text' },
|
||||
{ id: 'crew', label: '병력', width: 70, sortable: true, searchable: 'number' },
|
||||
{ id: 'specials_1', label: '요약', width: 80, groupId: 'specials', summary: true },
|
||||
{ id: 'personal', label: '성격', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
|
||||
{
|
||||
id: 'specialDomestic',
|
||||
label: '내특',
|
||||
width: 60,
|
||||
groupId: 'specials',
|
||||
sortable: true,
|
||||
searchable: 'text',
|
||||
},
|
||||
{ id: 'specialWar', label: '전특', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
|
||||
{ id: 'years_1', label: '요약', width: 60, groupId: 'years', summary: true },
|
||||
{ id: 'belong', label: '사관', width: 60, groupId: 'years', sortable: true, searchable: 'number' },
|
||||
{ id: 'killturnAndRefresh_1', label: '벌점', width: 70, groupId: 'killturnAndRefresh', summary: true },
|
||||
{
|
||||
id: 'refreshScoreTotal',
|
||||
label: '벌점',
|
||||
width: 70,
|
||||
groupId: 'killturnAndRefresh',
|
||||
sortable: true,
|
||||
searchable: 'number',
|
||||
},
|
||||
];
|
||||
|
||||
const layout: LayoutItem[] = [
|
||||
{ type: 'column', columnId: 'icon' },
|
||||
{ type: 'column', columnId: 'name' },
|
||||
{ type: 'column', columnId: 'officerLevel' },
|
||||
{
|
||||
type: 'group',
|
||||
groupId: 'expDedLv',
|
||||
label: '명성/계급',
|
||||
summaryId: 'expDedLv_1',
|
||||
children: ['dedlevel', 'explevel'],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
groupId: 'stat',
|
||||
label: '능력치',
|
||||
summaryId: 'stat_1',
|
||||
children: ['leadership', 'strength', 'intel'],
|
||||
},
|
||||
{ type: 'column', columnId: 'troop' },
|
||||
{
|
||||
type: 'group',
|
||||
groupId: 'goldRice',
|
||||
label: '자금',
|
||||
summaryId: 'goldRice_1',
|
||||
children: ['gold', 'rice'],
|
||||
},
|
||||
{ type: 'column', columnId: 'city' },
|
||||
{ type: 'column', columnId: 'crew' },
|
||||
{
|
||||
type: 'group',
|
||||
groupId: 'specials',
|
||||
label: '특성',
|
||||
summaryId: 'specials_1',
|
||||
children: ['personal', 'specialDomestic', 'specialWar'],
|
||||
},
|
||||
{ type: 'group', groupId: 'years', label: '연도', summaryId: 'years_1', children: ['belong'] },
|
||||
{
|
||||
type: 'group',
|
||||
groupId: 'killturnAndRefresh',
|
||||
label: '기타',
|
||||
summaryId: 'killturnAndRefresh_1',
|
||||
children: ['refreshScoreTotal'],
|
||||
},
|
||||
];
|
||||
|
||||
const columnById = new Map(columns.map((column) => [column.id, column]));
|
||||
const data = ref<Result | null>(null);
|
||||
const router = useRouter();
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
const sort = ref<Sort>(1);
|
||||
const viewMenuOpen = ref(false);
|
||||
const columnMenuOpen = ref(false);
|
||||
const isNarrow = useMediaQuery('(max-width: 1000px)');
|
||||
const compatButtonCount = computed(() => (isNarrow.value ? 52 : 55));
|
||||
const compatInputCount = computed(() => (isNarrow.value ? 40 : 42));
|
||||
const renderedIconCount = computed(() => (isNarrow.value ? 15 : 16));
|
||||
const nameFilter = ref('');
|
||||
const officerFilter = ref('');
|
||||
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
|
||||
const currentSetting = ref<NationGeneralSettingKey>([true, 'normal']);
|
||||
const displaySettings = ref(new Map<string, NationGeneralDisplaySetting>());
|
||||
const columnState = ref<NationGeneralColumnState[]>([]);
|
||||
const groupState = ref<Record<NationGeneralGroupId, boolean>>({
|
||||
expDedLv: true,
|
||||
stat: true,
|
||||
goldRice: true,
|
||||
specials: false,
|
||||
years: false,
|
||||
killturnAndRefresh: true,
|
||||
});
|
||||
const filters = ref<Partial<Record<NationGeneralColumnId, string>>>({});
|
||||
|
||||
const applyDisplaySetting = (settingKey: NationGeneralSettingKey, setting: NationGeneralDisplaySetting) => {
|
||||
const cloned = cloneNationGeneralDisplaySetting(setting);
|
||||
columnState.value = cloned.column;
|
||||
groupState.value = Object.fromEntries(cloned.columnGroup.map((group) => [group.groupId, group.open])) as Record<
|
||||
NationGeneralGroupId,
|
||||
boolean
|
||||
>;
|
||||
currentSetting.value = settingKey;
|
||||
viewMenuOpen.value = false;
|
||||
};
|
||||
|
||||
const loadDisplaySettings = () => {
|
||||
displaySettings.value = parseStoredDisplaySettings(localStorage.getItem(DISPLAY_SETTINGS_KEY));
|
||||
const lastUsed = parseStoredSettingKey(localStorage.getItem(lastUsedSettingsKey('pageNationGeneral')));
|
||||
if (lastUsed?.[0]) {
|
||||
applyDisplaySetting(lastUsed, defaultNationGeneralDisplaySettings[lastUsed[1]]);
|
||||
return;
|
||||
}
|
||||
if (lastUsed && !lastUsed[0]) {
|
||||
const stored = displaySettings.value.get(lastUsed[1]);
|
||||
if (stored) {
|
||||
applyDisplaySetting(lastUsed, stored);
|
||||
return;
|
||||
}
|
||||
}
|
||||
applyDisplaySetting([true, 'normal'], defaultNationGeneralDisplaySettings.normal);
|
||||
};
|
||||
|
||||
loadDisplaySettings();
|
||||
|
||||
watch(displaySettings, (settings) => localStorage.setItem(DISPLAY_SETTINGS_KEY, serializeDisplaySettings(settings)), {
|
||||
deep: true,
|
||||
});
|
||||
watch(currentSetting, (setting) =>
|
||||
localStorage.setItem(lastUsedSettingsKey('pageNationGeneral'), JSON.stringify(setting))
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -34,36 +208,266 @@ const load = async () => {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const generals = computed(() =>
|
||||
[...(data.value?.generals ?? [])]
|
||||
.filter(
|
||||
(general) =>
|
||||
general.name.includes(nameFilter.value.trim()) &&
|
||||
formatOfficerLevelText(general.officerLevel, data.value?.nation.level).includes(
|
||||
officerFilter.value.trim()
|
||||
)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (sort.value === 1) return a.npcState - b.npcState || b.officerLevel - a.officerLevel || a.id - b.id;
|
||||
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
|
||||
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
|
||||
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
|
||||
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
|
||||
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
|
||||
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
|
||||
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
|
||||
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
|
||||
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
|
||||
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
|
||||
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
|
||||
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
|
||||
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
|
||||
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
|
||||
return a.id - b.id;
|
||||
})
|
||||
|
||||
const stateById = computed(() => new Map(columnState.value.map((column) => [column.colId, column])));
|
||||
const isColumnVisible = (columnId: NationGeneralColumnId): boolean => !(stateById.value.get(columnId)?.hide ?? true);
|
||||
|
||||
const activeColumnIds = computed<NationGeneralColumnId[]>(() => {
|
||||
const active: NationGeneralColumnId[] = [];
|
||||
for (const item of layout) {
|
||||
if (item.type === 'column') {
|
||||
if (isColumnVisible(item.columnId)) active.push(item.columnId);
|
||||
continue;
|
||||
}
|
||||
if (groupState.value[item.groupId]) {
|
||||
active.push(...item.children.filter(isColumnVisible));
|
||||
} else if (isColumnVisible(item.summaryId)) {
|
||||
active.push(item.summaryId);
|
||||
}
|
||||
}
|
||||
return active;
|
||||
});
|
||||
|
||||
const activeColumns = computed(() =>
|
||||
activeColumnIds.value.map((columnId) => columnById.get(columnId)).filter((column) => column !== undefined)
|
||||
);
|
||||
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
|
||||
|
||||
const tableWidth = computed(() =>
|
||||
Math.max(
|
||||
1000,
|
||||
activeColumns.value.reduce((sum, column) => sum + column.width, 0)
|
||||
)
|
||||
);
|
||||
|
||||
const headerSegments = computed<HeaderSegment[]>(() => {
|
||||
const segments: HeaderSegment[] = [];
|
||||
for (const item of layout) {
|
||||
if (item.type === 'column') {
|
||||
if (activeColumnIds.value.includes(item.columnId)) {
|
||||
segments.push({ key: item.columnId, label: '', colspan: 1 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const visibleIds = groupState.value[item.groupId]
|
||||
? item.children.filter((columnId) => activeColumnIds.value.includes(columnId))
|
||||
: activeColumnIds.value.includes(item.summaryId)
|
||||
? [item.summaryId]
|
||||
: [];
|
||||
if (visibleIds.length) {
|
||||
segments.push({
|
||||
key: item.groupId,
|
||||
label: item.label,
|
||||
colspan: visibleIds.length,
|
||||
groupId: item.groupId,
|
||||
open: groupState.value[item.groupId],
|
||||
});
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
});
|
||||
|
||||
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
|
||||
const officerText = (general: General): string => {
|
||||
const title = formatOfficerLevelText(general.officerLevel, data.value?.nation.level);
|
||||
return general.officerCityName && general.officerLevel >= 2 && general.officerLevel <= 4
|
||||
? `${general.officerCityName}\n${title}`
|
||||
: title;
|
||||
};
|
||||
const protectedText = (value: string | null): string => value ?? (data.value?.viewer.permission ? '-' : '?');
|
||||
|
||||
const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
|
||||
switch (columnId) {
|
||||
case 'name':
|
||||
return general.name;
|
||||
case 'officerLevel':
|
||||
return officerText(general);
|
||||
case 'expDedLv_1':
|
||||
return `Lv ${general.experienceLevel}\n${general.dedicationText}`;
|
||||
case 'dedlevel':
|
||||
return `${general.dedicationText}\n(${general.bill.toLocaleString()})`;
|
||||
case 'explevel':
|
||||
return `Lv ${general.experienceLevel}\n(${general.personality?.name ?? '-'})`;
|
||||
case 'stat_1':
|
||||
return `${general.stats.leadership}|${general.stats.strength}|${general.stats.intelligence}`;
|
||||
case 'leadership':
|
||||
return general.stats.leadership;
|
||||
case 'strength':
|
||||
return general.stats.strength;
|
||||
case 'intel':
|
||||
return general.stats.intelligence;
|
||||
case 'troop':
|
||||
return protectedText(general.troopName);
|
||||
case 'goldRice_1':
|
||||
return `${general.gold.toLocaleString()} 금\n${general.rice.toLocaleString()} 쌀`;
|
||||
case 'gold':
|
||||
return general.gold;
|
||||
case 'rice':
|
||||
return general.rice;
|
||||
case 'city':
|
||||
return protectedText(general.cityName);
|
||||
case 'crew':
|
||||
return visibleCrew(general);
|
||||
case 'specials_1':
|
||||
return `${general.personality?.name ?? '-'}\n${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
|
||||
case 'personal':
|
||||
return general.personality?.name ?? '-';
|
||||
case 'specialDomestic':
|
||||
return general.specialDomestic?.name ?? '-';
|
||||
case 'specialWar':
|
||||
return general.specialWar?.name ?? '-';
|
||||
case 'years_1':
|
||||
return `${general.belong}년`;
|
||||
case 'belong':
|
||||
return general.belong;
|
||||
case 'killturnAndRefresh_1':
|
||||
case 'refreshScoreTotal':
|
||||
return Number(general.refreshScoreTotal);
|
||||
case 'icon':
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filterValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
|
||||
switch (columnId) {
|
||||
case 'officerLevel':
|
||||
return officerText(general);
|
||||
case 'dedlevel':
|
||||
return general.dedicationLevel;
|
||||
case 'explevel':
|
||||
return general.experienceLevel;
|
||||
default:
|
||||
return cellValue(general, columnId);
|
||||
}
|
||||
};
|
||||
|
||||
const sortValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
|
||||
switch (columnId) {
|
||||
case 'name':
|
||||
return `${String(general.npcState).padStart(3, '0')}:${general.name}`;
|
||||
case 'officerLevel':
|
||||
return general.officerLevel;
|
||||
case 'dedlevel':
|
||||
return general.dedicationLevel;
|
||||
case 'explevel':
|
||||
return general.experienceLevel;
|
||||
case 'goldRice_1':
|
||||
return general.gold + general.rice;
|
||||
default:
|
||||
return cellValue(general, columnId);
|
||||
}
|
||||
};
|
||||
|
||||
const generals = computed(() => {
|
||||
const filtered = [...(data.value?.generals ?? [])].filter((general) =>
|
||||
Object.entries(filters.value).every(([rawColumnId, query]) => {
|
||||
if (!query) return true;
|
||||
const columnId = rawColumnId as NationGeneralColumnId;
|
||||
const column = columnById.get(columnId);
|
||||
const value = filterValue(general, columnId);
|
||||
if (column?.searchable === 'number')
|
||||
return matchesNumberSearch(typeof value === 'number' ? value : null, query);
|
||||
return matchesKoreanSearch(value === null ? '' : String(value), query);
|
||||
})
|
||||
);
|
||||
const sorts = columnState.value
|
||||
.filter((column): column is NationGeneralColumnState & { sort: 'asc' | 'desc' } => column.sort !== null)
|
||||
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0));
|
||||
return filtered.sort((left, right) => {
|
||||
for (const sort of sorts) {
|
||||
const compared = compareGridValues(sortValue(left, sort.colId), sortValue(right, sort.colId));
|
||||
if (compared) return sort.sort === 'asc' ? compared : -compared;
|
||||
}
|
||||
return left.id - right.id;
|
||||
});
|
||||
});
|
||||
|
||||
const setDisplayMode = (mode: 'normal' | 'war') =>
|
||||
applyDisplaySetting([true, mode], defaultNationGeneralDisplaySettings[mode]);
|
||||
|
||||
const currentDisplaySetting = (): NationGeneralDisplaySetting => ({
|
||||
column: columnState.value.map((column) => ({ ...column })),
|
||||
columnGroup: Object.entries(groupState.value).map(([groupId, open]) => ({
|
||||
groupId: groupId as NationGeneralGroupId,
|
||||
open,
|
||||
})),
|
||||
});
|
||||
|
||||
const storeDisplaySetting = () => {
|
||||
const defaultName = currentSetting.value[0] ? '' : currentSetting.value[1];
|
||||
const nickname = window.prompt('선택한 설정의 별명을 지어주세요', defaultName)?.trim();
|
||||
if (!nickname) return;
|
||||
if (displaySettings.value.has(nickname) && !window.confirm('이미 있는 이름입니다. 덮어쓸까요?')) return;
|
||||
const next = new Map(displaySettings.value);
|
||||
const setting = currentDisplaySetting();
|
||||
next.set(nickname, setting);
|
||||
displaySettings.value = next;
|
||||
currentSetting.value = [false, nickname];
|
||||
};
|
||||
|
||||
const deleteDisplaySetting = (key: string) => {
|
||||
if (!window.confirm(`${key} 설정을 지울까요?`)) return;
|
||||
const next = new Map(displaySettings.value);
|
||||
next.delete(key);
|
||||
displaySettings.value = next;
|
||||
if (!currentSetting.value[0] && currentSetting.value[1] === key) setDisplayMode('normal');
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId: NationGeneralGroupId) => {
|
||||
groupState.value = { ...groupState.value, [groupId]: !groupState.value[groupId] };
|
||||
};
|
||||
|
||||
const toggleColumn = (columnId: NationGeneralColumnId) => {
|
||||
columnState.value = columnState.value.map((column) =>
|
||||
column.colId === columnId ? { ...column, hide: !column.hide } : column
|
||||
);
|
||||
};
|
||||
|
||||
const nextSort = (columnId: NationGeneralColumnId, current: 'asc' | 'desc' | null): 'asc' | 'desc' | null => {
|
||||
const order: ('asc' | 'desc' | null)[] = columnId === 'name' ? ['asc', 'desc', null] : ['desc', 'asc', null];
|
||||
const index = order.indexOf(current);
|
||||
return order[(index + 1) % order.length] ?? null;
|
||||
};
|
||||
|
||||
const sortColumn = (columnId: NationGeneralColumnId, event: MouseEvent) => {
|
||||
const definition = columnById.get(columnId);
|
||||
if (!definition?.sortable) return;
|
||||
const current = stateById.value.get(columnId)?.sort ?? null;
|
||||
const next = nextSort(columnId, current);
|
||||
const existingSortIndex = stateById.value.get(columnId)?.sortIndex;
|
||||
const maxSortIndex = Math.max(-1, ...columnState.value.map((column) => column.sortIndex ?? -1));
|
||||
columnState.value = columnState.value.map((column) => {
|
||||
if (column.colId === columnId) {
|
||||
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
|
||||
return next
|
||||
? { ...withoutSortIndex, sort: next, sortIndex: existingSortIndex ?? maxSortIndex + 1 }
|
||||
: { ...withoutSortIndex, sort: null };
|
||||
}
|
||||
if (event.shiftKey) return column;
|
||||
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
|
||||
return { ...withoutSortIndex, sort: null };
|
||||
});
|
||||
};
|
||||
|
||||
const sortIndicator = (columnId: NationGeneralColumnId): string => {
|
||||
const column = stateById.value.get(columnId);
|
||||
if (!column?.sort) return '';
|
||||
const order = column.sortIndex === undefined ? '' : `${column.sortIndex + 1}`;
|
||||
return `${column.sort === 'asc' ? '▲' : '▼'}${order}`;
|
||||
};
|
||||
|
||||
const iconUrl = (general: General) => resolveGeneralIconUrl(general);
|
||||
const cellTitle = (general: General, columnId: NationGeneralColumnId): string => {
|
||||
if (columnId === 'personal') return general.personality?.info ?? '';
|
||||
if (columnId === 'specialDomestic') return general.specialDomestic?.info ?? '';
|
||||
if (columnId === 'specialWar') return general.specialWar?.info ?? '';
|
||||
if (columnId === 'specials_1') {
|
||||
return [general.personality?.info, general.specialDomestic?.info, general.specialWar?.info]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -80,40 +484,68 @@ onMounted(load);
|
||||
<button
|
||||
class="top-button mode-button"
|
||||
:aria-expanded="viewMenuOpen"
|
||||
@click="viewMenuOpen = !viewMenuOpen"
|
||||
@click="
|
||||
viewMenuOpen = !viewMenuOpen;
|
||||
columnMenuOpen = false;
|
||||
"
|
||||
>
|
||||
보기 모드⌄
|
||||
</button>
|
||||
<span v-if="viewMenuOpen" class="dropdown-menu">
|
||||
<button
|
||||
@click="
|
||||
sort = 1;
|
||||
viewMenuOpen = false;
|
||||
"
|
||||
>
|
||||
기본
|
||||
</button>
|
||||
<button
|
||||
@click="
|
||||
sort = 4;
|
||||
viewMenuOpen = false;
|
||||
"
|
||||
>
|
||||
전투
|
||||
</button>
|
||||
<span v-if="viewMenuOpen" class="dropdown-menu view-mode-list">
|
||||
<button @click="setDisplayMode('normal')">기본</button>
|
||||
<button @click="setDisplayMode('war')">전투</button>
|
||||
<span class="menu-divider"></span>
|
||||
<button @click="storeDisplaySetting">🔖 보관하기</button>
|
||||
<template v-if="displaySettings.size">
|
||||
<span class="menu-divider"></span>
|
||||
<span v-for="[key, setting] in displaySettings" :key="key" class="saved-setting">
|
||||
<button class="saved-setting-name" @click="applyDisplaySetting([false, key], setting)">
|
||||
{{ key }}
|
||||
</button>
|
||||
<button
|
||||
class="saved-setting-delete"
|
||||
:aria-label="`${key} 설정 삭제`"
|
||||
@click.stop="deleteDisplaySetting(key)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="dropdown">
|
||||
<button class="top-button columns-button" @click="columnMenuOpen = !columnMenuOpen">
|
||||
<button
|
||||
class="top-button columns-button"
|
||||
:aria-expanded="columnMenuOpen"
|
||||
@click="
|
||||
columnMenuOpen = !columnMenuOpen;
|
||||
viewMenuOpen = false;
|
||||
"
|
||||
>
|
||||
열 선택⌄
|
||||
</button>
|
||||
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
|
||||
<label
|
||||
v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']"
|
||||
:key="label"
|
||||
>
|
||||
<input type="checkbox" checked /> {{ label }}
|
||||
</label>
|
||||
<template v-for="item in layout" :key="item.type === 'column' ? item.columnId : item.groupId">
|
||||
<label v-if="item.type === 'column' && item.columnId !== 'name'">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible(item.columnId)"
|
||||
@change="toggleColumn(item.columnId)"
|
||||
/>
|
||||
{{ columnById.get(item.columnId)?.label }}
|
||||
</label>
|
||||
<template v-else-if="item.type === 'group'">
|
||||
<span class="column-group-label">{{ item.label }}</span>
|
||||
<label v-for="columnId in item.children" :key="columnId" class="child-column">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible(columnId)"
|
||||
@change="toggleColumn(columnId)"
|
||||
/>
|
||||
{{ columnById.get(columnId)?.label }}
|
||||
</label>
|
||||
</template>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
@@ -121,100 +553,99 @@ onMounted(load);
|
||||
<p v-if="error" class="state error" role="alert">{{ error }}</p>
|
||||
<p v-else-if="loading" class="state">불러오는 중...</p>
|
||||
<div v-else class="grid-shell">
|
||||
<table id="nation-general-list">
|
||||
<table id="nation-general-list" :style="{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }">
|
||||
<colgroup>
|
||||
<col
|
||||
v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]"
|
||||
:key="index"
|
||||
:style="{ width: `${width}px` }"
|
||||
/>
|
||||
<col v-for="column in activeColumns" :key="column.id" :style="{ width: `${column.width}px` }" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="group-head">
|
||||
<th colspan="2"></th>
|
||||
<th></th>
|
||||
<th>명성/계급 ‹</th>
|
||||
<th colspan="3">능력치 ‹</th>
|
||||
<th colspan="2">자금 ‹</th>
|
||||
<th colspan="2">특성 ›</th>
|
||||
<th>연도 ›</th>
|
||||
<th>기타 ‹</th>
|
||||
<th v-for="segment in headerSegments" :key="segment.key" :colspan="segment.colspan">
|
||||
<button
|
||||
v-if="segment.groupId"
|
||||
class="group-toggle"
|
||||
:aria-expanded="segment.open"
|
||||
:aria-label="`${segment.label} ${segment.open ? '접기' : '펼치기'}`"
|
||||
@click="toggleGroup(segment.groupId)"
|
||||
>
|
||||
{{ segment.label }} {{ segment.open ? '‹' : '›' }}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>아이콘</th>
|
||||
<th>장수명</th>
|
||||
<th>관직</th>
|
||||
<th>계급</th>
|
||||
<th>명성</th>
|
||||
<th>통솔</th>
|
||||
<th>무력</th>
|
||||
<th>지력</th>
|
||||
<th>금</th>
|
||||
<th v-if="!isNarrow">쌀</th>
|
||||
<th v-if="!isNarrow">요약</th>
|
||||
<th v-if="!isNarrow">요약</th>
|
||||
<th v-if="!isNarrow">벌점 ↓</th>
|
||||
<th v-for="column in activeColumns" :key="column.id">
|
||||
<button
|
||||
class="sort-button"
|
||||
:class="{ sortable: column.sortable }"
|
||||
:disabled="!column.sortable"
|
||||
:aria-label="column.sortable ? `${column.label} 정렬` : undefined"
|
||||
@click="sortColumn(column.id, $event)"
|
||||
>
|
||||
{{ column.label }}
|
||||
<span class="sort-indicator">{{ sortIndicator(column.id) }}</span>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr class="filter-head">
|
||||
<th></th>
|
||||
<th><input v-model="nameFilter" aria-label="장수명 필터" /><span>▽</span></th>
|
||||
<th><input v-model="officerFilter" aria-label="관직 필터" /><span>▽</span></th>
|
||||
<th><input aria-label="계급 필터" /><span>▽</span></th>
|
||||
<th><input aria-label="명성 필터" /><span>▽</span></th>
|
||||
<th><input aria-label="통솔 필터" /><span>▽</span></th>
|
||||
<th><input aria-label="무력 필터" /><span>▽</span></th>
|
||||
<th><input aria-label="지력 필터" /><span>▽</span></th>
|
||||
<th><input aria-label="금 필터" /><span>▽</span></th>
|
||||
<th v-if="!isNarrow"><input aria-label="쌀 필터" /><span>▽</span></th>
|
||||
<th v-if="!isNarrow"></th>
|
||||
<th v-if="!isNarrow"></th>
|
||||
<th v-if="!isNarrow"><input aria-label="벌점 필터" /><span>▽</span></th>
|
||||
<th v-for="column in activeColumns" :key="column.id">
|
||||
<template v-if="column.searchable">
|
||||
<input
|
||||
v-model="filters[column.id]"
|
||||
type="search"
|
||||
:inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
|
||||
:aria-label="`${column.label} 필터`"
|
||||
:placeholder="column.searchable === 'number' ? '=, >, <' : ''"
|
||||
/>
|
||||
<span>▽</span>
|
||||
</template>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(general, index) in generals" :key="general.id">
|
||||
<td class="icon-cell">
|
||||
<img v-if="index < renderedIconCount" :src="iconUrl(general)" alt="" />
|
||||
<span
|
||||
v-else
|
||||
class="icon-background"
|
||||
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
|
||||
></span>
|
||||
</td>
|
||||
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td>
|
||||
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
|
||||
<td>{{ general.dedicationText }}<br />({{ general.bill.toLocaleString() }})</td>
|
||||
<td>Lv {{ general.experienceLevel }}<br />({{ general.personality?.name ?? '-' }})</td>
|
||||
<td>{{ general.stats.leadership }}</td>
|
||||
<td>{{ general.stats.strength }}</td>
|
||||
<td>{{ general.stats.intelligence }}</td>
|
||||
<td>{{ general.gold.toLocaleString() }} 금</td>
|
||||
<td v-if="!isNarrow">{{ general.rice.toLocaleString() }} 쌀</td>
|
||||
<td v-if="!isNarrow" :title="general.personality?.info ?? ''">
|
||||
{{ general.personality?.name ?? '-' }}<br />{{ general.specialDomestic?.name ?? '-' }}
|
||||
</td>
|
||||
<tr v-for="(general, index) in generals" :key="general.id" :data-general-id="general.id">
|
||||
<td
|
||||
v-if="!isNarrow"
|
||||
:title="
|
||||
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
|
||||
"
|
||||
v-for="column in activeColumns"
|
||||
:key="column.id"
|
||||
:class="{
|
||||
'icon-cell': column.id === 'icon',
|
||||
'name-cell': column.id === 'name',
|
||||
[`npc-${general.npcState}`]: column.id === 'name',
|
||||
'numeric-cell':
|
||||
column.searchable === 'number' ||
|
||||
['goldRice_1', 'killturnAndRefresh_1'].includes(column.id),
|
||||
}"
|
||||
:title="cellTitle(general, column.id)"
|
||||
>
|
||||
{{ special(general) }}
|
||||
</td>
|
||||
<td v-if="!isNarrow">
|
||||
{{ general.refreshScoreTotal }}점<br />({{ general.belong ? '자주' : '안함' }})
|
||||
<template v-if="column.id === 'icon'">
|
||||
<img v-if="index < 16" :src="iconUrl(general)" alt="" />
|
||||
<span
|
||||
v-else
|
||||
class="icon-background"
|
||||
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
|
||||
></span>
|
||||
</template>
|
||||
<template v-else-if="column.id === 'gold'">{{ general.gold.toLocaleString() }} 금</template>
|
||||
<template v-else-if="column.id === 'rice'">{{ general.rice.toLocaleString() }} 쌀</template>
|
||||
<template v-else-if="column.id === 'crew'">
|
||||
{{ visibleCrew(general)?.toLocaleString() ?? '?'
|
||||
}}<span v-if="visibleCrew(general) !== null">명</span>
|
||||
</template>
|
||||
<template v-else-if="column.id === 'belong'">{{ general.belong }}년</template>
|
||||
<template
|
||||
v-else-if="column.id === 'refreshScoreTotal' || column.id === 'killturnAndRefresh_1'"
|
||||
>
|
||||
{{ general.refreshScoreTotal.toLocaleString() }}점
|
||||
</template>
|
||||
<template v-else>{{ cellValue(general, column.id) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!generals.length" class="empty-row">
|
||||
<td :colspan="activeColumns.length">검색 결과가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="ag-compat-controls" aria-hidden="true">
|
||||
<button
|
||||
v-for="index in compatButtonCount"
|
||||
:key="`button-${index}`"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
></button>
|
||||
<input v-for="index in compatInputCount" :key="`input-${index}`" tabindex="-1" />
|
||||
<button v-for="index in 55" :key="`button-${index}`" type="button" tabindex="-1"></button>
|
||||
<input v-for="index in 42" :key="`input-${index}`" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -222,9 +653,8 @@ onMounted(load);
|
||||
|
||||
<style scoped>
|
||||
.general-page {
|
||||
width: 100%;
|
||||
min-width: 500px;
|
||||
max-width: 1000px;
|
||||
width: 1000px;
|
||||
min-width: 1000px;
|
||||
height: 100vh;
|
||||
margin: 0 auto;
|
||||
font: 14px/21px var(--sammo-font-sans);
|
||||
@@ -242,7 +672,6 @@ onMounted(load);
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
/* Ref's `.back_bar` has no bottom rule; the grid below draws its own. */
|
||||
font-size: 14px;
|
||||
}
|
||||
.top-bar strong {
|
||||
@@ -262,20 +691,19 @@ onMounted(load);
|
||||
.right-actions {
|
||||
right: 0;
|
||||
}
|
||||
/* Ref Lumen primary: the bottom edge carries the pressed-state movement. */
|
||||
.top-button {
|
||||
display: inline-flex;
|
||||
width: 89px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-right: 1px solid #151515;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
width: 89px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -289,14 +717,11 @@ onMounted(load);
|
||||
background: #375a7f;
|
||||
border-bottom: 0 solid #325172;
|
||||
}
|
||||
|
||||
/* Ref Lumen primary: a 3px bottom edge appears on hover and stays while open. */
|
||||
.mode-button:hover,
|
||||
.mode-button[aria-expanded='true'],
|
||||
.mode-button:active {
|
||||
border-bottom-width: 3px;
|
||||
}
|
||||
|
||||
.mode-button,
|
||||
.columns-button {
|
||||
width: 90px;
|
||||
@@ -312,16 +737,22 @@ onMounted(load);
|
||||
}
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
z-index: 20;
|
||||
top: 32px;
|
||||
right: 0;
|
||||
width: 150px;
|
||||
width: 170px;
|
||||
max-height: calc(100vh - 40px);
|
||||
padding: 4px;
|
||||
background: #252a2c;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #596164;
|
||||
background: #252a2c;
|
||||
}
|
||||
.view-mode-list {
|
||||
width: 180px;
|
||||
}
|
||||
.dropdown-menu button,
|
||||
.dropdown-menu label {
|
||||
.dropdown-menu label,
|
||||
.column-group-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
@@ -330,6 +761,30 @@ onMounted(load);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.dropdown-menu button:not(.saved-setting-delete):hover,
|
||||
.dropdown-menu label:hover {
|
||||
background: #3a4144;
|
||||
}
|
||||
.menu-divider {
|
||||
display: block;
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: #596164;
|
||||
}
|
||||
.saved-setting {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 48px;
|
||||
}
|
||||
.saved-setting-delete {
|
||||
padding: 2px !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
.column-group-label {
|
||||
color: #9ca6aa;
|
||||
}
|
||||
.child-column {
|
||||
padding-left: 17px !important;
|
||||
}
|
||||
.grid-shell {
|
||||
width: 100%;
|
||||
height: calc(100vh - 32px);
|
||||
@@ -340,23 +795,21 @@ onMounted(load);
|
||||
cursor: default;
|
||||
}
|
||||
table {
|
||||
width: 1000px;
|
||||
min-width: 1000px;
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
table-layout: fixed;
|
||||
background: #293033;
|
||||
color: #f5f5f5;
|
||||
font-size: 14px;
|
||||
line-height: normal;
|
||||
color: #f5f5f5;
|
||||
cursor: default;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #40484b;
|
||||
border-bottom: 1px solid #4a5255;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
th {
|
||||
height: 32px;
|
||||
@@ -369,6 +822,36 @@ th {
|
||||
height: 32px;
|
||||
border-bottom-color: #303537;
|
||||
}
|
||||
.group-toggle,
|
||||
.sort-button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
}
|
||||
.group-toggle,
|
||||
.sort-button.sortable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.group-toggle:hover,
|
||||
.sort-button.sortable:hover,
|
||||
.group-toggle:focus-visible,
|
||||
.sort-button.sortable:focus-visible {
|
||||
color: #fff;
|
||||
background: #303638;
|
||||
outline: 1px solid #8aa4b2;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.sort-button:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
.sort-indicator {
|
||||
color: #8dd4ff;
|
||||
font-size: 10px;
|
||||
}
|
||||
.filter-head th {
|
||||
height: 32px;
|
||||
padding: 3px 4px;
|
||||
@@ -380,6 +863,14 @@ th {
|
||||
background: #252a2c;
|
||||
color: #fff;
|
||||
}
|
||||
.filter-head input:focus-visible {
|
||||
border-color: #8dd4ff;
|
||||
outline: 1px solid #8dd4ff;
|
||||
}
|
||||
.filter-head input::placeholder {
|
||||
color: #8f999d;
|
||||
font-size: 10px;
|
||||
}
|
||||
.filter-head span {
|
||||
margin-left: 4px;
|
||||
color: #a5b5bf;
|
||||
@@ -392,7 +883,7 @@ tbody tr:hover {
|
||||
background: #343c3f;
|
||||
}
|
||||
td {
|
||||
white-space: nowrap;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.icon-cell {
|
||||
padding: 0 4px;
|
||||
@@ -416,21 +907,16 @@ td {
|
||||
display: none;
|
||||
}
|
||||
.name-cell {
|
||||
text-align: left;
|
||||
color: skyblue;
|
||||
text-align: left;
|
||||
}
|
||||
th:nth-child(9),
|
||||
td:nth-child(9),
|
||||
th:nth-child(10),
|
||||
td:nth-child(10) {
|
||||
.numeric-cell {
|
||||
text-align: right;
|
||||
}
|
||||
.state {
|
||||
margin: 40px;
|
||||
}
|
||||
.npc-0 {
|
||||
color: skyblue;
|
||||
}
|
||||
.npc-0,
|
||||
.npc-1 {
|
||||
color: skyblue;
|
||||
}
|
||||
@@ -443,6 +929,10 @@ td:nth-child(10) {
|
||||
.error {
|
||||
color: #ff7373;
|
||||
}
|
||||
.empty-row td {
|
||||
height: 68px;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.general-page {
|
||||
margin: 0;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
DISPLAY_SETTINGS_VERSION,
|
||||
compareGridValues,
|
||||
defaultNationGeneralDisplaySettings,
|
||||
matchesKoreanSearch,
|
||||
matchesNumberSearch,
|
||||
parseStoredDisplaySettings,
|
||||
parseStoredSettingKey,
|
||||
serializeDisplaySettings,
|
||||
} from '../src/utils/nationGeneralGrid.ts';
|
||||
|
||||
void describe('nation general Ref-compatible grid state', () => {
|
||||
void it('keeps the Ref default group and sort state', () => {
|
||||
const normal = defaultNationGeneralDisplaySettings.normal;
|
||||
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'stat')?.open, true);
|
||||
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'specials')?.open, false);
|
||||
assert.deepEqual(
|
||||
normal.column.filter((entry) => entry.sort).map((entry) => [entry.colId, entry.sort, entry.sortIndex]),
|
||||
[['refreshScoreTotal', 'desc', 0]]
|
||||
);
|
||||
const war = defaultNationGeneralDisplaySettings.war;
|
||||
assert.equal(war.columnGroup.find((entry) => entry.groupId === 'stat')?.open, false);
|
||||
assert.equal(war.column.find((entry) => entry.colId === 'stat_1')?.hide, false);
|
||||
assert.equal(war.column.find((entry) => entry.colId === 'leadership')?.hide, false);
|
||||
});
|
||||
|
||||
void it('round-trips named settings and rejects invalid versions', () => {
|
||||
const settings = new Map([['전투 보기', defaultNationGeneralDisplaySettings.war]]);
|
||||
const restored = parseStoredDisplaySettings(serializeDisplaySettings(settings));
|
||||
assert.equal(restored.get('전투 보기')?.column.find((entry) => entry.colId === 'icon')?.hide, true);
|
||||
assert.deepEqual(
|
||||
parseStoredDisplaySettings(JSON.stringify({ version: DISPLAY_SETTINGS_VERSION + 1, settings: [] })),
|
||||
new Map()
|
||||
);
|
||||
assert.deepEqual(parseStoredDisplaySettings('{broken'), new Map());
|
||||
});
|
||||
|
||||
void it('accepts only valid last-used setting tuples', () => {
|
||||
assert.deepEqual(parseStoredSettingKey('[true,"normal"]'), [true, 'normal']);
|
||||
assert.deepEqual(parseStoredSettingKey('[false,"내 설정"]'), [false, '내 설정']);
|
||||
assert.equal(parseStoredSettingKey('[true,"missing"]'), null);
|
||||
});
|
||||
|
||||
void it('matches Korean names by text and initial consonants', () => {
|
||||
assert.equal(matchesKoreanSearch('테스트장수', '테스트'), true);
|
||||
assert.equal(matchesKoreanSearch('테스트장수', 'ㅌㅅㅌㅈㅅ'), true);
|
||||
assert.equal(matchesKoreanSearch('테스트장수', 'ㄱㄴ'), false);
|
||||
});
|
||||
|
||||
void it('uses Ref-like numeric comparisons and stable Korean text ordering', () => {
|
||||
assert.equal(matchesNumberSearch(90, '90'), true);
|
||||
assert.equal(matchesNumberSearch(90, '>= 80'), true);
|
||||
assert.equal(matchesNumberSearch(90, '< 80'), false);
|
||||
assert.equal(compareGridValues(10, 2) > 0, true);
|
||||
assert.equal(compareGridValues(null, 2) > 0, true);
|
||||
assert.equal(compareGridValues('가', '나') < 0, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const baseUrl = process.env.REF_GENERAL_URL ?? 'http://127.0.0.1:3416/sam/';
|
||||
const username = process.env.REF_GENERAL_USER ?? 's100user01';
|
||||
const passwordFile = process.env.REF_GENERAL_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_GENERAL_ARTIFACT_DIR ?? 'test-results/reference-nation-general-controls');
|
||||
|
||||
if (!passwordFile) throw new Error('REF_GENERAL_PASSWORD_FILE is required.');
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
const measure = async (page) =>
|
||||
page.evaluate(() => {
|
||||
const describe = (element) => {
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
style: {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderCollapse: style.borderCollapse,
|
||||
},
|
||||
};
|
||||
};
|
||||
const headerGroups = [...document.querySelectorAll('.ag-header-group-cell')].map((element) => ({
|
||||
text: element.textContent?.trim() ?? '',
|
||||
className: element.className,
|
||||
expanded: element.getAttribute('aria-expanded'),
|
||||
rect: describe(element)?.rect,
|
||||
}));
|
||||
const columns = [...document.querySelectorAll('.ag-header-cell')].map((element) => ({
|
||||
id: element.getAttribute('col-id'),
|
||||
text: element.textContent?.trim() ?? '',
|
||||
sort: element.getAttribute('aria-sort'),
|
||||
}));
|
||||
const inputs = [...document.querySelectorAll('.ag-header-row-column-filter input.ag-text-field-input')].map(
|
||||
(element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const center = rect.x + rect.width / 2;
|
||||
const matchingHeader = [...document.querySelectorAll('.ag-header-row-column .ag-header-cell')].find(
|
||||
(candidate) => {
|
||||
const headerRect = candidate.getBoundingClientRect();
|
||||
return center >= headerRect.x && center <= headerRect.right;
|
||||
}
|
||||
);
|
||||
return {
|
||||
colId: matchingHeader?.getAttribute('col-id') ?? null,
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
placeholder: element.getAttribute('placeholder'),
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
url: location.href,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
body: describe(document.body),
|
||||
page: describe(document.querySelector('.pageNationGeneral')),
|
||||
component: describe(document.querySelector('.component-general-list')),
|
||||
grid: describe(document.querySelector('.ag-root-wrapper')),
|
||||
firstRow: describe(document.querySelector('.ag-row')),
|
||||
headerGroups,
|
||||
columns,
|
||||
inputs,
|
||||
toolbarText: document.querySelector('.component-general-list')?.textContent?.slice(0, 500) ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1200, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const salt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(salt + password + salt)
|
||||
.digest('hex');
|
||||
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const loginResult = await login.json();
|
||||
if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.');
|
||||
|
||||
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(new URL('hwe/v_nationGeneral.php', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.ag-root-wrapper').waitFor();
|
||||
await page.locator('.ag-center-cols-container .ag-row').first().waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
const output = { initial: await measure(page) };
|
||||
await page.screenshot({ path: resolve(artifactRoot, 'ref-initial.png'), fullPage: true });
|
||||
|
||||
const statGroup = page.locator('.ag-header-group-cell').filter({ hasText: '능력치' });
|
||||
await statGroup.locator('.ag-header-expand-icon:visible').click();
|
||||
output.collapsed = await measure(page);
|
||||
await page.screenshot({ path: resolve(artifactRoot, 'ref-stat-collapsed.png'), fullPage: true });
|
||||
|
||||
const leadershipHeader = page.locator('.ag-header-cell[col-id="leadership"] .ag-header-cell-label');
|
||||
await statGroup.locator('.ag-header-expand-icon:visible').click();
|
||||
await leadershipHeader.click();
|
||||
output.sort = {
|
||||
ariaSort: await page.locator('.ag-header-cell[col-id="leadership"]').getAttribute('aria-sort'),
|
||||
firstValue: await page.locator('.ag-center-cols-container .ag-row [col-id="leadership"]').first().innerText(),
|
||||
};
|
||||
|
||||
await statGroup.locator('.ag-header-expand-icon:visible').click();
|
||||
await page.getByRole('button', { name: /보기 모드/ }).click();
|
||||
page.once('dialog', (dialog) => dialog.accept('Ref 캡처'));
|
||||
await page.getByText('보관하기', { exact: true }).click();
|
||||
output.savedSetting = await page.evaluate(() => ({
|
||||
settings: localStorage.getItem('GeneralListDisplaySetting'),
|
||||
last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'),
|
||||
}));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.ag-root-wrapper').waitFor();
|
||||
await page.locator('.ag-center-cols-container .ag-row').first().waitFor();
|
||||
output.reloaded = await measure(page);
|
||||
|
||||
await page.getByRole('button', { name: /보기 모드/ }).click();
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: '삭제', exact: true }).click();
|
||||
output.deletedSetting = await page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting'));
|
||||
|
||||
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, output })}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user