fix(game): 외교권자와 조언자 복수 임명 UI를 복원한다

Ref처럼 역할별 후보를 분리하고 클릭과 터치만으로 최대 두 명을 선택할 수 있게 한다. API 후보·엔진 payload·모바일 Chromium 회귀를 함께 고정한다.
This commit is contained in:
2026-08-24 08:38:38 +00:00
parent 69e34e3c95
commit 87e565ae72
5 changed files with 368 additions and 37 deletions
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
type PermissionCandidate = {
id: number;
name: string;
};
const props = withDefaults(
defineProps<{
modelValue: number[];
candidates: PermissionCandidate[];
label: string;
max?: number;
}>(),
{ max: 2 }
);
const emit = defineEmits<{
'update:modelValue': [value: number[]];
limit: [];
}>();
const root = ref<HTMLElement | null>(null);
const open = ref(false);
const selectedCandidates = computed(() => {
const selected = new Set(props.modelValue);
return props.candidates.filter((candidate) => selected.has(candidate.id));
});
const toggleOpen = (): void => {
open.value = !open.value;
};
const toggleCandidate = (id: number): void => {
if (props.modelValue.includes(id)) {
emit(
'update:modelValue',
props.modelValue.filter((selectedId) => selectedId !== id)
);
return;
}
if (props.modelValue.length >= props.max) {
emit('limit');
return;
}
emit('update:modelValue', [...props.modelValue, id]);
};
const handleDocumentPointerDown = (event: PointerEvent): void => {
if (!root.value?.contains(event.target as Node)) open.value = false;
};
const handleKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault();
open.value = false;
}
};
onMounted(() => document.addEventListener('pointerdown', handleDocumentPointerDown));
onBeforeUnmount(() => document.removeEventListener('pointerdown', handleDocumentPointerDown));
</script>
<template>
<div ref="root" class="permission-multiselect" @keydown="handleKeydown">
<button
type="button"
class="permission-multiselect-trigger"
:aria-label="`${label} 선택, 현재 ${modelValue.length}`"
aria-haspopup="listbox"
:aria-expanded="open"
@click="toggleOpen"
>
<span v-if="selectedCandidates.length" class="permission-multiselect-values">
<span v-for="candidate in selectedCandidates" :key="candidate.id">{{ candidate.name }}</span>
</span>
<span v-else class="permission-multiselect-placeholder">선택 </span>
<span class="permission-multiselect-arrow" aria-hidden="true"></span>
</button>
<div
v-if="open"
class="permission-multiselect-options"
role="listbox"
aria-multiselectable="true"
:aria-label="`${label} 후보`"
>
<button
v-for="candidate in candidates"
:key="candidate.id"
type="button"
class="permission-multiselect-option"
role="option"
:aria-selected="modelValue.includes(candidate.id)"
@click="toggleCandidate(candidate.id)"
>
<span class="permission-multiselect-check" aria-hidden="true">
{{ modelValue.includes(candidate.id) ? '✓' : '' }}
</span>
<span>{{ candidate.name }}</span>
</button>
<p v-if="candidates.length === 0" class="permission-multiselect-empty">임명 가능한 장수가 없습니다.</p>
<p class="permission-multiselect-help">클릭해서 선택·해제 · 최대 {{ max }}</p>
</div>
</div>
</template>
<style scoped>
.permission-multiselect {
position: relative;
display: inline-block;
width: 300px;
max-width: calc(100% - 58px);
vertical-align: middle;
}
.permission-multiselect-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-height: 34px;
border: 1px solid #858585;
border-radius: 4px;
padding: 3px 7px;
color: #fff;
background: #000;
font: inherit;
text-align: left;
cursor: pointer;
}
.permission-multiselect-trigger:hover,
.permission-multiselect-trigger[aria-expanded='true'] {
border-color: #b9b9b9;
}
.permission-multiselect-trigger:focus-visible,
.permission-multiselect-option:focus-visible {
outline: 2px solid #fff;
outline-offset: 1px;
}
.permission-multiselect-values {
display: flex;
flex-wrap: wrap;
gap: 3px;
min-width: 0;
}
.permission-multiselect-values > span {
overflow: hidden;
max-width: 126px;
border-radius: 3px;
padding: 2px 5px;
color: #fff;
background: #4d4d4d;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-multiselect-placeholder {
color: #aaa;
}
.permission-multiselect-arrow {
margin-left: 5px;
color: #ccc;
}
.permission-multiselect-options {
position: absolute;
z-index: 40;
top: calc(100% + 2px);
left: 0;
width: 100%;
max-height: 220px;
overflow-y: auto;
border: 1px solid #858585;
border-radius: 3px;
color: #fff;
background: #101010;
box-shadow: 0 5px 14px rgb(0 0 0 / 70%);
}
.permission-multiselect-option {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 5px;
align-items: center;
width: 100%;
border: 0;
border-bottom: 1px solid #353535;
border-radius: 0;
padding: 7px 8px;
color: #fff;
background: #101010;
font: inherit;
text-align: left;
cursor: pointer;
}
.permission-multiselect-option:hover,
.permission-multiselect-option[aria-selected='true'] {
background: #424242;
}
.permission-multiselect-check {
display: grid;
width: 16px;
height: 16px;
place-items: center;
border: 1px solid #aaa;
border-radius: 2px;
color: #111;
background: #fff;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.permission-multiselect-empty,
.permission-multiselect-help {
margin: 0;
padding: 6px 8px;
color: #bbb;
font-size: 11px;
}
.permission-multiselect-help {
border-top: 1px solid #454545;
}
@media (max-width: 620px) {
.permission-multiselect {
width: calc(100% - 54px);
max-width: none;
}
.permission-multiselect-values > span {
max-width: 82px;
}
.permission-multiselect-option {
min-height: 38px;
}
}
</style>
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue';
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon';
@@ -249,9 +250,7 @@ const applySelection = async (id: number): Promise<void> => {
else await appointCityOfficer(context.level, context.cityId, id);
};
const enforcePermissionLimit = (selection: number[]) => {
if (selection.length <= 2) return;
selection.splice(0, selection.length - 2);
const reportPermissionLimit = () => {
showErrorToast('최대 2명까지 설정 가능합니다.');
};
@@ -388,23 +387,16 @@ onMounted(() => void loadPersonnel());
<tr>
<td class="green-cell permission-label">외교권자</td>
<td>
<select
<PermissionMultiSelect
v-model="ambassadorSelection"
multiple
aria-label="외교권자"
@change="enforcePermissionLimit(ambassadorSelection)"
>
<option
v-for="candidate in data.permissionCandidates.ambassadors"
:key="candidate.id"
:value="candidate.id"
>
{{ candidate.name }}
</option>
</select>
label="외교권자"
:candidates="data.permissionCandidates.ambassadors"
@limit="reportPermissionLimit"
/>
<button
class="legacy-button legacy-button--primary"
type="button"
aria-label="외교권자 임명 반영"
@click="changePermissions(true)"
>
임명
@@ -412,23 +404,16 @@ onMounted(() => void loadPersonnel());
</td>
<td class="green-cell permission-label">조언자</td>
<td>
<select
<PermissionMultiSelect
v-model="auditorSelection"
multiple
aria-label="조언자"
@change="enforcePermissionLimit(auditorSelection)"
>
<option
v-for="candidate in data.permissionCandidates.auditors"
:key="candidate.id"
:value="candidate.id"
>
{{ candidate.name }}
</option>
</select>
label="조언자"
:candidates="data.permissionCandidates.auditors"
@limit="reportPermissionLimit"
/>
<button
class="legacy-button legacy-button--primary"
type="button"
aria-label="조언자 임명 반영"
@click="changePermissions(false)"
>
임명
@@ -664,10 +649,6 @@ select {
background: #000;
font: inherit;
}
select[multiple] {
width: 300px;
height: 34px;
}
.nation-heading {
height: 32px;
text-align: center;