初始化足球投注平台 MVP Monorepo
包含 NestJS 后端、三端前端、Prisma 数据模型、结算引擎测试与 PRD 文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
12
apps/admin/index.html
Normal file
12
apps/admin/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" />
|
||||
<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
23
apps/admin/package.json
Normal 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
4
apps/admin/src/App.vue
Normal 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
9
apps/admin/src/api.ts
Normal 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;
|
||||
40
apps/admin/src/layouts/AdminLayout.vue
Normal file
40
apps/admin/src/layouts/AdminLayout.vue
Normal 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
7
apps/admin/src/main.ts
Normal 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');
|
||||
23
apps/admin/src/router/index.ts
Normal file
23
apps/admin/src/router/index.ts
Normal 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') },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
52
apps/admin/src/views/Agents.vue
Normal file
52
apps/admin/src/views/Agents.vue
Normal 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>
|
||||
23
apps/admin/src/views/Audit.vue
Normal file
23
apps/admin/src/views/Audit.vue
Normal 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>
|
||||
24
apps/admin/src/views/Bets.vue
Normal file
24
apps/admin/src/views/Bets.vue
Normal 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>
|
||||
39
apps/admin/src/views/Cashback.vue
Normal file
39
apps/admin/src/views/Cashback.vue
Normal 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>
|
||||
21
apps/admin/src/views/Dashboard.vue
Normal file
21
apps/admin/src/views/Dashboard.vue
Normal 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>
|
||||
40
apps/admin/src/views/Login.vue
Normal file
40
apps/admin/src/views/Login.vue
Normal 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>
|
||||
74
apps/admin/src/views/Matches.vue
Normal file
74
apps/admin/src/views/Matches.vue
Normal 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>
|
||||
46
apps/admin/src/views/Settlement.vue
Normal file
46
apps/admin/src/views/Settlement.vue
Normal 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>
|
||||
38
apps/admin/src/views/Users.vue
Normal file
38
apps/admin/src/views/Users.vue
Normal 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
5
apps/admin/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;
|
||||
}
|
||||
1
apps/admin/tsconfig.json
Normal file
1
apps/admin/tsconfig.json
Normal 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
10
apps/admin/vite.config.ts
Normal 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 } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user