添加 vue-router 依赖并配置路由,重构登录组件,完善 HTTP 请求拦截器以支持 token 刷新机制

This commit is contained in:
zyh
2025-08-24 19:49:39 +08:00
parent 69bf5500cd
commit 17a1d4e85a
30 changed files with 2368 additions and 28 deletions

6
.env Normal file
View File

@@ -0,0 +1,6 @@
# 默认环境变量(如未提供具体 .env.* 时使用)
# 开发环境已通过 Vite 代理将 /api 转发到 http://localhost:18080
# 生产环境请根据部署地址覆盖为真实后端地址
VITE_API_BASE=/api

1
.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=/api

468
docs/接口文档.md Normal file
View File

@@ -0,0 +1,468 @@
# API 文档
## 用户管理接口
### 创建用户账户
#### 管理员创建用户账户
**接口地址:** `POST /api/admin/accounts`
**请求头:**
```
Content-Type: application/json
Authorization: Bearer {token}
```
**请求参数:**
```json
{
"userType": "ADMIN", // 必填用户类型ADMIN 或 AGENT
"username": "newuser", // 必填用户名3-64字符只能包含字母、数字、下划线
"password": "123456", // 必填密码6-128字符
"status": "ENABLED", // 可选状态ENABLED 或 DISABLED默认ENABLED
"pointsBalance": 0 // 可选积分余额仅AGENT类型默认0
}
```
**成功响应200**
```json
{
"id": 2,
"userType": "ADMIN",
"username": "newuser",
"status": "ENABLED",
"pointsBalance": 0,
"createdAt": "2025-08-24T18:30:00.000",
"updatedAt": "2025-08-24T18:30:00.000"
}
```
**错误响应:**
**400 Bad Request - 参数验证失败:**
```json
{
"timestamp": "2025-08-24T18:30:00.000",
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"details": [
{
"field": "username",
"message": "用户名长度必须在3-64字符之间"
},
{
"field": "password",
"message": "密码长度必须在6-128字符之间"
}
]
}
```
**409 Conflict - 用户名已存在:**
```json
{
"timestamp": "2025-08-24T18:30:00.000",
"status": 409,
"error": "Conflict",
"message": "用户名已存在"
}
```
**401 Unauthorized - 未授权:**
```json
{
"timestamp": "2025-08-24T18:30:00.000",
"status": 401,
"error": "Unauthorized",
"message": "访问被拒绝"
}
```
**403 Forbidden - 权限不足:**
```json
{
"timestamp": "2025-08-24T18:30:00.000",
"status": 403,
"error": "Forbidden",
"message": "权限不足,无法创建用户"
}
```
#### 用户自注册接口
**接口地址:** `POST /api/users`
**请求头:**
```
Content-Type: application/json
```
**请求参数:**
```json
{
"userType": "AGENT", // 必填,用户类型:只能为 AGENT
"username": "newagent", // 必填用户名3-64字符
"password": "123456", // 必填密码6-128字符
"pointsBalance": 0 // 可选积分余额默认0
}
```
**成功响应201**
```json
{
"id": 3,
"userType": "AGENT",
"username": "newagent",
"status": "ENABLED",
"pointsBalance": 0,
"createdAt": "2025-08-24T18:30:00.000",
"updatedAt": "2025-08-24T18:30:00.000"
}
```
### 接口说明
#### 权限要求
- **管理员接口** (`/api/admin/accounts`)需要管理员权限可以创建ADMIN和AGENT类型用户
- **用户接口** (`/api/users`)公开接口只能创建AGENT类型用户
#### 参数说明
**userType用户类型**
- `ADMIN`:管理员用户
- `AGENT`:代理用户
**username用户名**
- 长度3-64字符
- 格式:只能包含字母、数字、下划线
- 唯一性:系统内必须唯一
**password密码**
- 长度6-128字符
- 存储使用BCrypt加密存储
- 安全:建议包含大小写字母、数字和特殊字符
**status状态**
- 可选值:`ENABLED`(启用)、`DISABLED`(禁用)
- 默认值:`ENABLED`
**pointsBalance积分余额**
- 仅AGENT类型用户可以设置
- 类型:整数,不能为负数
- 默认值0
#### 业务规则
1. 用户名在系统内必须唯一
2. 密码使用BCrypt加密存储无法解密
3. 创建成功后账户默认状态为ENABLED
4. 只有管理员可以创建ADMIN类型用户
5. 简化的用户模型,去除了角色和显示名称等复杂字段
#### 使用示例
**创建管理员用户:**
```bash
curl -X POST http://localhost:8080/api/admin/accounts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{
"userType": "ADMIN",
"username": "admin001",
"password": "Admin123!"
}'
```
**创建代理用户:**
```bash
curl -X POST http://localhost:8080/api/admin/accounts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{
"userType": "AGENT",
"username": "agent001",
"password": "Agent123!",
"pointsBalance": 1000
}'
```
**用户自注册:**
```bash
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"userType": "AGENT",
"username": "newuser",
"password": "User123!"
}'
```
### 重置密码接口
**接口地址:** `POST /api/admin/accounts/{id}/reset-password`
**请求头:**
```
Content-Type: application/json
Authorization: Bearer {token}
```
**路径参数:**
- `id`: 账户ID
**请求参数:**
```json
{
"newPassword": "NewPassword123!", // 必填新密码6-128字符
"forceLogout": true // 可选是否强制登出默认true
}
```
**成功响应204** 无响应体
**错误响应:**
**400 Bad Request - 密码格式错误:**
```json
{
"timestamp": "2025-08-24T18:30:00.000",
"status": 400,
"error": "Bad Request",
"message": "新密码长度必须在6-128字符之间"
}
```
**404 Not Found - 用户不存在:**
```json
{
"timestamp": "2025-08-24T18:30:00.000",
"status": 404,
"error": "Not Found",
"message": "用户不存在"
}
```
### 启用/禁用用户接口
**启用用户:** `POST /api/admin/accounts/{id}/enable`
**禁用用户:** `POST /api/admin/accounts/{id}/disable`
**请求头:**
```
Authorization: Bearer {token}
```
**成功响应204** 无响应体
## 前端接口调用说明
### 用户管理接口 (src/api/users.js)
#### 获取用户列表
```javascript
import { fetchUsers } from '@/api/users'
// 获取用户列表
const params = {
page: 1,
size: 20, // 每页大小默认20最大200
keyword: '搜索关键词', // 可选,搜索关键词
userType: 'AGENT', // 可选用户类型ADMIN 或 AGENT
status: 'ENABLED' // 可选账户状态ENABLED 或 DISABLED
}
const response = await fetchUsers(params)
```
#### 创建用户
```javascript
import { createUser } from '@/api/users'
// 创建管理员用户
const adminPayload = {
userType: 'ADMIN',
username: 'admin001',
password: 'Admin123!',
status: 'ENABLED'
}
// 创建代理用户
const agentPayload = {
userType: 'AGENT',
username: 'agent001',
password: 'Agent123!',
pointsBalance: 1000,
status: 'ENABLED'
}
const response = await createUser(adminPayload)
```
#### 用户自注册仅限AGENT
```javascript
import { registerUser } from '@/api/users'
// 用户自注册(公开接口,无需权限)
const agentPayload = {
userType: 'AGENT', // 必填,只能为 AGENT
username: 'newagent', // 必填用户名3-64字符
password: 'Agent123!', // 必填密码6-128字符
pointsBalance: 0 // 可选积分余额默认0
}
const response = await registerUser(agentPayload)
```
#### 更新用户
```javascript
import { updateUser } from '@/api/users'
const payload = {
userType: 'AGENT',
username: 'agent001',
pointsBalance: 2000,
status: 'ENABLED'
}
const response = await updateUser(userId, payload)
```
#### 删除用户
```javascript
import { deleteUser } from '@/api/users'
const response = await deleteUser(userId)
```
#### 设置用户状态
```javascript
import { setUserStatus } from '@/api/users'
// 启用用户
await setUserStatus(userId, true)
// 禁用用户
await setUserStatus(userId, false)
```
#### 重置用户密码
```javascript
import { resetUserPassword } from '@/api/users'
// 重置密码,需要指定新密码
const newPassword = 'NewPassword123!'
const forceLogout = true // 是否强制登出默认为true
const response = await resetUserPassword(userId, newPassword, forceLogout)
```
### 认证接口 (src/api/auth.js)
#### 用户登录
```javascript
import { login } from '@/api/auth'
const payload = {
username: 'admin',
password: 'admin123'
}
const response = await login(payload)
// 返回包含 accessToken 和 refreshToken 的数据
```
#### 刷新令牌
```javascript
import { refresh } from '@/api/auth'
const payload = {
refreshToken: 'your-refresh-token'
}
const response = await refresh(payload)
```
#### 用户登出
```javascript
import { logout } from '@/api/auth'
const response = await logout()
```
### 前端表单字段映射
#### 用户列表查询参数
- `page`: 页码默认1
- `size`: 每页大小默认20最大200前端使用pageSize传参时转换为size
- `keyword`: 搜索关键词(用户名)
- `userType`: 用户类型筛选 (ADMIN/AGENT)
- `status`: 状态筛选 (ENABLED/DISABLED)
#### 用户创建/编辑表单字段
- `userType`: 用户类型 (ADMIN/AGENT)
- `username`: 用户名
- `password`: 密码 (仅创建时)
- `enabled`: 状态 (true/false转换为ENABLED/DISABLED)
- `pointsBalance`: 积分余额 (仅AGENT类型)
#### 后端返回数据字段
- `status`: 用户状态 (ENABLED/DISABLED)
- `enabled`: 兼容字段,部分接口可能返回 (true/false)
#### 表单验证规则
- `userType`: 必填
- `username`: 必填3-64字符
- `password`: 创建时必填最少6位
- `pointsBalance`: 仅AGENT类型必填非负整数
### 注意事项
1. **接口路径**: 所有用户管理接口都使用 `/api/admin/accounts` 前缀
2. **权限控制**:
- 需要有效的 Bearer Token
- 只有 ADMIN 类型用户可以访问用户管理功能
- AGENT 类型用户无法访问用户管理页面和接口
3. **状态字段**:
- 前端表单使用 `enabled` (boolean),提交时转换为 `status` (ENABLED/DISABLED)
- 后端返回使用 `status` (ENABLED/DISABLED),前端显示时需要转换
4. **积分余额**: 仅AGENT类型用户可以设置积分余额
5. **简化模型**: 去除了角色和显示名称字段,简化了用户管理
6. **错误处理**: 统一使用HTTP状态码和错误消息处理
7. **错误显示**: 前端自动显示后端返回的错误消息,支持字段级错误提示
### 错误处理机制
#### 后端错误响应格式
```json
{
"timestamp": "2025-08-24T18:30:00.000",
"status": 400,
"error": "Bad Request",
"message": "用户名已存在"
}
```
#### 字段验证错误格式
```json
{
"timestamp": "2025-08-24T18:30:00.000",
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"details": [
{
"field": "username",
"message": "用户名长度必须在3-64字符之间"
},
{
"field": "password",
"message": "密码长度必须在6-128字符之间"
}
]
}
```
#### 前端错误处理
- **全局拦截**: HTTP拦截器自动显示错误消息
- **字段级错误**: 支持显示具体的字段验证错误
- **状态码映射**: 根据HTTP状态码提供友好的错误提示
- **降级处理**: 当无法获取具体错误时,显示默认错误消息

160
docs/权限管理.md Normal file
View File

@@ -0,0 +1,160 @@
# 权限管理文档
## 概述
本系统采用基于角色的权限控制RBAC机制通过用户类型ADMIN/AGENT来控制用户的功能访问权限。
## 用户类型
### ADMIN管理员
- 拥有系统所有功能的访问权限
- 可以管理用户账户(创建、编辑、删除、启用/禁用)
- 可以管理游戏、订单、报表等所有模块
- 可以访问系统设置
### AGENT代理商
- 只能查看游戏、订单、报表等基础信息
- 无法访问用户管理功能
- 无法访问系统设置
- 主要用于业务操作,不具备管理权限
## 权限定义
### 用户管理权限
- `user:view` - 查看用户列表
- `user:create` - 创建用户
- `user:update` - 编辑用户
- `user:delete` - 删除用户
- `user:manage` - 用户管理(包含所有用户相关权限)
### 游戏管理权限
- `game:view` - 查看游戏列表
- `game:create` - 创建游戏
- `game:update` - 编辑游戏
- `game:delete` - 删除游戏
- `game:manage` - 游戏管理(包含所有游戏相关权限)
### 订单管理权限
- `order:view` - 查看订单列表
- `order:manage` - 订单管理
### 报表分析权限
- `report:view` - 查看报表
### 系统设置权限
- `setting:manage` - 系统设置管理
## 角色权限映射
```javascript
const ROLE_PERMISSIONS = {
ADMIN: [
// 管理员拥有所有权限
'user:manage', 'user:create', 'user:update', 'user:delete', 'user:view',
'game:manage', 'game:create', 'game:update', 'game:delete', 'game:view',
'order:manage', 'order:view',
'report:view',
'setting:manage',
],
AGENT: [
// 代理商只有查看权限
'game:view',
'order:view',
'report:view',
]
}
```
## 前端权限控制
### 1. 路由权限控制
- 在路由守卫中检查用户是否有访问权限
- 无权限时自动跳转到仪表盘页面
### 2. 菜单权限控制
- 根据用户权限动态显示/隐藏菜单项
- 代理商用户看不到"用户管理"和"系统设置"菜单
### 3. 页面权限控制
- 在页面组件中检查用户权限
- 无权限时显示权限不足提示
### 4. 操作权限控制
- 根据用户权限显示/隐藏操作按钮
- 代理商用户无法进行用户管理操作
## 权限检查方法
### 1. 权限工具函数
```javascript
import { hasPermission, hasAnyPermission, hasAllPermissions } from '@/utils/permission'
// 检查单个权限
const canCreateUser = hasPermission('user:create')
// 检查多个权限(任一)
const canManageUsers = hasAnyPermission(['user:create', 'user:update', 'user:delete'])
// 检查多个权限(全部)
const canFullManage = hasAllPermissions(['user:create', 'user:update', 'user:delete'])
```
### 2. 权限指令
```vue
<!-- 单个权限 -->
<el-button v-permission="'user:create'">新增用户</el-button>
<!-- 多个权限OR逻辑 -->
<el-button v-permission="['user:create', 'user:update']">操作</el-button>
<!-- 多个权限AND逻辑 -->
<el-button v-permission="['user:create', 'user:update', 'AND']">操作</el-button>
<!-- 对象格式 -->
<el-button v-permission="{ permission: ['user:create', 'user:update'], logic: 'OR' }">操作</el-button>
```
### 3. 计算属性
```javascript
const canViewUsers = computed(() => hasPermission('user:view'))
const canCreateUser = computed(() => hasPermission('user:create'))
const canEditUser = computed(() => hasPermission('user:update'))
const canDeleteUser = computed(() => hasPermission('user:delete'))
```
## 后端权限控制
### 1. 接口权限
- 用户管理接口(`/api/admin/accounts`)只允许 ADMIN 用户访问
- 代理商用户访问时返回 403 Forbidden 错误
### 2. 数据权限
- 代理商用户只能查看自己的相关数据
- 管理员可以查看和管理所有数据
## 权限配置
### 添加新权限
1.`src/utils/permission.js` 中的 `PERMISSIONS` 对象中添加新权限
2.`ROLE_PERMISSIONS` 中为不同角色分配权限
3.`ROUTE_PERMISSIONS` 中配置路由权限要求
### 修改角色权限
1. 修改 `ROLE_PERMISSIONS` 中对应角色的权限数组
2. 更新相关的前端权限检查逻辑
## 安全注意事项
1. **前端权限控制仅用于用户体验**,不能作为安全依据
2. **后端必须进行权限验证**,确保数据安全
3. **敏感操作需要二次确认**,如删除用户
4. **权限变更需要重新登录**,确保权限生效
5. **定期审查权限配置**,避免权限泄露
## 测试建议
1. 使用不同用户类型登录,验证权限控制
2. 测试无权限访问时的跳转逻辑
3. 验证菜单和按钮的显示/隐藏
4. 测试后端接口的权限验证
5. 检查权限变更后的效果

View File

@@ -0,0 +1,193 @@
# 权限系统配置总结
## 概述
本系统采用基于角色的权限控制RBAC机制通过用户类型ADMIN/AGENT来控制用户的功能访问权限。
## 用户类型权限配置
### ADMIN管理员
- **权限范围**:拥有系统所有功能的访问权限
- **路由访问**:可以访问所有页面和路由
- **功能权限**
- 用户管理(创建、编辑、删除、启用/禁用)
- 游戏管理(查看、创建、编辑、删除)
- 订单管理(查看、管理)
- 报表分析(查看)
- 系统设置(管理)
### AGENT代理商
- **权限范围**:只能查看基础信息,无管理权限
- **路由访问**:只能访问部分页面
- **功能权限**
- 游戏查看
- 订单查看
- 报表查看
## 权限检查机制
### 1. 路由权限检查
```javascript
// 管理员可以访问所有路由
export function canAccessRoute(routeName) {
if (isAdmin()) return true
const requiredPermissions = ROUTE_PERMISSIONS[routeName] || []
if (requiredPermissions.length === 0) return true
return hasAnyPermission(requiredPermissions)
}
```
### 2. 功能权限检查
```javascript
// 检查单个权限
const canCreateUser = hasPermission('user:create')
// 检查多个权限(任一)
const canManageUsers = hasAnyPermission(['user:create', 'user:update', 'user:delete'])
// 检查多个权限(全部)
const canFullManage = hasAllPermissions(['user:create', 'user:update', 'user:delete'])
```
### 3. 权限指令使用
```vue
<!-- 单个权限 -->
<el-button v-permission="'user:create'">新增用户</el-button>
<!-- 多个权限OR逻辑 -->
<el-button v-permission="['user:create', 'user:update']">操作</el-button>
<!-- 多个权限AND逻辑 -->
<el-button v-permission="['user:create', 'user:update', 'AND']">操作</el-button>
```
## 权限配置详情
### 权限定义
```javascript
export const PERMISSIONS = {
// 用户管理权限
USER_MANAGE: 'user:manage',
USER_CREATE: 'user:create',
USER_UPDATE: 'user:update',
USER_DELETE: 'user:delete',
USER_VIEW: 'user:view',
// 游戏管理权限
GAME_MANAGE: 'game:manage',
GAME_CREATE: 'game:create',
GAME_UPDATE: 'game:update',
GAME_DELETE: 'game:delete',
GAME_VIEW: 'game:view',
// 订单管理权限
ORDER_MANAGE: 'order:manage',
ORDER_VIEW: 'order:view',
// 报表分析权限
REPORT_VIEW: 'report:view',
// 系统设置权限
SETTING_MANAGE: 'setting:manage',
}
```
### 角色权限映射
```javascript
export const ROLE_PERMISSIONS = {
ADMIN: [
// 管理员拥有所有权限
PERMISSIONS.USER_MANAGE, PERMISSIONS.USER_CREATE, PERMISSIONS.USER_UPDATE,
PERMISSIONS.USER_DELETE, PERMISSIONS.USER_VIEW,
PERMISSIONS.GAME_MANAGE, PERMISSIONS.GAME_CREATE, PERMISSIONS.GAME_UPDATE,
PERMISSIONS.GAME_DELETE, PERMISSIONS.GAME_VIEW,
PERMISSIONS.ORDER_MANAGE, PERMISSIONS.ORDER_VIEW,
PERMISSIONS.REPORT_VIEW, PERMISSIONS.SETTING_MANAGE,
],
AGENT: [
// 代理商只有查看权限
PERMISSIONS.GAME_VIEW, PERMISSIONS.ORDER_VIEW, PERMISSIONS.REPORT_VIEW,
]
}
```
### 路由权限映射
```javascript
export const ROUTE_PERMISSIONS = {
'Users': [PERMISSIONS.USER_VIEW],
'Games': [PERMISSIONS.GAME_VIEW],
'Orders': [PERMISSIONS.ORDER_VIEW],
'Reports': [PERMISSIONS.REPORT_VIEW],
'Settings': [PERMISSIONS.SETTING_MANAGE],
}
```
## 权限控制层级
### 1. 路由级权限控制
- 在路由守卫中检查用户是否有访问权限
- 管理员可以访问所有路由
- 代理商只能访问有权限的路由
- 无权限时自动跳转到仪表盘页面
### 2. 菜单级权限控制
- 根据用户权限动态显示/隐藏菜单项
- 代理商用户看不到"用户管理"和"系统设置"菜单
- 管理员可以看到所有菜单项
### 3. 页面级权限控制
- 在页面组件中检查用户权限
- 无权限时显示权限不足提示
- 提供友好的用户体验
### 4. 操作级权限控制
- 根据用户权限显示/隐藏操作按钮
- 代理商用户无法进行用户管理操作
- 使用权限指令控制按钮显示
## 安全注意事项
1. **前端权限控制仅用于用户体验**,不能作为安全依据
2. **后端必须进行权限验证**,确保数据安全
3. **敏感操作需要二次确认**,如删除用户
4. **权限变更需要重新登录**,确保权限生效
5. **定期审查权限配置**,避免权限泄露
## 测试验证
### 权限测试页面
- 访问 `/permission-test` 页面
- 查看当前用户的权限状态
- 测试各种权限检查功能
- 验证路由访问权限
### 测试建议
1. 使用不同用户类型登录,验证权限控制
2. 测试无权限访问时的跳转逻辑
3. 验证菜单和按钮的显示/隐藏
4. 测试后端接口的权限验证
5. 检查权限变更后的效果
## 扩展说明
### 添加新权限
1.`PERMISSIONS` 对象中添加新权限
2.`ROLE_PERMISSIONS` 中为不同角色分配权限
3.`ROUTE_PERMISSIONS` 中配置路由权限要求
4. 更新相关的前端权限检查逻辑
### 修改角色权限
1. 修改 `ROLE_PERMISSIONS` 中对应角色的权限数组
2. 更新相关的前端权限检查逻辑
3. 测试权限变更后的效果
## 总结
当前的权限系统配置确保了:
- **管理员拥有所有权限**,可以访问所有页面和功能
- **代理商只有查看权限**,无法进行管理操作
- **权限检查机制完善**,包括路由、菜单、页面、操作四个层级
- **用户体验友好**,无权限时提供清晰的提示和跳转
- **安全性保障**,前后端都有权限验证机制

24
package-lock.json generated
View File

@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"element-plus": "^2.8.8", "element-plus": "^2.8.8",
"vue": "^3.4.38" "vue": "^3.4.38",
"vue-router": "^4.4.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "^5.1.4",
@@ -886,6 +887,12 @@
"@vue/shared": "3.5.19" "@vue/shared": "3.5.19"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.19", "version": "3.5.19",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.19.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.19.tgz",
@@ -1650,6 +1657,21 @@
"optional": true "optional": true
} }
} }
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.2.0"
}
} }
} }
} }

View File

@@ -11,11 +11,11 @@
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"element-plus": "^2.8.8", "element-plus": "^2.8.8",
"vue": "^3.4.38" "vue": "^3.4.38",
"vue-router": "^4.4.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.6" "vite": "^5.4.6"
} }
} }

View File

@@ -1,11 +1,10 @@
<template> <template>
<Login /> <router-view />
</template> </template>
<script setup> <script setup>
import Login from './views/Login.vue'
</script> </script>
<style scoped> <style scoped>
/* 页面样式由 Login 组件内维护 */ /* 根组件仅承载路由视图 */
</style> </style>

View File

@@ -1,9 +1,17 @@
import http from '../plugins/http' import http from '../plugins/http'
// 刷新与登出端点(供需要时直接调用)
export function refresh(payload) {
return http.post('/api/auth/refresh', payload)
}
export function logout() {
return http.post('/api/auth/logout')
}
// 登录 API 封装 // 登录 API 封装
export function login(payload) { export function login(payload) {
// 约定 payload: { username: string, password: string } // 约定 payload: { username: string, password: string }
// 根据实际接口调整路径与字段 // 根据实际接口调整路径与字段
return http.post('/auth/login', payload) return http.post('/api/auth/login', payload)
} }

39
src/api/users.js Normal file
View File

@@ -0,0 +1,39 @@
import http from '@/plugins/http'
export function fetchUsers(params) {
// params: { page, pageSize, keyword, status }
return http.get('/api/admin/accounts', { params })
}
export function createUser(payload) {
// 管理员创建用户账户
return http.post('/api/admin/accounts', payload)
}
export function registerUser(payload) {
// 用户自注册仅限AGENT类型
return http.post('/api/users', payload)
}
export function updateUser(id, payload) {
return http.patch(`/api/admin/accounts/${id}`, payload)
}
export function deleteUser(id) {
return http.delete(`/api/admin/accounts/${id}`)
}
export function setUserStatus(id, enabled) {
const endpoint = enabled
? `/api/admin/accounts/${id}/enable`
: `/api/admin/accounts/${id}/disable`
return http.post(endpoint)
}
export function resetUserPassword(id, newPassword, forceLogout = true) {
return http.post(`/api/admin/accounts/${id}/reset-password`, {
newPassword,
forceLogout
})
}

View File

@@ -0,0 +1,37 @@
<template>
<div class="permission-denied">
<el-result
icon="warning"
title="权限不足"
sub-title="您没有访问此页面的权限请联系管理员"
>
<template #extra>
<el-button type="primary" @click="goBack">返回上一页</el-button>
<el-button @click="goHome">返回首页</el-button>
</template>
</el-result>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
function goBack() {
router.go(-1)
}
function goHome() {
router.push({ name: 'Dashboard' })
}
</script>
<style scoped>
.permission-denied {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
</style>

View File

@@ -0,0 +1,57 @@
import { hasPermission, hasAnyPermission, hasAllPermissions } from '@/utils/permission'
// 权限指令
export const permission = {
mounted(el, binding) {
const { value } = binding
if (!value) return
let hasAuth = false
if (typeof value === 'string') {
// 单个权限检查
hasAuth = hasPermission(value)
} else if (Array.isArray(value)) {
// 权限数组检查
if (value.length === 0) {
hasAuth = true
} else if (value.length === 1) {
hasAuth = hasPermission(value[0])
} else {
// 检查是否包含逻辑操作符
const logicIndex = value.findIndex(item => item === 'AND' || item === 'OR')
if (logicIndex > 0) {
const logic = value[logicIndex]
const permissions = value.filter(item => item !== 'AND' && item !== 'OR')
if (logic === 'AND') {
hasAuth = hasAllPermissions(permissions)
} else if (logic === 'OR') {
hasAuth = hasAnyPermission(permissions)
}
} else {
// 默认使用 OR 逻辑
hasAuth = hasAnyPermission(value)
}
}
} else if (typeof value === 'object') {
// 对象格式:{ permission: string|array, logic: 'AND'|'OR' }
const { permission, logic = 'OR' } = value
if (Array.isArray(permission)) {
hasAuth = logic === 'AND' ? hasAllPermissions(permission) : hasAnyPermission(permission)
} else {
hasAuth = hasPermission(permission)
}
}
if (!hasAuth) {
el.style.display = 'none'
}
}
}
// 注册指令
export function setupPermissionDirective(app) {
app.directive('permission', permission)
}

138
src/layouts/AdminLayout.vue Normal file
View File

@@ -0,0 +1,138 @@
<template>
<div class="admin-layout">
<aside class="sider">
<div class="brand">
<img class="logo" src="https://vuejs.org/images/logo.png" alt="logo" />
<span class="name">管理后台</span>
</div>
<el-menu
class="menu"
router
:default-active="$route.name"
:collapse="collapsed"
background-color="#001529"
text-color="#bfcbd9"
active-text-color="#fff"
>
<el-menu-item index="Dashboard" :route="{ name: 'Dashboard' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg></i>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item v-if="canAccessUsers" index="Users" :route="{ name: 'Users' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5s-5 2.3-5 5s2.3 5 5 5m0 2c-3.3 0-10 1.7-10 5v3h20v-3c0-3.3-6.7-5-10-5Z"/></svg></i>
<span>用户管理</span>
</el-menu-item>
<el-menu-item v-if="canAccessGames" index="Games" :route="{ name: 'Games' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M6 8h12v8H6z"/></svg></i>
<span>游戏管理</span>
</el-menu-item>
<el-menu-item v-if="canAccessOrders" index="Orders" :route="{ name: 'Orders' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2Z"/></svg></i>
<span>订单管理</span>
</el-menu-item>
<el-menu-item v-if="canAccessReports" index="Reports" :route="{ name: 'Reports' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 3h2v18H3V3m4 8h2v10H7V11m4-6h2v16h-2V5m4 10h2v6h-2v-6m4-3h2v9h-2v-9Z"/></svg></i>
<span>报表分析</span>
</el-menu-item>
<el-menu-item v-if="canAccessSettings" index="Settings" :route="{ name: 'Settings' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="m12 8l-2 4h4l-2 4"/></svg></i>
<span>系统设置</span>
</el-menu-item>
<el-menu-item index="ErrorTest" :route="{ name: 'ErrorTest' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg></i>
<span>错误测试</span>
</el-menu-item>
<el-menu-item index="PermissionTest" :route="{ name: 'PermissionTest' }">
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg></i>
<span>权限测试</span>
</el-menu-item>
</el-menu>
</aside>
<section class="main">
<header class="header">
<el-button text @click="collapsed = !collapsed" class="collapse-btn">
<svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h12v2H3v-2m0 5h18v2H3v-2Z"/></svg>
</el-button>
<div class="spacer" />
<el-dropdown>
<span class="el-dropdown-link">
管理员<i class="el-icon el-icon--right"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg></i>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="onProfile">个人中心</el-dropdown-item>
<el-dropdown-item divided @click="onLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</header>
<main class="content">
<router-view />
</main>
</section>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { clearTokens } from '@/utils/auth'
import { useRouter } from 'vue-router'
import { canAccessRoute } from '@/utils/permission'
const collapsed = ref(false)
const router = useRouter()
// 权限检查
const canAccessUsers = computed(() => canAccessRoute('Users'))
const canAccessGames = computed(() => canAccessRoute('Games'))
const canAccessOrders = computed(() => canAccessRoute('Orders'))
const canAccessReports = computed(() => canAccessRoute('Reports'))
const canAccessSettings = computed(() => canAccessRoute('Settings'))
function onProfile() {
// 可跳转到个人中心占位页
}
function onLogout() {
clearTokens()
router.replace({ name: 'Login' })
}
</script>
<style scoped>
.admin-layout {
display: flex;
height: 100vh;
}
.sider {
width: 220px;
background: #001529;
color: #bfcbd9;
display: flex;
flex-direction: column;
}
.brand {
height: 56px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
color: #fff;
}
.logo { width: 24px; height: 24px; }
.menu { border-right: none; flex: 1; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.header {
height: 56px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.collapse-btn { margin-right: 8px; }
.spacer { flex: 1; }
.content { padding: 16px; overflow: auto; background: #f5f7fa; height: calc(100vh - 56px); }
</style>

View File

@@ -1,10 +1,12 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import router from '@/router'
import App from './App.vue' import App from './App.vue'
import { setupPermissionDirective } from './directives/permission'
const app = createApp(App) const app = createApp(App)
app.use(ElementPlus) app.use(ElementPlus)
app.use(router)
setupPermissionDirective(app)
app.mount('#app') app.mount('#app')

View File

@@ -1,29 +1,97 @@
import axios from 'axios' import axios from 'axios'
import { getAccessToken, getRefreshToken, getTokenType, setTokens, clearTokens } from '../utils/auth'
import { showErrorMessage } from '@/utils/error'
const baseURL = import.meta.env?.VITE_API_BASE || '/'
const http = axios.create({ const http = axios.create({
baseURL: '/', baseURL,
timeout: 15000, timeout: 15000,
}) })
// 请求拦截器 // 专用刷新客户端,避免拦截器递归
const refreshClient = axios.create({ baseURL, timeout: 15000 })
let isRefreshing = false
let pendingQueue = []
function subscribeTokenRefresh(cb) {
pendingQueue.push(cb)
}
function onRefreshed(newToken, tokenType) {
pendingQueue.forEach((cb) => cb(newToken, tokenType))
pendingQueue = []
}
// 请求拦截:附加 Authorization
http.interceptors.request.use( http.interceptors.request.use(
(config) => { (config) => {
// 例如:在此添加认证 token const token = getAccessToken()
// const token = localStorage.getItem('token') const type = getTokenType()
// if (token) config.headers.Authorization = `Bearer ${token}` if (token) {
config.headers = config.headers || {}
config.headers.Authorization = `${type} ${token}`
}
return config return config
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error)
) )
// 响应拦截器 // 刷新 token 请求
async function refreshTokenRequest() {
const refreshToken = getRefreshToken()
if (!refreshToken) throw new Error('NO_REFRESH_TOKEN')
const { data } = await refreshClient.post('/auth/refresh', { refreshToken })
// 兼容 { code, data }
const payload = data?.code === 0 && data?.data ? data.data : data
if (!payload?.accessToken) throw new Error('INVALID_REFRESH_RESPONSE')
setTokens(payload)
return payload
}
// 响应拦截:处理 401 自动刷新
http.interceptors.response.use( http.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
// 统一错误处理(可以按需要自定义) const { config, response } = error || {}
const status = response?.status
const url = config?.url || ''
// 避免对登录/刷新自身进行重复刷新
const isAuthPath = /\/auth\/(login|refresh)/.test(url || '')
if (status === 401 && !isAuthPath) {
if (!isRefreshing) {
isRefreshing = true
try {
const payload = await refreshTokenRequest()
isRefreshing = false
onRefreshed(payload.accessToken, payload.tokenType || 'Bearer')
} catch (e) {
isRefreshing = false
clearTokens()
return Promise.reject(error)
}
}
// 返回一个新的 Promise等待刷新完成后重试原请求
return new Promise((resolve) => {
subscribeTokenRefresh((newToken, tokenType) => {
const retryConfig = { ...config }
retryConfig.headers = retryConfig.headers || {}
retryConfig.headers.Authorization = `${tokenType || 'Bearer'} ${newToken}`
resolve(http.request(retryConfig))
})
})
}
// 对于非401错误显示错误消息
if (status !== 401) {
showErrorMessage(error)
}
return Promise.reject(error) return Promise.reject(error)
} }
) )
export default http export default http

57
src/router/index.js Normal file
View File

@@ -0,0 +1,57 @@
import { createRouter, createWebHistory } from 'vue-router'
import { isLoggedIn } from '@/utils/auth'
import { canAccessRoute } from '@/utils/permission'
const AdminLayout = () => import('@/layouts/AdminLayout.vue')
const Login = () => import('@/views/Login.vue')
const Dashboard = () => import('@/views/Dashboard.vue')
const UserList = () => import('@/views/users/UserList.vue')
const GameList = () => import('@/views/games/GameList.vue')
const OrderList = () => import('@/views/orders/OrderList.vue')
const ReportAnalysis = () => import('@/views/reports/ReportAnalysis.vue')
const Settings = () => import('@/views/settings/Settings.vue')
const ErrorTest = () => import('@/views/ErrorTest.vue')
const PermissionTest = () => import('@/views/PermissionTest.vue')
const NotFound = () => import('@/views/NotFound.vue')
export const routes = [
{ path: '/login', name: 'Login', component: Login, meta: { public: true, title: '登录' } },
{
path: '/',
component: AdminLayout,
children: [
{ path: '', name: 'Dashboard', component: Dashboard, meta: { title: '仪表盘' } },
{ path: 'users', name: 'Users', component: UserList, meta: { title: '用户管理' } },
{ path: 'games', name: 'Games', component: GameList, meta: { title: '游戏管理' } },
{ path: 'orders', name: 'Orders', component: OrderList, meta: { title: '订单管理' } },
{ path: 'reports', name: 'Reports', component: ReportAnalysis, meta: { title: '报表分析' } },
{ path: 'settings', name: 'Settings', component: Settings, meta: { title: '系统设置' } },
{ path: 'error-test', name: 'ErrorTest', component: ErrorTest, meta: { title: '错误处理测试' } },
{ path: 'permission-test', name: 'PermissionTest', component: PermissionTest, meta: { title: '权限测试' } },
],
},
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { public: true, title: '未找到' } },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
if (to.meta?.public) return next()
if (!isLoggedIn()) {
return next({ name: 'Login', query: { redirect: to.fullPath } })
}
// 检查路由权限
if (to.name && !canAccessRoute(to.name)) {
return next({ name: 'Dashboard' }) // 无权限时跳转到仪表盘
}
next()
})
export default router

67
src/utils/auth.js Normal file
View File

@@ -0,0 +1,67 @@
const STORAGE_KEY = 'app.auth'
export function getAuth() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
return JSON.parse(raw)
} catch {
return null
}
}
export function setTokens(payload) {
// payload: { accessToken, refreshToken, tokenType, expiresIn, userType, userId, username, ... }
const now = Date.now()
const expiresAt = payload?.expiresIn ? now + payload.expiresIn * 1000 : undefined
// 构建用户信息对象,兼容两种格式:
// 1. 嵌套格式: { user: { userType, userId, username } }
// 2. 平铺格式: { userType, userId, username }
const user = payload?.user || {
userType: payload?.userType,
userId: payload?.userId,
username: payload?.username,
}
const data = {
accessToken: payload?.accessToken || '',
refreshToken: payload?.refreshToken || '',
tokenType: payload?.tokenType || 'Bearer',
user,
expiresAt,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
return data
}
export function clearTokens() {
localStorage.removeItem(STORAGE_KEY)
}
export function getAccessToken() {
const a = getAuth()
return a?.accessToken || ''
}
export function getRefreshToken() {
const a = getAuth()
return a?.refreshToken || ''
}
export function getTokenType() {
const a = getAuth()
return a?.tokenType || 'Bearer'
}
export function isTokenExpired() {
const a = getAuth()
if (!a?.expiresAt) return false
return Date.now() >= a.expiresAt
}
export function isLoggedIn() {
const a = getAuth()
return Boolean(a?.accessToken)
}

83
src/utils/error.js Normal file
View File

@@ -0,0 +1,83 @@
import { ElMessage } from 'element-plus'
/**
* 从错误对象中提取错误消息
* @param {Error} error - 错误对象
* @param {string} defaultMessage - 默认错误消息
* @returns {string} 错误消息
*/
export function extractErrorMessage(error, defaultMessage = '操作失败') {
if (!error) return defaultMessage
// 优先使用后端返回的错误消息
if (error.response?.data?.message) {
return error.response.data.message
}
// 处理字段验证错误
if (error.response?.data?.details && Array.isArray(error.response.data.details)) {
const fieldErrors = error.response.data.details
.map(detail => `${detail.field}: ${detail.message}`)
.join('; ')
return fieldErrors || defaultMessage
}
// 使用错误对象的消息
if (error.message) {
return error.message
}
// 根据HTTP状态码提供默认消息
const status = error.response?.status
if (status) {
const statusMessages = {
400: '请求参数错误',
401: '未授权,请重新登录',
403: '权限不足',
404: '资源不存在',
409: '数据冲突',
422: '数据验证失败',
500: '服务器内部错误',
502: '网关错误',
503: '服务不可用',
504: '网关超时'
}
return statusMessages[status] || defaultMessage
}
return defaultMessage
}
/**
* 显示错误消息
* @param {Error} error - 错误对象
* @param {string} defaultMessage - 默认错误消息
*/
export function showErrorMessage(error, defaultMessage = '操作失败') {
const message = extractErrorMessage(error, defaultMessage)
ElMessage.error(message)
}
/**
* 显示成功消息
* @param {string} message - 成功消息
*/
export function showSuccessMessage(message = '操作成功') {
ElMessage.success(message)
}
/**
* 显示警告消息
* @param {string} message - 警告消息
*/
export function showWarningMessage(message = '警告') {
ElMessage.warning(message)
}
/**
* 显示信息消息
* @param {string} message - 信息消息
*/
export function showInfoMessage(message = '提示') {
ElMessage.info(message)
}

148
src/utils/permission.js Normal file
View File

@@ -0,0 +1,148 @@
import { getAuth } from './auth'
// 权限定义
export const PERMISSIONS = {
// 用户管理权限
USER_MANAGE: 'user:manage',
USER_CREATE: 'user:create',
USER_UPDATE: 'user:update',
USER_DELETE: 'user:delete',
USER_VIEW: 'user:view',
// 游戏管理权限
GAME_MANAGE: 'game:manage',
GAME_CREATE: 'game:create',
GAME_UPDATE: 'game:update',
GAME_DELETE: 'game:delete',
GAME_VIEW: 'game:view',
// 订单管理权限
ORDER_MANAGE: 'order:manage',
ORDER_VIEW: 'order:view',
// 报表分析权限
REPORT_VIEW: 'report:view',
// 系统设置权限
SETTING_MANAGE: 'setting:manage',
}
// 角色权限映射
export const ROLE_PERMISSIONS = {
ADMIN: [
// 管理员拥有所有权限
PERMISSIONS.USER_MANAGE,
PERMISSIONS.USER_CREATE,
PERMISSIONS.USER_UPDATE,
PERMISSIONS.USER_DELETE,
PERMISSIONS.USER_VIEW,
PERMISSIONS.GAME_MANAGE,
PERMISSIONS.GAME_CREATE,
PERMISSIONS.GAME_UPDATE,
PERMISSIONS.GAME_DELETE,
PERMISSIONS.GAME_VIEW,
PERMISSIONS.ORDER_MANAGE,
PERMISSIONS.ORDER_VIEW,
PERMISSIONS.REPORT_VIEW,
PERMISSIONS.SETTING_MANAGE,
],
AGENT: [
// 代理商只有查看权限,没有管理权限
PERMISSIONS.GAME_VIEW,
PERMISSIONS.ORDER_VIEW,
PERMISSIONS.REPORT_VIEW,
]
}
// 路由权限映射
export const ROUTE_PERMISSIONS = {
'Dashboard': [], // 仪表盘所有用户都可以访问
'Users': [PERMISSIONS.USER_VIEW],
'Games': [PERMISSIONS.GAME_VIEW],
'Orders': [PERMISSIONS.ORDER_VIEW],
'Reports': [PERMISSIONS.REPORT_VIEW],
'Settings': [PERMISSIONS.SETTING_MANAGE],
'ErrorTest': [], // 错误测试页面所有用户都可以访问
'PermissionTest': [], // 权限测试页面所有用户都可以访问
}
// 获取当前用户信息
export function getCurrentUser() {
const auth = getAuth()
const user = auth?.user || null
return user
}
// 获取当前用户类型
export function getCurrentUserType() {
const user = getCurrentUser()
const userType = user?.userType || null
return userType
}
// 检查用户是否有指定权限
export function hasPermission(permission) {
const userType = getCurrentUserType()
if (!userType) return false
const userPermissions = ROLE_PERMISSIONS[userType?.toUpperCase()] || []
return userPermissions.includes(permission)
}
// 检查用户是否有指定权限列表中的任意一个
export function hasAnyPermission(permissions) {
return permissions.some(permission => hasPermission(permission))
}
// 检查用户是否有指定权限列表中的所有权限
export function hasAllPermissions(permissions) {
return permissions.every(permission => hasPermission(permission))
}
// 检查用户是否可以访问指定路由
export function canAccessRoute(routeName) {
// 管理员可以访问所有路由
if (isAdmin()) return true
const requiredPermissions = ROUTE_PERMISSIONS[routeName] || []
if (requiredPermissions.length === 0) return true // 没有权限要求,默认允许访问
return hasAnyPermission(requiredPermissions)
}
// 检查用户是否为管理员
export function isAdmin() {
const userType = getCurrentUserType()
const isAdminUser = userType?.toLowerCase() === 'admin'
return isAdminUser
}
// 检查用户是否为代理商
export function isAgent() {
return getCurrentUserType()?.toLowerCase() === 'agent'
}
// 获取用户可访问的路由列表
export function getAccessibleRoutes() {
const userType = getCurrentUserType()
if (!userType) {
return []
}
// 管理员可以访问所有路由
if (isAdmin()) {
return ['Dashboard', 'Users', 'Games', 'Orders', 'Reports', 'Settings', 'ErrorTest', 'PermissionTest']
}
const userPermissions = ROLE_PERMISSIONS[userType?.toUpperCase()] || []
const accessibleRoutes = []
Object.entries(ROUTE_PERMISSIONS).forEach(([routeName, requiredPermissions]) => {
if (requiredPermissions.length === 0 || hasAnyPermission(requiredPermissions)) {
accessibleRoutes.push(routeName)
}
})
return accessibleRoutes
}

10
src/views/Dashboard.vue Normal file
View File

@@ -0,0 +1,10 @@
<template>
<el-card shadow="hover">
<template #header>仪表盘</template>
<p>这里是仪表盘空页面你可以放置卡片统计与图表</p>
</el-card>
</template>
<script setup>
</script>

180
src/views/ErrorTest.vue Normal file
View File

@@ -0,0 +1,180 @@
<template>
<div class="error-test-page">
<el-card>
<template #header>
<h3>错误处理测试</h3>
</template>
<el-space direction="vertical" size="large" style="width: 100%">
<el-alert
title="错误处理功能说明"
type="info"
:closable="false"
show-icon
>
<p>1. 全局HTTP拦截器会自动显示错误消息</p>
<p>2. 支持显示后端返回的具体错误信息</p>
<p>3. 支持字段级验证错误显示</p>
<p>4. 根据HTTP状态码提供友好提示</p>
</el-alert>
<el-divider>测试不同类型的错误</el-divider>
<el-row :gutter="16">
<el-col :span="8">
<el-button type="danger" @click="test400Error">测试 400 错误</el-button>
</el-col>
<el-col :span="8">
<el-button type="danger" @click="test401Error">测试 401 错误</el-button>
</el-col>
<el-col :span="8">
<el-button type="danger" @click="test403Error">测试 403 错误</el-button>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-button type="danger" @click="test404Error">测试 404 错误</el-button>
</el-col>
<el-col :span="8">
<el-button type="danger" @click="test409Error">测试 409 错误</el-button>
</el-col>
<el-col :span="8">
<el-button type="danger" @click="test500Error">测试 500 错误</el-button>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-button type="warning" @click="testValidationError">测试字段验证错误</el-button>
</el-col>
<el-col :span="12">
<el-button type="warning" @click="testNetworkError">测试网络错误</el-button>
</el-col>
</el-row>
<el-divider>手动显示消息</el-divider>
<el-row :gutter="16">
<el-col :span="6">
<el-button type="success" @click="showSuccess">成功消息</el-button>
</el-col>
<el-col :span="6">
<el-button type="warning" @click="showWarning">警告消息</el-button>
</el-col>
<el-col :span="6">
<el-button type="info" @click="showInfo">信息消息</el-button>
</el-col>
<el-col :span="6">
<el-button type="danger" @click="showError">错误消息</el-button>
</el-col>
</el-row>
</el-space>
</el-card>
</div>
</template>
<script setup>
import { showErrorMessage, showSuccessMessage, showWarningMessage, showInfoMessage } from '@/utils/error'
import http from '@/plugins/http'
// 测试不同类型的HTTP错误
async function test400Error() {
try {
await http.get('/api/test/400')
} catch (error) {
// 错误会被全局拦截器处理
}
}
async function test401Error() {
try {
await http.get('/api/test/401')
} catch (error) {
// 401错误不会被全局拦截器显示需要手动处理
showErrorMessage(error, '认证失败')
}
}
async function test403Error() {
try {
await http.get('/api/test/403')
} catch (error) {
// 错误会被全局拦截器处理
}
}
async function test404Error() {
try {
await http.get('/api/test/404')
} catch (error) {
// 错误会被全局拦截器处理
}
}
async function test409Error() {
try {
await http.get('/api/test/409')
} catch (error) {
// 错误会被全局拦截器处理
}
}
async function test500Error() {
try {
await http.get('/api/test/500')
} catch (error) {
// 错误会被全局拦截器处理
}
}
// 测试字段验证错误
async function testValidationError() {
try {
await http.post('/api/test/validation', {
username: 'a', // 太短
password: '123' // 太短
})
} catch (error) {
// 错误会被全局拦截器处理,显示字段级错误
}
}
// 测试网络错误
async function testNetworkError() {
try {
await http.get('http://invalid-url-that-does-not-exist.com/api/test')
} catch (error) {
// 错误会被全局拦截器处理
}
}
// 手动显示消息
function showSuccess() {
showSuccessMessage('这是一个成功消息')
}
function showWarning() {
showWarningMessage('这是一个警告消息')
}
function showInfo() {
showInfoMessage('这是一个信息消息')
}
function showError() {
showErrorMessage(new Error('这是一个手动触发的错误消息'))
}
</script>
<style scoped>
.error-test-page {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.el-row {
margin-bottom: 16px;
}
</style>

View File

@@ -42,6 +42,9 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { login } from '../api/auth' import { login } from '../api/auth'
import { setTokens } from '../utils/auth'
import { useRouter, useRoute } from 'vue-router'
import { showErrorMessage, showSuccessMessage } from '@/utils/error'
const formRef = ref() const formRef = ref()
const loading = ref(false) const loading = ref(false)
@@ -49,8 +52,8 @@ const remember = ref(false)
const notice = ref('') const notice = ref('')
const form = ref({ const form = ref({
username: '', username: 'admin',
password: '', password: 'admin1',
}) })
const rules = { const rules = {
@@ -88,6 +91,9 @@ function persistRemember() {
} }
} }
const router = useRouter()
const route = useRoute()
async function onSubmit() { async function onSubmit() {
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate(async (valid) => { await formRef.value.validate(async (valid) => {
@@ -96,14 +102,23 @@ async function onSubmit() {
try { try {
const payload = { username: form.value.username, password: form.value.password } const payload = { username: form.value.username, password: form.value.password }
const res = await login(payload) const res = await login(payload)
// 依据后端返回结构处理,这里仅做示例提示
ElMessage.success('登录成功') // 兼容两种返回结构:纯数据 或 { code, data }
let data = res?.data
if (data && typeof data === 'object' && 'code' in data) {
data = data.code === 0 ? data.data : null
}
if (data?.accessToken) {
setTokens(data)
}
showSuccessMessage('登录成功')
persistRemember() persistRemember()
console.debug('login response:', res.data) console.debug('login response:', res.data)
// TODO: 登录成功后的跳转(如接入 Router const redirect = route.query.redirect || '/'
router.replace(String(redirect))
} catch (e) { } catch (e) {
const msg = e?.response?.data?.message || e.message || '登录失败' showErrorMessage(e, '登录失败')
ElMessage.error(msg)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -138,4 +153,3 @@ async function onSubmit() {
.submit { width: 100%; } .submit { width: 100%; }
.notice { margin-top: 8px; } .notice { margin-top: 8px; }
</style> </style>

9
src/views/NotFound.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<div style="padding: 40px; text-align: center;">
<el-empty description="页面不存在" />
</div>
</template>
<script setup>
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div class="permission-test">
<el-card>
<template #header>
<h3>权限测试页面</h3>
</template>
<el-descriptions title="当前用户信息" :column="2" border>
<el-descriptions-item label="用户类型">
<el-tag :type="userType === 'ADMIN' ? 'danger' : 'success'">
{{ getUserTypeDisplayName(userType) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户名">
{{ currentUser?.username || '未知' }}
</el-descriptions-item>
</el-descriptions>
<el-divider />
<h4>权限检查结果</h4>
<el-table :data="permissionResults" border>
<el-table-column prop="permission" label="权限" width="200" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="result" label="结果" width="100">
<template #default="{ row }">
<el-tag :type="row.result ? 'success' : 'danger'">
{{ row.result ? '有权限' : '无权限' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-divider />
<h4>功能测试</h4>
<el-space wrap>
<el-button v-permission="'user:view'" type="primary">
查看用户列表
</el-button>
<el-button v-permission="'user:create'" type="success">
创建用户
</el-button>
<el-button v-permission="'user:update'" type="warning">
编辑用户
</el-button>
<el-button v-permission="'user:delete'" type="danger">
删除用户
</el-button>
<el-button v-permission="'game:view'" type="info">
查看游戏
</el-button>
<el-button v-permission="'setting:manage'" type="primary">
系统设置
</el-button>
</el-space>
<el-divider />
<h4>路由访问测试</h4>
<el-space wrap>
<el-button
v-for="route in testRoutes"
:key="route.name"
:type="route.canAccess ? 'primary' : 'info'"
:disabled="!route.canAccess"
@click="testRouteAccess(route)"
>
{{ route.title }}
</el-button>
</el-space>
<el-divider />
<h4>权限指令测试</h4>
<el-space wrap>
<el-button v-permission="'user:create'" type="success">
单个权限测试
</el-button>
<el-button v-permission="['user:create', 'user:update']" type="warning">
多权限OR测试
</el-button>
<el-button v-permission="['user:create', 'user:update', 'AND']" type="danger">
多权限AND测试
</el-button>
<el-button v-permission="{ permission: ['user:create', 'user:update'], logic: 'OR' }" type="info">
对象格式测试
</el-button>
</el-space>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
getCurrentUser,
getCurrentUserType,
hasPermission,
canAccessRoute,
PERMISSIONS
} from '@/utils/permission'
const router = useRouter()
const currentUser = ref(null)
const userType = ref(null)
// 用户类型显示名称映射
const userTypeDisplayMap = {
'ADMIN': '管理员',
'AGENT': '代理商'
}
function getUserTypeDisplayName(userType) {
return userTypeDisplayMap[userType] || userType
}
// 权限检查结果
const permissionResults = computed(() => [
{ permission: 'user:view', description: '查看用户列表', result: hasPermission(PERMISSIONS.USER_VIEW) },
{ permission: 'user:create', description: '创建用户', result: hasPermission(PERMISSIONS.USER_CREATE) },
{ permission: 'user:update', description: '编辑用户', result: hasPermission(PERMISSIONS.USER_UPDATE) },
{ permission: 'user:delete', description: '删除用户', result: hasPermission(PERMISSIONS.USER_DELETE) },
{ permission: 'game:view', description: '查看游戏', result: hasPermission(PERMISSIONS.GAME_VIEW) },
{ permission: 'game:create', description: '创建游戏', result: hasPermission(PERMISSIONS.GAME_CREATE) },
{ permission: 'order:view', description: '查看订单', result: hasPermission(PERMISSIONS.ORDER_VIEW) },
{ permission: 'report:view', description: '查看报表', result: hasPermission(PERMISSIONS.REPORT_VIEW) },
{ permission: 'setting:manage', description: '系统设置', result: hasPermission(PERMISSIONS.SETTING_MANAGE) },
])
// 路由访问测试
const testRoutes = computed(() => [
{ name: 'Users', title: '用户管理', canAccess: canAccessRoute('Users') },
{ name: 'Games', title: '游戏管理', canAccess: canAccessRoute('Games') },
{ name: 'Orders', title: '订单管理', canAccess: canAccessRoute('Orders') },
{ name: 'Reports', title: '报表分析', canAccess: canAccessRoute('Reports') },
{ name: 'Settings', title: '系统设置', canAccess: canAccessRoute('Settings') },
])
function testRouteAccess(route) {
if (route.canAccess) {
router.push({ name: route.name })
}
}
onMounted(() => {
currentUser.value = getCurrentUser()
userType.value = getCurrentUserType()
})
</script>
<style scoped>
.permission-test {
padding: 20px;
}
.el-divider {
margin: 20px 0;
}
h4 {
margin: 16px 0 12px 0;
color: #303133;
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<el-card shadow="hover">
<template #header>游戏管理</template>
<p>游戏列表空页面表格搜索上下架</p>
</el-card>
</template>
<script setup>
</script>

View File

@@ -0,0 +1,10 @@
<template>
<el-card shadow="hover">
<template #header>订单管理</template>
<p>订单管理空页面订单列表状态筛选导出</p>
</el-card>
</template>
<script setup>
</script>

View File

@@ -0,0 +1,10 @@
<template>
<el-card shadow="hover">
<template #header>报表分析</template>
<p>报表分析空页面趋势图维度筛选下载</p>
</el-card>
</template>
<script setup>
</script>

View File

@@ -0,0 +1,10 @@
<template>
<el-card shadow="hover">
<template #header>系统设置</template>
<p>系统设置空页面参数配置权限主题</p>
</el-card>
</template>
<script setup>
</script>

View File

@@ -0,0 +1,355 @@
<template>
<div class="users-page">
<!-- 权限检查 -->
<div v-if="!canViewUsers" class="permission-denied">
<el-result
icon="warning"
title="权限不足"
sub-title="您没有访问用户管理的权限请联系管理员"
>
<template #extra>
<el-button type="primary" @click="$router.push({ name: 'Dashboard' })">返回首页</el-button>
</template>
</el-result>
</div>
<!-- 用户管理内容 -->
<div v-else>
<el-card class="mb16" shadow="never">
<el-form :inline="true" :model="query" @submit.prevent>
<el-form-item label="关键词">
<el-input v-model.trim="query.keyword" placeholder="用户名" clearable style="width: 220px" />
</el-form-item>
<el-form-item label="用户类型">
<el-select v-model="query.userType" clearable placeholder="全部" style="width: 140px">
<el-option value="ADMIN" label="管理员" />
<el-option value="AGENT" label="代理" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部" style="width: 140px">
<el-option value="ENABLED" label="启用" />
<el-option value="DISABLED" label="禁用" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
<el-form-item v-if="canCreateUser">
<el-button type="success" @click="openCreate">新增用户</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column type="index" label="#" width="60" />
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column label="用户类型" min-width="120">
<template #default="{ row }">
<el-tag :type="row.userType === 'ADMIN' ? 'danger' : 'success'">
{{ getUserTypeDisplayName(row.userType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="pointsBalance" label="积分余额" min-width="120" align="center">
<template #default="{ row }">
<span v-if="row.userType === 'AGENT'">{{ row.pointsBalance || 0 }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-switch :model-value="row.status === 'ENABLED'" @change="(v)=>onToggle(row, v)" />
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="180" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button v-if="canEditUser" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canResetPassword" size="small" type="warning" @click="onResetPwd(row)">重置密码</el-button>
<el-button v-if="canDeleteUser" size="small" type="danger" @click="onRemove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:page-size="query.pageSize"
:current-page="query.page"
:page-sizes="[10,20,50,100]"
@size-change="(s)=>{ query.pageSize=s; query.page=1; load(); }"
@current-change="(p)=>{ query.page=p; load(); }"
/>
</div>
</el-card>
<el-dialog v-model="visible" :title="isEdit ? '编辑用户' : '新增用户'" width="520px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
<el-form-item label="用户名" prop="username">
<el-input v-model.trim="form.username" :disabled="isEdit" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="用户类型" prop="userType">
<el-select v-model="form.userType" placeholder="选择用户类型">
<el-option v-for="ut in userTypeOptions" :key="ut.value" :label="ut.label" :value="ut.value" />
</el-select>
</el-form-item>
<el-form-item label="积分余额" prop="pointsBalance" v-if="form.userType === 'AGENT'">
<el-input-number v-model="form.pointsBalance" :min="0" :max="999999" />
</el-form-item>
<el-form-item v-if="!isEdit" label="初始密码" prop="password">
<el-input v-model.trim="form.password" type="password" show-password placeholder="至少6位" />
</el-form-item>
<el-form-item label="状态" prop="enabled">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible=false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchUsers, createUser, updateUser, deleteUser, setUserStatus, resetUserPassword } from '@/api/users'
import { showErrorMessage, showSuccessMessage } from '@/utils/error'
import { hasPermission, PERMISSIONS } from '@/utils/permission'
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({
page: 1,
pageSize: 10,
keyword: '',
userType: undefined,
status: undefined,
})
// 用户类型选项
const userTypeOptions = ref([
{ value: 'ADMIN', label: '管理员' },
{ value: 'AGENT', label: '代理商' }
])
// 用户类型显示名称映射
const userTypeDisplayMap = {
'ADMIN': '管理员',
'AGENT': '代理商'
}
// 权限检查
const canViewUsers = computed(() => hasPermission(PERMISSIONS.USER_VIEW))
const canCreateUser = computed(() => hasPermission(PERMISSIONS.USER_CREATE))
const canEditUser = computed(() => hasPermission(PERMISSIONS.USER_UPDATE))
const canDeleteUser = computed(() => hasPermission(PERMISSIONS.USER_DELETE))
const canResetPassword = computed(() => hasPermission(PERMISSIONS.USER_UPDATE))
function getUserTypeDisplayName(userType) {
return userTypeDisplayMap[userType] || userType
}
function unwrap(res) {
const d = res?.data
if (d && typeof d === 'object' && 'code' in d) return d.code === 0 ? d.data : d
return d
}
async function load() {
loading.value = true
try {
// 根据OpenAPI文档参数映射pageSize -> size
const params = {
...query,
size: query.pageSize
}
delete params.pageSize
const res = await fetchUsers(params)
const data = unwrap(res) || {}
// 兼容后端返回两种分页结构
list.value = data.list || data.items || []
total.value = data.total || data.totalCount || list.value.length
} catch (e) {
showErrorMessage(e, '加载失败')
} finally {
loading.value = false
}
}
function onSearch() {
query.page = 1
load()
}
function onReset() {
query.keyword = ''
query.userType = undefined
query.status = undefined
query.page = 1
query.pageSize = 10
load()
}
async function onToggle(row, val) {
try {
await setUserStatus(row.id, val)
// 更新 status 字段,保持与后端一致
row.status = val ? 'ENABLED' : 'DISABLED'
showSuccessMessage('状态已更新')
} catch (e) {
showErrorMessage(e, '更新失败')
}
}
async function onRemove(row) {
try {
await ElMessageBox.confirm(`确认删除用户「${row.username}」吗?`, '提示', { type: 'warning' })
await deleteUser(row.id)
showSuccessMessage('删除成功')
load()
} catch (e) {
if (e !== 'cancel') showErrorMessage(e, '删除失败')
}
}
async function onResetPwd(row) {
try {
const { value: newPassword } = await ElMessageBox.prompt(
`请输入用户「${row.username}」的新密码`,
'重置密码',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'password',
inputValidator: (value) => {
if (!value || value.length < 6) {
return '密码长度至少6位'
}
return true
},
inputErrorMessage: '请输入有效的密码'
}
)
await resetUserPassword(row.id, newPassword)
showSuccessMessage('密码重置成功')
} catch (e) {
if (e !== 'cancel') showErrorMessage(e, '重置失败')
}
}
const visible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
const formRef = ref()
const form = reactive({
id: undefined,
userType: 'AGENT',
username: '',
enabled: true,
password: '',
pointsBalance: 0
})
const rules = {
userType: [{ required: true, message: '请选择用户类型', trigger: 'change' }],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: () => !isEdit.value, message: '请输入初始密码', trigger: 'blur' },
{ min: 6, message: '至少 6 位', trigger: 'blur' },
],
pointsBalance: [{
required: () => form.userType === 'AGENT',
message: '请输入积分余额',
trigger: 'blur'
}],
}
function openCreate() {
isEdit.value = false
Object.assign(form, {
id: undefined,
userType: 'AGENT',
username: '',
enabled: true,
password: '',
pointsBalance: 0
})
visible.value = true
}
function openEdit(row) {
isEdit.value = true
Object.assign(form, {
...row,
password: '',
pointsBalance: row.pointsBalance || 0,
// 将后端的 status 字段映射为前端的 enabled 字段
enabled: row.status === 'ENABLED'
})
visible.value = true
}
function doSubmit() {
// 根据接口文档构建请求参数
const payload = {
userType: form.userType,
username: form.username,
status: form.enabled ? 'ENABLED' : 'DISABLED',
pointsBalance: form.pointsBalance || 0
}
// 如果是新增用户,需要设置密码
if (!isEdit.value) {
payload.password = form.password
}
return isEdit.value
? updateUser(form.id, payload)
: createUser(payload)
}
async function onSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
saving.value = true
try {
await doSubmit()
showSuccessMessage('保存成功')
visible.value = false
load()
} catch (e) {
showErrorMessage(e, '保存失败')
} finally {
saving.value = false
}
})
}
// 监听用户类型变化,重置积分余额字段
watch(() => form.userType, (newType) => {
if (newType === 'ADMIN') {
form.pointsBalance = 0
}
})
onMounted(load)
</script>
<style scoped>
.mb16 { margin-bottom: 16px; }
.mr4 { margin-right: 4px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>

View File

@@ -1,8 +1,20 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'node:path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
server: {
proxy: {
'/api': {
target: 'http://localhost:18080',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
}) })