feat(ui): 플레이 감사 조회를 2단 메뉴로 구성

This commit is contained in:
2026-09-16 15:07:01 +00:00
parent c81a9eaf42
commit 21c68323fa
3 changed files with 166 additions and 29 deletions
+40 -3
View File
@@ -901,7 +901,7 @@ test('policy history reads summaries and selected versions only, preserving deep
cursor: 2,
nationId: 2,
});
await page.getByLabel('정책 영역').selectOption('NPC_GENERAL_PRIORITY');
await page.getByRole('link', { name: '장수 행동 우선순위', exact: true }).click();
const before = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect
@@ -951,9 +951,10 @@ test('policy filter drafts do not read until applied, including default dates',
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
await page.getByRole('link', { name: '국방 설정', exact: true }).click();
await expect.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area).toBe('DEFENCE');
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByLabel('시작 월', { exact: true }).fill('3');
await page.getByLabel('정책 영역').selectOption('DEFENCE');
await page.getByRole('button', { name: '조회', exact: true }).focus();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count);
await page.getByRole('button', { name: '조회', exact: true }).click();
@@ -975,7 +976,7 @@ test('initial observation is separate from month-end and final snapshots', async
expect(requests.find(({ operation }) => operation === 'playAudit.nationSnapshot')?.input).toMatchObject({
at: { kind: 'INITIAL' },
});
await page.getByLabel('조회 대상').selectOption('generals');
await page.getByRole('link', { name: '장수', exact: true }).click();
await page.getByRole('button', { name: '조회', exact: true }).click();
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
await expect(page.getByText('190년 6월 수집 시작 기준', { exact: true })).toBeVisible();
@@ -1320,3 +1321,39 @@ test('diplomacy request state does not reload documents or lists', async ({ page
expect(requests.at(-1)?.input.kind).toBe('DIPLOMACY');
await capture(page, 'desktop-diplomacy-request');
});
for (const width of [1280, 390]) {
test(`two-level audit navigation preserves pages and bounds at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals'));
const menu = page.getByRole('navigation', { name: '플레이 감사 메뉴' });
await menu.getByRole('link', { name: 'NPC', exact: true }).click();
await expect(page).toHaveURL(/population=npc/);
await expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page');
await page.reload();
await expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page');
await menu.getByRole('link', { name: '정책', exact: true }).click();
await menu.getByRole('link', { name: '국방 설정', exact: true }).click();
await expect(page).toHaveURL(/policyArea=DEFENCE/);
await page.goBack();
await expect(menu.getByRole('link', { name: 'NPC 국가 정책', exact: true })).toHaveAttribute('aria-current', 'page');
await page.goBack();
await expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page');
await menu.getByRole('link', { name: '도시', exact: true }).hover();
await menu.getByRole('link', { name: '도시', exact: true }).focus();
await expect(menu.getByRole('link', { name: '도시', exact: true })).toBeFocused();
const bounds = await menu.evaluate((node) => ({
right: node.getBoundingClientRect().right,
width: document.documentElement.scrollWidth,
viewport: innerWidth,
}));
expect(bounds.right).toBeLessThanOrEqual(width);
expect(bounds.width).toBeLessThanOrEqual(width);
expect(requests.some((request) => request.operation === 'playAudit.cities')).toBe(false);
await mkdir('/tmp/play-audit-menu', { recursive: true });
await writeFile(`/tmp/play-audit-menu/${width}.json`, JSON.stringify(bounds));
await writeFile(`/tmp/play-audit-menu/${width}.html`, await page.content());
await page.screenshot({ path: `/tmp/play-audit-menu/${width}.png`, fullPage: true });
});
}
+117 -26
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
@@ -133,6 +133,71 @@ const result = computed(() =>
? { ...nationSnapshot.value, nextCursor: null }
: series.value
);
// 메뉴 상태도 기존 query 계약을 사용해 저장된 링크와 뒤로가기를 보존한다.
const primaryMenus = [
{ key: 'nations', label: '국가' },
{ key: 'generals', label: '장수' },
{ key: 'cities', label: '도시' },
{ key: 'diplomacy', label: '외교' },
{ key: 'policies', label: '정책' },
];
const secondaryMenus = computed(() => {
if (tab.value === 'policies')
return [
{ key: 'NPC_VALUES', label: 'NPC 국가 정책' },
{ key: 'NPC_NATION_PRIORITY', label: '국가 행동 우선순위' },
{ key: 'NPC_GENERAL_PRIORITY', label: '장수 행동 우선순위' },
{ key: 'DEFENCE', label: '국방 설정' },
];
if (tab.value === 'generals')
return [
{ key: '', label: '전체 장수' },
{ key: 'human', label: '유저' },
{ key: 'npc', label: 'NPC' },
{ key: 'troopNpc', label: '부대장 NPC' },
];
if (tab.value === 'diplomacy') return [{ key: 'history', label: '문서·관계 이력' }];
if (tab.value === 'nations')
return [
{ key: 'series', label: '자원·숙련도 추이' },
{ key: 'initial', label: '수집 시작 표본' },
{ key: 'final', label: '최종 표본' },
];
return [
{ key: 'current', label: '현재 도시' },
{ key: 'month', label: '월말 도시' },
{ key: 'initial', label: '수집 시작 표본' },
{ key: 'final', label: '최종 표본' },
];
});
const activeSecondary = computed(() => {
if (tab.value === 'policies') return policyArea.value;
if (tab.value === 'generals') return population.value;
if (tab.value === 'diplomacy') return 'history';
if (tab.value === 'nations') return ['initial', 'final'].includes(moment.value) ? moment.value : 'series';
return moment.value;
});
const menuTarget = (target: string, secondary?: string) => {
const query: LocationQueryRaw = { ...route.query, tab: target };
for (const key of [
'general',
'cityRecord',
'decision',
'policy',
'event',
'city',
'name',
'population',
'policyArea',
])
delete query[key];
if (target !== 'diplomacy') delete query.otherNation;
if (target === 'generals' && secondary) query.population = secondary;
if (target === 'policies') query.policyArea = secondary ?? 'NPC_VALUES';
if (target === 'cities') query.at = secondary ?? 'current';
if (target === 'nations') query.at = secondary === 'initial' || secondary === 'final' ? secondary : 'current';
return { query };
};
const readQuery = () => {
tab.value = ['nations', 'generals', 'cities', 'policies', 'diplomacy'].includes(String(route.query.tab))
? String(route.query.tab)
@@ -351,16 +416,31 @@ onMounted(async () => {
상태·정책 수집 시작: {{ coverage.collectionStart.year }} {{ coverage.collectionStart.month }} ·
{{ coverage.collectionStart.observedAt }}
</p>
<nav class="audit-navigation" aria-label="플레이 감사 메뉴">
<div class="menu-row" aria-label="감사 분류">
<RouterLink
v-for="item in primaryMenus"
:key="item.key"
class="legacy-button"
:class="{ selected: tab === item.key }"
:aria-current="tab === item.key ? 'true' : undefined"
:to="menuTarget(item.key)"
>{{ item.label }}</RouterLink
>
</div>
<div class="menu-row secondary-menu" aria-label="세부 조회">
<RouterLink
v-for="item in secondaryMenus"
:key="item.key"
class="legacy-button"
:class="{ selected: activeSecondary === item.key }"
:aria-current="activeSecondary === item.key ? 'page' : undefined"
:to="menuTarget(tab, item.key)"
>{{ item.label }}</RouterLink
>
</div>
</nav>
<form class="filters" @submit.prevent="apply">
<label
>조회 대상<select class="legacy-sort-select" v-model="tab">
<option value="nations">국가 시계열</option>
<option value="generals">전체 장수</option>
<option value="cities">도시 상태</option>
<option value="policies">정책 변경 이력</option>
<option value="diplomacy">외교 이력</option>
</select></label
>
<label
>국가<select class="legacy-sort-select" v-model="nationId">
<option value="">
@@ -474,14 +554,6 @@ onMounted(async () => {
</option>
</select></label
>
<label v-if="tab === 'policies'"
>정책 영역<select class="legacy-sort-select" v-model="policyArea">
<option value="NPC_VALUES">NPC 국가 정책</option>
<option value="NPC_NATION_PRIORITY">국가 행동 우선순위</option>
<option value="NPC_GENERAL_PRIORITY">장수 행동 우선순위</option>
<option value="DEFENCE">국방 설정</option>
</select></label
>
<template v-if="tab === 'generals'">
<label
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
@@ -497,14 +569,6 @@ onMounted(async () => {
</select></label
>
<label>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시" /></label>
<label
>장수 분류<select class="legacy-sort-select" v-model="population">
<option value="">전체</option>
<option value="human">유저</option>
<option value="npc">NPC</option>
<option value="troopNpc">부대장 NPC</option>
</select></label
>
</template>
<button class="legacy-button" type="submit" :disabled="loading">조회</button>
</form>
@@ -708,6 +772,33 @@ onMounted(async () => {
.audit-page > :deep(.panel-card) {
min-width: 0;
}
.audit-navigation {
display: grid;
gap: 8px;
margin: 12px 0;
}
.menu-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.menu-row a {
padding: 6px 12px;
text-decoration: none;
}
.menu-row .selected {
background: #254e3c;
border-color: #b6d6c2;
color: #fff;
}
.menu-row a:focus-visible {
outline: 2px solid #d1e6a1;
outline-offset: 2px;
}
.secondary-menu {
padding-top: 8px;
border-top: 1px solid #536b60;
}
.filters {
display: flex;
flex-wrap: wrap;