feat(ui): 플레이 감사 조회를 2단 메뉴로 구성
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
시도별 전체 이력이나 실제 변경 횟수는 아니다. 요청이 없거나 참조 순번이 맞지 않으면
|
||||
그 사유를 표시한다. 버튼을 누를 때만 한 건을 조회하고 원문 요청/오류 내용은 제공하지 않는다.
|
||||
|
||||
## 화면 탐색
|
||||
|
||||
상위 메뉴에서 국가·장수·도시·외교·정책을 선택하고, 아래 메뉴에서 세부 조회를 선택한다.
|
||||
국가는 추이/수집 시작/최종 표본, 장수는 전체/유저/NPC/부대장 NPC,
|
||||
도시는 현재/월말/수집 시작/최종 표본, 정책은 NPC 정책/국가·장수 우선순위/국방 설정으로 나뉜다.
|
||||
외교는 문서·관계 이력을 제공한다. 메뉴 선택은 즉시 이동하며 국가·기간 등 조회 조건은
|
||||
조회 버튼으로 적용한다. 기존 query URL과 직접 접근·새로고침·뒤로가기를 지원한다.
|
||||
화면을 전환하면 이전 상세 선택은 해제하고 선택한 화면에 필요한 자료만 읽는다.
|
||||
|
||||
## 진입과 권한
|
||||
|
||||
1. Gateway의 사용자 관리에서 대상 관리자에게 정확한 프로필 scope를 부여한다.
|
||||
|
||||
Reference in New Issue
Block a user