初始化足球投注平台 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

12
apps/player/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>TheBet365</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
apps/player/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@thebet365/player",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@thebet365/shared": "workspace:*",
"axios": "^1.7.9",
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vue-tsc": "^2.2.0"
}
}

6
apps/player/src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,22 @@
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(err);
},
);
export default api;

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useBetSlipStore } from '../stores/betSlip';
import api from '../api';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
const { t } = useI18n();
const slip = useBetSlipStore();
const show = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
const loading = ref(false);
const error = ref('');
const success = ref('');
function genId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async function placeBet() {
if (!slip.items.length) return;
loading.value = true;
error.value = '';
success.value = '';
try {
const requestId = genId();
if (slip.mode === 'parlay' && slip.items.length >= 2) {
if (slip.hasSameMatch) {
error.value = '同一场比赛不能串关';
return;
}
await api.post('/player/bets/parlay', {
legs: slip.items.map((i) => ({
selectionId: i.selectionId,
oddsVersion: i.oddsVersion,
})),
stake: slip.stake,
requestId,
});
} else if (slip.items.length === 1) {
const item = slip.items[0];
await api.post('/player/bets/single', {
selectionId: item.selectionId,
oddsVersion: item.oddsVersion,
stake: slip.stake,
requestId,
});
} else {
error.value = '请选择投注项';
return;
}
success.value = '下注成功!';
slip.clear();
setTimeout(() => { show.value = false; success.value = ''; }, 1500);
} catch (e: unknown) {
error.value = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || '下注失败';
} finally {
loading.value = false;
}
}
</script>
<template>
<div v-if="show" class="overlay" @click.self="show = false">
<div class="drawer">
<div class="drawer-header">
<h3>{{ t('bet.bet_slip') }} ({{ slip.count }})</h3>
<button @click="show = false"></button>
</div>
<div v-if="!slip.items.length" class="empty">点击赔率添加投注</div>
<div v-for="item in slip.items" :key="item.selectionId" class="slip-item">
<div class="item-name">{{ item.matchName }}</div>
<div class="item-sel">{{ item.selectionName }} @ {{ item.odds }}</div>
<button class="remove" @click="slip.removeItem(item.selectionId)">移除</button>
</div>
<div v-if="slip.hasSameMatch" class="warn">同场比赛不能串关可作为单关分别投注</div>
<div v-if="slip.items.length" class="stake-area">
<label>{{ t('bet.stake') }}</label>
<input v-model.number="slip.stake" type="number" min="1" />
<div v-if="slip.isParlay" class="mode-tag">{{ t('bet.parlay') }} · 赔率 {{ slip.totalOdds.toFixed(2) }}</div>
<div class="return">预计返还: {{ slip.potentialReturn.toFixed(2) }}</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="success" class="success">{{ success }}</p>
<button class="btn-primary" :disabled="loading || !slip.items.length" @click="placeBet">
{{ t('bet.place_bet') }}
</button>
</div>
</div>
</template>
<style scoped>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; display: flex; align-items: flex-end; }
.drawer { background: #1a2332; width: 100%; max-height: 80vh; border-radius: 16px 16px 0 0; padding: 16px; overflow-y: auto; }
.drawer-header { display: flex; justify-content: space-between; margin-bottom: 16px; }
.drawer-header button { background: none; color: var(--text-muted); font-size: 20px; }
.slip-item { padding: 12px; background: var(--bg-hover); border-radius: 6px; margin-bottom: 8px; }
.item-name { font-size: 13px; font-weight: 600; }
.item-sel { font-size: 12px; color: var(--text-muted); }
.remove { background: none; color: var(--danger); font-size: 12px; margin-top: 4px; }
.stake-area { margin: 16px 0; }
.stake-area label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom: 4px; }
.return { font-size: 14px; color: #ffd700; margin-top: 8px; }
.mode-tag { font-size: 12px; color: var(--primary); margin-top: 4px; }
.warn { color: #ff9800; font-size: 12px; margin-bottom: 8px; }
.error { color: var(--danger); font-size: 13px; }
.success { color: var(--primary); font-size: 13px; }
.empty { text-align: center; color: var(--text-muted); padding: 24px; }
</style>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import { useBetSlipStore } from '../stores/betSlip';
import BetSlipDrawer from '../components/BetSlipDrawer.vue';
import { ref } from 'vue';
const { t, locale } = useI18n();
const auth = useAuthStore();
const slip = useBetSlipStore();
const route = useRoute();
const showSlip = ref(false);
const locales = [
{ code: 'zh-CN', label: '中文' },
{ code: 'en-US', label: 'EN' },
{ code: 'ms-MY', label: 'BM' },
];
function setLocale(code: string) {
locale.value = code;
localStorage.setItem('locale', code);
}
</script>
<template>
<div class="layout">
<header class="header">
<span class="logo">TheBet365</span>
<div class="header-actions">
<select :value="locale" @change="setLocale(($event.target as HTMLSelectElement).value)" class="lang-select">
<option v-for="l in locales" :key="l.code" :value="l.code">{{ l.label }}</option>
</select>
<span class="balance">{{ auth.user?.username }}</span>
</div>
</header>
<main class="main">
<RouterView />
</main>
<nav class="bottom-nav">
<RouterLink to="/" :class="{ active: route.path === '/' }">{{ t('nav.home') }}</RouterLink>
<RouterLink to="/football" :class="{ active: route.path.startsWith('/football') || route.path.startsWith('/match') }">{{ t('nav.football') }}</RouterLink>
<button class="slip-btn" @click="showSlip = true">
{{ t('bet.bet_slip') }}
<span v-if="slip.count" class="badge">{{ slip.count }}</span>
</button>
<RouterLink to="/bets" :class="{ active: route.path === '/bets' }">{{ t('nav.my_bets') }}</RouterLink>
<RouterLink to="/profile" :class="{ active: route.path === '/profile' }">{{ t('nav.profile') }}</RouterLink>
</nav>
<BetSlipDrawer v-model="showSlip" />
</div>
</template>
<style scoped>
.layout { display: flex; flex-direction: column; min-height: 100vh; padding-bottom: 60px; }
.header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; background: #1a2332; border-bottom: 1px solid var(--border);
}
.logo { font-weight: 800; color: var(--primary); font-size: 18px; }
.header-actions { display: flex; gap: 12px; align-items: center; }
.lang-select { background: var(--bg-card); color: #fff; border: 1px solid var(--border); padding: 4px 8px; border-radius: 4px; width: auto; }
.balance { font-size: 13px; color: var(--text-muted); }
.main { flex: 1; padding: 12px; }
.bottom-nav {
position: fixed; bottom: 0; left: 0; right: 0;
display: flex; background: #1a2332; border-top: 1px solid var(--border);
z-index: 100;
}
.bottom-nav a, .slip-btn {
flex: 1; text-align: center; padding: 10px 4px; font-size: 11px;
color: var(--text-muted); background: none; position: relative;
}
.bottom-nav a.active, .slip-btn.active { color: var(--primary); }
.badge {
position: absolute; top: 2px; right: 20%;
background: var(--danger); color: #fff; font-size: 10px;
padding: 1px 5px; border-radius: 10px;
}
</style>

34
apps/player/src/main.ts Normal file
View File

@@ -0,0 +1,34 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { createI18n } from 'vue-i18n';
import App from './App.vue';
import router from './router';
import './styles.css';
const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('locale') || 'zh-CN',
fallbackLocale: 'en-US',
messages: {
'zh-CN': {
nav: { home: '首页', football: '足球', my_bets: '我的投注', profile: '我的' },
auth: { login: '登录', username: '账号', password: '密码' },
wallet: { balance: '余额' },
bet: { bet_slip: '投注单', stake: '投注金额', place_bet: '确认下注', parlay: '串关' },
},
'en-US': {
nav: { home: 'Home', football: 'Football', my_bets: 'My Bets', profile: 'Profile' },
auth: { login: 'Login', username: 'Username', password: 'Password' },
wallet: { balance: 'Balance' },
bet: { bet_slip: 'Bet Slip', stake: 'Stake', place_bet: 'Place Bet', parlay: 'Parlay' },
},
'ms-MY': {
nav: { home: 'Laman Utama', football: 'Bola Sepak', my_bets: 'Pertaruhan Saya', profile: 'Profil' },
auth: { login: 'Log Masuk', username: 'Nama Pengguna', password: 'Kata Laluan' },
wallet: { balance: 'Baki' },
bet: { bet_slip: 'Slip Pertaruhan', stake: 'Jumlah', place_bet: 'Letak Pertaruhan', parlay: 'Berganda' },
},
},
});
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app');

View File

@@ -0,0 +1,29 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from './stores/auth';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('./views/LoginView.vue') },
{
path: '/',
component: () => import('./layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', component: () => import('./views/HomeView.vue') },
{ path: 'football', component: () => import('./views/FootballView.vue') },
{ path: 'match/:id', component: () => import('./views/MatchDetailView.vue') },
{ path: 'bets', component: () => import('./views/MyBetsView.vue') },
{ path: 'profile', component: () => import('./views/ProfileView.vue') },
],
},
],
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.token) return '/login';
if (to.path === '/login' && auth.token) return '/';
});
export default router;

View File

@@ -0,0 +1,25 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import api from '../api';
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '');
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'));
async function login(username: string, password: string) {
const { data } = await api.post('/player/auth/login', { username, password });
token.value = data.data.token;
user.value = data.data.user;
localStorage.setItem('token', token.value);
localStorage.setItem('user', JSON.stringify(user.value));
}
function logout() {
token.value = '';
user.value = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
}
return { token, user, login, logout };
});

View File

@@ -0,0 +1,74 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export interface SlipItem {
selectionId: string;
oddsVersion: string;
matchId: string;
matchName: string;
selectionName: string;
odds: number;
marketType: string;
}
export const useBetSlipStore = defineStore('betSlip', () => {
const items = ref<SlipItem[]>([]);
const stake = ref<number>(10);
const mode = ref<'single' | 'parlay'>('single');
const count = computed(() => items.value.length);
const isParlay = computed(() => items.value.length >= 2);
function addItem(item: SlipItem) {
const existing = items.value.findIndex(
(i) => i.selectionId === item.selectionId,
);
if (existing >= 0) {
items.value.splice(existing, 1);
return;
}
items.value.push(item);
if (items.value.length >= 2) mode.value = 'parlay';
}
function removeItem(selectionId: string) {
items.value = items.value.filter((i) => i.selectionId !== selectionId);
if (items.value.length < 2) mode.value = 'single';
}
function clear() {
items.value = [];
mode.value = 'single';
}
const totalOdds = computed(() =>
items.value.reduce((acc, i) => acc * i.odds, 1),
);
const potentialReturn = computed(() =>
mode.value === 'parlay'
? stake.value * totalOdds.value
: items.value.length === 1
? stake.value * items.value[0].odds
: 0,
);
const hasSameMatch = computed(() => {
const matchIds = items.value.map((i) => i.matchId);
return new Set(matchIds).size !== matchIds.length;
});
return {
items,
stake,
mode,
count,
isParlay,
totalOdds,
potentialReturn,
hasSameMatch,
addItem,
removeItem,
clear,
};
});

View File

@@ -0,0 +1,58 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f1419;
color: #e8eaed;
min-height: 100vh;
}
:root {
--primary: #00a826;
--primary-dark: #008a1f;
--bg-card: #1a2332;
--bg-hover: #243044;
--text-muted: #8b95a5;
--border: #2d3a4d;
--danger: #ff4444;
}
a { color: inherit; text-decoration: none; }
button {
cursor: pointer;
border: none;
font-family: inherit;
}
input {
font-family: inherit;
background: var(--bg-card);
border: 1px solid var(--border);
color: #fff;
padding: 10px 12px;
border-radius: 6px;
width: 100%;
}
.btn-primary {
background: var(--primary);
color: #fff;
padding: 12px 24px;
border-radius: 6px;
font-weight: 600;
width: 100%;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.odds-btn {
background: var(--bg-card);
border: 1px solid var(--border);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
text-align: center;
min-width: 70px;
}
.odds-btn.selected { background: var(--primary); border-color: var(--primary); }
.odds-btn .label { font-size: 11px; color: var(--text-muted); }
.odds-btn .value { font-size: 15px; font-weight: 700; color: #ffd700; }
.card {
background: var(--bg-card);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
}

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>

5
apps/player/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}

11
apps/player/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
},
},
});