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:
2026-06-02 17:50:58 +08:00
parent b5dca1bfb1
commit 31737286b9
34 changed files with 363 additions and 255 deletions

View File

@@ -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:5174admin/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
View 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/*`(按角色调用)。

View File

@@ -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>

View File

@@ -2,6 +2,7 @@
"name": "@thebet365/admin",
"version": "1.0.0",
"private": true,
"description": "统一管理后台(平台管理员 + 代理,单入口登录)",
"type": "module",
"scripts": {
"dev": "vite --port 5174",

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

View File

@@ -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>

View File

@@ -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;

View 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,
};
}

View 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>

View File

@@ -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>

View File

@@ -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="状态" />

View 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>

View File

@@ -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 }">

View File

@@ -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="已用" />

View File

@@ -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>

View File

@@ -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"
}
}

View File

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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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');

View File

@@ -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') },
],
},
],
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,5 +0,0 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}

View File

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

View File

@@ -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 } },
},
});

View File

@@ -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')

View File

@@ -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 },

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

View File

@@ -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 项)

View File

@@ -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
View File

@@ -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':