feat: 토너먼트 뷰 추가 및 라우터 설정
This commit is contained in:
@@ -13,6 +13,7 @@ import BattleCenterView from '../views/BattleCenterView.vue';
|
||||
import BattleSimulatorView from '../views/BattleSimulatorView.vue';
|
||||
import NpcControlView from '../views/NpcControlView.vue';
|
||||
import NotFoundView from '../views/NotFoundView.vue';
|
||||
import TournamentView from '../views/TournamentView.vue';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
const routes = [
|
||||
@@ -120,6 +121,15 @@ const routes = [
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/tournament',
|
||||
name: 'tournament',
|
||||
component: TournamentView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
|
||||
@@ -98,6 +98,7 @@ watch(
|
||||
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
|
||||
<RouterLink class="ghost" to="/battle-center">감찰부</RouterLink>
|
||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||
<RouterLink class="ghost" to="/tournament">토너먼트</RouterLink>
|
||||
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
|
||||
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,787 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
|
||||
type TournamentSnapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||
type TournamentBettingSummary = Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>>;
|
||||
type GeneralMe = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const snapshot = ref<TournamentSnapshot | null>(null);
|
||||
const bettingSummary = ref<TournamentBettingSummary | null>(null);
|
||||
const myGeneral = ref<GeneralMe | null>(null);
|
||||
const lastActionMessage = ref<string | null>(null);
|
||||
|
||||
const mobileTabs = [
|
||||
{ key: 'status', label: '상태' },
|
||||
{ key: 'bracket', label: '대진' },
|
||||
{ key: 'betting', label: '베팅' },
|
||||
] as const;
|
||||
|
||||
type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
||||
const mobileTab = ref<MobileTabKey>('status');
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown_error';
|
||||
};
|
||||
|
||||
const loadTournament = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
lastActionMessage.value = null;
|
||||
|
||||
try {
|
||||
const [snapshotResult, bettingResult, generalResult] = await Promise.all([
|
||||
trpc.tournament.getSnapshot.query(),
|
||||
trpc.tournament.getBettingSummary.query(),
|
||||
trpc.general.me.query(),
|
||||
]);
|
||||
snapshot.value = snapshotResult;
|
||||
bettingSummary.value = bettingResult;
|
||||
myGeneral.value = generalResult;
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadTournament();
|
||||
});
|
||||
|
||||
const stageLabelMap: Record<number, string> = {
|
||||
0: '경기 없음',
|
||||
1: '참가 모집중',
|
||||
2: '예선 진행중',
|
||||
3: '본선 추첨중',
|
||||
4: '본선 진행중',
|
||||
5: '16강 배정중',
|
||||
6: '베팅 진행중',
|
||||
7: '16강 진행중',
|
||||
8: '8강 진행중',
|
||||
9: '4강 진행중',
|
||||
10: '결승 진행중',
|
||||
};
|
||||
|
||||
const typeLabelMap: Record<number, string> = {
|
||||
0: '전력전',
|
||||
1: '통솔전',
|
||||
2: '일기토',
|
||||
3: '설전',
|
||||
};
|
||||
|
||||
const stageLabel = computed(() => {
|
||||
const state = snapshot.value?.state;
|
||||
if (!state) {
|
||||
return '토너먼트 정보를 불러오는 중';
|
||||
}
|
||||
if (state.stage === 2 || state.stage === 4) {
|
||||
return `예선 진행중(페이즈 ${state.phase + 1})`;
|
||||
}
|
||||
return stageLabelMap[state.stage] ?? '토너먼트 상태 확인 중';
|
||||
});
|
||||
|
||||
const bettingCloseLabel = computed(() => {
|
||||
const closeAt = snapshot.value?.state?.bettingCloseAt;
|
||||
if (!closeAt) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(closeAt);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return `${date.toLocaleString('ko-KR')} 마감`;
|
||||
});
|
||||
|
||||
const bracketEntries = computed(() => {
|
||||
const matches = snapshot.value?.matches ?? [];
|
||||
const participants = snapshot.value?.participants ?? [];
|
||||
const nameMap = new Map(participants.map((entry) => [entry.id, entry.name]));
|
||||
|
||||
return matches.map((match) => ({
|
||||
...match,
|
||||
attackerName: nameMap.get(match.attackerId) ?? `#${match.attackerId}`,
|
||||
defenderName: nameMap.get(match.defenderId) ?? `#${match.defenderId}`,
|
||||
winnerName: match.winnerId ? nameMap.get(match.winnerId) ?? `#${match.winnerId}` : null,
|
||||
}));
|
||||
});
|
||||
|
||||
const stageMatches = (stage: number) => bracketEntries.value.filter((match) => match.stage === stage);
|
||||
|
||||
const currentMatch = computed(() => {
|
||||
const state = snapshot.value?.state;
|
||||
if (!state || state.stage < 7 || state.stage > 10) {
|
||||
return null;
|
||||
}
|
||||
const matches = stageMatches(state.stage).sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex);
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const fallbackIndex = Math.min(state.phase, matches.length - 1);
|
||||
return matches[fallbackIndex] ?? matches.find((match) => !match.winnerId) ?? matches[0];
|
||||
});
|
||||
|
||||
const extractEnergy = (logs?: string[]) => {
|
||||
if (!logs || logs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const line = [...logs].reverse().find((entry) => entry.includes('合')) ?? null;
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
const values: number[] = [];
|
||||
const regex = /<C>(\d+)<\/>/g;
|
||||
let match: RegExpExecArray | null = null;
|
||||
while ((match = regex.exec(line)) !== null) {
|
||||
values.push(Number(match[1]));
|
||||
}
|
||||
if (values.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return { attacker: values[0], defender: values[1] };
|
||||
};
|
||||
|
||||
const currentEnergy = computed(() => extractEnergy(currentMatch.value?.log));
|
||||
const formattedLogs = computed(() =>
|
||||
(currentMatch.value?.log ?? []).map((entry) => formatLog(entry))
|
||||
);
|
||||
|
||||
const finalists = computed(() => {
|
||||
const matches = stageMatches(7);
|
||||
const ids = new Set<number>();
|
||||
for (const match of matches) {
|
||||
ids.add(match.attackerId);
|
||||
ids.add(match.defenderId);
|
||||
}
|
||||
return Array.from(ids);
|
||||
});
|
||||
|
||||
const finalistOptions = computed(() => {
|
||||
const participants = snapshot.value?.participants ?? [];
|
||||
const nameMap = new Map(participants.map((entry) => [entry.id, entry.name]));
|
||||
return finalists.value.map((id) => ({ id, name: nameMap.get(id) ?? `#${id}` }));
|
||||
});
|
||||
|
||||
const myGeneralId = computed(() => myGeneral.value?.general?.id ?? 0);
|
||||
const isParticipant = computed(() => {
|
||||
if (!myGeneralId.value) {
|
||||
return false;
|
||||
}
|
||||
return (snapshot.value?.participants ?? []).some((entry) => entry.id === myGeneralId.value);
|
||||
});
|
||||
|
||||
const showJoinButton = computed(() => snapshot.value?.state?.stage === 1);
|
||||
|
||||
const betTargetId = ref<number>(0);
|
||||
const betAmount = ref<number>(0);
|
||||
|
||||
const isBettingOpen = computed(() => {
|
||||
const state = snapshot.value?.state;
|
||||
if (!state || state.stage !== 6) {
|
||||
return false;
|
||||
}
|
||||
if (!state.bettingCloseAt) {
|
||||
return true;
|
||||
}
|
||||
const closeAt = new Date(state.bettingCloseAt).getTime();
|
||||
return Number.isFinite(closeAt) ? Date.now() < closeAt : true;
|
||||
});
|
||||
|
||||
const placeBet = async () => {
|
||||
if (!betTargetId.value || betAmount.value <= 0) {
|
||||
lastActionMessage.value = '베팅 대상과 금액을 입력하세요.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await trpc.tournament.placeBet.mutate({ targetId: betTargetId.value, amount: betAmount.value });
|
||||
lastActionMessage.value = '베팅이 등록되었습니다.';
|
||||
await loadTournament();
|
||||
} catch (err) {
|
||||
lastActionMessage.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleJoin = async () => {
|
||||
try {
|
||||
await trpc.general.setMySetting.mutate({ tnmt: 1 });
|
||||
lastActionMessage.value = '참가 신청이 반영되었습니다.';
|
||||
await loadTournament();
|
||||
} catch (err) {
|
||||
lastActionMessage.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
|
||||
const bettingTotals = computed(() => bettingSummary.value?.totals ?? {});
|
||||
const bettingMine = computed(() => bettingSummary.value?.myTotals ?? {});
|
||||
const totalBetAmount = computed(() => bettingSummary.value?.totalAmount ?? 0);
|
||||
const myBetAmount = computed(() => bettingSummary.value?.myAmount ?? 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="tournament-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">토너먼트</h1>
|
||||
<p class="page-subtitle">{{ stageLabel }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="ghost" @click="loadTournament">새로고침</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="lastActionMessage" class="notice">{{ lastActionMessage }}</div>
|
||||
|
||||
<section v-if="isMobile" class="layout-mobile">
|
||||
<div class="mobile-tabs">
|
||||
<button
|
||||
v-for="tab in mobileTabs"
|
||||
:key="tab.key"
|
||||
:class="{ active: mobileTab === tab.key }"
|
||||
@click="mobileTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'status'" class="mobile-panel">
|
||||
<PanelCard title="토너먼트 상태" :subtitle="bettingCloseLabel ?? undefined">
|
||||
<SkeletonLines v-if="loading" :lines="3" />
|
||||
<div v-else class="status-grid">
|
||||
<div>종목: {{ typeLabelMap[snapshot?.state?.type ?? 0] }}</div>
|
||||
<div>개최: {{ snapshot?.state?.openYear ?? '-' }}년 {{ snapshot?.state?.openMonth ?? '-' }}월</div>
|
||||
<div>페이즈: {{ snapshot?.state?.phase ?? '-' }}</div>
|
||||
<div>자동 진행: {{ snapshot?.state?.auto ? 'ON' : 'OFF' }}</div>
|
||||
</div>
|
||||
<div class="status-actions">
|
||||
<button
|
||||
v-if="showJoinButton"
|
||||
class="ghost"
|
||||
:disabled="isParticipant"
|
||||
@click="toggleJoin"
|
||||
>
|
||||
{{ isParticipant ? '참가 완료' : '참가 신청' }}
|
||||
</button>
|
||||
<span v-else class="muted">참가 신청 기간이 아닙니다.</span>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="현재 전투 정보">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else>
|
||||
<div v-if="currentMatch" class="match-summary">
|
||||
<div class="match-title">
|
||||
{{ currentMatch.attackerName }} vs {{ currentMatch.defenderName }}
|
||||
</div>
|
||||
<div class="match-meta">
|
||||
남은 체력: {{ currentEnergy?.attacker ?? '-' }} / {{ currentEnergy?.defender ?? '-' }}
|
||||
</div>
|
||||
<div class="log-box">
|
||||
<div
|
||||
v-for="(entry, idx) in formattedLogs"
|
||||
:key="idx"
|
||||
class="log-line"
|
||||
v-html="entry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="placeholder">현재 진행 중인 전투가 없습니다.</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'bracket'" class="mobile-panel">
|
||||
<PanelCard title="토너먼트 진출 정보" subtitle="16강 기준">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="candidate-list">
|
||||
<div
|
||||
v-for="entry in stageMatches(7)"
|
||||
:key="entry.id"
|
||||
class="match-row"
|
||||
>
|
||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="토너먼트 진행">
|
||||
<div class="bracket-section">
|
||||
<h3>8강</h3>
|
||||
<div v-if="stageMatches(8).length === 0" class="placeholder">진행 대기</div>
|
||||
<div v-else>
|
||||
<div v-for="entry in stageMatches(8)" :key="entry.id" class="match-row">
|
||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bracket-section">
|
||||
<h3>4강</h3>
|
||||
<div v-if="stageMatches(9).length === 0" class="placeholder">진행 대기</div>
|
||||
<div v-else>
|
||||
<div v-for="entry in stageMatches(9)" :key="entry.id" class="match-row">
|
||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bracket-section">
|
||||
<h3>결승</h3>
|
||||
<div v-if="stageMatches(10).length === 0" class="placeholder">진행 대기</div>
|
||||
<div v-else>
|
||||
<div v-for="entry in stageMatches(10)" :key="entry.id" class="match-row">
|
||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'betting'" class="mobile-panel">
|
||||
<PanelCard title="베팅 현황" :subtitle="bettingCloseLabel ?? undefined">
|
||||
<SkeletonLines v-if="loading" :lines="3" />
|
||||
<div v-else>
|
||||
<div class="status-grid">
|
||||
<div>전체 베팅: {{ totalBetAmount.toLocaleString('ko-KR') }}</div>
|
||||
<div>내 베팅: {{ myBetAmount.toLocaleString('ko-KR') }}</div>
|
||||
</div>
|
||||
<div class="betting-grid">
|
||||
<div v-for="entry in finalistOptions" :key="entry.id" class="betting-row">
|
||||
<span>{{ entry.name }}</span>
|
||||
<span class="muted">전체 {{ (bettingTotals[entry.id] ?? 0).toLocaleString('ko-KR') }}</span>
|
||||
<span class="muted">내 {{ (bettingMine[entry.id] ?? 0).toLocaleString('ko-KR') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="베팅하기">
|
||||
<div v-if="!isBettingOpen" class="placeholder">베팅 기간이 아닙니다.</div>
|
||||
<div v-else class="bet-form">
|
||||
<label>
|
||||
대상
|
||||
<select v-model.number="betTargetId">
|
||||
<option :value="0">선택</option>
|
||||
<option v-for="entry in finalistOptions" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
금액
|
||||
<input v-model.number="betAmount" type="number" min="1" />
|
||||
</label>
|
||||
<button class="ghost" @click="placeBet">베팅 등록</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="layout-desktop">
|
||||
<div class="stack">
|
||||
<PanelCard title="토너먼트 상태" :subtitle="bettingCloseLabel ?? undefined">
|
||||
<SkeletonLines v-if="loading" :lines="3" />
|
||||
<div v-else class="status-grid">
|
||||
<div>종목: {{ typeLabelMap[snapshot?.state?.type ?? 0] }}</div>
|
||||
<div>개최: {{ snapshot?.state?.openYear ?? '-' }}년 {{ snapshot?.state?.openMonth ?? '-' }}월</div>
|
||||
<div>페이즈: {{ snapshot?.state?.phase ?? '-' }}</div>
|
||||
<div>자동 진행: {{ snapshot?.state?.auto ? 'ON' : 'OFF' }}</div>
|
||||
</div>
|
||||
<div class="status-actions">
|
||||
<button
|
||||
v-if="showJoinButton"
|
||||
class="ghost"
|
||||
:disabled="isParticipant"
|
||||
@click="toggleJoin"
|
||||
>
|
||||
{{ isParticipant ? '참가 완료' : '참가 신청' }}
|
||||
</button>
|
||||
<span v-else class="muted">참가 신청 기간이 아닙니다.</span>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="현재 전투 정보">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else>
|
||||
<div v-if="currentMatch" class="match-summary">
|
||||
<div class="match-title">
|
||||
{{ currentMatch.attackerName }} vs {{ currentMatch.defenderName }}
|
||||
</div>
|
||||
<div class="match-meta">
|
||||
남은 체력: {{ currentEnergy?.attacker ?? '-' }} / {{ currentEnergy?.defender ?? '-' }}
|
||||
</div>
|
||||
<div class="log-box">
|
||||
<div
|
||||
v-for="(entry, idx) in formattedLogs"
|
||||
:key="idx"
|
||||
class="log-line"
|
||||
v-html="entry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="placeholder">현재 진행 중인 전투가 없습니다.</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="예선 참가자" subtitle="간단 목록">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="participant-grid">
|
||||
<div v-for="entry in snapshot?.participants ?? []" :key="entry.id" class="participant-item">
|
||||
<div class="participant-name">{{ entry.name }}</div>
|
||||
<div class="muted">통{{ entry.leadership }} / 무{{ entry.strength }} / 지{{ entry.intel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<PanelCard title="토너먼트 진출 정보" subtitle="16강 기준">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="candidate-list">
|
||||
<div
|
||||
v-for="entry in stageMatches(7)"
|
||||
:key="entry.id"
|
||||
class="match-row"
|
||||
>
|
||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="토너먼트 진행">
|
||||
<div class="bracket-section">
|
||||
<h3>8강</h3>
|
||||
<div v-if="stageMatches(8).length === 0" class="placeholder">진행 대기</div>
|
||||
<div v-else>
|
||||
<div v-for="entry in stageMatches(8)" :key="entry.id" class="match-row">
|
||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bracket-section">
|
||||
<h3>4강</h3>
|
||||
<div v-if="stageMatches(9).length === 0" class="placeholder">진행 대기</div>
|
||||
<div v-else>
|
||||
<div v-for="entry in stageMatches(9)" :key="entry.id" class="match-row">
|
||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bracket-section">
|
||||
<h3>결승</h3>
|
||||
<div v-if="stageMatches(10).length === 0" class="placeholder">진행 대기</div>
|
||||
<div v-else>
|
||||
<div v-for="entry in stageMatches(10)" :key="entry.id" class="match-row">
|
||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="베팅 현황" :subtitle="bettingCloseLabel ?? undefined">
|
||||
<SkeletonLines v-if="loading" :lines="3" />
|
||||
<div v-else>
|
||||
<div class="status-grid">
|
||||
<div>전체 베팅: {{ totalBetAmount.toLocaleString('ko-KR') }}</div>
|
||||
<div>내 베팅: {{ myBetAmount.toLocaleString('ko-KR') }}</div>
|
||||
</div>
|
||||
<div class="betting-grid">
|
||||
<div v-for="entry in finalistOptions" :key="entry.id" class="betting-row">
|
||||
<span>{{ entry.name }}</span>
|
||||
<span class="muted">전체 {{ (bettingTotals[entry.id] ?? 0).toLocaleString('ko-KR') }}</span>
|
||||
<span class="muted">내 {{ (bettingMine[entry.id] ?? 0).toLocaleString('ko-KR') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="베팅하기">
|
||||
<div v-if="!isBettingOpen" class="placeholder">베팅 기간이 아닙니다.</div>
|
||||
<div v-else class="bet-form">
|
||||
<label>
|
||||
대상
|
||||
<select v-model.number="betTargetId">
|
||||
<option :value="0">선택</option>
|
||||
<option v-for="entry in finalistOptions" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
금액
|
||||
<input v-model.number="betAmount" type="number" min="1" />
|
||||
</label>
|
||||
<button class="ghost" @click="placeBet">베팅 등록</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tournament-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ghost:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f5b7b1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: #f5d08a;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.layout-desktop {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1.2fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.layout-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mobile-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mobile-tabs button {
|
||||
padding: 6px 4px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.mobile-tabs button.active {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
}
|
||||
|
||||
.mobile-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.match-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.match-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.match-meta {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.log-box {
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
background: rgba(12, 12, 12, 0.6);
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.candidate-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.match-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.match-row .vs {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.winner {
|
||||
color: #f5d08a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bracket-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bracket-section h3 {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.participant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.participant-item {
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
padding: 8px;
|
||||
background: rgba(12, 12, 12, 0.5);
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.betting-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.betting-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bet-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bet-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bet-form input,
|
||||
.bet-form select {
|
||||
padding: 6px;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user