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

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

@@ -0,0 +1,25 @@
<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,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

@@ -0,0 +1,68 @@
<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,39 @@
<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>