Merge remote-tracking branch 'origin/main' into fix/nation-general-filter-menu-20260813

This commit is contained in:
2026-08-13 17:56:41 +00:00
5 changed files with 469 additions and 138 deletions
+146 -27
View File
@@ -465,6 +465,7 @@ test('enters general and nation command arguments and sends exact values', async
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
await expect(page.getByTestId('current-city-marker')).toHaveCount(0);
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
@@ -474,6 +475,12 @@ test('enters general and nation command arguments and sends exact values', async
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
const commandMap = form.getByTestId('command-argument-map');
const currentCityMarker = commandMap.getByTestId('current-city-marker');
const selectionStatus = form.getByTestId('command-map-selection-status');
await expect(currentCityMarker).toHaveText('현재');
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
await expect(selectionStatus).toContainText('현재 도시업');
await expect(selectionStatus).toContainText('선택 도시업');
const mapCities = commandMap.locator('.city-base');
await expect(mapCities).toHaveCount(2);
await expect(commandMap.locator('.city-bg')).toHaveCount(2);
@@ -515,11 +522,17 @@ test('enters general and nation command arguments and sends exact values', async
await mapCities.nth(1).click();
await expect(form.locator('select')).toHaveValue('2');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
await expect(selectionStatus).toContainText('현재 도시업');
await expect(selectionStatus).toContainText('선택 도시허창');
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
await expect(mapCities.nth(0)).toHaveClass(/mine/);
await expect(mapCities.nth(1)).toHaveClass(/selected/);
await mapCities.nth(1).hover();
expect(await mapCities.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
await mapCities.nth(1).focus();
await expect(mapCities.nth(1)).toBeFocused();
await expect(page).toHaveURL(/\/$/);
await commandMap.screenshot({ path: test.info().outputPath('main-city-current-marker-desktop.png') });
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
const area = element.querySelector<HTMLElement>('.map-area')!;
const rect = area.getBoundingClientRect();
@@ -565,7 +578,7 @@ test('enters general and nation command arguments and sends exact values', async
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
});
test('shows Ref recruitment details and preserves the 1000px desktop and 500px mobile information layouts', async ({
test('uses a Ref-style full recruitment page without horizontal overflow on desktop or mobile', async ({
page,
}, testInfo) => {
const requests = await install(page);
@@ -576,7 +589,10 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m
let picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '징병', exact: true }).click();
let form = picker.getByTestId('recruitment-command-form');
await expect(picker).toHaveAttribute('role', 'dialog');
await expect(picker).toHaveAttribute('aria-modal', 'true');
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused();
const form = picker.getByTestId('recruitment-command-form');
await expect(form).toContainText('현재 기술력 : 1등급');
await expect(form).toContainText('공격');
await expect(form).toContainText('방어');
@@ -606,19 +622,23 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m
})
: null;
return {
overlay: element.closest('[data-testid="command-picker"]')?.getBoundingClientRect().toJSON(),
formWidth: element.getBoundingClientRect().width,
rowHeight: row?.getBoundingClientRect().height ?? 0,
infoWidth: info?.getBoundingClientRect().width ?? 0,
imageNaturalSize: naturalSize,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
bodyOverflow: getComputedStyle(document.body).overflow,
};
});
expect(desktopGeometry.formWidth).toBeCloseTo(986, 0);
expect(desktopGeometry.overlay).toMatchObject({ x: 0, y: 0, width: 1200, height: 900 });
expect(desktopGeometry.formWidth).toBe(1000);
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(64);
expect(desktopGeometry.infoWidth).toBeCloseTo(250, 0);
expect(desktopGeometry.imageNaturalSize).toEqual({ width: 128, height: 128 });
expect(desktopGeometry.scrollWidth).toBe(desktopGeometry.clientWidth);
expect(desktopGeometry.bodyOverflow).toBe('hidden');
const infantry = form.getByRole('button', { name: '보병 선택 가능', exact: true });
await infantry.getByRole('button', { name: '절반', exact: true }).click();
@@ -628,49 +648,116 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m
expect(JSON.stringify(requests)).toContain('"crewType":1100');
expect(JSON.stringify(requests)).toContain('"amount":3500');
await page.setViewportSize({ width: 390, height: 844 });
await page.setViewportSize({ width: 500, height: 844 });
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '징병', exact: true }).click();
form = picker.getByTestId('recruitment-command-form');
await page.evaluate(() => window.scrollTo(0, 0));
const mobileGeometry = await form.evaluate((element) => {
const row = element.querySelector('.crew-row');
const image = row?.querySelector('.crew-image');
const info = row?.querySelector('.crew-info');
const selectedPanel = element.querySelector('.mobile-selected-panel');
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused();
const referenceWidthGeometry = await picker.evaluate((element) => {
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
const row = formElement.querySelector<HTMLElement>('.crew-row')!;
const selectedPanel = formElement.querySelector<HTMLElement>('.mobile-selected-panel')!;
return {
formLeft: element.getBoundingClientRect().left,
formWidth: element.getBoundingClientRect().width,
rowWidth: row?.getBoundingClientRect().width ?? 0,
rowHeight: row?.getBoundingClientRect().height ?? 0,
imageWidth: image?.getBoundingClientRect().width ?? 0,
infoWidth: info?.getBoundingClientRect().width ?? 0,
selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '',
overlayWidth: element.getBoundingClientRect().width,
rowWidth: row.getBoundingClientRect().width,
rowGridColumns: getComputedStyle(row).gridTemplateColumns,
selectedWidth: selectedPanel.getBoundingClientRect().width,
selectedGridColumns: getComputedStyle(selectedPanel).gridTemplateColumns,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobileGeometry.formLeft).toBe(0);
expect(mobileGeometry.formWidth).toBe(500);
expect(mobileGeometry.rowWidth).toBe(500);
expect(referenceWidthGeometry).toMatchObject({
overlayWidth: 500,
rowWidth: 500,
rowGridColumns: '64px 76px 30px 30px 30px 270px',
selectedWidth: 500,
selectedGridColumns: '64px 76px 270px 90px',
scrollWidth: 500,
clientWidth: 500,
});
await page.setViewportSize({ width: 390, height: 844 });
const mobileGeometry = await picker.evaluate((element) => {
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
const row = formElement.querySelector('.crew-row');
const image = row?.querySelector('.crew-image');
const info = row?.querySelector('.crew-info');
const selectedPanel = formElement.querySelector('.mobile-selected-panel');
const rect = element.getBoundingClientRect();
const bottomElement = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 1);
return {
overlay: rect.toJSON(),
overlayCoversBottom: bottomElement instanceof Element && element.contains(bottomElement),
formLeft: formElement.getBoundingClientRect().left,
formWidth: formElement.getBoundingClientRect().width,
rowWidth: row?.getBoundingClientRect().width ?? 0,
rowHeight: row?.getBoundingClientRect().height ?? 0,
rowGridColumns: row ? getComputedStyle(row).gridTemplateColumns : '',
rowGridRows: row ? getComputedStyle(row).gridTemplateRows : '',
imageWidth: image?.getBoundingClientRect().width ?? 0,
infoWidth: info?.getBoundingClientRect().width ?? 0,
selectedWidth: selectedPanel?.getBoundingClientRect().width ?? 0,
selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '',
actionsBottom: element.querySelector<HTMLElement>('.picker-actions')?.getBoundingClientRect().bottom ?? 0,
viewportHeight: window.innerHeight,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth,
bodyOverflow: getComputedStyle(document.body).overflow,
};
});
expect(mobileGeometry.overlay).toMatchObject({ x: 0, y: 0, width: 390, height: 844 });
expect(mobileGeometry.overlayCoversBottom).toBe(true);
expect(mobileGeometry.rowGridColumns).toBe('64px 76px 30px 30px 30px 160px');
expect(mobileGeometry).toMatchObject({
formLeft: 0,
formWidth: 390,
rowWidth: 390,
imageWidth: 64,
selectedWidth: 390,
selectedDisplay: 'grid',
actionsBottom: 844,
viewportHeight: 844,
bodyOverflow: 'hidden',
});
expect(mobileGeometry.rowHeight).toBeGreaterThanOrEqual(64);
expect(mobileGeometry.rowHeight).toBeLessThanOrEqual(66);
expect(mobileGeometry.imageWidth).toBe(64);
expect(mobileGeometry.infoWidth).toBe(270);
expect(mobileGeometry.selectedDisplay).toBe('grid');
expect(mobileGeometry.infoWidth).toBeGreaterThanOrEqual(150);
expect(mobileGeometry.scrollWidth).toBe(mobileGeometry.clientWidth);
expect(mobileGeometry.documentScrollWidth).toBeGreaterThanOrEqual(500);
await page.setViewportSize({ width: 390, height: 360 });
await picker.evaluate((element) => (element.scrollTop = 160));
const stickyGeometry = await picker.evaluate((element) => {
const header = element.querySelector<HTMLElement>(':scope > header')!;
const listFront = element.querySelector<HTMLElement>('.recruitment-list-front')!;
const actions = element.querySelector<HTMLElement>('.picker-actions')!;
return {
scrollTop: element.scrollTop,
headerTop: header.getBoundingClientRect().top,
listFrontTop: listFront.getBoundingClientRect().top,
actionsBottom: actions.getBoundingClientRect().bottom,
viewportHeight: window.innerHeight,
};
});
expect(stickyGeometry.scrollTop).toBeGreaterThan(0);
expect(stickyGeometry.headerTop).toBe(0);
expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(44);
expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight);
await page.setViewportSize({ width: 390, height: 844 });
await picker.evaluate((element) => (element.scrollTop = 0));
await page.screenshot({ path: testInfo.outputPath('recruitment-mobile.png') });
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
await picker.getByRole('button', { name: '내정', exact: true }).click();
await picker.getByRole('button', { name: '모병', exact: true }).click();
const mercenaryForm = picker.getByTestId('recruitment-command-form');
await expect(mercenaryForm).toContainText('모병은 가격 2배의 자금이 소요됩니다.');
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
await page.keyboard.press('Escape');
await expect(picker).toHaveCount(0);
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
});
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
@@ -685,6 +772,9 @@ test('uses the map to choose a nation target in the chief command window', async
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
await expect(form.locator('select')).toHaveValue('2');
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
await expect(page).toHaveURL(/\/che\/chief-center$/);
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
@@ -697,6 +787,16 @@ test('fits the city map option window inside the Ref-compatible 500px mobile pag
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /화계/ }).click();
const mobileForm = picker.getByTestId('command-argument-form');
const mobileMap = mobileForm.getByTestId('command-argument-map');
const mobileCities = mobileMap.locator('.city-base');
await mobileCities.nth(1).click();
await expect(mobileForm.locator('select')).toHaveValue('2');
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
await expect(mobileMap.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
await expect(mobileCities.nth(0)).toHaveClass(/mine/);
await expect(mobileCities.nth(1)).toHaveClass(/selected/);
const geometry = await picker.evaluate((element) => {
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
const pickerRect = element.getBoundingClientRect();
@@ -708,6 +808,19 @@ test('fits the city map option window inside the Ref-compatible 500px mobile pag
pickerScrollWidth: element.scrollWidth,
mapWidth: mapRect.width,
mapHeight: mapRect.height,
marker: (() => {
const marker = element.querySelector<HTMLElement>('[data-testid="current-city-marker"]')!;
const rect = marker.getBoundingClientRect();
const style = getComputedStyle(marker);
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
pointerEvents: style.pointerEvents,
borderColor: style.borderColor,
};
})(),
};
});
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
@@ -716,6 +829,12 @@ test('fits the city map option window inside the Ref-compatible 500px mobile pag
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
expect(geometry.mapWidth).toBeGreaterThan(470);
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
expect(geometry.marker.left).toBeGreaterThanOrEqual(0);
expect(geometry.marker.right).toBeLessThanOrEqual(500);
expect(geometry.marker.top).toBeGreaterThanOrEqual(0);
expect(geometry.marker.bottom).toBeLessThanOrEqual(900);
expect(geometry.marker.pointerEvents).toBe('none');
expect(geometry.marker.borderColor).toBe('rgb(130, 207, 255)');
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
});
@@ -423,14 +423,16 @@ watch(
display: none;
}
@media (max-width: 600px) {
@media (max-width: 939px) {
.recruitment-command-form {
width: 500px;
width: 100%;
max-width: 100%;
overflow-x: clip;
}
.recruitment-list-front {
position: sticky;
z-index: 5;
top: 0;
top: 44px;
background: #1d1d1d;
}
.recruitment-status {
@@ -438,7 +440,7 @@ watch(
}
.mobile-selected-panel {
display: grid;
grid-template-columns: 64px 76px 270px 90px;
grid-template-columns: 64px minmax(58px, 76px) minmax(0, 1fr) minmax(64px, 90px);
min-height: 64px;
align-items: stretch;
}
@@ -458,10 +460,11 @@ watch(
text-align: right;
}
.crew-grid {
grid-template:
'image name attack defence speed info' 32px
'image name avoid cost rice info' 32px /
64px 76px 30px 30px 30px 270px;
grid-template-areas:
'image name attack defence speed info'
'image name avoid cost rice info';
grid-template-columns: 64px minmax(58px, 76px) repeat(3, minmax(26px, 30px)) minmax(0, 1fr);
grid-template-rows: 32px 32px;
}
.crew-grid .crew-image {
grid-area: image;
@@ -518,7 +521,27 @@ watch(
}
.crew-info {
overflow: hidden;
overflow-wrap: anywhere;
font-size: 0.9em;
}
}
@media (max-width: 440px) {
.mobile-selected-panel {
grid-template-columns: 64px minmax(58px, 76px) minmax(0, 1fr) 72px;
min-height: 86px;
}
.mobile-selected-panel .amount-panel label {
grid-template-columns: auto minmax(34px, 1fr) auto;
}
.mobile-selected-panel output {
grid-column: 1 / -1;
min-height: 22px;
padding: 2px 4px;
}
.mobile-selected-panel .quick-buttons button,
.mobile-selected-panel .submit-recruit {
min-height: 32px;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue';
import { commandArgumentPresentation } from './commandArgumentPresentation';
@@ -70,6 +70,7 @@ const commandArgsValid = ref(false);
const expanded = ref(false);
const menuRevision = ref(0);
const pendingReservation = ref<CommandPatternEntry | null>(null);
const pickerElement = ref<HTMLElement | null>(null);
const collapsedRowCount = 15;
const loadStorage = (key: string) => {
@@ -136,6 +137,7 @@ const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px
const isRecruitmentCommand = computed(
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
);
const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value);
const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action;
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
const pattern = () => extractPattern(props.rows, selectedIndices());
@@ -172,6 +174,46 @@ const closePicker = () => {
quickTarget.value = null;
selectedCommand.value = null;
};
let previousBodyOverflow: string | null = null;
const restoreBodyScroll = () => {
if (previousBodyOverflow === null) return;
document.body.style.overflow = previousBodyOverflow;
previousBodyOverflow = null;
};
watch(isRecruitmentOverlayOpen, async (open) => {
if (!open) {
restoreBodyScroll();
return;
}
if (previousBodyOverflow === null) previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
await nextTick();
pickerElement.value?.querySelector<HTMLElement>('[data-picker-close]')?.focus();
});
onBeforeUnmount(restoreBodyScroll);
const trapRecruitmentFocus = (event: KeyboardEvent) => {
if (!isRecruitmentOverlayOpen.value || event.key !== 'Tab' || !pickerElement.value) return;
const focusable = [
...pickerElement.value.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]'
),
].filter((element) => element.offsetParent !== null);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const togglePicker = (turnIndex?: number) => {
const target = turnIndex ?? null;
if (pickerOpen.value && quickTarget.value === target) {
@@ -600,65 +642,79 @@ const clickOutsideMenu = (event: Event) => {
</div>
</div>
<div
v-if="pickerOpen"
class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker"
:style="quickTarget === null || props.compact ? undefined : { top: quickPickerTop }"
>
<header>
<strong>{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}` }} 명령 입력</strong
><button type="button" aria-label="명령 입력 닫기" @click="closePicker">×</button>
</header>
<CommandSelectForm
v-if="!selectedCommand"
:command-table="scopedTable"
:loading="props.loading"
:scope="props.scope"
:active-category="activeCategory"
:allow-blocked="true"
@update:active-category="activeCategory = $event"
@select="selectCommand"
/>
<template v-else>
<div class="selected-command">
<strong>{{ selectedCommand.name }}</strong>
<small v-if="selectedCommand.reason"
>현재 상태: {{ selectedCommand.reason }} · 예약 입력은 가능합니다.</small
>
</div>
<RecruitmentCommandForm
v-if="
isRecruitmentCommand &&
props.commandTable?.inputOptions.recruitment &&
(selectedCommand.key === 'che_징병' || selectedCommand.key === 'che_모병')
"
:command-key="selectedCommand.key"
:info="props.commandTable.inputOptions.recruitment"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
@submit="submitCommand"
<Teleport to="body" :disabled="!isRecruitmentCommand">
<div
v-if="pickerOpen"
ref="pickerElement"
class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker"
:style="isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }"
:role="isRecruitmentCommand ? 'dialog' : undefined"
:aria-modal="isRecruitmentCommand ? 'true' : undefined"
:aria-label="
isRecruitmentCommand
? `${selectedCommand?.name ?? ''} ${quickTarget === null ? '선택한 ' : `${quickTarget + 1}`} 명령 입력`
: undefined
"
@keydown.esc.stop.prevent="closePicker"
@keydown="trapRecruitmentFocus"
>
<header>
<strong
><template v-if="isRecruitmentCommand">{{ selectedCommand?.name }} · </template
>{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}` }} 명령 입력</strong
><button data-picker-close type="button" aria-label="명령 입력 닫기" @click="closePicker">×</button>
</header>
<CommandSelectForm
v-if="!selectedCommand"
:command-table="scopedTable"
:loading="props.loading"
:scope="props.scope"
:active-category="activeCategory"
:allow-blocked="true"
@update:active-category="activeCategory = $event"
@select="selectCommand"
/>
<CommandArgumentForm
v-else-if="selectedCommand.reqArg && props.commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
<div class="picker-actions">
<button :disabled="Boolean(pendingReservation)" @click="selectedCommand = null">
명령 다시 선택</button
><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand">
{{ pendingReservation ? '저장 ' : '입력' }}
</button>
</div>
</template>
</div>
<template v-else>
<div class="selected-command">
<strong>{{ selectedCommand.name }}</strong>
<small v-if="selectedCommand.reason"
>현재 상태: {{ selectedCommand.reason }} · 예약 입력은 가능합니다.</small
>
</div>
<RecruitmentCommandForm
v-if="
isRecruitmentCommand &&
props.commandTable?.inputOptions.recruitment &&
(selectedCommand.key === 'che_징병' || selectedCommand.key === 'che_모병')
"
:command-key="selectedCommand.key"
:info="props.commandTable.inputOptions.recruitment"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
@submit="submitCommand"
/>
<CommandArgumentForm
v-else-if="selectedCommand.reqArg && props.commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
<div class="picker-actions">
<button :disabled="Boolean(pendingReservation)" @click="selectedCommand = null">
명령 다시 선택</button
><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand">
{{ pendingReservation ? '저장 ' : '입력' }}
</button>
</div>
</template>
</div>
</Teleport>
</article>
</template>
@@ -967,18 +1023,59 @@ const clickOutsideMenu = (event: Event) => {
max-height: 344px;
}
.reserved-command-editor .command-picker.recruitment-picker {
.command-picker.recruitment-picker {
position: fixed;
z-index: 1100;
top: 76px;
right: auto;
bottom: auto;
left: 50%;
width: 1000px;
height: auto;
max-height: calc(100vh - 82px);
overflow: auto;
transform: translateX(-50%);
inset: 0;
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
height: 100dvh;
max-height: none;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0;
border: 0;
background: #1d1d1d;
box-shadow: none;
transform: none;
}
.command-picker.recruitment-picker > header {
position: sticky;
z-index: 30;
top: 0;
box-sizing: border-box;
min-height: 44px;
padding: 6px 8px;
border-bottom: 1px solid #777;
background: #302016 var(--sammo-texture-walnut);
}
.command-picker.recruitment-picker > header button {
min-width: 36px;
min-height: 32px;
cursor: pointer;
}
.command-picker.recruitment-picker .selected-command,
.command-picker.recruitment-picker :deep(.recruitment-command-form),
.command-picker.recruitment-picker .picker-actions {
width: min(100%, 1000px);
margin-right: auto;
margin-left: auto;
}
.command-picker.recruitment-picker :deep(.recruitment-command-form) {
flex: 1 0 auto;
}
.command-picker.recruitment-picker .picker-actions {
position: sticky;
z-index: 30;
bottom: 0;
box-sizing: border-box;
margin-top: 0;
padding: 6px;
border-top: 1px solid #777;
background: #302016 var(--sammo-texture-walnut);
}
@media (min-width: 1025px) {
@@ -1002,14 +1099,6 @@ const clickOutsideMenu = (event: Event) => {
max-height: calc(100vh - 104px);
overflow: auto;
}
.compact:not(.mobile) .command-picker.recruitment-picker {
top: 76px;
left: 50%;
width: 1000px;
max-height: calc(100vh - 82px);
overflow: auto;
transform: translateX(-50%);
}
}
.mobile.compact .editor-layout {
@@ -1055,36 +1144,10 @@ const clickOutsideMenu = (event: Event) => {
margin-top: -330px;
overflow: visible;
}
.mobile.compact .command-picker.recruitment-picker {
position: fixed;
top: 76px;
left: 0;
width: 500px;
height: auto;
max-height: calc(100vh - 82px);
margin-top: 0;
overflow: auto;
transform: none;
}
.mobile.compact .advanced-actions {
right: 0;
bottom: 0;
left: 109px;
}
@media (max-width: 600px) {
.reserved-command-editor .command-picker.recruitment-picker,
.reserved-command-editor.compact .command-picker.recruitment-picker {
top: 76px;
left: 0;
width: 500px;
height: auto;
max-height: calc(100vh - 82px);
overflow: auto;
padding: 0;
border-right: 0;
border-left: 0;
transform: none;
}
}
</style>
@@ -109,6 +109,26 @@ const mapSelectedCityId = computed<number | null>(() => {
return null;
});
const currentCityName = computed(() => {
const cityId = props.mapData?.myCity;
if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
});
const selectedMapTargetName = computed(() => {
if (presentation.value.mapTarget === 'city') {
const cityId = mapSelectedCityId.value;
if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const nationId = values[nationTargetField.value.key];
if (typeof nationId !== 'number') return '-';
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
}
return '-';
});
const distanceFromMyCity = (destination: number): number | null => {
const start = props.mapData?.myCity;
if (!start || !props.mapLayout) return null;
@@ -260,9 +280,23 @@ watch(
:selected-city-id="mapSelectedCityId"
:detail-mode="true"
:fit-container="true"
:show-current-city-marker="true"
@select-city="selectMapCity"
/>
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
<span class="current-city-status">
<span class="status-key">현재 도시</span>
<strong>{{ currentCityName }}</strong>
</span>
<span aria-hidden="true"></span>
<span class="selected-target-status">
<span class="status-key">{{
presentation.mapTarget === 'nation' ? '선택 국가' : '선택 도시'
}}</span>
<strong>{{ selectedMapTargetName }}</strong>
</span>
</div>
<div v-if="mapTargetSummary" class="map-target-summary" data-testid="command-map-target-summary">
{{ mapTargetSummary }}
</div>
@@ -376,6 +410,41 @@ watch(
line-height: 1.35;
}
.map-selection-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 7px;
padding: 0 8px 5px;
color: rgba(232, 221, 196, 0.82);
line-height: 18px;
}
.map-selection-status > span:not([aria-hidden='true']) {
display: inline-flex;
gap: 4px;
align-items: center;
}
.status-key {
border-radius: 2px;
padding: 0 4px;
font-size: 10px;
font-weight: 700;
}
.current-city-status .status-key {
border: 1px solid #82cfff;
background: rgba(5, 27, 43, 0.92);
color: #d9f3ff;
}
.selected-target-status .status-key {
border: 1px solid rgba(255, 235, 150, 0.9);
background: rgba(201, 164, 90, 0.18);
color: #ffe996;
}
.command-guidance {
display: grid;
gap: 3px;
@@ -71,6 +71,7 @@ const props = withDefaults(
selectedCityId?: number | null;
detailMode?: boolean;
fitContainer?: boolean;
showCurrentCityMarker?: boolean;
}>(),
{
// Vue casts an absent Boolean prop to false unless undefined is an explicit default.
@@ -214,6 +215,20 @@ const cityViews = computed<CityView[]>(() => {
});
});
const currentCityMarker = computed(() =>
props.showCurrentCityMarker ? (cityViews.value.find((city) => city.isMyCity) ?? null) : null
);
const currentCityMarkerStyle = computed(() => {
const city = currentCityMarker.value;
if (!city) return undefined;
const verticalOffset = (effectiveDetailMode.value ? 22 : 14) * mapScale.value;
return {
left: `${city.x}px`,
top: `${city.y - verticalOffset}px`,
};
});
const mapSeason = computed(() => {
if (!props.mapData) {
return 'spring';
@@ -433,6 +448,16 @@ const selectCity = (cityId: number) => {
@leave="setHoveredCity(null)"
@select="selectCity"
/>
<div
v-if="currentCityMarker"
class="current-city-marker"
:style="currentCityMarkerStyle"
data-testid="current-city-marker"
role="note"
:aria-label="`현재 도시 ${currentCityMarker.name}`"
>
현재
</div>
<div v-if="hoveredCity" class="map-tooltip" :style="tooltipPosition">
<div class="tooltip-title">{{ hoveredCityTitle }}</div>
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
@@ -583,6 +608,38 @@ const selectCity = (cityId: number) => {
white-space: nowrap;
}
.current-city-marker {
position: absolute;
z-index: 12;
box-sizing: border-box;
min-width: 30px;
border: 1px solid #82cfff;
border-radius: 2px;
padding: 1px 4px;
background: rgba(5, 27, 43, 0.92);
color: #d9f3ff;
font-size: 10px;
font-weight: 700;
line-height: 14px;
pointer-events: none;
text-align: center;
text-shadow: 0 1px #000;
transform: translate(-50%, -100%);
white-space: nowrap;
}
.current-city-marker::after {
position: absolute;
top: 100%;
left: 50%;
width: 0;
height: 0;
border: 4px solid transparent;
border-top-color: #82cfff;
content: '';
transform: translateX(-50%);
}
.tooltip-title {
height: 15px;
}