初始化足球投注平台 MVP Monorepo
包含 NestJS 后端、三端前端、Prisma 数据模型、结算引擎测试与 PRD 文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
12
apps/player/index.html
Normal file
12
apps/player/index.html
Normal 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
25
apps/player/package.json
Normal 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
6
apps/player/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
22
apps/player/src/api/index.ts
Normal file
22
apps/player/src/api/index.ts
Normal 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;
|
||||
121
apps/player/src/components/BetSlipDrawer.vue
Normal file
121
apps/player/src/components/BetSlipDrawer.vue
Normal 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>
|
||||
84
apps/player/src/layouts/MainLayout.vue
Normal file
84
apps/player/src/layouts/MainLayout.vue
Normal 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
34
apps/player/src/main.ts
Normal 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');
|
||||
29
apps/player/src/router/index.ts
Normal file
29
apps/player/src/router/index.ts
Normal 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;
|
||||
25
apps/player/src/stores/auth.ts
Normal file
25
apps/player/src/stores/auth.ts
Normal 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 };
|
||||
});
|
||||
74
apps/player/src/stores/betSlip.ts
Normal file
74
apps/player/src/stores/betSlip.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
58
apps/player/src/styles.css
Normal file
58
apps/player/src/styles.css
Normal 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;
|
||||
}
|
||||
66
apps/player/src/views/FootballView.vue
Normal file
66
apps/player/src/views/FootballView.vue
Normal 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>
|
||||
57
apps/player/src/views/HomeView.vue
Normal file
57
apps/player/src/views/HomeView.vue
Normal 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>
|
||||
54
apps/player/src/views/LoginView.vue
Normal file
54
apps/player/src/views/LoginView.vue
Normal 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>
|
||||
102
apps/player/src/views/MatchDetailView.vue
Normal file
102
apps/player/src/views/MatchDetailView.vue
Normal 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>
|
||||
62
apps/player/src/views/MyBetsView.vue
Normal file
62
apps/player/src/views/MyBetsView.vue
Normal 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>
|
||||
86
apps/player/src/views/ProfileView.vue
Normal file
86
apps/player/src/views/ProfileView.vue
Normal 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
5
apps/player/src/vite-env.d.ts
vendored
Normal 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
11
apps/player/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
12
apps/player/vite.config.ts
Normal file
12
apps/player/vite.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user