Files
core2026/app/game-frontend/src/views/BettingView.vue
T

612 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
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>>;
const snapshot = ref<Snapshot | null>(null);
const summary = ref<Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>> | null>(null);
const rankings = ref<Awaited<ReturnType<typeof trpc.tournament.getRankings.query>>>([]);
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 = [
'경기 없음',
'참가 모집중',
'예선 진행중',
'본선 추첨중',
'본선 진행중',
'16강 배정중',
'베팅 진행중',
'16강 진행중',
'8강 진행중',
'4강 진행중',
'결승 진행중',
];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => {
loading.value = true;
error.value = null;
try {
[snapshot.value, summary.value, rankings.value] = await Promise.all([
trpc.tournament.getSnapshot.query(),
trpc.tournament.getBettingSummary.query(),
trpc.tournament.getRankings.query(),
]);
} catch (value) {
error.value = errorText(value);
} finally {
loading.value = false;
}
};
onMounted(() => void load());
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);
const myBetTotals = computed(() => summary.value?.myTotals as Record<number, number> | undefined);
const ratio = (id: number) => {
const totals = summary.value?.totals as Record<number, number> | undefined;
const amount = totals?.[id] ?? 0;
return amount ? (totalAmount.value / amount).toFixed(2) : '0';
};
const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
);
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;
if (!state.bettingCloseAt) return true;
return new Date(state.bettingCloseAt).getTime() > Date.now();
});
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 = '베팅이 등록되었습니다.';
await load();
closeBetDialog();
} catch (value) {
betError.value = errorText(value);
message.value = betError.value;
} finally {
placingBet.value = false;
}
};
</script>
<template>
<main id="tournament-betting-container" class="betting-page">
<TournamentPageHeader class="bg0" active-page="betting" title="베 팅 장" />
<section class="toolbar bg0">
<button type="button" @click="load">갱신</button>
<span v-if="loading">불러오는 중...</span>
<span v-if="message" role="status">{{ message }}</span>
</section>
<section v-if="error" class="error bg0" role="alert">{{ error }}</section>
<section class="state bg0">
<span>{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
({{ stageNames[snapshot?.state?.stage ?? 0] }}, 개막시간 {{ openingTime }}, 경기당
{{ snapshot?.state?.termSeconds ?? '-' }})
</section>
<section class="section-title bg2">
16 상황<br />
<small>(전체 금액 : {{ totalAmount }} / 투자 금액 : {{ myAmount }})</small>
</section>
<TournamentBracket
class="bg0 betting-bracket"
:participants="snapshot?.participants ?? []"
:matches="snapshot?.matches ?? []"
:winner-id="snapshot?.state?.winnerId"
:bet-totals="betTotals"
:my-bet-totals="myBetTotals"
:total-bet="totalAmount"
:tournament-type="snapshot?.state?.type ?? 0"
:show-legend="false"
:betting-open="bettingOpen"
@request-bet="openBetDialog"
/>
<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">
<tbody>
<tr v-for="rowIndex in 5" :key="rowIndex">
<td></td>
</tr>
</tbody>
</table>
</div>
<section class="ranking-title bg2">토너먼트 랭킹</section>
<section class="ranking-placeholder bg0">
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
</section>
<div class="ranking-tabs bg0" role="tablist" aria-label="토너먼트 랭킹 종목 선택">
<button
v-for="section in rankings"
:key="`ranking-tab-${section.prefix}`"
type="button"
role="tab"
:aria-selected="activeRankingPrefix === section.prefix"
:class="{ active: activeRankingPrefix === section.prefix }"
@click="activeRankingPrefix = section.prefix"
>
{{ section.title.replaceAll(' ', '') }}
</button>
</div>
<section class="ranking-grid bg0">
<table
v-for="section in rankings"
:key="section.prefix"
class="ranking-table"
:class="{ 'mobile-active': activeRankingPrefix === section.prefix }"
>
<thead>
<tr>
<th colspan="9">{{ section.title }}</th>
</tr>
<tr class="bg1">
<th></th>
<th>장수</th>
<th>{{ section.statLabel }}</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="entry in section.entries" :key="entry.generalId">
<td>{{ entry.rank }}</td>
<td class="ranking-general">
<GeneralIdentity
:name="entry.name"
:picture="entry.picture"
:image-server="entry.imageServer"
/>
</td>
<td>{{ entry.stat }}</td>
<td>{{ entry.games }}</td>
<td>{{ entry.win }}</td>
<td>{{ entry.draw }}</td>
<td>{{ entry.lose }}</td>
<td>{{ entry.score }}</td>
<td>{{ entry.prizes }}</td>
</tr>
<tr v-if="section.entries.length === 0">
<td colspan="9">-</td>
</tr>
</tbody>
</table>
</section>
<section class="guide bg0">
ㆍ토너먼트의 16 대진표가 완성되면, 베팅 기간이 주어집니다.<br />
ㆍ유저들의 베팅 상황에 따라 배당률이 실시간 결정됩니다.<br />
ㆍ베팅은 16슬롯에 각각 가능하며, 도합 최대 1000까지 베팅 가능합니다.<br />
ㆍ소지금 500 이하일땐 베팅이 불가능합니다.
</section>
<footer class="betting-footer bg0">
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
</small>
</footer>
</main>
</template>
<style scoped>
.betting-page {
width: 100%;
max-width: 1200px;
min-width: 0;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.3;
text-align: center;
}
.betting-footer {
padding-top: 20px;
text-align: left;
}
.betting-footer small {
display: block;
}
.betting-page,
.betting-page * {
box-sizing: border-box;
}
.bg0 {
background: #3a2118 var(--sammo-texture-walnut);
}
.bg2 {
background: #142b42 var(--sammo-texture-blue);
}
.toolbar {
min-height: 46px;
padding: 1px;
text-align: left;
}
.toolbar button {
min-width: 72px;
height: 44px;
padding: 10px 16px;
font-size: 14px;
}
.error {
min-height: 32px;
padding: 5px;
text-align: left;
}
.state {
min-height: 42px;
padding: 5px;
font-size: 24px;
}
.state span {
color: cyan;
}
.section-title {
min-height: 50px;
padding: 5px;
color: limegreen;
font-size: 24px;
}
.section-title small {
color: orange;
font-size: 14px;
}
.ratio-color {
color: skyblue;
}
.expected,
.return-color {
color: cyan;
}
.gold-color {
color: orange;
}
select {
width: 100%;
min-height: 27px;
padding: 2px 1px;
border: 1px solid #555;
background: #000;
color: #fff;
}
button {
height: 35.5px;
color: #fff;
background: #444;
border: 1px solid #666;
border-radius: 5.25px;
cursor: pointer;
}
button:hover,
button:focus {
filter: brightness(1.25);
}
button:focus-visible,
select:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
button:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.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;
padding: 8px;
color: yellow;
font-size: 24px;
}
.ranking-placeholder {
min-height: 40px;
padding: 8px;
color: skyblue;
}
.ranking-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 8px;
padding: 8px;
}
.ranking-table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
font-size: 12px;
line-height: 14px;
}
.ranking-table th,
.ranking-table td {
height: 28px;
padding: 1px;
border: 1px solid #555;
}
.ranking-table tbody tr:has(.general-identity) td {
height: 66px;
}
.ranking-table thead tr:first-child th {
height: 18px;
background: #000;
font-size: 18px;
line-height: 18px;
font-weight: normal;
}
.ranking-table .bg1 {
background: #213b52;
}
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) {
width: 130px;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ranking-general {
text-align: left;
}
.ranking-tabs {
display: none;
}
.guide {
padding: 10px;
text-align: left;
}
.error {
color: #ff8080;
}
@media (max-width: 800px) {
.betting-page {
max-width: 100%;
font-size: 13px;
}
.state {
font-size: 18px;
}
.section-title,
.ranking-title {
font-size: 20px;
}
.bet-return-preview {
grid-template-columns: auto auto auto;
}
.bet-return-preview .return-color {
grid-column: 1 / -1;
}
.ranking-placeholder {
display: none;
}
.ranking-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
padding: 8px;
}
.ranking-tabs button {
height: 36px;
margin: 0;
border-radius: 3px;
}
.ranking-tabs button.active {
border-color: #f39c12;
background: #8a5b13;
}
.ranking-grid {
display: block;
overflow-x: auto;
padding: 0;
}
.ranking-table {
display: none;
min-width: 390px;
font-size: 11px;
}
.ranking-table.mobile-active {
display: table;
}
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) {
width: 112px;
max-width: 112px;
}
.guide,
.betting-footer {
padding: 10px;
}
.betting-footer small {
white-space: normal;
}
}
</style>