初始化足球投注平台 MVP Monorepo

包含 NestJS 后端、三端前端、Prisma 数据模型、结算引擎测试与 PRD 文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 14:35:48 +08:00
commit 14e49374ac
118 changed files with 15944 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
const router = useRouter();
const matches = ref<Match[]>([]);
interface Match {
id: string;
homeTeamName: string;
awayTeamName: string;
startTime: string;
leagueName: string;
markets?: Array<{ marketType: string; selections: Selection[] }>;
}
interface Selection {
id: string;
selectionCode: string;
selectionName: string;
odds: string;
oddsVersion: string;
}
onMounted(async () => {
const { data } = await api.get('/player/matches');
matches.value = data.data;
});
function goMatch(id: string) {
router.push(`/match/${id}`);
}
</script>
<template>
<div>
<h2 class="title">足球赛事</h2>
<div v-for="match in matches" :key="match.id" class="card">
<div class="league">{{ match.leagueName }}</div>
<div class="teams" @click="goMatch(match.id)">
{{ match.homeTeamName }} vs {{ match.awayTeamName }}
</div>
<div class="time">{{ new Date(match.startTime).toLocaleString() }}</div>
<div v-if="match.markets?.length" class="odds-row">
<template v-for="market in match.markets.filter(m => m.marketType === 'FT_1X2')" :key="market.marketType">
<div v-for="sel in market.selections" :key="sel.id" class="odds-btn" @click="goMatch(match.id)">
<div class="label">{{ sel.selectionName }}</div>
<div class="value">{{ sel.odds }}</div>
</div>
</template>
</div>
</div>
<div v-if="!matches.length" class="empty">暂无赛事</div>
</div>
</template>
<style scoped>
.title { font-size: 18px; margin-bottom: 16px; }
.league { font-size: 11px; color: var(--primary); margin-bottom: 4px; }
.teams { font-weight: 600; cursor: pointer; margin-bottom: 4px; }
.time { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; }
.odds-row { display: flex; gap: 8px; }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
const router = useRouter();
const home = ref<{ banners: unknown[]; hotMatches: Match[]; ticker: unknown[] } | null>(null);
interface Match {
id: string;
homeTeamName: string;
awayTeamName: string;
startTime: string;
isHot: boolean;
markets?: Array<{ marketType: string; selections: Array<{ id: string; selectionName: string; odds: string; oddsVersion: string }> }>;
}
onMounted(async () => {
const { data } = await api.get('/player/home');
home.value = data.data;
});
function goMatch(id: string) {
router.push(`/match/${id}`);
}
</script>
<template>
<div>
<div v-if="home?.banners?.length" class="banner card">
{{ (home.banners[0] as { translation?: { title?: string } })?.translation?.title || 'Welcome' }}
</div>
<div v-if="home?.ticker?.length" class="ticker">
{{ (home.ticker[0] as { translation?: { body?: string } })?.translation?.body }}
</div>
<h2 class="section-title">热门赛事</h2>
<div v-for="match in home?.hotMatches || []" :key="match.id" class="card match-card" @click="goMatch(match.id)">
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
<div class="match-time">{{ new Date(match.startTime).toLocaleString() }}</div>
</div>
<div v-if="!home?.hotMatches?.length" class="empty">暂无赛事</div>
</div>
</template>
<style scoped>
.banner { background: linear-gradient(135deg, #1a472a, #0f1419); padding: 24px; font-size: 18px; font-weight: 600; }
.ticker { background: #243044; padding: 8px 12px; font-size: 12px; margin-bottom: 12px; border-radius: 4px; overflow: hidden; white-space: nowrap; }
.section-title { font-size: 16px; margin-bottom: 12px; }
.match-card { cursor: pointer; }
.match-card:hover { background: var(--bg-hover); }
.match-teams { font-weight: 600; margin-bottom: 4px; }
.match-time { font-size: 12px; color: var(--text-muted); }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
const { t } = useI18n();
const auth = useAuthStore();
const router = useRouter();
const username = ref('player1');
const password = ref('Player@123');
const error = ref('');
const loading = ref(false);
async function submit() {
loading.value = true;
error.value = '';
try {
await auth.login(username.value, password.value);
router.push('/');
} catch (e: unknown) {
error.value = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Login failed';
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-page">
<h1 class="logo">TheBet365</h1>
<form @submit.prevent="submit" class="login-form">
<label>{{ t('auth.username') }}</label>
<input v-model="username" required />
<label>{{ t('auth.password') }}</label>
<input v-model="password" type="password" required />
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-primary" :disabled="loading">
{{ t('auth.login') }}
</button>
</form>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center; padding: 24px;
}
.logo { color: var(--primary); font-size: 32px; margin-bottom: 32px; }
.login-form { width: 100%; max-width: 320px; display: flex; flex-direction: column; gap: 12px; }
label { font-size: 13px; color: var(--text-muted); }
.error { color: var(--danger); font-size: 13px; }
</style>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import api from '../api';
import { useBetSlipStore } from '../stores/betSlip';
const route = useRoute();
const slip = useBetSlipStore();
const match = ref<MatchDetail | null>(null);
interface MatchDetail {
id: string;
homeTeamName: string;
awayTeamName: string;
startTime: string;
markets: Market[];
}
interface Market {
id: string;
marketType: string;
period: string;
lineValue?: string;
selections: Selection[];
}
interface Selection {
id: string;
selectionCode: string;
selectionName: string;
odds: string;
oddsVersion: string;
}
const marketLabels: Record<string, string> = {
FT_1X2: '全场独赢',
FT_HANDICAP: '全场让球',
FT_OVER_UNDER: '全场大小',
FT_ODD_EVEN: '全场单双',
HT_1X2: '半场独赢',
FT_CORRECT_SCORE: '波胆',
};
onMounted(async () => {
const { data } = await api.get(`/player/matches/${route.params.id}`);
match.value = data.data;
});
function isSelected(id: string) {
return slip.items.some((i) => i.selectionId === id);
}
function toggleSelection(sel: Selection, market: Market) {
if (!match.value) return;
slip.addItem({
selectionId: sel.id,
oddsVersion: sel.oddsVersion,
matchId: match.value.id,
matchName: `${match.value.homeTeamName} vs ${match.value.awayTeamName}`,
selectionName: sel.selectionName,
odds: parseFloat(sel.odds),
marketType: market.marketType,
});
}
const groupedMarkets = computed(() => {
if (!match.value) return [];
return match.value.markets;
});
</script>
<template>
<div v-if="match">
<div class="match-header card">
<h2>{{ match.homeTeamName }} vs {{ match.awayTeamName }}</h2>
<p class="time">{{ new Date(match.startTime).toLocaleString() }}</p>
</div>
<div v-for="market in groupedMarkets" :key="market.id" class="card market-group">
<h3>{{ marketLabels[market.marketType] || market.marketType }}</h3>
<div class="selections">
<button
v-for="sel in market.selections"
:key="sel.id"
class="odds-btn"
:class="{ selected: isSelected(sel.id) }"
@click="toggleSelection(sel, market)"
>
<div class="label">{{ sel.selectionName }}</div>
<div class="value">{{ sel.odds }}</div>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.match-header h2 { font-size: 18px; margin-bottom: 4px; }
.time { color: var(--text-muted); font-size: 13px; }
.market-group h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); }
.selections { display: flex; flex-wrap: wrap; gap: 8px; }
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const bets = ref<{ items: Bet[]; total: number }>({ items: [], total: 0 });
interface Bet {
betNo: string;
betType: string;
stake: string;
totalOdds: string;
potentialReturn: string;
actualReturn: string;
status: string;
placedAt: string;
selections: Array<{ selectionNameSnapshot: string; odds: string; resultStatus?: string }>;
}
onMounted(load);
async function load() {
const { data } = await api.get('/player/bets');
bets.value = data.data;
}
</script>
<template>
<div>
<h2>我的投注</h2>
<div v-for="bet in bets.items" :key="bet.betNo" class="card bet-card">
<div class="bet-header">
<span class="bet-no">{{ bet.betNo }}</span>
<span :class="['status', bet.status.toLowerCase()]">{{ bet.status }}</span>
</div>
<div v-for="(sel, i) in bet.selections" :key="i" class="sel">
{{ sel.selectionNameSnapshot }} @ {{ sel.odds }}
<span v-if="sel.resultStatus"> {{ sel.resultStatus }}</span>
</div>
<div class="bet-footer">
<span>{{ bet.betType }} · 投注 {{ bet.stake }}</span>
<span v-if="bet.status === 'WON'">返还 {{ bet.actualReturn }}</span>
<span v-else-if="bet.status === 'PENDING'">预计 {{ bet.potentialReturn }}</span>
</div>
<div class="time">{{ new Date(bet.placedAt).toLocaleString() }}</div>
</div>
<div v-if="!bets.items.length" class="empty">暂无投注</div>
</div>
</template>
<style scoped>
h2 { margin-bottom: 16px; }
.bet-header { display: flex; justify-content: space-between; margin-bottom: 8px; }
.bet-no { font-size: 12px; color: var(--text-muted); }
.status { font-size: 12px; font-weight: 600; }
.status.pending { color: #ff9800; }
.status.won { color: var(--primary); }
.status.lost { color: var(--danger); }
.sel { font-size: 13px; margin-bottom: 4px; }
.bet-footer { font-size: 13px; margin-top: 8px; display: flex; justify-content: space-between; }
.time { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import api from '../api';
const { t, locale } = useI18n();
const auth = useAuthStore();
const router = useRouter();
const profile = ref<{ wallet?: { availableBalance: string; frozenBalance: string } } | null>(null);
const transactions = ref<unknown[]>([]);
onMounted(async () => {
const [prof, txns] = await Promise.all([
api.get('/player/profile'),
api.get('/player/wallet/transactions'),
]);
profile.value = prof.data.data;
transactions.value = txns.data.data.items;
});
async function changeLocale(code: string) {
locale.value = code;
localStorage.setItem('locale', code);
await api.post('/player/language', { locale: code });
}
function logout() {
auth.logout();
router.push('/login');
}
</script>
<template>
<div>
<div class="card profile-card">
<div class="username">{{ auth.user?.username }}</div>
<div class="balance-row">
<span>{{ t('wallet.balance') }}</span>
<span class="amount">{{ profile?.wallet?.availableBalance ?? '0' }}</span>
</div>
<div class="frozen">冻结: {{ profile?.wallet?.frozenBalance ?? '0' }}</div>
</div>
<div class="card">
<h3>语言</h3>
<div class="lang-btns">
<button @click="changeLocale('zh-CN')" :class="{ active: locale === 'zh-CN' }">中文</button>
<button @click="changeLocale('en-US')" :class="{ active: locale === 'en-US' }">English</button>
<button @click="changeLocale('ms-MY')" :class="{ active: locale === 'ms-MY' }">BM</button>
</div>
</div>
<div class="card">
<h3>账变记录</h3>
<div v-for="tx in transactions as Array<{ transactionType: string; amount: string; createdAt: string }>" :key="(tx as { transactionId?: string }).transactionId" class="tx-row">
<span>{{ tx.transactionType }}</span>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">{{ tx.amount }}</span>
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
</div>
</div>
<button class="btn-logout" @click="logout">退出登录</button>
</div>
</template>
<style scoped>
.profile-card { margin-bottom: 12px; }
.username { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
.balance-row { display: flex; justify-content: space-between; font-size: 16px; }
.amount { color: #ffd700; font-weight: 700; }
.frozen { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
h3 { font-size: 14px; margin-bottom: 12px; }
.lang-btns { display: flex; gap: 8px; }
.lang-btns button {
flex: 1; padding: 8px; background: var(--bg-hover); color: #fff;
border-radius: 6px; border: 1px solid var(--border);
}
.lang-btns button.active { border-color: var(--primary); color: var(--primary); }
.tx-row { display: flex; justify-content: space-between; font-size: 13px; padding: 8px 0; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.pos { color: var(--primary); }
.neg { color: var(--danger); }
.tx-time { width: 100%; font-size: 11px; color: var(--text-muted); }
.btn-logout { width: 100%; margin-top: 16px; padding: 12px; background: var(--bg-card); color: var(--danger); border-radius: 6px; border: 1px solid var(--border); }
</style>