Gateway 화면을 route 단위로 지연 로딩하고 common runtime import를 실제 소유 leaf export로 분리한다.\n\npackage boundary와 route loading 회귀 테스트로 root barrel 재유입을 차단한다.
679 lines
19 KiB
Vue
679 lines
19 KiB
Vue
<script setup lang="ts">
|
|
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
|
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
|
import { trpc } from '../utils/trpc';
|
|
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
|
|
|
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
|
|
type Troop = TroopList['troops'][number];
|
|
type Member = Troop['members'][number];
|
|
type DialogKind = 'rename' | 'kick' | null;
|
|
|
|
const loading = ref(false);
|
|
const data = ref<TroopList | null>(null);
|
|
const errorMessage = ref('');
|
|
const noticeMessage = ref('');
|
|
const noticeKind = ref<'success' | 'error'>('success');
|
|
const createName = ref('');
|
|
const editName = ref('');
|
|
const kickTargetId = ref(0);
|
|
const dialogKind = ref<DialogKind>(null);
|
|
const dialogTroopId = ref(0);
|
|
const popupMember = ref<Member | null>(null);
|
|
const popupTop = ref(0);
|
|
const router = useRouter();
|
|
|
|
const me = computed(() => data.value?.me ?? null);
|
|
|
|
const getErrorMessage = (error: unknown): string => {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
if (typeof error === 'string') {
|
|
return error;
|
|
}
|
|
return '요청을 처리하지 못했습니다.';
|
|
};
|
|
|
|
const showNotice = (message: string, kind: 'success' | 'error') => {
|
|
noticeMessage.value = message;
|
|
noticeKind.value = kind;
|
|
};
|
|
|
|
const refresh = async () => {
|
|
if (loading.value) {
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
errorMessage.value = '';
|
|
try {
|
|
data.value = await trpc.troop.getList.query();
|
|
} catch (error) {
|
|
errorMessage.value = getErrorMessage(error);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const runAction = async (action: () => Promise<void>) => {
|
|
errorMessage.value = '';
|
|
try {
|
|
await action();
|
|
await refresh();
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
errorMessage.value = message;
|
|
showNotice(message, 'error');
|
|
}
|
|
};
|
|
|
|
const makeTroop = async () => {
|
|
const troopName = createName.value;
|
|
await runAction(async () => {
|
|
await trpc.troop.create.mutate({ troopName });
|
|
createName.value = '';
|
|
showNotice(`${troopName} 부대가 생성되었습니다.`, 'success');
|
|
});
|
|
};
|
|
|
|
const joinTroop = async (troop: Troop) => {
|
|
await runAction(async () => {
|
|
await trpc.troop.join.mutate({ troopId: troop.id });
|
|
showNotice(` ${troop.name} 부대에 가입했습니다.`, 'success');
|
|
});
|
|
};
|
|
|
|
const exitTroop = async (troop: Troop) => {
|
|
const isLeader = me.value?.id === troop.id;
|
|
const prompt = isLeader ? `${troop.name} 부대를 해산하겠습니까?` : `${troop.name} 부대에서 탈퇴하겠습니까?`;
|
|
if (!window.confirm(prompt)) {
|
|
return;
|
|
}
|
|
await runAction(async () => {
|
|
await trpc.troop.exit.mutate();
|
|
showNotice(isLeader ? '부대를 해산했습니다.' : '부대에서 탈퇴했습니다.', 'success');
|
|
});
|
|
};
|
|
|
|
const openRename = (troop: Troop) => {
|
|
dialogKind.value = 'rename';
|
|
dialogTroopId.value = troop.id;
|
|
editName.value = troop.name;
|
|
};
|
|
|
|
const openKick = (troop: Troop) => {
|
|
dialogKind.value = 'kick';
|
|
dialogTroopId.value = troop.id;
|
|
kickTargetId.value = troop.members.find((member) => member.id !== troop.id)?.id ?? 0;
|
|
};
|
|
|
|
const closeDialog = () => {
|
|
dialogKind.value = null;
|
|
dialogTroopId.value = 0;
|
|
};
|
|
|
|
const hasFinalConsonant = (value: string): boolean => {
|
|
const last = Array.from(value.trim()).at(-1);
|
|
if (!last) {
|
|
return false;
|
|
}
|
|
const code = last.codePointAt(0);
|
|
return code !== undefined && code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0;
|
|
};
|
|
|
|
const renameTroop = async (troop: Troop) => {
|
|
const troopName = editName.value;
|
|
const particle = hasFinalConsonant(troopName) ? '으로' : '로';
|
|
if (!window.confirm(`${troop.name} 부대의 이름을 ${troopName}${particle} 바꾸시겠습니까?`)) {
|
|
return;
|
|
}
|
|
await runAction(async () => {
|
|
await trpc.troop.rename.mutate({ troopId: troop.id, troopName });
|
|
closeDialog();
|
|
showNotice('부대명을 변경했습니다.', 'success');
|
|
});
|
|
};
|
|
|
|
const kickMember = async (troop: Troop) => {
|
|
const member = troop.members.find((candidate) => candidate.id === kickTargetId.value);
|
|
if (!member) {
|
|
showNotice('잘못된 접근입니다.', 'error');
|
|
return;
|
|
}
|
|
const particle = hasFinalConsonant(member.name) ? '을' : '를';
|
|
if (!window.confirm(`${troop.name} 부대에서 ${member.name}${particle} 추방하시겠습니까?`)) {
|
|
return;
|
|
}
|
|
await runAction(async () => {
|
|
await trpc.troop.kick.mutate({ troopId: troop.id, targetGeneralId: member.id });
|
|
closeDialog();
|
|
showNotice(`${member.name}${particle} 추방했습니다.`, 'success');
|
|
});
|
|
};
|
|
|
|
const showMemberPopup = (event: MouseEvent, member: Member) => {
|
|
const row = (event.currentTarget as HTMLElement).closest('.troopMembers') as HTMLElement | null;
|
|
popupMember.value = member;
|
|
popupTop.value = row ? row.offsetTop + row.offsetHeight : 0;
|
|
};
|
|
|
|
const hideMemberPopup = () => {
|
|
popupMember.value = null;
|
|
};
|
|
|
|
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
|
|
|
|
const formatTurn = (turnTime: string | null): string => {
|
|
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
|
|
};
|
|
|
|
onMounted(() => {
|
|
void refresh();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<main id="container" class="legacy-troop-page">
|
|
<header class="topBackBar bg0">
|
|
<button
|
|
class="legacy-button legacy-button--navigation legacyNavButton backLink"
|
|
type="button"
|
|
@click="router.push('/')"
|
|
>
|
|
돌아가기
|
|
</button>
|
|
<button
|
|
class="legacy-button legacy-button--navigation legacyNavButton reloadButton"
|
|
type="button"
|
|
:disabled="loading"
|
|
@click="refresh"
|
|
>
|
|
갱신
|
|
</button>
|
|
<h2>부대 편성</h2>
|
|
<div></div>
|
|
<div></div>
|
|
</header>
|
|
|
|
<div
|
|
v-if="noticeMessage"
|
|
class="notice"
|
|
:class="noticeKind"
|
|
:role="noticeKind === 'error' ? 'alert' : 'status'"
|
|
>
|
|
{{ noticeMessage }}
|
|
</div>
|
|
<div v-if="errorMessage && !noticeMessage" class="notice error" role="alert">{{ errorMessage }}</div>
|
|
<div v-if="loading && !data" class="loading">불러오는 중...</div>
|
|
|
|
<div v-if="data" id="troopList" class="bg0">
|
|
<div v-for="troop in data.troops" :key="troop.id" class="troopItem" :data-troop-id="troop.id">
|
|
<div class="troopInfo">
|
|
{{ troop.name }}<br />
|
|
【 {{ troop.leader?.cityName ?? '알 수 없음' }} 】
|
|
</div>
|
|
<div class="troopTurn">【턴】 {{ formatTurn(troop.turnTime) }}</div>
|
|
<div class="troopLeaderIcon">
|
|
<img
|
|
height="64"
|
|
width="64"
|
|
:src="iconPath(troop)"
|
|
:alt="`${troop.leader?.name ?? '부대장'} 아이콘`"
|
|
@error="useDefaultGeneralIcon"
|
|
/>
|
|
</div>
|
|
<div class="troopLeaderName">{{ troop.leader?.name ?? '알 수 없음' }}</div>
|
|
<div class="troopReservedCommand">
|
|
<div v-for="(brief, index) in troop.reservedCommands" :key="`${troop.id}-${index}`">
|
|
{{ `${index + 1}: ${brief}` }}
|
|
</div>
|
|
</div>
|
|
<div class="troopMembers">
|
|
<template v-for="(member, index) in troop.members" :key="member.id">
|
|
<template v-if="index !== 0">, </template>
|
|
<span
|
|
class="troopMember"
|
|
:class="{
|
|
troopLeader: member.id === troop.id,
|
|
troopDiffCityMemeber: member.cityId !== troop.leader?.cityId,
|
|
}"
|
|
@mouseenter="showMemberPopup($event, member)"
|
|
@mouseleave="hideMemberPopup"
|
|
>
|
|
{{ member.name
|
|
}}<template v-if="member.cityId !== troop.leader?.cityId">
|
|
({{ member.cityName }})</template
|
|
>
|
|
</span>
|
|
</template>
|
|
({{ troop.members.length }}명)
|
|
</div>
|
|
|
|
<div class="troopAction">
|
|
<div v-if="dialogKind === null || dialogTroopId !== troop.id" class="actionButtons">
|
|
<button
|
|
v-if="data.me.troopId === 0"
|
|
class="legacy-button legacy-button--primary"
|
|
@click="joinTroop(troop)"
|
|
>
|
|
부대 탑승
|
|
</button>
|
|
<button
|
|
v-if="data.me.troopId === troop.id"
|
|
class="legacy-button"
|
|
:class="data.me.id === data.me.troopId ? 'legacy-button--danger' : 'legacy-button--primary'"
|
|
@click="exitTroop(troop)"
|
|
>
|
|
{{ data.me.id === data.me.troopId ? '부대 해산' : '부대 탈퇴' }}
|
|
</button>
|
|
<button
|
|
v-if="data.me.troopId === troop.id && data.me.id === data.me.troopId"
|
|
class="legacy-button legacy-button--secondary"
|
|
@click="openKick(troop)"
|
|
>
|
|
부대원 추방...
|
|
</button>
|
|
<button
|
|
v-if="data.permission >= 4"
|
|
class="legacy-button legacy-button--info"
|
|
@click="openRename(troop)"
|
|
>
|
|
부대명 변경...
|
|
</button>
|
|
</div>
|
|
<div v-else-if="dialogKind === 'rename'" class="subDialog renameDialog">
|
|
<div class="subTitle bg1 center"><span>부대명 변경</span></div>
|
|
<div class="subForm">
|
|
<input v-model.trim="editName" class="formControl" type="text" aria-label="새 부대명" />
|
|
</div>
|
|
<div class="subBtnCancel">
|
|
<button class="legacy-button legacy-button--secondary" @click="closeDialog">취소</button>
|
|
</div>
|
|
<div class="subBtnOK">
|
|
<button class="legacy-button legacy-button--primary" @click="renameTroop(troop)">
|
|
변경
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else class="subDialog kickDialog">
|
|
<div class="subTitle bg1 center"><span>부대원 추방</span></div>
|
|
<div class="subForm">
|
|
<select v-model.number="kickTargetId" class="formControl" aria-label="추방할 부대원">
|
|
<option
|
|
v-for="member in troop.members.filter((candidate) => candidate.id !== troop.id)"
|
|
:key="member.id"
|
|
:value="member.id"
|
|
>
|
|
{{ member.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="subBtnCancel">
|
|
<button class="legacy-button legacy-button--secondary" @click="closeDialog">취소</button>
|
|
</div>
|
|
<div class="subBtnOK">
|
|
<button class="legacy-button legacy-button--primary" @click="kickMember(troop)">
|
|
추방
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="filler"><span class="dummy"></span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="data" class="additionalTroopOptions">
|
|
<div v-if="data.me.troopId === 0" class="makeNewTroop">
|
|
<div class="makeTitle bg1 center">부대 창설</div>
|
|
<input v-model.trim="createName" class="formControl troopNameField" type="text" aria-label="부대명" />
|
|
<button class="legacy-button legacy-button--secondary createButton" @click="makeTroop">
|
|
부대 창설
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="bottomBar bg0">
|
|
<button
|
|
class="legacy-button legacy-button--navigation legacyNavButton backLink"
|
|
type="button"
|
|
@click="router.push('/')"
|
|
>
|
|
돌아가기
|
|
</button>
|
|
<div></div>
|
|
</footer>
|
|
<div v-if="popupMember" id="generalPopup" :style="{ top: `${popupTop}px` }" role="tooltip">
|
|
<div class="popup-title">
|
|
<strong>{{ popupMember.name }}</strong>
|
|
<span>{{ popupMember.cityName }}</span>
|
|
</div>
|
|
<LegacyGeneralProgress :general="popupMember" />
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.legacy-troop-page {
|
|
position: relative;
|
|
margin: 0 auto;
|
|
color: #fff;
|
|
background: transparent;
|
|
font-family: var(--sammo-font-sans);
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.bg0 {
|
|
background-image: var(--sammo-texture-walnut);
|
|
}
|
|
|
|
.bg1 {
|
|
background-image: var(--sammo-texture-green);
|
|
}
|
|
|
|
.center {
|
|
text-align: center;
|
|
}
|
|
|
|
.topBackBar {
|
|
width: 100%;
|
|
height: 32px;
|
|
display: grid;
|
|
grid-template-columns: 90px 90px 1fr 90px 90px;
|
|
position: relative;
|
|
}
|
|
|
|
.topBackBar h2 {
|
|
margin: 0;
|
|
font-size: 24px;
|
|
font-weight: 500;
|
|
line-height: 32px;
|
|
text-align: center;
|
|
}
|
|
|
|
.legacyNavButton {
|
|
height: 32px;
|
|
min-height: 32px;
|
|
margin-right: 2px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.notice {
|
|
padding: 6px 10px;
|
|
border: 1px solid #477a47;
|
|
color: #d8f5d8;
|
|
}
|
|
|
|
.notice.error {
|
|
border-color: #9b4848;
|
|
color: #ffd0d0;
|
|
}
|
|
|
|
.loading {
|
|
padding: 16px;
|
|
text-align: center;
|
|
}
|
|
|
|
#generalPopup {
|
|
position: absolute;
|
|
z-index: 10;
|
|
width: 500px;
|
|
min-height: 58px;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid #999;
|
|
background: #202020;
|
|
}
|
|
|
|
.popup-title {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 3px 6px;
|
|
}
|
|
|
|
.additionalTroopOptions {
|
|
margin-top: 1em;
|
|
}
|
|
|
|
.makeNewTroop {
|
|
width: 240px;
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr;
|
|
border: 1px solid gray;
|
|
}
|
|
|
|
.makeTitle {
|
|
grid-column: 1/3;
|
|
padding: 0.15em;
|
|
font-size: 1.2em;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.troopDiffCityMemeber {
|
|
color: red;
|
|
}
|
|
|
|
.troopLeader {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.troopMember {
|
|
cursor: help;
|
|
}
|
|
|
|
.troopItem {
|
|
display: grid;
|
|
border-right: 1px solid gray;
|
|
}
|
|
|
|
.troopItem > div {
|
|
border-top: 1px solid gray;
|
|
border-left: 1px solid gray;
|
|
}
|
|
|
|
.troopInfo {
|
|
grid-column: 1/3;
|
|
grid-row: 1/2;
|
|
display: grid;
|
|
align-content: center;
|
|
text-align: center;
|
|
}
|
|
|
|
.troopTurn {
|
|
grid-column: 1/3;
|
|
grid-row: 2/3;
|
|
display: grid;
|
|
align-content: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.troopLeaderIcon {
|
|
grid-column: 3/4;
|
|
grid-row: 1/2;
|
|
display: grid;
|
|
align-content: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.troopLeaderIcon img {
|
|
object-fit: contain;
|
|
}
|
|
|
|
.troopLeaderName {
|
|
grid-column: 3/4;
|
|
grid-row: 2/3;
|
|
display: grid;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.troopReservedCommand {
|
|
grid-column: 4/5;
|
|
grid-row: 1/3;
|
|
overflow: hidden;
|
|
font-size: 85%;
|
|
text-align: left;
|
|
}
|
|
|
|
.troopMembers {
|
|
text-align: left;
|
|
padding: 0.5em 0.7em;
|
|
}
|
|
|
|
.troopAction {
|
|
grid-column: 6/7;
|
|
grid-row: 1/3;
|
|
}
|
|
|
|
.formControl {
|
|
width: 100%;
|
|
min-height: 31px;
|
|
box-sizing: border-box;
|
|
border: 1px solid #777;
|
|
border-radius: 4px;
|
|
padding: 4px 8px;
|
|
color: #eee;
|
|
background: #1b1b1b;
|
|
font: inherit;
|
|
}
|
|
|
|
.makeNewTroop .formControl {
|
|
color: #111;
|
|
background: #ddd;
|
|
}
|
|
|
|
.subForm,
|
|
.subBtnCancel,
|
|
.subBtnOK {
|
|
display: grid;
|
|
}
|
|
|
|
.bottomBar {
|
|
margin-top: 0;
|
|
padding-top: 20px;
|
|
}
|
|
|
|
.bottomBar .legacyNavButton {
|
|
width: 70px;
|
|
margin: 0;
|
|
padding-right: 5px;
|
|
padding-left: 5px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
@media (min-width: 501px) {
|
|
.legacy-troop-page {
|
|
width: 1000px;
|
|
}
|
|
|
|
#generalPopup {
|
|
left: 260px;
|
|
}
|
|
|
|
.troopItem {
|
|
grid-template-rows: 65px 28px 34.5px;
|
|
grid-template-columns: 65px 65px 130px 100px 1fr;
|
|
}
|
|
|
|
.troopMembers {
|
|
grid-column: 5/6;
|
|
grid-row: 1/3;
|
|
}
|
|
|
|
.troopItem:last-of-type {
|
|
border-bottom: 1px solid gray;
|
|
}
|
|
|
|
.filler {
|
|
grid-column: 1/2;
|
|
grid-row: 3/4;
|
|
display: grid;
|
|
align-content: center;
|
|
justify-content: right;
|
|
}
|
|
|
|
.dummy::after {
|
|
content: '└';
|
|
padding-right: 1.5ch;
|
|
}
|
|
|
|
.troopAction {
|
|
grid-column: 2/7;
|
|
grid-row: 3/4;
|
|
border-left-color: transparent !important;
|
|
}
|
|
|
|
.actionButtons {
|
|
display: grid;
|
|
grid-template-columns: 110px 110px 110px;
|
|
grid-template-rows: 33.5px;
|
|
}
|
|
|
|
.subDialog {
|
|
display: grid;
|
|
grid-template-columns: 90px 140px 50px 50px;
|
|
}
|
|
|
|
.subTitle {
|
|
display: grid;
|
|
align-content: center;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 500px) {
|
|
.legacy-troop-page {
|
|
width: 500px;
|
|
}
|
|
|
|
#generalPopup {
|
|
left: 0;
|
|
}
|
|
|
|
.troopItem {
|
|
grid-template-rows: 65px 28px auto;
|
|
grid-template-columns: 65px 65px 130px 100px 0 140px;
|
|
}
|
|
|
|
.troopMembers {
|
|
grid-column: 3/7;
|
|
grid-row: 3/4;
|
|
}
|
|
|
|
.troopItem:last-of-type .troopMembers {
|
|
border-bottom: 1px solid gray;
|
|
}
|
|
|
|
.filler {
|
|
grid-column: 1/3;
|
|
grid-row: 3/4;
|
|
border-top: 1px solid gray;
|
|
}
|
|
|
|
.actionButtons {
|
|
display: grid;
|
|
}
|
|
|
|
.subDialog {
|
|
height: 100%;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
grid-template-rows: 1fr 1fr 1fr;
|
|
}
|
|
|
|
.subTitle,
|
|
.subForm {
|
|
grid-column: 1/3;
|
|
display: grid;
|
|
align-content: center;
|
|
}
|
|
|
|
.makeNewTroop {
|
|
width: 240px;
|
|
}
|
|
}
|
|
</style>
|