merge: 최신 main을 사령부 고급모드 제어행 수정에 통합한다
This commit is contained in:
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||
import RichTooltip from '../ui/RichTooltip.vue';
|
||||
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||
@@ -30,6 +31,28 @@ interface ItemDisplayNames {
|
||||
item?: string | null;
|
||||
}
|
||||
|
||||
interface ItemDisplayInfo {
|
||||
horse?: string | null;
|
||||
weapon?: string | null;
|
||||
book?: string | null;
|
||||
item?: string | null;
|
||||
}
|
||||
|
||||
interface CrewTypeDisplayInfo {
|
||||
name: string;
|
||||
info: string[];
|
||||
requirements: string[];
|
||||
stats: {
|
||||
attack: number;
|
||||
defence: number;
|
||||
speed: number;
|
||||
avoid: number;
|
||||
magicCoef: number;
|
||||
cost: number;
|
||||
rice: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GeneralTroopDisplay {
|
||||
name: string;
|
||||
status: 'inactive' | 'present' | 'away';
|
||||
@@ -73,9 +96,12 @@ interface GeneralInfo {
|
||||
refreshScore?: GeneralRefreshScore;
|
||||
crewTypeId?: number;
|
||||
crewTypeName?: string;
|
||||
crewTypeInfo?: CrewTypeDisplayInfo | null;
|
||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
traitInfo?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
progression?: GeneralProgression;
|
||||
itemNames?: ItemDisplayNames;
|
||||
itemInfo?: ItemDisplayInfo;
|
||||
equipmentNames?: ItemDisplayNames;
|
||||
}
|
||||
|
||||
@@ -243,9 +269,36 @@ const specialText = computed(() => {
|
||||
</strong>
|
||||
</template>
|
||||
|
||||
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
|
||||
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
|
||||
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
|
||||
<span class="cell-label">명마</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="itemNames.horse ?? ''"
|
||||
:description="props.general.itemInfo?.horse"
|
||||
test-id="horse"
|
||||
>
|
||||
{{ itemNames.horse ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
<span class="cell-label">무기</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="itemNames.weapon ?? ''"
|
||||
:description="props.general.itemInfo?.weapon"
|
||||
test-id="weapon"
|
||||
>
|
||||
{{ itemNames.weapon ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
<span class="cell-label">서적</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="itemNames.book ?? ''"
|
||||
:description="props.general.itemInfo?.book"
|
||||
test-id="book"
|
||||
>
|
||||
{{ itemNames.book ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
|
||||
<span
|
||||
class="general-image general-crew-type-icon"
|
||||
@@ -255,15 +308,91 @@ const specialText = computed(() => {
|
||||
/>
|
||||
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong>
|
||||
<span class="cell-label">도구</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="itemNames.item ?? ''"
|
||||
:description="props.general.itemInfo?.item"
|
||||
test-id="item"
|
||||
>
|
||||
{{ itemNames.item ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
|
||||
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong>
|
||||
<span class="cell-label">병종</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="props.general.crewTypeName ?? ''"
|
||||
:description="props.general.crewTypeInfo?.info"
|
||||
test-id="crew-type"
|
||||
>
|
||||
{{ props.general.crewTypeName ?? '-' }}
|
||||
<template v-if="props.general.crewTypeInfo" #content>
|
||||
<span class="rich-tooltip-content__title">{{ props.general.crewTypeInfo.name }}</span>
|
||||
<span
|
||||
v-for="(line, index) in props.general.crewTypeInfo.info"
|
||||
:key="`crew-info:${index}`"
|
||||
class="rich-tooltip-content__line"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
<span class="rich-tooltip-content__section">전투 정보</span>
|
||||
<span class="rich-tooltip-content__meta">
|
||||
공격 {{ props.general.crewTypeInfo.stats.attack }} · 방어
|
||||
{{ props.general.crewTypeInfo.stats.defence }} · 속도
|
||||
{{ props.general.crewTypeInfo.stats.speed }} · 회피
|
||||
{{ props.general.crewTypeInfo.stats.avoid }}% · 계략
|
||||
{{ props.general.crewTypeInfo.stats.magicCoef }}%
|
||||
</span>
|
||||
<span class="rich-tooltip-content__meta">
|
||||
병사 100명 기준 금 {{ props.general.crewTypeInfo.stats.cost }} · 쌀
|
||||
{{ props.general.crewTypeInfo.stats.rice }}
|
||||
</span>
|
||||
<template v-if="props.general.crewTypeInfo.requirements.length">
|
||||
<span class="rich-tooltip-content__section">생성 조건</span>
|
||||
<span
|
||||
v-for="(requirement, index) in props.general.crewTypeInfo.requirements"
|
||||
:key="`crew-requirement:${index}`"
|
||||
class="rich-tooltip-content__line"
|
||||
>
|
||||
{{ requirement }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong>
|
||||
<span class="cell-label">성격</span>
|
||||
<strong>
|
||||
<RichTooltip
|
||||
:title="props.general.traits?.personal ?? ''"
|
||||
:description="props.general.traitInfo?.personal"
|
||||
test-id="personality"
|
||||
>
|
||||
{{ props.general.traits?.personal ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
|
||||
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
|
||||
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
|
||||
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong>
|
||||
<span class="cell-label">특기</span>
|
||||
<strong class="special-value" :aria-label="specialText">
|
||||
<RichTooltip
|
||||
:title="`내정특기 · ${props.general.traits?.specialDomestic ?? '-'}`"
|
||||
:description="props.general.traitInfo?.specialDomestic"
|
||||
test-id="special-domestic"
|
||||
>
|
||||
{{ props.general.traits?.specialDomestic ?? '-' }}
|
||||
</RichTooltip>
|
||||
/
|
||||
<RichTooltip
|
||||
:title="`전투특기 · ${props.general.traits?.specialWar ?? '-'}`"
|
||||
:description="props.general.traitInfo?.specialWar"
|
||||
test-id="special-war"
|
||||
>
|
||||
{{ props.general.traits?.specialWar ?? '-' }}
|
||||
</RichTooltip>
|
||||
</strong>
|
||||
|
||||
<span class="cell-label level-label">Lv</span>
|
||||
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import RichTooltip from '../ui/RichTooltip.vue';
|
||||
import { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
|
||||
import { formatOfficerLevelText } from '../../utils/nationFormat';
|
||||
import { getNpcColor } from '../../utils/npcColor';
|
||||
@@ -19,6 +20,7 @@ interface NationInfo {
|
||||
rice: number;
|
||||
tech: number;
|
||||
typeName: string;
|
||||
typeInfo?: string;
|
||||
typePros: string;
|
||||
typeCons: string;
|
||||
population: { cityCount: number; current: number; max: number };
|
||||
@@ -67,9 +69,37 @@ const displayChiefName = (chief: NationChief | undefined): string => {
|
||||
|
||||
<span class="head">성향</span>
|
||||
<strong class="body type-body">
|
||||
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||
<span class="cons">{{ props.nation.typeCons }}</span
|
||||
>)
|
||||
<RichTooltip
|
||||
v-if="props.nation.typeInfo"
|
||||
:title="`국가 성향 · ${props.nation.typeName}`"
|
||||
:description="props.nation.typeInfo"
|
||||
test-id="nation-type"
|
||||
>
|
||||
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||
<span class="cons">{{ props.nation.typeCons }}</span
|
||||
>)
|
||||
<template #content="{ descriptionLines }">
|
||||
<span class="rich-tooltip-content__title">국가 성향 · {{ props.nation.typeName }}</span>
|
||||
<span
|
||||
v-for="(line, index) in descriptionLines"
|
||||
:key="`nation-type-info:${index}`"
|
||||
class="rich-tooltip-content__line"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
<span class="rich-tooltip-content__line rich-tooltip-content__pros">
|
||||
장점 {{ props.nation.typePros || '-' }}
|
||||
</span>
|
||||
<span class="rich-tooltip-content__line rich-tooltip-content__cons">
|
||||
단점 {{ props.nation.typeCons || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</RichTooltip>
|
||||
<template v-else>
|
||||
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||
<span class="cons">{{ props.nation.typeCons }}</span
|
||||
>)
|
||||
</template>
|
||||
</strong>
|
||||
|
||||
<span class="head">{{ formatOfficerLevelText(12, props.nation.level) }}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveTournamentCoreStat,
|
||||
type TournamentBracketMatch,
|
||||
type TournamentBracketParticipant,
|
||||
type TournamentBracketSlot,
|
||||
} from '../../utils/tournamentBracket';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -17,6 +18,11 @@ const props = defineProps<{
|
||||
totalBet: number;
|
||||
tournamentType?: number;
|
||||
showLegend?: boolean;
|
||||
bettingOpen?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
requestBet: [slot: TournamentBracketSlot];
|
||||
}>();
|
||||
|
||||
const bracket = computed(() => buildTournamentBracket(props.participants, props.matches, props.winnerId));
|
||||
@@ -72,6 +78,10 @@ const odds = (id: number | null) => {
|
||||
const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0));
|
||||
const coreStat = (slot: (typeof bracket.value.top16.slots)[number]) =>
|
||||
resolveTournamentCoreStat(slot, props.tournamentType ?? 0);
|
||||
const requestBet = (slot: TournamentBracketSlot) => {
|
||||
if (!props.bettingOpen || slot.id === null) return;
|
||||
emit('requestBet', slot);
|
||||
};
|
||||
const mobilePairs = computed(() => {
|
||||
const column = roundColumns.value[activeMobileRound.value] ?? [];
|
||||
if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]);
|
||||
@@ -114,7 +124,10 @@ const mobilePairs = computed(() => {
|
||||
v-for="(slot, slotIndex) in column"
|
||||
:key="`${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
||||
class="desktop-bracket-name"
|
||||
:class="{ advanced: slot.advanced }"
|
||||
:class="{
|
||||
advanced: slot.advanced,
|
||||
'betting-target': columnIndex === 0 && bettingOpen && slot.id !== null,
|
||||
}"
|
||||
:data-general-id="slot.id ?? undefined"
|
||||
:style="{
|
||||
left: `${(desktopX[columnIndex]! / 1200) * 100}%`,
|
||||
@@ -122,6 +135,15 @@ const mobilePairs = computed(() => {
|
||||
}"
|
||||
>
|
||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||
<button
|
||||
v-if="columnIndex === 0 && bettingOpen && slot.id !== null"
|
||||
type="button"
|
||||
class="bracket-bet-button"
|
||||
:aria-label="`${slot.name}에게 베팅하기`"
|
||||
@click="requestBet(slot)"
|
||||
>
|
||||
베팅하기
|
||||
</button>
|
||||
<div v-if="columnIndex === 0" class="bracket-bet-summary">
|
||||
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
||||
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
||||
@@ -154,10 +176,22 @@ const mobilePairs = computed(() => {
|
||||
v-for="(slot, slotIndex) in pair"
|
||||
:key="`${slot.id ?? 'empty'}-${slotIndex}`"
|
||||
class="mobile-bracket-name"
|
||||
:class="{ advanced: slot.advanced }"
|
||||
:class="{
|
||||
advanced: slot.advanced,
|
||||
'betting-target': activeMobileRound === 0 && bettingOpen && slot.id !== null,
|
||||
}"
|
||||
:data-general-id="slot.id ?? undefined"
|
||||
>
|
||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||
<button
|
||||
v-if="activeMobileRound === 0 && bettingOpen && slot.id !== null"
|
||||
type="button"
|
||||
class="bracket-bet-button"
|
||||
:aria-label="`${slot.name}에게 베팅하기`"
|
||||
@click="requestBet(slot)"
|
||||
>
|
||||
베팅하기
|
||||
</button>
|
||||
<div v-if="activeMobileRound === 0" class="bracket-bet-summary">
|
||||
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
||||
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
||||
@@ -228,6 +262,36 @@ const mobilePairs = computed(() => {
|
||||
color: #fff;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
.betting-target {
|
||||
position: absolute;
|
||||
}
|
||||
.mobile-bracket-name.betting-target {
|
||||
position: relative;
|
||||
}
|
||||
.bracket-bet-button {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
min-width: 58px;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid #9a7632;
|
||||
border-radius: 3px;
|
||||
color: #fff3cd;
|
||||
background: #59400e;
|
||||
font: 700 11px/1 var(--sammo-font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bracket-bet-button:hover,
|
||||
.bracket-bet-button:focus {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
.bracket-bet-button:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.desktop-bracket-name.advanced,
|
||||
.mobile-bracket-name.advanced {
|
||||
border-color: #ff4b4b;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
|
||||
import tippy, { type Instance, type Placement } from 'tippy.js';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string | readonly string[] | null;
|
||||
placement?: Placement;
|
||||
maxWidth?: number;
|
||||
testId?: string;
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
description: null,
|
||||
placement: 'top',
|
||||
maxWidth: 360,
|
||||
testId: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
const triggerElement = ref<HTMLElement | null>(null);
|
||||
const contentElement = ref<HTMLElement | null>(null);
|
||||
let instance: Instance | null = null;
|
||||
|
||||
const descriptionLines = computed(() => {
|
||||
const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
|
||||
return source
|
||||
.flatMap((line) => line.split(/<br\s*\/?\s*>|\r?\n/giu))
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const hasContent = computed(() => Boolean(slots.content) || descriptionLines.value.length > 0);
|
||||
|
||||
const destroyTooltip = () => {
|
||||
instance?.destroy();
|
||||
instance = null;
|
||||
};
|
||||
|
||||
const installTooltip = async () => {
|
||||
destroyTooltip();
|
||||
await nextTick();
|
||||
if (!hasContent.value || !triggerElement.value || !contentElement.value) return;
|
||||
|
||||
instance = tippy(triggerElement.value, {
|
||||
allowHTML: true,
|
||||
appendTo: () => document.body,
|
||||
content: () => contentElement.value?.innerHTML ?? '',
|
||||
maxWidth: props.maxWidth,
|
||||
placement: props.placement,
|
||||
theme: 'sammo-rich',
|
||||
trigger: 'mouseenter focus',
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => void installTooltip());
|
||||
onBeforeUnmount(destroyTooltip);
|
||||
watch(
|
||||
() => [props.title, props.description, props.placement, props.maxWidth],
|
||||
() => void installTooltip(),
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="triggerElement"
|
||||
class="rich-tooltip-trigger"
|
||||
:class="{ 'rich-tooltip-trigger--enabled': hasContent }"
|
||||
:tabindex="hasContent ? 0 : undefined"
|
||||
:data-rich-tooltip="props.testId"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<span ref="contentElement" class="rich-tooltip-template" hidden aria-hidden="true">
|
||||
<slot name="content" :description-lines="descriptionLines">
|
||||
<span v-if="props.title" class="rich-tooltip-content__title">{{ props.title }}</span>
|
||||
<span
|
||||
v-for="(line, index) in descriptionLines"
|
||||
:key="`${index}:${line}`"
|
||||
class="rich-tooltip-content__line"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.rich-tooltip-trigger {
|
||||
display: inline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rich-tooltip-trigger--enabled {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted rgb(150 210 255 / 85%);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.rich-tooltip-trigger--enabled:focus-visible {
|
||||
border-radius: 2px;
|
||||
outline: 1px solid #6fc7ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'] {
|
||||
border: 1px solid #8c8c8c;
|
||||
border-radius: 3px;
|
||||
background: #101010;
|
||||
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
|
||||
color: #f5f5f5;
|
||||
font-family: Pretendard, sans-serif;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'][data-placement^='top'] > .tippy-arrow::before {
|
||||
border-top-color: #101010;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'][data-placement^='bottom'] > .tippy-arrow::before {
|
||||
border-bottom-color: #101010;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'][data-placement^='left'] > .tippy-arrow::before {
|
||||
border-left-color: #101010;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'][data-placement^='right'] > .tippy-arrow::before {
|
||||
border-right-color: #101010;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~='sammo-rich'] .tippy-content {
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__title,
|
||||
.rich-tooltip-content__line,
|
||||
.rich-tooltip-content__section,
|
||||
.rich-tooltip-content__meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__title {
|
||||
margin-bottom: 4px;
|
||||
color: #7fd4ff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__line + .rich-tooltip-content__line {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__section {
|
||||
margin-top: 5px;
|
||||
border-top: 1px solid #4d4d4d;
|
||||
padding-top: 4px;
|
||||
color: #ffdc76;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__meta {
|
||||
color: #d5d5d5;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__pros {
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
.rich-tooltip-content__cons {
|
||||
color: magenta;
|
||||
}
|
||||
</style>
|
||||
@@ -13,3 +13,18 @@ export const tournamentStageNames = [
|
||||
] as const;
|
||||
|
||||
export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중';
|
||||
|
||||
export interface TournamentSectionVisibility {
|
||||
preliminary: boolean;
|
||||
final: boolean;
|
||||
knockout: boolean;
|
||||
}
|
||||
|
||||
export const resolveTournamentSectionVisibility = (stage: number, winnerId?: number): TournamentSectionVisibility => {
|
||||
const completed = stage === 0 && winnerId !== undefined;
|
||||
return {
|
||||
preliminary: stage >= 1 || completed,
|
||||
final: stage >= 3 || completed,
|
||||
knockout: stage >= 5 || completed,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||
import type { TournamentBracketSlot } from '../utils/tournamentBracket';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||
@@ -15,6 +16,11 @@ const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const message = ref<string | null>(null);
|
||||
const amounts = ref<Record<number, number>>({});
|
||||
const selectedTarget = ref<TournamentBracketSlot | null>(null);
|
||||
const betDialog = ref<HTMLDialogElement | null>(null);
|
||||
const betAmountSelect = ref<HTMLSelectElement | null>(null);
|
||||
const placingBet = ref(false);
|
||||
const betError = ref<string | null>(null);
|
||||
const activeRankingPrefix = ref('tt');
|
||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||
const stageNames = [
|
||||
@@ -49,27 +55,6 @@ const load = async () => {
|
||||
};
|
||||
onMounted(() => void load());
|
||||
|
||||
const participantMap = computed(
|
||||
() => new Map((snapshot.value?.participants ?? []).map((participant) => [participant.id, participant]))
|
||||
);
|
||||
const final16Ids = computed(() =>
|
||||
(snapshot.value?.matches ?? [])
|
||||
.filter((match) => match.stage === 7)
|
||||
.sort((a, b) => a.roundIndex - b.roundIndex)
|
||||
.flatMap((match) => [match.attackerId, match.defenderId])
|
||||
);
|
||||
const candidates = computed(() =>
|
||||
Array.from({ length: 16 }, (_, index) => {
|
||||
const id = final16Ids.value[index] ?? 0;
|
||||
const participant = id ? participantMap.value.get(id) : null;
|
||||
return {
|
||||
id,
|
||||
name: id ? (participant?.name ?? `#${id}`) : '-',
|
||||
picture: participant?.picture ?? null,
|
||||
imageServer: participant?.imageServer ?? 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
||||
const myAmount = computed(() => summary.value?.myAmount ?? 0);
|
||||
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
|
||||
@@ -82,12 +67,25 @@ const ratio = (id: number) => {
|
||||
const openingTime = computed(() =>
|
||||
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||
);
|
||||
const expected = (id: number) => {
|
||||
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
|
||||
const current = myTotals?.[id] ?? 0;
|
||||
const numericRatio = Number(ratio(id));
|
||||
return Number.isFinite(numericRatio) ? Math.floor(current * numericRatio) : 0;
|
||||
};
|
||||
const selectedAmount = computed({
|
||||
get: () => {
|
||||
const targetId = selectedTarget.value?.id;
|
||||
return targetId === null || targetId === undefined ? 10 : (amounts.value[targetId] ?? 10);
|
||||
},
|
||||
set: (amount: number) => {
|
||||
const targetId = selectedTarget.value?.id;
|
||||
if (targetId === null || targetId === undefined) return;
|
||||
amounts.value[targetId] = amount;
|
||||
},
|
||||
});
|
||||
const selectedRatio = computed(() => {
|
||||
const targetId = selectedTarget.value?.id;
|
||||
return targetId === null || targetId === undefined ? '0' : ratio(targetId);
|
||||
});
|
||||
const selectedExpectedReturn = computed(() => {
|
||||
const numericRatio = Number(selectedRatio.value);
|
||||
return Number.isFinite(numericRatio) ? Math.round(selectedAmount.value * numericRatio) : 0;
|
||||
});
|
||||
const bettingOpen = computed(() => {
|
||||
const state = snapshot.value?.state;
|
||||
if (!state || state.stage !== 6) return false;
|
||||
@@ -95,17 +93,35 @@ const bettingOpen = computed(() => {
|
||||
return new Date(state.bettingCloseAt).getTime() > Date.now();
|
||||
});
|
||||
|
||||
const placeBet = async (targetId: number) => {
|
||||
if (!targetId) return;
|
||||
const amount = amounts.value[targetId] ?? 10;
|
||||
const openBetDialog = async (target: TournamentBracketSlot) => {
|
||||
if (target.id === null || !bettingOpen.value) return;
|
||||
selectedTarget.value = target;
|
||||
betError.value = null;
|
||||
if (amounts.value[target.id] === undefined) amounts.value[target.id] = 10;
|
||||
await nextTick();
|
||||
betDialog.value?.showModal();
|
||||
betAmountSelect.value?.focus();
|
||||
};
|
||||
const closeBetDialog = () => {
|
||||
betDialog.value?.close();
|
||||
};
|
||||
const placeBet = async () => {
|
||||
const targetId = selectedTarget.value?.id;
|
||||
if (targetId === null || targetId === undefined || placingBet.value) return;
|
||||
const amount = selectedAmount.value;
|
||||
message.value = null;
|
||||
betError.value = null;
|
||||
placingBet.value = true;
|
||||
try {
|
||||
await trpc.tournament.placeBet.mutate({ targetId, amount });
|
||||
message.value = '베팅이 등록되었습니다.';
|
||||
} catch (value) {
|
||||
message.value = errorText(value);
|
||||
} finally {
|
||||
await load();
|
||||
closeBetDialog();
|
||||
} catch (value) {
|
||||
betError.value = errorText(value);
|
||||
message.value = betError.value;
|
||||
} finally {
|
||||
placingBet.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -139,47 +155,57 @@ const placeBet = async (targetId: number) => {
|
||||
:total-bet="totalAmount"
|
||||
:tournament-type="snapshot?.state?.type ?? 0"
|
||||
:show-legend="false"
|
||||
:betting-open="bettingOpen"
|
||||
@request-bet="openBetDialog"
|
||||
/>
|
||||
|
||||
<section class="candidate-table bg0">
|
||||
<div class="candidate-grid">
|
||||
<article v-for="candidate in candidates" :key="candidate.id || candidate.name" class="candidate-card">
|
||||
<GeneralIdentity
|
||||
:name="candidate.name"
|
||||
:picture="candidate.picture"
|
||||
:image-server="candidate.imageServer"
|
||||
/>
|
||||
<div class="candidate-return">
|
||||
<span class="ratio-color">{{ ratio(candidate.id) }}</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
<span class="gold-color">{{ amounts[candidate.id] ?? 10 }}</span>
|
||||
<span aria-hidden="true">=</span>
|
||||
<strong class="return-color">{{ expected(candidate.id) }}</strong>
|
||||
</div>
|
||||
<div v-if="bettingOpen" class="candidate-actions">
|
||||
<select
|
||||
v-model.number="amounts[candidate.id]"
|
||||
:aria-label="`${candidate.name} 베팅 금액`"
|
||||
:disabled="!candidate.id"
|
||||
>
|
||||
<option :value="10">금10</option>
|
||||
<option :value="20">금20</option>
|
||||
<option :value="50">금50</option>
|
||||
<option :value="100">금100</option>
|
||||
<option :value="200">금200</option>
|
||||
<option :value="500">금500</option>
|
||||
<option :value="1000">최대</option>
|
||||
</select>
|
||||
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p class="candidate-help">
|
||||
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
|
||||
<span class="return-color">적중시 환수금</span><br />
|
||||
<span class="ratio-color">( 베팅후 500원 이하일땐 베팅이 불가능합니다. )</span>
|
||||
</p>
|
||||
</section>
|
||||
<dialog
|
||||
ref="betDialog"
|
||||
class="bet-dialog"
|
||||
aria-labelledby="bet-dialog-title"
|
||||
@close="selectedTarget = null"
|
||||
>
|
||||
<form v-if="selectedTarget" class="bet-dialog-content" @submit.prevent="placeBet">
|
||||
<header>
|
||||
<h2 id="bet-dialog-title">베팅하기</h2>
|
||||
<button type="button" aria-label="베팅 창 닫기" :disabled="placingBet" @click="closeBetDialog">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<GeneralIdentity
|
||||
:name="selectedTarget.name"
|
||||
:picture="selectedTarget.picture"
|
||||
:image-server="selectedTarget.imageServer"
|
||||
/>
|
||||
<label class="bet-amount-field">
|
||||
<span>베팅 금액</span>
|
||||
<select ref="betAmountSelect" v-model.number="selectedAmount" :disabled="placingBet">
|
||||
<option :value="10">금10</option>
|
||||
<option :value="20">금20</option>
|
||||
<option :value="50">금50</option>
|
||||
<option :value="100">금100</option>
|
||||
<option :value="200">금200</option>
|
||||
<option :value="500">금500</option>
|
||||
<option :value="1000">최대 금1000</option>
|
||||
</select>
|
||||
</label>
|
||||
<output class="bet-return-preview" aria-live="polite">
|
||||
<span class="ratio-color">배당 {{ selectedRatio }}</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
<span class="gold-color">금{{ selectedAmount }}</span>
|
||||
<span aria-hidden="true">=</span>
|
||||
<strong class="return-color">예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }}</strong>
|
||||
</output>
|
||||
<p class="bet-preview-note">현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 수 있습니다.</p>
|
||||
<p v-if="betError" class="bet-dialog-error" role="alert">{{ betError }}</p>
|
||||
<footer>
|
||||
<button type="button" :disabled="placingBet" @click="closeBetDialog">취소</button>
|
||||
<button type="submit" class="bet-submit" :disabled="placingBet">
|
||||
{{ placingBet ? '등록 중...' : '베팅 등록' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div class="legacy-table-signature" hidden>
|
||||
<table v-for="tableIndex in 6" :key="tableIndex">
|
||||
@@ -336,36 +362,6 @@ const placeBet = async (targetId: number) => {
|
||||
color: orange;
|
||||
font-size: 14px;
|
||||
}
|
||||
.candidate-table {
|
||||
border: 1px solid gray;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.candidate-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.candidate-card {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid #5b504b;
|
||||
background: rgb(0 0 0 / 26%);
|
||||
text-align: left;
|
||||
}
|
||||
.candidate-return {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.candidate-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 64px;
|
||||
gap: 6px;
|
||||
}
|
||||
.ratio-color {
|
||||
color: skyblue;
|
||||
}
|
||||
@@ -376,8 +372,7 @@ const placeBet = async (targetId: number) => {
|
||||
.gold-color {
|
||||
color: orange;
|
||||
}
|
||||
select,
|
||||
.candidate-actions button {
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 27px;
|
||||
padding: 2px 1px;
|
||||
@@ -407,11 +402,84 @@ select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.candidate-help {
|
||||
min-height: 20px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 18px;
|
||||
line-height: 14px;
|
||||
.bet-dialog {
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
border: 1px solid #8d713d;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: #3a2118 var(--sammo-texture-walnut);
|
||||
box-shadow: 0 18px 56px rgb(0 0 0 / 75%);
|
||||
}
|
||||
.bet-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 72%);
|
||||
}
|
||||
.bet-dialog-content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
.bet-dialog-content header,
|
||||
.bet-dialog-content footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.bet-dialog-content h2 {
|
||||
margin: 0;
|
||||
color: #ffd25e;
|
||||
font-size: 20px;
|
||||
}
|
||||
.bet-dialog-content header button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
.bet-dialog-content :deep(.general-identity) {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.bet-amount-field {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.bet-return-preview {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 12px;
|
||||
border: 1px solid #66563c;
|
||||
background: rgb(0 0 0 / 28%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bet-preview-note,
|
||||
.bet-dialog-error {
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bet-preview-note {
|
||||
color: #c9c1b2;
|
||||
}
|
||||
.bet-dialog-error {
|
||||
color: #ff8080;
|
||||
}
|
||||
.bet-dialog-content footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.bet-dialog-content footer button {
|
||||
min-width: 80px;
|
||||
}
|
||||
.bet-dialog-content .bet-submit {
|
||||
border-color: #9a7632;
|
||||
background: #59400e;
|
||||
}
|
||||
.ranking-title {
|
||||
min-height: 50px;
|
||||
@@ -490,25 +558,12 @@ select:disabled {
|
||||
.ranking-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.candidate-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.bet-return-preview {
|
||||
grid-template-columns: auto auto auto;
|
||||
}
|
||||
.candidate-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
.candidate-return {
|
||||
margin: 0;
|
||||
}
|
||||
.candidate-actions {
|
||||
.bet-return-preview .return-color {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.candidate-help {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.ranking-placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||
|
||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||
|
||||
@@ -18,6 +18,7 @@ const actionMessage = ref<string | null>(null);
|
||||
const adminEnabled = ref(false);
|
||||
const activeFinalGroup = ref(0);
|
||||
const activePreliminaryGroup = ref(0);
|
||||
const tournamentContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||
const typeStatNames = ['종합', '통솔', '무력', '지력'];
|
||||
@@ -63,6 +64,12 @@ const myBetTotals = computed(() => betting.value?.myTotals as Record<number, num
|
||||
const isParticipant = computed(() =>
|
||||
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
||||
);
|
||||
const sectionVisibility = computed(() =>
|
||||
resolveTournamentSectionVisibility(snapshot.value?.state?.stage ?? 0, snapshot.value?.state?.winnerId)
|
||||
);
|
||||
const preliminaryGroupIdOf = (participant: Snapshot['participants'][number]): number | undefined =>
|
||||
participant.preliminaryGroupId ??
|
||||
(participant.groupId !== undefined && participant.groupId < 8 ? participant.groupId : undefined);
|
||||
const groups = computed(() =>
|
||||
Array.from({ length: 8 }, (_, index) =>
|
||||
(snapshot.value?.participants ?? [])
|
||||
@@ -74,10 +81,7 @@ const preliminaryGroups = computed(() =>
|
||||
Array.from({ length: 8 }, (_, index) =>
|
||||
(snapshot.value?.participants ?? [])
|
||||
.filter((participant) => {
|
||||
const groupId =
|
||||
participant.preliminaryGroupId ??
|
||||
(participant.groupId !== undefined && participant.groupId < 8 ? participant.groupId : undefined);
|
||||
return groupId === index;
|
||||
return preliminaryGroupIdOf(participant) === index;
|
||||
})
|
||||
.map((participant) => ({
|
||||
...participant,
|
||||
@@ -113,15 +117,38 @@ const currentMatch = computed(() => {
|
||||
return matchesAt(state.stage).find((match) => !match.winnerId) ?? matchesAt(state.stage)[state.phase] ?? null;
|
||||
});
|
||||
|
||||
const revealMyPreliminaryGroup = async (): Promise<number | undefined> => {
|
||||
const participant = (snapshot.value?.participants ?? []).find((entry) => entry.id === myGeneralId.value);
|
||||
if (!participant) return undefined;
|
||||
|
||||
const groupId = preliminaryGroupIdOf(participant);
|
||||
if (groupId === undefined || groupId < 0 || groupId >= groupNames.length) return undefined;
|
||||
|
||||
activePreliminaryGroup.value = groupId;
|
||||
await nextTick();
|
||||
tournamentContainer.value
|
||||
?.querySelector<HTMLElement>(`[data-preliminary-group="${groupId}"]`)
|
||||
?.scrollIntoView({ block: 'center' });
|
||||
return groupId;
|
||||
};
|
||||
|
||||
const join = async () => {
|
||||
actionMessage.value = null;
|
||||
let joined = false;
|
||||
try {
|
||||
await trpc.tournament.join.mutate();
|
||||
actionMessage.value = '참가 신청이 반영되었습니다.';
|
||||
joined = true;
|
||||
} catch (value) {
|
||||
actionMessage.value = errorText(value);
|
||||
} finally {
|
||||
await load();
|
||||
if (joined) {
|
||||
const groupId = await revealMyPreliminaryGroup();
|
||||
actionMessage.value =
|
||||
groupId === undefined
|
||||
? '참가 신청이 반영되었습니다.'
|
||||
: `참가 신청이 반영되었습니다. ${groupNames[groupId]}조에 배정되었습니다.`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,7 +186,7 @@ const start = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main id="tournament-container" class="legacy-page">
|
||||
<main id="tournament-container" ref="tournamentContainer" class="legacy-page">
|
||||
<TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" />
|
||||
|
||||
<section class="toolbar bg0">
|
||||
@@ -183,145 +210,152 @@ const start = async () => {
|
||||
({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
|
||||
{{ snapshot?.state?.termSeconds ?? '-' }}초)
|
||||
</section>
|
||||
<section class="section-title bg2">16강 승자전</section>
|
||||
<template v-if="sectionVisibility.knockout">
|
||||
<section class="section-title bg2">16강 승자전</section>
|
||||
|
||||
<TournamentBracket
|
||||
class="bg0"
|
||||
:participants="snapshot?.participants ?? []"
|
||||
:matches="snapshot?.matches ?? []"
|
||||
:winner-id="snapshot?.state?.winnerId"
|
||||
:bet-totals="betTotals"
|
||||
:my-bet-totals="myBetTotals"
|
||||
:total-bet="totalBet"
|
||||
:tournament-type="snapshot?.state?.type ?? 0"
|
||||
/>
|
||||
<TournamentBracket
|
||||
class="bg0"
|
||||
:participants="snapshot?.participants ?? []"
|
||||
:matches="snapshot?.matches ?? []"
|
||||
:winner-id="snapshot?.state?.winnerId"
|
||||
:bet-totals="betTotals"
|
||||
:my-bet-totals="myBetTotals"
|
||||
:total-bet="totalBet"
|
||||
:tournament-type="snapshot?.state?.type ?? 0"
|
||||
/>
|
||||
|
||||
<section v-if="currentMatch" class="fight bg0">
|
||||
<h2>{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}</h2>
|
||||
<p v-for="(line, index) in currentMatch.log ?? []" :key="index">{{ line }}</p>
|
||||
</section>
|
||||
<section v-if="currentMatch" class="fight bg0">
|
||||
<h2>{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}</h2>
|
||||
<p v-for="(line, index) in currentMatch.log ?? []" :key="index">{{ line }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
||||
<div class="group-tabs bg0" role="tablist" aria-label="본선 조 선택">
|
||||
<button
|
||||
v-for="(groupName, groupIndex) in groupNames"
|
||||
:key="`final-tab-${groupName}`"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeFinalGroup === groupIndex"
|
||||
:class="{ active: activeFinalGroup === groupIndex }"
|
||||
@click="activeFinalGroup = groupIndex"
|
||||
>
|
||||
{{ groupName }}조
|
||||
</button>
|
||||
</div>
|
||||
<section class="group-grid bg0">
|
||||
<table
|
||||
v-for="(group, groupIndex) in groups"
|
||||
:key="groupIndex"
|
||||
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
|
||||
>
|
||||
<caption>
|
||||
{{
|
||||
groupNames[groupIndex]
|
||||
}}조
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>순</th>
|
||||
<th>장수</th>
|
||||
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||
<th>경</th>
|
||||
<th>승</th>
|
||||
<th>무</th>
|
||||
<th>패</th>
|
||||
<th>점</th>
|
||||
<th>득</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rowIndex in 4" :key="rowIndex">
|
||||
<td>{{ rowIndex }}</td>
|
||||
<td class="general-cell">
|
||||
<GeneralIdentity
|
||||
v-if="group[rowIndex - 1]"
|
||||
:name="group[rowIndex - 1]!.name"
|
||||
:picture="group[rowIndex - 1]!.picture"
|
||||
:image-server="group[rowIndex - 1]!.imageServer"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<template v-if="sectionVisibility.final">
|
||||
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
||||
<div class="group-tabs bg0" role="tablist" aria-label="본선 조 선택">
|
||||
<button
|
||||
v-for="(groupName, groupIndex) in groupNames"
|
||||
:key="`final-tab-${groupName}`"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeFinalGroup === groupIndex"
|
||||
:class="{ active: activeFinalGroup === groupIndex }"
|
||||
@click="activeFinalGroup = groupIndex"
|
||||
>
|
||||
{{ groupName }}조
|
||||
</button>
|
||||
</div>
|
||||
<section class="group-grid bg0">
|
||||
<table
|
||||
v-for="(group, groupIndex) in groups"
|
||||
:key="groupIndex"
|
||||
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
|
||||
>
|
||||
<caption>
|
||||
{{
|
||||
groupNames[groupIndex]
|
||||
}}조
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>순</th>
|
||||
<th>장수</th>
|
||||
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||
<th>경</th>
|
||||
<th>승</th>
|
||||
<th>무</th>
|
||||
<th>패</th>
|
||||
<th>점</th>
|
||||
<th>득</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rowIndex in 4" :key="rowIndex">
|
||||
<td>{{ rowIndex }}</td>
|
||||
<td class="general-cell">
|
||||
<GeneralIdentity
|
||||
v-if="group[rowIndex - 1]"
|
||||
:name="group[rowIndex - 1]!.name"
|
||||
:picture="group[rowIndex - 1]!.picture"
|
||||
:image-server="group[rowIndex - 1]!.imageServer"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section class="section-title groups-title bg2">조별 예선 순위</section>
|
||||
<div class="group-tabs bg0" role="tablist" aria-label="예선 조 선택">
|
||||
<button
|
||||
v-for="(groupName, groupIndex) in groupNames"
|
||||
:key="`preliminary-tab-${groupName}`"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activePreliminaryGroup === groupIndex"
|
||||
:class="{ active: activePreliminaryGroup === groupIndex }"
|
||||
@click="activePreliminaryGroup = groupIndex"
|
||||
>
|
||||
{{ groupName }}조
|
||||
</button>
|
||||
</div>
|
||||
<section class="group-grid preliminary-grid bg0">
|
||||
<table
|
||||
v-for="(group, groupIndex) in preliminaryGroups"
|
||||
:key="`preliminary-${groupIndex}`"
|
||||
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
|
||||
>
|
||||
<caption>
|
||||
{{
|
||||
groupNames[groupIndex]
|
||||
}}조
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>순</th>
|
||||
<th>장수</th>
|
||||
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||
<th>경</th>
|
||||
<th>승</th>
|
||||
<th>무</th>
|
||||
<th>패</th>
|
||||
<th>점</th>
|
||||
<th>득</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rowIndex in 8" :key="rowIndex">
|
||||
<td>{{ rowIndex }}</td>
|
||||
<td class="general-cell">
|
||||
<GeneralIdentity
|
||||
v-if="group[rowIndex - 1]"
|
||||
:name="group[rowIndex - 1]!.name"
|
||||
:picture="group[rowIndex - 1]!.picture"
|
||||
:image-server="group[rowIndex - 1]!.imageServer"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<template v-if="sectionVisibility.preliminary">
|
||||
<section class="section-title groups-title bg2">조별 예선 순위</section>
|
||||
<div class="group-tabs bg0" role="tablist" aria-label="예선 조 선택">
|
||||
<button
|
||||
v-for="(groupName, groupIndex) in groupNames"
|
||||
:key="`preliminary-tab-${groupName}`"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activePreliminaryGroup === groupIndex"
|
||||
:class="{ active: activePreliminaryGroup === groupIndex }"
|
||||
@click="activePreliminaryGroup = groupIndex"
|
||||
>
|
||||
{{ groupName }}조
|
||||
</button>
|
||||
</div>
|
||||
<section class="group-grid preliminary-grid bg0">
|
||||
<table
|
||||
v-for="(group, groupIndex) in preliminaryGroups"
|
||||
:key="`preliminary-${groupIndex}`"
|
||||
:data-preliminary-group="groupIndex"
|
||||
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
|
||||
>
|
||||
<caption>
|
||||
{{
|
||||
groupNames[groupIndex]
|
||||
}}조
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>순</th>
|
||||
<th>장수</th>
|
||||
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||
<th>경</th>
|
||||
<th>승</th>
|
||||
<th>무</th>
|
||||
<th>패</th>
|
||||
<th>점</th>
|
||||
<th>득</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rowIndex in 8" :key="rowIndex">
|
||||
<td>{{ rowIndex }}</td>
|
||||
<td class="general-cell">
|
||||
<GeneralIdentity
|
||||
v-if="group[rowIndex - 1]"
|
||||
:name="group[rowIndex - 1]!.name"
|
||||
:picture="group[rowIndex - 1]!.picture"
|
||||
:image-server="group[rowIndex - 1]!.imageServer"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div class="legacy-bracket-table-signature" hidden>
|
||||
<table v-for="(rowCount, tableIndex) in [11, 11, 11, 10]" :key="tableIndex">
|
||||
|
||||
Reference in New Issue
Block a user