refactor(admin): 合并管理后台并移除 apps/agent
- 平台与代理共用 apps/admin,统一登录 manage/auth/login - 按 userType 展示菜单,修复 token 循环跳转 - 删除独立 apps/agent 前端工程 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,7 +8,7 @@ Monorepo 项目,包含 NestJS 后端与 Vue 3 三端前端。
|
||||
|------|------|
|
||||
| 后端 | NestJS + Prisma + PostgreSQL + Redis |
|
||||
| 玩家前台 | Vue 3 + Vite + Pinia (H5 移动端优先) |
|
||||
| 平台/代理后台 | Vue 3 + Vite + Element Plus |
|
||||
| 管理后台(平台+代理) | Vue 3 + Vite + Element Plus,单应用 `apps/admin` |
|
||||
| 包管理 | pnpm workspace |
|
||||
|
||||
## 快速开始
|
||||
@@ -43,8 +43,8 @@ pnpm db:seed
|
||||
```bash
|
||||
pnpm dev:api # API http://localhost:3000
|
||||
pnpm dev:player # 玩家前台 http://localhost:5173
|
||||
pnpm dev:admin # 平台后台 http://localhost:5174
|
||||
pnpm dev:agent # 代理后台 http://localhost:5175
|
||||
pnpm dev:admin # 管理后台 http://localhost:5174(admin/agent 同一入口)
|
||||
pnpm dev:manage # 同上别名
|
||||
```
|
||||
|
||||
API 文档:http://localhost:3000/api/docs
|
||||
@@ -83,8 +83,7 @@ apps/api/src/
|
||||
|
||||
apps/
|
||||
player/ 玩家 H5 前台
|
||||
admin/ 平台后台
|
||||
agent/ 代理后台
|
||||
admin/ 管理后台(平台 + 代理,单应用)
|
||||
packages/
|
||||
shared/ 共享类型与常量
|
||||
```
|
||||
|
||||
30
apps/admin/README.md
Normal file
30
apps/admin/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 统一管理后台(平台 + 代理)
|
||||
|
||||
单一 Vue 3 应用,按登录账号 `userType` 展示不同菜单:
|
||||
|
||||
| 类型 | 演示账号 | 菜单 |
|
||||
|------|----------|------|
|
||||
| 平台管理员 `ADMIN` | admin / Admin@123 | 控制台、玩家、代理、赛事、注单、返水、审计 |
|
||||
| 代理 `AGENT` | agent1 / Agent@123 | 概览、直属玩家、下级代理、注单 |
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
pnpm dev:api # 需先启动 API :3000
|
||||
pnpm dev:admin # http://localhost:5174
|
||||
```
|
||||
|
||||
登录接口:`POST /api/manage/auth/login`
|
||||
|
||||
## 源码结构
|
||||
|
||||
```
|
||||
src/
|
||||
layouts/ManageLayout.vue # 共用布局
|
||||
views/ # 平台端页面
|
||||
views/agent/ # 代理端页面
|
||||
stores/auth.ts # 统一会话
|
||||
router/index.ts # 路由 + 权限守卫
|
||||
```
|
||||
|
||||
后端仍为两套 API 前缀:`/api/admin/*`、`/api/agent/*`(按角色调用)。
|
||||
@@ -6,7 +6,7 @@
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>TheBet365 Admin</title>
|
||||
<title>TheBet365 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "@thebet365/admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "统一管理后台(平台管理员 + 代理,单入口登录)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5174",
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import axios from 'axios';
|
||||
import router from './router';
|
||||
import { clearStaffSession } from './stores/auth';
|
||||
|
||||
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;
|
||||
|
||||
let handling401 = false;
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const t = localStorage.getItem('manage_token');
|
||||
if (t) config.headers.Authorization = `Bearer ${t}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err) => {
|
||||
if (err.response?.status === 401 && !handling401) {
|
||||
handling401 = true;
|
||||
clearStaffSession();
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
await router.replace('/login');
|
||||
}
|
||||
handling401 = false;
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
BIN
apps/admin/src/assets/images/bg.png
Normal file
BIN
apps/admin/src/assets/images/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 MiB |
@@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('admin_token');
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
const menus = [
|
||||
const adminMenus = [
|
||||
{ path: '/', label: '控制台' },
|
||||
{ path: '/users', label: '玩家管理' },
|
||||
{ path: '/agents', label: '代理管理' },
|
||||
@@ -18,6 +16,20 @@ const menus = [
|
||||
{ path: '/cashback', label: '返水管理' },
|
||||
{ path: '/audit', label: '操作日志' },
|
||||
];
|
||||
|
||||
const agentMenus = [
|
||||
{ path: '/', label: '概览' },
|
||||
{ path: '/my-players', label: '直属玩家' },
|
||||
{ path: '/sub-agents', label: '下级代理' },
|
||||
{ path: '/my-bets', label: '注单查询' },
|
||||
];
|
||||
|
||||
const menus = computed(() => (auth.isAdmin.value ? adminMenus : agentMenus));
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
router.push('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,16 +37,26 @@ const menus = [
|
||||
<el-aside width="200px" style="background: #1a2332">
|
||||
<div style="padding: 20px">
|
||||
<img src="/logo.png" alt="TheBet365" style="height: 56px; width: auto; display: block" />
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #888">平台后台</div>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #888">{{ auth.portalLabel }}</div>
|
||||
<div v-if="auth.user" style="margin-top: 6px; font-size: 12px; color: #aaa">
|
||||
{{ auth.user.username }}
|
||||
</div>
|
||||
</div>
|
||||
<el-menu background-color="#1a2332" text-color="#ccc" active-text-color="#00a826" :default-active="route.path">
|
||||
<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-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>
|
||||
@@ -1,23 +1,95 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
export default createRouter({
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: () => import('../views/Login.vue') },
|
||||
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/AdminLayout.vue'),
|
||||
component: () => import('../layouts/ManageLayout.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') },
|
||||
{ path: '', component: () => import('../views/HomeEntry.vue') },
|
||||
{
|
||||
path: 'users',
|
||||
component: () => import('../views/Users.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
component: () => import('../views/Agents.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'matches',
|
||||
component: () => import('../views/Matches.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'bets',
|
||||
component: () => import('../views/Bets.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'settlement/:id',
|
||||
component: () => import('../views/Settlement.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'cashback',
|
||||
component: () => import('../views/Cashback.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
component: () => import('../views/Audit.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'my-players',
|
||||
component: () => import('../views/agent/Players.vue'),
|
||||
meta: { agentOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'sub-agents',
|
||||
component: () => import('../views/agent/SubAgents.vue'),
|
||||
meta: { agentOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'my-bets',
|
||||
component: () => import('../views/agent/Bets.vue'),
|
||||
meta: { agentOnly: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
const hasToken = !!auth.token.value;
|
||||
const hasUser = !!auth.user.value?.userType;
|
||||
|
||||
if (to.meta.public) {
|
||||
if (hasToken && hasUser) return '/';
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasToken || !hasUser) {
|
||||
auth.clearStaffSession();
|
||||
return { path: '/login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
if (to.meta.adminOnly && !auth.isAdmin.value) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (to.meta.agentOnly && !auth.isAgent.value) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
84
apps/admin/src/stores/auth.ts
Normal file
84
apps/admin/src/stores/auth.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export type StaffUserType = 'ADMIN' | 'AGENT';
|
||||
|
||||
export interface StaffUser {
|
||||
id: string;
|
||||
username: string;
|
||||
userType: StaffUserType;
|
||||
locale?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'manage_token';
|
||||
const USER_KEY = 'manage_user';
|
||||
|
||||
function loadUser(): StaffUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? (JSON.parse(raw) as StaffUser) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacyTokens() {
|
||||
if (localStorage.getItem(TOKEN_KEY)) return;
|
||||
const legacyAdmin = localStorage.getItem('admin_token');
|
||||
const legacyAgent = localStorage.getItem('agent_token');
|
||||
if (legacyAdmin) {
|
||||
localStorage.setItem(TOKEN_KEY, legacyAdmin);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify({ userType: 'ADMIN' }));
|
||||
localStorage.removeItem('admin_token');
|
||||
return;
|
||||
}
|
||||
if (legacyAgent) {
|
||||
localStorage.setItem(TOKEN_KEY, legacyAgent);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify({ userType: 'AGENT' }));
|
||||
localStorage.removeItem('agent_token');
|
||||
}
|
||||
}
|
||||
|
||||
migrateLegacyTokens();
|
||||
|
||||
const token = ref(localStorage.getItem(TOKEN_KEY) || '');
|
||||
const user = ref<StaffUser | null>(loadUser());
|
||||
|
||||
export function clearStaffSession() {
|
||||
token.value = '';
|
||||
user.value = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
localStorage.removeItem('admin_token');
|
||||
localStorage.removeItem('agent_token');
|
||||
}
|
||||
|
||||
export function useAuthStore() {
|
||||
const isAdmin = computed(() => user.value?.userType === 'ADMIN');
|
||||
const isAgent = computed(() => user.value?.userType === 'AGENT');
|
||||
const portalLabel = computed(() => (isAdmin.value ? '平台后台' : '代理后台'));
|
||||
|
||||
function setSession(newToken: string, newUser: StaffUser) {
|
||||
token.value = newToken;
|
||||
user.value = newUser;
|
||||
localStorage.setItem(TOKEN_KEY, newToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(newUser));
|
||||
localStorage.removeItem('admin_token');
|
||||
localStorage.removeItem('agent_token');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearStaffSession();
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAdmin,
|
||||
isAgent,
|
||||
portalLabel,
|
||||
setSession,
|
||||
logout,
|
||||
clearStaffSession,
|
||||
};
|
||||
}
|
||||
12
apps/admin/src/views/HomeEntry.vue
Normal file
12
apps/admin/src/views/HomeEntry.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import AdminDashboard from './Dashboard.vue';
|
||||
import AgentDashboard from './agent/Dashboard.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminDashboard v-if="auth.isAdmin" />
|
||||
<AgentDashboard v-else />
|
||||
</template>
|
||||
@@ -1,21 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAuthStore, type StaffUser } from '../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const form = ref({ username: 'admin', password: 'Admin@123' });
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const form = ref({ username: '', password: '' });
|
||||
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('/');
|
||||
const { data } = await api.post('/manage/auth/login', form.value);
|
||||
const payload = data.data as { token: string; user: StaffUser };
|
||||
auth.setSession(payload.token, payload.user);
|
||||
const redirect = (route.query.redirect as string) || '/';
|
||||
router.push(redirect);
|
||||
} catch {
|
||||
ElMessage.error('登录失败');
|
||||
ElMessage.error('登录失败,请检查账号与密码');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -27,17 +33,24 @@ async function login() {
|
||||
<el-card style="width: 400px">
|
||||
<div style="text-align: center; margin-bottom: 24px">
|
||||
<img src="/logo.png" alt="TheBet365" style="height: 64px; width: auto" />
|
||||
<h2 style="margin-top: 12px; font-size: 16px; font-weight: 500">平台后台登录</h2>
|
||||
<h2 style="margin-top: 12px; font-size: 16px; font-weight: 500">管理后台登录</h2>
|
||||
<p style="margin-top: 8px; font-size: 12px; color: #888">
|
||||
管理员与代理使用同一入口,系统将根据账号类型进入对应后台
|
||||
</p>
|
||||
</div>
|
||||
<el-form @submit.prevent="login">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" />
|
||||
<el-input v-model="form.username" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" />
|
||||
<el-input v-model="form.password" type="password" autocomplete="current-password" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
|
||||
</el-form>
|
||||
<p style="margin-top: 16px; font-size: 12px; color: #999; line-height: 1.6">
|
||||
演示账号:admin / Admin@123(平台)<br />
|
||||
agent1 / Agent@123(一级代理)
|
||||
</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../api';
|
||||
import api from '../../api';
|
||||
|
||||
const bets = ref<unknown[]>([]);
|
||||
|
||||
@@ -15,7 +15,9 @@ onMounted(async () => {
|
||||
<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>
|
||||
<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="状态" />
|
||||
35
apps/admin/src/views/agent/Dashboard.vue
Normal file
35
apps/admin/src/views/agent/Dashboard.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../api';
|
||||
import api from '../../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const players = ref<unknown[]>([]);
|
||||
@@ -55,7 +55,9 @@ async function withdraw(playerId: string, amount: number) {
|
||||
<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>
|
||||
<template #default="{ row }">
|
||||
{{ (row as { wallet?: { availableBalance: string } }).wallet?.availableBalance }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作">
|
||||
<template #default="{ row }">
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../api';
|
||||
import api from '../../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const agents = ref<unknown[]>([]);
|
||||
@@ -29,7 +29,9 @@ async function create() {
|
||||
</el-form>
|
||||
<el-table :data="agents">
|
||||
<el-table-column label="用户名">
|
||||
<template #default="{ row }">{{ (row as { user?: { username: string } }).user?.username }}</template>
|
||||
<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="已用" />
|
||||
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>TheBet365 Agent</title>
|
||||
</head>
|
||||
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body>
|
||||
</html>
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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,4 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
<template><RouterView /></template>
|
||||
@@ -1,9 +0,0 @@
|
||||
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;
|
||||
@@ -1,32 +0,0 @@
|
||||
<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">
|
||||
<img src="/logo.png" alt="TheBet365" style="height: 48px; width: auto; display: block" />
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #888">代理后台</div>
|
||||
</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>
|
||||
@@ -1,7 +0,0 @@
|
||||
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');
|
||||
@@ -1,18 +0,0 @@
|
||||
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') },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@@ -1,35 +0,0 @@
|
||||
<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">
|
||||
<div style="text-align: center; margin-bottom: 20px">
|
||||
<img src="/logo.png" alt="TheBet365" style="height: 64px; width: auto" />
|
||||
<h2 style="margin-top: 12px; font-size: 16px; font-weight: 500">代理后台登录</h2>
|
||||
</div>
|
||||
<el-form @submit.prevent="login" style="margin-top: 0">
|
||||
<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>
|
||||
5
apps/agent/src/vite-env.d.ts
vendored
5
apps/agent/src/vite-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<object, object, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true }, "include": ["src/**/*.ts", "src/**/*.vue"] }
|
||||
@@ -1,12 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
publicDir: resolve(__dirname, '../../packages/shared/public'),
|
||||
server: {
|
||||
port: 5175,
|
||||
proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } },
|
||||
},
|
||||
});
|
||||
@@ -32,6 +32,13 @@ export class AuthController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('manage/auth/login')
|
||||
async manageLogin(@Body() dto: LoginDto) {
|
||||
const result = await this.auth.staffLogin(dto.username, dto.password);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Post('player/auth/change-password')
|
||||
|
||||
@@ -22,6 +22,19 @@ export class AuthService {
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */
|
||||
async staffLogin(username: string, password: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { userType: true },
|
||||
});
|
||||
if (!user || (user.userType !== 'ADMIN' && user.userType !== 'AGENT')) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const portal = user.userType === 'ADMIN' ? 'admin' : 'agent';
|
||||
return this.login(username, password, portal);
|
||||
}
|
||||
|
||||
async login(username: string, password: string, portal: 'player' | 'admin' | 'agent') {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { username },
|
||||
|
||||
BIN
apps/player/src/assets/images/bg.png
Normal file
BIN
apps/player/src/assets/images/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
@@ -5,8 +5,8 @@
|
||||
| 角色 | 用户名 | 密码 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 超级管理员 | admin | Admin@123 | 平台后台 |
|
||||
| 一级代理 | agent1 | Agent@123 | 代理后台,授信 100000 |
|
||||
| 二级代理 | agent2 | Agent@123 | 隶属 agent1 |
|
||||
| 一级代理 | agent1 | Agent@123 | 管理后台 :5174 登录,授信 100000 |
|
||||
| 二级代理 | agent2 | Agent@123 | 隶属 agent1(无独立前端) |
|
||||
| 测试玩家 | player1 | Player@123 | 初始余额 1000 |
|
||||
|
||||
## MVP 验收清单(18 项)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"dev:api": "pnpm --filter @thebet365/api dev",
|
||||
"dev:player": "pnpm --filter @thebet365/player dev",
|
||||
"dev:admin": "pnpm --filter @thebet365/admin dev",
|
||||
"dev:agent": "pnpm --filter @thebet365/agent dev",
|
||||
"dev:manage": "pnpm --filter @thebet365/admin dev",
|
||||
"build": "pnpm -r run build",
|
||||
"test": "pnpm -r run test",
|
||||
"db:generate": "pnpm --filter @thebet365/api db:generate",
|
||||
|
||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -36,34 +36,6 @@ importers:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(typescript@5.7.3)
|
||||
|
||||
apps/agent:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^1.7.9
|
||||
version: 1.16.1
|
||||
element-plus:
|
||||
specifier: ^2.9.3
|
||||
version: 2.14.1(vue@3.5.35(typescript@5.7.3))
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.35(typescript@5.7.3)
|
||||
vue-router:
|
||||
specifier: ^4.5.0
|
||||
version: 4.6.4(vue@3.5.35(typescript@5.7.3))
|
||||
devDependencies:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(terser@5.48.0))(vue@3.5.35(typescript@5.7.3))
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.7.3
|
||||
vite:
|
||||
specifier: ^6.0.11
|
||||
version: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(terser@5.48.0)
|
||||
vue-tsc:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(typescript@5.7.3)
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@nestjs/common':
|
||||
|
||||
Reference in New Issue
Block a user