初始化足球投注平台 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/admin/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" />
<title>TheBet365 Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

23
apps/admin/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@thebet365/admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"vue": "^3.5.13",
"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"
}
}

4
apps/admin/src/App.vue Normal file
View File

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

9
apps/admin/src/api.ts Normal file
View File

@@ -0,0 +1,9 @@
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
api.interceptors.request.use((c) => {
const t = localStorage.getItem('admin_token');
if (t) c.headers.Authorization = `Bearer ${t}`;
return c;
});
export default api;

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
function logout() {
localStorage.removeItem('admin_token');
router.push('/login');
}
const menus = [
{ path: '/', label: '控制台' },
{ path: '/users', label: '玩家管理' },
{ path: '/agents', label: '代理管理' },
{ path: '/matches', label: '赛事管理' },
{ path: '/bets', label: '注单管理' },
{ path: '/cashback', label: '返水管理' },
{ path: '/audit', label: '操作日志' },
];
</script>
<template>
<el-container style="min-height: 100vh">
<el-aside width="200px" style="background: #1a2332">
<div style="padding: 20px; color: #00a826; font-weight: 800">TheBet365 Admin</div>
<el-menu background-color="#1a2332" text-color="#ccc" active-text-color="#00a826" :default-active="route.path">
<el-menu-item v-for="m in menus" :key="m.path" :index="m.path">
<RouterLink :to="m.path" style="color: inherit; width: 100%">{{ m.label }}</RouterLink>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="display: flex; justify-content: flex-end; align-items: center; border-bottom: 1px solid #eee">
<el-button @click="logout">退出</el-button>
</el-header>
<el-main><RouterView /></el-main>
</el-container>
</el-container>
</template>

7
apps/admin/src/main.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router';
createApp(App).use(router).use(ElementPlus).mount('#app');

View File

@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router';
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('./views/Login.vue') },
{
path: '/',
component: () => import('./layouts/AdminLayout.vue'),
meta: { auth: true },
children: [
{ path: '', component: () => import('./views/Dashboard.vue') },
{ path: 'users', component: () => import('./views/Users.vue') },
{ path: 'agents', component: () => import('./views/Agents.vue') },
{ path: 'matches', component: () => import('./views/Matches.vue') },
{ path: 'bets', component: () => import('./views/Bets.vue') },
{ path: 'settlement/:id', component: () => import('./views/Settlement.vue') },
{ path: 'cashback', component: () => import('./views/Cashback.vue') },
{ path: 'audit', component: () => import('./views/Audit.vue') },
],
},
],
});

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
const agents = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Agent@123', creditLimit: 50000 });
onMounted(load);
async function load() {
const { data } = await api.get('/admin/agents');
agents.value = data.data;
}
async function create() {
await api.post('/admin/agents', form.value);
ElMessage.success('创建成功');
load();
}
async function adjustCredit(agent: { userId: string }, amount: number) {
await api.post(`/admin/agents/${agent.userId}/credit`, {
amount,
requestId: `credit-${Date.now()}`,
});
ElMessage.success('额度已调整');
load();
}
</script>
<template>
<h2>代理管理</h2>
<el-form inline style="margin: 16px 0">
<el-input v-model="form.username" placeholder="用户名" style="width: 120px" />
<el-input-number v-model="form.creditLimit" placeholder="额度" />
<el-button type="primary" @click="create">创建一级代理</el-button>
</el-form>
<el-table :data="agents">
<el-table-column label="用户名">
<template #default="{ row }">{{ (row as { user?: { username: string } }).user?.username }}</template>
</el-table-column>
<el-table-column prop="level" label="层级" />
<el-table-column prop="creditLimit" label="授信额度" />
<el-table-column prop="usedCredit" label="已用额度" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button size="small" @click="adjustCredit(row as { userId: string }, 10000)">+10000</el-button>
</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const logs = ref<unknown[]>([]);
onMounted(async () => {
const { data } = await api.get('/admin/audit-logs');
logs.value = data.data.items;
});
</script>
<template>
<h2>操作日志</h2>
<el-table :data="logs">
<el-table-column prop="action" label="操作" />
<el-table-column prop="module" label="模块" />
<el-table-column prop="targetId" label="目标" />
<el-table-column label="时间">
<template #default="{ row }">{{ new Date((row as { createdAt: string }).createdAt).toLocaleString() }}</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const bets = ref<unknown[]>([]);
onMounted(async () => {
const { data } = await api.get('/admin/bets');
bets.value = data.data.items;
});
</script>
<template>
<h2>注单管理</h2>
<el-table :data="bets">
<el-table-column prop="betNo" label="注单号" />
<el-table-column prop="betType" label="类型" />
<el-table-column prop="stake" label="投注额" />
<el-table-column prop="status" label="状态" />
<el-table-column label="时间">
<template #default="{ row }">{{ new Date((row as { placedAt: string }).placedAt).toLocaleString() }}</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ref } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
const preview = ref<Record<string, unknown> | null>(null);
const period = ref({
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
end: new Date().toISOString().slice(0, 10),
});
async function generatePreview() {
const { data } = await api.post('/admin/cashbacks/preview', {
periodStart: period.value.start,
periodEnd: period.value.end,
});
preview.value = data.data;
}
async function confirm() {
if (!preview.value?.batch) return;
await api.post(`/admin/cashbacks/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success('返水已发放');
}
</script>
<template>
<h2>返水管理</h2>
<el-form inline>
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" />
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" />
<el-button @click="generatePreview">生成预览</el-button>
</el-form>
<el-card v-if="preview" style="margin-top: 16px">
<p>玩家数: {{ (preview.batch as { playerCount: number })?.playerCount }}</p>
<p>总金额: {{ preview.totalAmount }}</p>
<el-button type="success" @click="confirm">确认发放</el-button>
</el-card>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const stats = ref<Record<string, unknown>>({});
onMounted(async () => {
const { data } = await api.get('/admin/dashboard');
stats.value = data.data;
});
</script>
<template>
<h2>控制台</h2>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="6"><el-statistic title="今日投注笔数" :value="(stats.todayBetCount as number) || 0" /></el-col>
<el-col :span="6"><el-statistic title="今日投注额" :value="Number(stats.todayStake) || 0" :precision="2" /></el-col>
<el-col :span="6"><el-statistic title="今日派彩" :value="Number(stats.todayPayout) || 0" :precision="2" /></el-col>
<el-col :span="6"><el-statistic title="待结算赛事" :value="(stats.pendingSettlement as number) || 0" /></el-col>
</el-row>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
const router = useRouter();
const form = ref({ username: 'admin', password: 'Admin@123' });
const loading = ref(false);
async function login() {
loading.value = true;
try {
const { data } = await api.post('/admin/auth/login', form.value);
localStorage.setItem('admin_token', data.data.token);
router.push('/');
} catch {
ElMessage.error('登录失败');
} finally {
loading.value = false;
}
}
</script>
<template>
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center">
<el-card style="width: 400px">
<h2 style="text-align: center; margin-bottom: 24px">平台后台登录</h2>
<el-form @submit.prevent="login">
<el-form-item label="用户名">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
</el-form>
</el-card>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
const router = useRouter();
const matches = ref<unknown[]>([]);
const form = ref({
leagueId: '',
homeTeamId: '',
awayTeamId: '',
startTime: '',
});
onMounted(load);
async function load() {
const { data } = await api.get('/admin/matches');
matches.value = data.data;
}
async function create() {
await api.post('/admin/matches', form.value);
ElMessage.success('赛事已创建');
load();
}
async function publish(id: string) {
await api.post(`/admin/matches/${id}/publish`);
await api.post(`/admin/matches/${id}/markets/templates`, {
marketTypes: ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER', 'FT_ODD_EVEN'],
});
ElMessage.success('已发布并生成盘口');
load();
}
async function close(id: string) {
await api.post(`/admin/matches/${id}/close`);
ElMessage.success('已封盘');
load();
}
function settle(id: string) {
router.push(`/settlement/${id}`);
}
</script>
<template>
<h2>赛事管理</h2>
<el-card style="margin-bottom: 16px">
<el-form inline>
<el-input v-model="form.leagueId" placeholder="联赛ID" style="width: 100px" />
<el-input v-model="form.homeTeamId" placeholder="主队ID" style="width: 100px" />
<el-input v-model="form.awayTeamId" placeholder="客队ID" style="width: 100px" />
<el-input v-model="form.startTime" placeholder="开赛时间 ISO" style="width: 200px" />
<el-button type="primary" @click="create">创建赛事</el-button>
</el-form>
</el-card>
<el-table :data="matches">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="status" label="状态" />
<el-table-column label="开赛时间">
<template #default="{ row }">{{ new Date((row as { startTime: string }).startTime).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="操作" width="300">
<template #default="{ row }">
<el-button v-if="(row as { status: string }).status === 'DRAFT'" size="small" @click="publish((row as { id: string }).id)">发布</el-button>
<el-button v-if="(row as { status: string }).status === 'PUBLISHED'" size="small" @click="close((row as { id: string }).id)">封盘</el-button>
<el-button size="small" type="warning" @click="settle((row as { id: string }).id)">结算</el-button>
</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
const route = useRoute();
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
const preview = ref<Record<string, unknown> | null>(null);
async function recordScore() {
await api.post(`/admin/matches/${route.params.id}/settlement/score`, score.value);
ElMessage.success('比分已录入');
}
async function previewSettlement() {
const { data } = await api.post(`/admin/matches/${route.params.id}/settlement/preview`);
preview.value = data.data;
}
async function confirm() {
if (!preview.value?.batch) return;
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success('结算已确认');
}
</script>
<template>
<h2>赛事结算 #{{ route.params.id }}</h2>
<el-form inline style="margin: 16px 0">
<el-input-number v-model="score.htHome" :min="0" />
<el-input-number v-model="score.htAway" :min="0" />
<span>半场</span>
<el-input-number v-model="score.ftHome" :min="0" />
<el-input-number v-model="score.ftAway" :min="0" />
<span>全场</span>
<el-button @click="recordScore">录入比分</el-button>
<el-button type="primary" @click="previewSettlement">生成预览</el-button>
</el-form>
<el-card v-if="preview">
<p>单关注单: {{ preview.singleBetCount }}</p>
<p>预计派彩: {{ preview.totalPayout }}</p>
<p>退款: {{ preview.totalRefund }}</p>
<el-button type="success" @click="confirm">确认结算</el-button>
</el-card>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
const users = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Player@123', parentId: '' });
onMounted(load);
async function load() {
const { data } = await api.get('/admin/users');
users.value = data.data.items;
}
async function create() {
await api.post('/admin/users', form.value);
ElMessage.success('创建成功');
form.value.username = '';
load();
}
</script>
<template>
<h2>玩家管理</h2>
<el-form inline style="margin: 16px 0">
<el-input v-model="form.username" placeholder="用户名" style="width: 150px" />
<el-input v-model="form.password" placeholder="密码" style="width: 150px" />
<el-button type="primary" @click="create">创建玩家</el-button>
</el-form>
<el-table :data="users">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="status" label="状态" />
<el-table-column label="余额">
<template #default="{ row }">{{ (row as { wallet?: { availableBalance: string } }).wallet?.availableBalance }}</template>
</el-table-column>
</el-table>
</template>

5
apps/admin/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;
}

1
apps/admin/tsconfig.json Normal file
View File

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

10
apps/admin/vite.config.ts Normal file
View File

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