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

5
apps/agent/index.html Normal file
View File

@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><title>TheBet365 Agent</title></head>
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body>
</html>

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

@@ -0,0 +1,23 @@
{
"name": "@thebet365/agent",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5175",
"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"
}
}

1
apps/agent/src/App.vue Normal file
View File

@@ -0,0 +1 @@
<script setup lang="ts"><template><router-view /></template></script>

9
apps/agent/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('agent_token');
if (t) c.headers.Authorization = `Bearer ${t}`;
return c;
});
export default api;

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRouter } from 'vue-router';
const router = useRouter();
function logout() {
localStorage.removeItem('agent_token');
router.push('/login');
}
</script>
<template>
<el-container style="min-height: 100vh">
<el-aside width="180px" style="background: #1a2332">
<div style="padding: 16px; color: #00a826; font-weight: 700">代理后台</div>
<el-menu background-color="#1a2332" text-color="#ccc" active-text-color="#00a826">
<el-menu-item index="/"><RouterLink to="/" style="color: inherit">概览</RouterLink></el-menu-item>
<el-menu-item index="/players"><RouterLink to="/players" style="color: inherit">直属玩家</RouterLink></el-menu-item>
<el-menu-item index="/agents"><RouterLink to="/agents" style="color: inherit">下级代理</RouterLink></el-menu-item>
<el-menu-item index="/bets"><RouterLink to="/bets" style="color: inherit">注单查询</RouterLink></el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="text-align: right; line-height: 60px">
<el-button @click="logout">退出</el-button>
</el-header>
<el-main><RouterView /></el-main>
</el-container>
</el-container>
</template>

7
apps/agent/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,18 @@
import { createRouter, createWebHistory } from 'vue-router';
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('./views/Login.vue') },
{
path: '/',
component: () => import('./layouts/AgentLayout.vue'),
children: [
{ path: '', component: () => import('./views/Dashboard.vue') },
{ path: 'players', component: () => import('./views/Players.vue') },
{ path: 'agents', component: () => import('./views/SubAgents.vue') },
{ path: 'bets', component: () => import('./views/Bets.vue') },
],
},
],
});

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const bets = ref<unknown[]>([]);
onMounted(async () => {
const { data } = await api.get('/agent/bets');
bets.value = data.data.items;
});
</script>
<template>
<h2>下级注单</h2>
<el-table :data="bets">
<el-table-column prop="betNo" label="注单号" />
<el-table-column label="玩家">
<template #default="{ row }">{{ (row as { user?: { username: string } }).user?.username }}</template>
</el-table-column>
<el-table-column prop="stake" label="投注额" />
<el-table-column prop="status" label="状态" />
</el-table>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
const summary = ref<Record<string, unknown>>({});
onMounted(async () => {
const { data } = await api.get('/agent/reports/summary');
summary.value = data.data;
});
</script>
<template>
<h2>代理概览</h2>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="6"><el-statistic title="授信额度" :value="Number((summary.profile as { creditLimit?: string })?.creditLimit) || 0" /></el-col>
<el-col :span="6"><el-statistic title="已用额度" :value="Number((summary.profile as { usedCredit?: string })?.usedCredit) || 0" /></el-col>
<el-col :span="6"><el-statistic title="直属玩家" :value="(summary.directPlayerCount as number) || 0" /></el-col>
<el-col :span="6"><el-statistic title="今日投注" :value="Number(summary.todayStake) || 0" :precision="2" /></el-col>
</el-row>
</template>

View File

@@ -0,0 +1,32 @@
<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: 'agent1', password: 'Agent@123' });
async function login() {
try {
const { data } = await api.post('/agent/auth/login', form.value);
localStorage.setItem('agent_token', data.data.token);
router.push('/');
} catch {
ElMessage.error('登录失败');
}
}
</script>
<template>
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center">
<el-card style="width: 360px">
<h2 style="text-align: center">代理后台登录</h2>
<el-form @submit.prevent="login" style="margin-top: 20px">
<el-input v-model="form.username" placeholder="用户名" style="margin-bottom: 12px" />
<el-input v-model="form.password" type="password" placeholder="密码" style="margin-bottom: 12px" />
<el-button type="primary" native-type="submit" style="width: 100%">登录</el-button>
</el-form>
</el-card>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
const players = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Player@123' });
const depositForm = ref({ playerId: '', amount: 100, requestId: '' });
onMounted(load);
async function load() {
const { data } = await api.get('/agent/players');
players.value = data.data;
}
async function create() {
await api.post('/agent/players', form.value);
ElMessage.success('玩家已创建');
load();
}
async function deposit() {
depositForm.value.requestId = `dep-${Date.now()}`;
await api.post(`/agent/players/${depositForm.value.playerId}/deposit`, {
amount: depositForm.value.amount,
requestId: depositForm.value.requestId,
});
ElMessage.success('上分成功');
load();
}
async function withdraw(playerId: string, amount: number) {
await api.post(`/agent/players/${playerId}/withdraw`, {
amount,
requestId: `wd-${Date.now()}`,
});
ElMessage.success('下分成功');
load();
}
</script>
<template>
<h2>直属玩家</h2>
<el-form inline style="margin-bottom: 16px">
<el-input v-model="form.username" placeholder="用户名" />
<el-button type="primary" @click="create">创建玩家</el-button>
</el-form>
<el-form inline style="margin-bottom: 16px">
<el-input v-model="depositForm.playerId" placeholder="玩家ID" style="width: 100px" />
<el-input-number v-model="depositForm.amount" :min="1" />
<el-button type="success" @click="deposit">上分</el-button>
</el-form>
<el-table :data="players">
<el-table-column prop="id" label="ID" />
<el-table-column prop="username" label="用户名" />
<el-table-column label="余额">
<template #default="{ row }">{{ (row as { wallet?: { availableBalance: string } }).wallet?.availableBalance }}</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button size="small" @click="withdraw((row as { id: string }).id, 50)">下分50</el-button>
</template>
</el-table-column>
</el-table>
</template>

View File

@@ -0,0 +1,37 @@
<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: 10000 });
onMounted(load);
async function load() {
const { data } = await api.get('/agent/agents');
agents.value = data.data;
}
async function create() {
await api.post('/agent/agents', form.value);
ElMessage.success('下级代理已创建');
load();
}
</script>
<template>
<h2>下级代理仅一级代理可见</h2>
<el-form inline style="margin-bottom: 16px">
<el-input v-model="form.username" placeholder="用户名" />
<el-input-number v-model="form.creditLimit" />
<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="creditLimit" label="额度" />
<el-table-column prop="usedCredit" label="已用" />
</el-table>
</template>

5
apps/agent/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/agent/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/agent/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: 5175,
proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } },
},
});