diff --git a/README.md b/README.md
index e9987ba..48d6b30 100644
--- a/README.md
+++ b/README.md
@@ -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/ 共享类型与常量
```
diff --git a/apps/admin/README.md b/apps/admin/README.md
new file mode 100644
index 0000000..d3b6737
--- /dev/null
+++ b/apps/admin/README.md
@@ -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/*`(按角色调用)。
diff --git a/apps/admin/index.html b/apps/admin/index.html
index d9a1580..5df508f 100644
--- a/apps/admin/index.html
+++ b/apps/admin/index.html
@@ -6,7 +6,7 @@
-
TheBet365 Admin
+ TheBet365 管理后台
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 7b68c1c..686b7ba 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -2,6 +2,7 @@
"name": "@thebet365/admin",
"version": "1.0.0",
"private": true,
+ "description": "统一管理后台(平台管理员 + 代理,单入口登录)",
"type": "module",
"scripts": {
"dev": "vite --port 5174",
diff --git a/apps/admin/src/api.ts b/apps/admin/src/api.ts
index 52f05ca..dd3d176 100644
--- a/apps/admin/src/api.ts
+++ b/apps/admin/src/api.ts
@@ -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;
diff --git a/apps/admin/src/assets/images/bg.png b/apps/admin/src/assets/images/bg.png
new file mode 100644
index 0000000..962a393
Binary files /dev/null and b/apps/admin/src/assets/images/bg.png differ
diff --git a/apps/admin/src/layouts/AdminLayout.vue b/apps/admin/src/layouts/ManageLayout.vue
similarity index 58%
rename from apps/admin/src/layouts/AdminLayout.vue
rename to apps/admin/src/layouts/ManageLayout.vue
index de98543..4f88fb2 100644
--- a/apps/admin/src/layouts/AdminLayout.vue
+++ b/apps/admin/src/layouts/ManageLayout.vue
@@ -1,15 +1,13 @@
@@ -25,16 +37,26 @@ const menus = [

-
平台后台
+
{{ auth.portalLabel }}
+
+ {{ auth.user.username }}
+
-
+
{{ m.label }}
-
+
退出
diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts
index bd4e891..ac9c872 100644
--- a/apps/admin/src/router/index.ts
+++ b/apps/admin/src/router/index.ts
@@ -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;
diff --git a/apps/admin/src/stores/auth.ts b/apps/admin/src/stores/auth.ts
new file mode 100644
index 0000000..ed9d32d
--- /dev/null
+++ b/apps/admin/src/stores/auth.ts
@@ -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(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,
+ };
+}
diff --git a/apps/admin/src/views/HomeEntry.vue b/apps/admin/src/views/HomeEntry.vue
new file mode 100644
index 0000000..05fb3f8
--- /dev/null
+++ b/apps/admin/src/views/HomeEntry.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/apps/admin/src/views/Login.vue b/apps/admin/src/views/Login.vue
index 1deb5b0..0ca10b4 100644
--- a/apps/admin/src/views/Login.vue
+++ b/apps/admin/src/views/Login.vue
@@ -1,21 +1,27 @@
+
+
+ 代理概览
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/agent/src/views/Players.vue b/apps/admin/src/views/agent/Players.vue
similarity index 92%
rename from apps/agent/src/views/Players.vue
rename to apps/admin/src/views/agent/Players.vue
index fee4721..351d1b7 100644
--- a/apps/agent/src/views/Players.vue
+++ b/apps/admin/src/views/agent/Players.vue
@@ -1,6 +1,6 @@
-