feat(game-frontend): restore nation general grid controls
This commit is contained in:
@@ -18,6 +18,8 @@ const general = {
|
|||||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
experienceLevel: 9,
|
experienceLevel: 9,
|
||||||
dedicationLevel: 1,
|
dedicationLevel: 1,
|
||||||
|
dedicationText: '30품관',
|
||||||
|
bill: 600,
|
||||||
injury: 0,
|
injury: 0,
|
||||||
gold: 1000,
|
gold: 1000,
|
||||||
rice: 2000,
|
rice: 2000,
|
||||||
@@ -28,6 +30,24 @@ const general = {
|
|||||||
refreshScoreTotal: 10,
|
refreshScoreTotal: 10,
|
||||||
permission: 'normal',
|
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) => {
|
const install = async (page: Page, secretAllowed = true) => {
|
||||||
await page.addInitScript((profile) => {
|
await page.addInitScript((profile) => {
|
||||||
localStorage.setItem('sammo-game-token', 'ga_general');
|
localStorage.setItem('sammo-game-token', 'ga_general');
|
||||||
@@ -42,7 +62,7 @@ const install = async (page: Page, secretAllowed = true) => {
|
|||||||
return response({
|
return response({
|
||||||
nation: { id: 1, name: '위', color: '#008000', level: 3 },
|
nation: { id: 1, name: '위', color: '#008000', level: 3 },
|
||||||
viewer: { generalId: 1, permission: 0 },
|
viewer: { generalId: 1, permission: 0 },
|
||||||
generals: [general],
|
generals: [general, otherGeneral],
|
||||||
});
|
});
|
||||||
if (operation === 'nation.getSecretGeneralList') {
|
if (operation === 'nation.getSecretGeneralList') {
|
||||||
if (!secretAllowed)
|
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.setViewportSize({ width: 1200, height: 900 });
|
||||||
await page.goto('nation/generals');
|
await page.goto('nation/generals');
|
||||||
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
|
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 computed = await page.locator('.general-page').evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
|
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
|
||||||
});
|
});
|
||||||
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
|
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '14px' });
|
||||||
expect(computed.fontFamily).toContain('Times New Roman');
|
expect(computed.fontFamily).toContain('Pretendard');
|
||||||
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
|
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
|
||||||
'separate'
|
'separate'
|
||||||
);
|
);
|
||||||
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
|
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1000);
|
||||||
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
|
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 }) => {
|
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">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
|
||||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||||
import { resolveGeneralIconUrl } from '../utils/generalIcon';
|
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';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
||||||
type General = Result['generals'][number];
|
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 data = ref<Result | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const sort = ref<Sort>(1);
|
|
||||||
const viewMenuOpen = ref(false);
|
const viewMenuOpen = ref(false);
|
||||||
const columnMenuOpen = ref(false);
|
const columnMenuOpen = ref(false);
|
||||||
const isNarrow = useMediaQuery('(max-width: 1000px)');
|
const currentSetting = ref<NationGeneralSettingKey>([true, 'normal']);
|
||||||
const compatButtonCount = computed(() => (isNarrow.value ? 52 : 55));
|
const displaySettings = ref(new Map<string, NationGeneralDisplaySetting>());
|
||||||
const compatInputCount = computed(() => (isNarrow.value ? 40 : 42));
|
const columnState = ref<NationGeneralColumnState[]>([]);
|
||||||
const renderedIconCount = computed(() => (isNarrow.value ? 15 : 16));
|
const groupState = ref<Record<NationGeneralGroupId, boolean>>({
|
||||||
const nameFilter = ref('');
|
expDedLv: true,
|
||||||
const officerFilter = ref('');
|
stat: true,
|
||||||
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
|
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 () => {
|
const load = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -34,36 +208,266 @@ const load = async () => {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const generals = computed(() =>
|
|
||||||
[...(data.value?.generals ?? [])]
|
const stateById = computed(() => new Map(columnState.value.map((column) => [column.colId, column])));
|
||||||
.filter(
|
const isColumnVisible = (columnId: NationGeneralColumnId): boolean => !(stateById.value.get(columnId)?.hide ?? true);
|
||||||
(general) =>
|
|
||||||
general.name.includes(nameFilter.value.trim()) &&
|
const activeColumnIds = computed<NationGeneralColumnId[]>(() => {
|
||||||
formatOfficerLevelText(general.officerLevel, data.value?.nation.level).includes(
|
const active: NationGeneralColumnId[] = [];
|
||||||
officerFilter.value.trim()
|
for (const item of layout) {
|
||||||
)
|
if (item.type === 'column') {
|
||||||
)
|
if (isColumnVisible(item.columnId)) active.push(item.columnId);
|
||||||
.sort((a, b) => {
|
continue;
|
||||||
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 (groupState.value[item.groupId]) {
|
||||||
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
|
active.push(...item.children.filter(isColumnVisible));
|
||||||
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
|
} else if (isColumnVisible(item.summaryId)) {
|
||||||
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
|
active.push(item.summaryId);
|
||||||
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;
|
return active;
|
||||||
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 ?? '');
|
const activeColumns = computed(() =>
|
||||||
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
|
activeColumnIds.value.map((columnId) => columnById.get(columnId)).filter((column) => column !== undefined)
|
||||||
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 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 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);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -80,40 +484,68 @@ onMounted(load);
|
|||||||
<button
|
<button
|
||||||
class="top-button mode-button"
|
class="top-button mode-button"
|
||||||
:aria-expanded="viewMenuOpen"
|
:aria-expanded="viewMenuOpen"
|
||||||
@click="viewMenuOpen = !viewMenuOpen"
|
@click="
|
||||||
|
viewMenuOpen = !viewMenuOpen;
|
||||||
|
columnMenuOpen = false;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
보기 모드⌄
|
보기 모드⌄
|
||||||
</button>
|
</button>
|
||||||
<span v-if="viewMenuOpen" class="dropdown-menu">
|
<span v-if="viewMenuOpen" class="dropdown-menu view-mode-list">
|
||||||
<button
|
<button @click="setDisplayMode('normal')">기본</button>
|
||||||
@click="
|
<button @click="setDisplayMode('war')">전투</button>
|
||||||
sort = 1;
|
<span class="menu-divider"></span>
|
||||||
viewMenuOpen = false;
|
<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>
|
<button class="saved-setting-name" @click="applyDisplaySetting([false, key], setting)">
|
||||||
<button
|
{{ key }}
|
||||||
@click="
|
</button>
|
||||||
sort = 4;
|
<button
|
||||||
viewMenuOpen = false;
|
class="saved-setting-delete"
|
||||||
"
|
:aria-label="`${key} 설정 삭제`"
|
||||||
>
|
@click.stop="deleteDisplaySetting(key)"
|
||||||
전투
|
>
|
||||||
</button>
|
삭제
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="dropdown">
|
<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>
|
</button>
|
||||||
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
|
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
|
||||||
<label
|
<template v-for="item in layout" :key="item.type === 'column' ? item.columnId : item.groupId">
|
||||||
v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']"
|
<label v-if="item.type === 'column' && item.columnId !== 'name'">
|
||||||
:key="label"
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
<input type="checkbox" checked /> {{ label }}
|
:checked="isColumnVisible(item.columnId)"
|
||||||
</label>
|
@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>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -121,100 +553,99 @@ onMounted(load);
|
|||||||
<p v-if="error" class="state error" role="alert">{{ error }}</p>
|
<p v-if="error" class="state error" role="alert">{{ error }}</p>
|
||||||
<p v-else-if="loading" class="state">불러오는 중...</p>
|
<p v-else-if="loading" class="state">불러오는 중...</p>
|
||||||
<div v-else class="grid-shell">
|
<div v-else class="grid-shell">
|
||||||
<table id="nation-general-list">
|
<table id="nation-general-list" :style="{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col
|
<col v-for="column in activeColumns" :key="column.id" :style="{ width: `${column.width}px` }" />
|
||||||
v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]"
|
|
||||||
:key="index"
|
|
||||||
:style="{ width: `${width}px` }"
|
|
||||||
/>
|
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="group-head">
|
<tr class="group-head">
|
||||||
<th colspan="2"></th>
|
<th v-for="segment in headerSegments" :key="segment.key" :colspan="segment.colspan">
|
||||||
<th></th>
|
<button
|
||||||
<th>명성/계급 ‹</th>
|
v-if="segment.groupId"
|
||||||
<th colspan="3">능력치 ‹</th>
|
class="group-toggle"
|
||||||
<th colspan="2">자금 ‹</th>
|
:aria-expanded="segment.open"
|
||||||
<th colspan="2">특성 ›</th>
|
:aria-label="`${segment.label} ${segment.open ? '접기' : '펼치기'}`"
|
||||||
<th>연도 ›</th>
|
@click="toggleGroup(segment.groupId)"
|
||||||
<th>기타 ‹</th>
|
>
|
||||||
|
{{ segment.label }} {{ segment.open ? '‹' : '›' }}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>아이콘</th>
|
<th v-for="column in activeColumns" :key="column.id">
|
||||||
<th>장수명</th>
|
<button
|
||||||
<th>관직</th>
|
class="sort-button"
|
||||||
<th>계급</th>
|
:class="{ sortable: column.sortable }"
|
||||||
<th>명성</th>
|
:disabled="!column.sortable"
|
||||||
<th>통솔</th>
|
:aria-label="column.sortable ? `${column.label} 정렬` : undefined"
|
||||||
<th>무력</th>
|
@click="sortColumn(column.id, $event)"
|
||||||
<th>지력</th>
|
>
|
||||||
<th>금</th>
|
{{ column.label }}
|
||||||
<th v-if="!isNarrow">쌀</th>
|
<span class="sort-indicator">{{ sortIndicator(column.id) }}</span>
|
||||||
<th v-if="!isNarrow">요약</th>
|
</button>
|
||||||
<th v-if="!isNarrow">요약</th>
|
</th>
|
||||||
<th v-if="!isNarrow">벌점 ↓</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="filter-head">
|
<tr class="filter-head">
|
||||||
<th></th>
|
<th v-for="column in activeColumns" :key="column.id">
|
||||||
<th><input v-model="nameFilter" aria-label="장수명 필터" /><span>▽</span></th>
|
<template v-if="column.searchable">
|
||||||
<th><input v-model="officerFilter" aria-label="관직 필터" /><span>▽</span></th>
|
<input
|
||||||
<th><input aria-label="계급 필터" /><span>▽</span></th>
|
v-model="filters[column.id]"
|
||||||
<th><input aria-label="명성 필터" /><span>▽</span></th>
|
type="search"
|
||||||
<th><input aria-label="통솔 필터" /><span>▽</span></th>
|
:inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
|
||||||
<th><input aria-label="무력 필터" /><span>▽</span></th>
|
:aria-label="`${column.label} 필터`"
|
||||||
<th><input aria-label="지력 필터" /><span>▽</span></th>
|
:placeholder="column.searchable === 'number' ? '=, >, <' : ''"
|
||||||
<th><input aria-label="금 필터" /><span>▽</span></th>
|
/>
|
||||||
<th v-if="!isNarrow"><input aria-label="쌀 필터" /><span>▽</span></th>
|
<span>▽</span>
|
||||||
<th v-if="!isNarrow"></th>
|
</template>
|
||||||
<th v-if="!isNarrow"></th>
|
</th>
|
||||||
<th v-if="!isNarrow"><input aria-label="벌점 필터" /><span>▽</span></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(general, index) in generals" :key="general.id">
|
<tr v-for="(general, index) in generals" :key="general.id" :data-general-id="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>
|
|
||||||
<td
|
<td
|
||||||
v-if="!isNarrow"
|
v-for="column in activeColumns"
|
||||||
:title="
|
:key="column.id"
|
||||||
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
|
: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) }}
|
<template v-if="column.id === 'icon'">
|
||||||
</td>
|
<img v-if="index < 16" :src="iconUrl(general)" alt="" />
|
||||||
<td v-if="!isNarrow">
|
<span
|
||||||
{{ general.refreshScoreTotal }}점<br />({{ general.belong ? '자주' : '안함' }})
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="!generals.length" class="empty-row">
|
||||||
|
<td :colspan="activeColumns.length">검색 결과가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="ag-compat-controls" aria-hidden="true">
|
<div class="ag-compat-controls" aria-hidden="true">
|
||||||
<button
|
<button v-for="index in 55" :key="`button-${index}`" type="button" tabindex="-1"></button>
|
||||||
v-for="index in compatButtonCount"
|
<input v-for="index in 42" :key="`input-${index}`" tabindex="-1" />
|
||||||
:key="`button-${index}`"
|
|
||||||
type="button"
|
|
||||||
tabindex="-1"
|
|
||||||
></button>
|
|
||||||
<input v-for="index in compatInputCount" :key="`input-${index}`" tabindex="-1" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -222,9 +653,8 @@ onMounted(load);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.general-page {
|
.general-page {
|
||||||
width: 100%;
|
width: 1000px;
|
||||||
min-width: 500px;
|
min-width: 1000px;
|
||||||
max-width: 1000px;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
font: 14px/21px var(--sammo-font-sans);
|
font: 14px/21px var(--sammo-font-sans);
|
||||||
@@ -242,7 +672,6 @@ onMounted(load);
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
background-image: var(--sammo-texture-walnut);
|
background-image: var(--sammo-texture-walnut);
|
||||||
/* Ref's `.back_bar` has no bottom rule; the grid below draws its own. */
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.top-bar strong {
|
.top-bar strong {
|
||||||
@@ -262,20 +691,19 @@ onMounted(load);
|
|||||||
.right-actions {
|
.right-actions {
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
/* Ref Lumen primary: the bottom edge carries the pressed-state movement. */
|
|
||||||
.top-button {
|
.top-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
width: 89px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-right: 1px solid #151515;
|
border-right: 1px solid #151515;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
width: 89px;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -289,14 +717,11 @@ onMounted(load);
|
|||||||
background: #375a7f;
|
background: #375a7f;
|
||||||
border-bottom: 0 solid #325172;
|
border-bottom: 0 solid #325172;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ref Lumen primary: a 3px bottom edge appears on hover and stays while open. */
|
|
||||||
.mode-button:hover,
|
.mode-button:hover,
|
||||||
.mode-button[aria-expanded='true'],
|
.mode-button[aria-expanded='true'],
|
||||||
.mode-button:active {
|
.mode-button:active {
|
||||||
border-bottom-width: 3px;
|
border-bottom-width: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-button,
|
.mode-button,
|
||||||
.columns-button {
|
.columns-button {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
@@ -312,16 +737,22 @@ onMounted(load);
|
|||||||
}
|
}
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 20;
|
||||||
top: 32px;
|
top: 32px;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 150px;
|
width: 170px;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: #252a2c;
|
overflow-y: auto;
|
||||||
border: 1px solid #596164;
|
border: 1px solid #596164;
|
||||||
|
background: #252a2c;
|
||||||
|
}
|
||||||
|
.view-mode-list {
|
||||||
|
width: 180px;
|
||||||
}
|
}
|
||||||
.dropdown-menu button,
|
.dropdown-menu button,
|
||||||
.dropdown-menu label {
|
.dropdown-menu label,
|
||||||
|
.column-group-label {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@@ -330,6 +761,30 @@ onMounted(load);
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: left;
|
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 {
|
.grid-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100vh - 32px);
|
height: calc(100vh - 32px);
|
||||||
@@ -340,23 +795,21 @@ onMounted(load);
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
width: 1000px;
|
border-collapse: separate;
|
||||||
min-width: 1000px;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
background: #293033;
|
background: #293033;
|
||||||
|
color: #f5f5f5;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
color: #f5f5f5;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
|
padding: 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
border-right: 1px solid #40484b;
|
border-right: 1px solid #40484b;
|
||||||
border-bottom: 1px solid #4a5255;
|
border-bottom: 1px solid #4a5255;
|
||||||
padding: 0 4px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
th {
|
th {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -369,6 +822,36 @@ th {
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
border-bottom-color: #303537;
|
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 {
|
.filter-head th {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 3px 4px;
|
padding: 3px 4px;
|
||||||
@@ -380,6 +863,14 @@ th {
|
|||||||
background: #252a2c;
|
background: #252a2c;
|
||||||
color: #fff;
|
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 {
|
.filter-head span {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
color: #a5b5bf;
|
color: #a5b5bf;
|
||||||
@@ -392,7 +883,7 @@ tbody tr:hover {
|
|||||||
background: #343c3f;
|
background: #343c3f;
|
||||||
}
|
}
|
||||||
td {
|
td {
|
||||||
white-space: nowrap;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
.icon-cell {
|
.icon-cell {
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
@@ -416,21 +907,16 @@ td {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.name-cell {
|
.name-cell {
|
||||||
text-align: left;
|
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
th:nth-child(9),
|
.numeric-cell {
|
||||||
td:nth-child(9),
|
|
||||||
th:nth-child(10),
|
|
||||||
td:nth-child(10) {
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.state {
|
.state {
|
||||||
margin: 40px;
|
margin: 40px;
|
||||||
}
|
}
|
||||||
.npc-0 {
|
.npc-0,
|
||||||
color: skyblue;
|
|
||||||
}
|
|
||||||
.npc-1 {
|
.npc-1 {
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
}
|
}
|
||||||
@@ -443,6 +929,10 @@ td:nth-child(10) {
|
|||||||
.error {
|
.error {
|
||||||
color: #ff7373;
|
color: #ff7373;
|
||||||
}
|
}
|
||||||
|
.empty-row td {
|
||||||
|
height: 68px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1000px) {
|
||||||
.general-page {
|
.general-page {
|
||||||
margin: 0;
|
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