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,
|
cursor: 2,
|
||||||
nationId: 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;
|
const before = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
|
||||||
await page.getByRole('button', { name: '조회', exact: true }).click();
|
await page.getByRole('button', { name: '조회', exact: true }).click();
|
||||||
await expect
|
await expect
|
||||||
@@ -951,9 +951,10 @@ test('policy filter drafts do not read until applied, including default dates',
|
|||||||
const requests = await install(page);
|
const requests = await install(page);
|
||||||
await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
|
await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
|
||||||
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
|
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;
|
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
|
||||||
await page.getByLabel('시작 월', { exact: true }).fill('3');
|
await page.getByLabel('시작 월', { exact: true }).fill('3');
|
||||||
await page.getByLabel('정책 영역').selectOption('DEFENCE');
|
|
||||||
await page.getByRole('button', { name: '조회', exact: true }).focus();
|
await page.getByRole('button', { name: '조회', exact: true }).focus();
|
||||||
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count);
|
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count);
|
||||||
await page.getByRole('button', { name: '조회', exact: true }).click();
|
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({
|
expect(requests.find(({ operation }) => operation === 'playAudit.nationSnapshot')?.input).toMatchObject({
|
||||||
at: { kind: 'INITIAL' },
|
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: '조회', exact: true }).click();
|
||||||
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
|
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
|
||||||
await expect(page.getByText('190년 6월 수집 시작 기준', { exact: true })).toBeVisible();
|
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');
|
expect(requests.at(-1)?.input.kind).toBe('DIPLOMACY');
|
||||||
await capture(page, 'desktop-diplomacy-request');
|
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">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
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 PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
|
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
|
||||||
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
|
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
|
||||||
@@ -133,6 +133,71 @@ const result = computed(() =>
|
|||||||
? { ...nationSnapshot.value, nextCursor: null }
|
? { ...nationSnapshot.value, nextCursor: null }
|
||||||
: series.value
|
: 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 = () => {
|
const readQuery = () => {
|
||||||
tab.value = ['nations', 'generals', 'cities', 'policies', 'diplomacy'].includes(String(route.query.tab))
|
tab.value = ['nations', 'generals', 'cities', 'policies', 'diplomacy'].includes(String(route.query.tab))
|
||||||
? String(route.query.tab)
|
? String(route.query.tab)
|
||||||
@@ -351,16 +416,31 @@ onMounted(async () => {
|
|||||||
상태·정책 수집 시작: {{ coverage.collectionStart.year }}년 {{ coverage.collectionStart.month }}월 ·
|
상태·정책 수집 시작: {{ coverage.collectionStart.year }}년 {{ coverage.collectionStart.month }}월 ·
|
||||||
{{ coverage.collectionStart.observedAt }}
|
{{ coverage.collectionStart.observedAt }}
|
||||||
</p>
|
</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">
|
<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
|
<label
|
||||||
>국가<select class="legacy-sort-select" v-model="nationId">
|
>국가<select class="legacy-sort-select" v-model="nationId">
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -474,14 +554,6 @@ onMounted(async () => {
|
|||||||
</option>
|
</option>
|
||||||
</select></label
|
</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'">
|
<template v-if="tab === 'generals'">
|
||||||
<label
|
<label
|
||||||
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
|
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
|
||||||
@@ -497,14 +569,6 @@ onMounted(async () => {
|
|||||||
</select></label
|
</select></label
|
||||||
>
|
>
|
||||||
<label>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시" /></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>
|
</template>
|
||||||
<button class="legacy-button" type="submit" :disabled="loading">조회</button>
|
<button class="legacy-button" type="submit" :disabled="loading">조회</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -708,6 +772,33 @@ onMounted(async () => {
|
|||||||
.audit-page > :deep(.panel-card) {
|
.audit-page > :deep(.panel-card) {
|
||||||
min-width: 0;
|
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 {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -29,6 +29,15 @@
|
|||||||
시도별 전체 이력이나 실제 변경 횟수는 아니다. 요청이 없거나 참조 순번이 맞지 않으면
|
시도별 전체 이력이나 실제 변경 횟수는 아니다. 요청이 없거나 참조 순번이 맞지 않으면
|
||||||
그 사유를 표시한다. 버튼을 누를 때만 한 건을 조회하고 원문 요청/오류 내용은 제공하지 않는다.
|
그 사유를 표시한다. 버튼을 누를 때만 한 건을 조회하고 원문 요청/오류 내용은 제공하지 않는다.
|
||||||
|
|
||||||
|
## 화면 탐색
|
||||||
|
|
||||||
|
상위 메뉴에서 국가·장수·도시·외교·정책을 선택하고, 아래 메뉴에서 세부 조회를 선택한다.
|
||||||
|
국가는 추이/수집 시작/최종 표본, 장수는 전체/유저/NPC/부대장 NPC,
|
||||||
|
도시는 현재/월말/수집 시작/최종 표본, 정책은 NPC 정책/국가·장수 우선순위/국방 설정으로 나뉜다.
|
||||||
|
외교는 문서·관계 이력을 제공한다. 메뉴 선택은 즉시 이동하며 국가·기간 등 조회 조건은
|
||||||
|
조회 버튼으로 적용한다. 기존 query URL과 직접 접근·새로고침·뒤로가기를 지원한다.
|
||||||
|
화면을 전환하면 이전 상세 선택은 해제하고 선택한 화면에 필요한 자료만 읽는다.
|
||||||
|
|
||||||
## 진입과 권한
|
## 진입과 권한
|
||||||
|
|
||||||
1. Gateway의 사용자 관리에서 대상 관리자에게 정확한 프로필 scope를 부여한다.
|
1. Gateway의 사용자 관리에서 대상 관리자에게 정확한 프로필 scope를 부여한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user