项目初始化

This commit is contained in:
Stev_Wang
2025-12-22 23:51:21 +08:00
commit 4a97b964ac
64 changed files with 8371 additions and 0 deletions

3
.commitlintrc.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional']
};

107
.cz-config.js Normal file
View File

@@ -0,0 +1,107 @@
module.exports = {
messages: {
type: "选择你要提交的类型 :",
scope: "选择一个提交范围(可选):",
customScope: "请输入自定义的提交范围 :",
subject: "填写简短精炼的变更描述 :",
body: "填写详细描述(可选)。使用 '|' 换行 :",
breaking: "列出任何突破性变更(可选)。使用 '|' 换行 :",
footerPrefixesSelect: "选择关联issue前缀可选:",
customFooterPrefix: "输入自定义issue前缀 :",
footer: "填写关联issue (可选) 例如: #123, #456 :",
confirmCommit: "确认提交?"
},
types: [
{
value: "feat",
name: "feat: 新增功能",
emoji: "✨"
},
{
value: "fix",
name: "fix: 修复缺陷",
emoji: "🐛"
},
{
value: "docs",
name: "docs: 文档更新",
emoji: "📝"
},
{
value: "style",
name: "style: 代码格式",
emoji: "💄"
},
{
value: "refactor",
name: "refactor: 代码重构",
emoji: "♻️"
},
{
value: "perf",
name: "perf: 性能优化",
emoji: "⚡️"
},
{
value: "test",
name: "test: 测试相关",
emoji: "🧪"
},
{
value: "build",
name: "build: 构建相关",
emoji: "🏗️"
},
{
value: "ci",
name: "ci: 持续集成",
emoji: "🔧"
},
{
value: "chore",
name: "chore: 其他修改",
emoji: "📌"
},
{
value: "revert",
name: "revert: 回退代码",
emoji: "⏪️"
}
],
useEmoji: true,
emojiAlign: "center",
themeColorCode: "",
scopes: [],
allowCustomScopes: true,
allowEmptyScopes: true,
customScopesAlign: "bottom",
customScopesAlias: "custom",
emptyScopesAlias: "empty",
upperCaseSubject: false,
allowBreakingChanges: ["feat", "fix"],
breaklineNumber: 100,
breaklineChar: "|",
skipQuestions: [],
issuePrefixes: [
{
value: "#",
name: "#: 关联issue"
}
],
customIssuePrefixAlign: "top",
emptyIssuePrefixAlias: "skip",
allowCustomIssuePrefix: true,
allowEmptyIssuePrefix: true,
confirmColorize: true,
maxHeaderLength: 100,
maxSubjectLength: 100,
minSubjectLength: 0,
scopeOverrides: {
feat: [],
fix: []
},
defaultBody: "",
defaultIssues: "",
defaultScope: "",
defaultType: ""
};

75
.czrc Normal file
View File

@@ -0,0 +1,75 @@
{
"$schema": "https://cdn.jsdelivr.net/npm/cz-git@1.12.0/schema/cz-git.json",
"path": "node_modules/cz-git",
"messages": {
"type": "选择你要提交的类型 :",
"scope": "选择一个提交范围(可选):",
"customScope": "请输入自定义的提交范围 :",
"subject": "填写简短精炼的变更描述 :",
"body": "填写详细描述(可选)。使用 '|' 换行 :",
"breaking": "列出任何突破性变更(可选)。使用 '|' 换行 :",
"footerPrefixesSelect": "选择关联issue前缀可选:",
"customFooterPrefix": "输入自定义issue前缀 :",
"footer": "填写关联issue (可选) 例如: #123, #456 :",
"confirmCommit": "确认提交?"
},
"types": [
{
"value": "feat",
"name": "feat: 新增功能",
"emoji": "✨"
},
{
"value": "fix",
"name": "fix: 修复缺陷",
"emoji": "🐛"
},
{
"value": "docs",
"name": "docs: 文档更新",
"emoji": "📝"
},
{
"value": "style",
"name": "style: 代码格式",
"emoji": "💄"
},
{
"value": "refactor",
"name": "refactor: 代码重构",
"emoji": "♻️"
},
{
"value": "perf",
"name": "perf: 性能优化",
"emoji": "⚡️"
},
{
"value": "test",
"name": "test: 测试相关",
"emoji": "🧪"
},
{
"value": "build",
"name": "build: 构建相关",
"emoji": "🏗️"
},
{
"value": "ci",
"name": "ci: 持续集成",
"emoji": "🔧"
},
{
"value": "chore",
"name": "chore: 其他修改",
"emoji": "📌"
},
{
"value": "revert",
"name": "revert: 回退代码",
"emoji": "⏪️"
}
],
"useEmoji": true,
"emojiAlign": "center"
}

90
.gitignore vendored Normal file
View File

@@ -0,0 +1,90 @@
# Dependencies
node_modules/
# Build outputs
dist/
build/
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.*.local
.env.development
.env.test
.env.production
# Editor directories and files
.vscode/
.idea/
.trae/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Testing
coverage/
.nyc_output/
*.lcov
# Database
*.dump
*.sqlite
pgdata/
# Don't ignore SQL script files in the sql directory
!sql/*.sql
# Docker
.docker/
*.dockerignore
# OS files
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
.cache/
# Frontend specific
frontend/dist/
frontend/node_modules/
frontend/.env
frontend/.env.local
frontend/.env.*.local
# Backend specific
backend/dist/
backend/node_modules/
backend/.env
backend/.env.local
backend/.env.*.local
backend/test-game-server.js
# Package manager files
package-lock.json
yarn.lock
pnpm-lock.yaml
# TypeScript
*.tsbuildinfo
# Misc
*.bak
*.old
*.backup
# Docs
docs/

174
README.md Normal file
View File

@@ -0,0 +1,174 @@
# 一体化游戏运营平台
## 项目概述
一体化游戏运营平台是一个基于 Vue 3 + Node.js + MySQL 的全栈项目,用于管理游戏运营相关的功能。
## 技术栈
### 前端
- Vue 3 + TypeScript
- Vite
- Element Plus
### 后端
- Node.js + Express
- TypeScript
- TypeORM
- MySQL 8.4
### 容器化
- Docker
- Docker Compose
## 项目结构
```
├── backend/ # 后端代码
├── frontend/ # 前端代码
├── sql/ # 数据库脚本
├── docs/ # 项目文档
├── docker/ # Docker 配置
└── docker-compose.yml # Docker Compose 配置
```
## 快速开始
### 1. 克隆项目
```bash
git clone <项目地址>
cd MHXY_Web
```
### 2. 配置环境变量
#### 后端环境变量
`backend` 目录下创建 `.env` 文件:
```env
# 服务器配置
PORT=3000
HOST=0.0.0.0
# 数据库配置
DB_HOST=database
DB_PORT=3306
DB_NAME=mhxy_web
DB_USER=root
DB_PASSWORD=password
# JWT配置
JWT_SECRET=your_jwt_secret_key_here
JWT_EXPIRES_IN=24h
JWT_REFRESH_SECRET=your_jwt_refresh_secret_key_here
JWT_REFRESH_EXPIRES_IN=7d
# 游戏服务端API配置
GAME_API_URL=http://your_game_server_url/tool/http
GAME_PSK=THIS_IS_A_32_BYTE_FIXED_PSK!!!!! # 必须是32字节固定长度
# 日志配置
LOG_LEVEL=info
# 环境配置
NODE_ENV=production
```
#### 前端环境变量
`frontend` 目录下创建 `.env` 文件:
```env
VITE_API_BASE_URL=http://localhost:3000
```
### 3. 使用 Docker 启动项目
```bash
docker-compose up -d
```
### 4. 直接启动项目
#### 后端启动
```bash
cd backend
npm install
npm run dev
```
#### 前端启动
```bash
cd frontend
npm install
npm run dev
```
## 数据库配置
项目使用 MySQL 8.4 数据库,主要配置文件:
- `sql/init_mysql.sql` - 数据库初始化脚本
- `backend/src/config/typeorm.config.ts` - TypeORM 配置
## 开发指南
### 代码规范
- 使用 TypeScript 编写代码
- 遵循 ESLint 和 Prettier 配置
- 提交代码前运行 `npm run lint` 检查代码规范
### 数据库迁移
```bash
cd backend
npm run typeorm migration:generate -- -n <迁移名称>
npm run typeorm migration:run
```
## 部署指南
### 使用 Docker Compose 部署
```bash
docker-compose up -d --build
```
### 手动部署
1. 构建前端代码
```bash
cd frontend
npm install
npm run build
```
2. 构建后端代码
```bash
cd backend
npm install
npm run build
```
3. 启动服务
```bash
node dist/index.js
```
## 文档
- `docs/数据库迁移/MySQL_迁移说明.md` - MySQL 迁移说明
- `docs/开发指南/` - 开发相关文档
- `docs/部署指南/` - 部署相关文档
## 许可证
MIT

26
backend/.env.example Normal file
View File

@@ -0,0 +1,26 @@
# 服务器配置
PORT=3000
HOST=0.0.0.0
# 数据库配置
DB_HOST=database
DB_PORT=3306
DB_NAME=mhxy_web
DB_USER=root
DB_PASSWORD=password
# JWT配置
JWT_SECRET=your_jwt_secret_key_here
JWT_EXPIRES_IN=24h
JWT_REFRESH_SECRET=your_jwt_refresh_secret_key_here
JWT_REFRESH_EXPIRES_IN=7d
# 游戏服务端API配置
GAME_API_URL=http://your_game_server_url/tool/http
GAME_PSK=THIS_IS_A_32_BYTE_FIXED_PSK!!!!! # 必须是32字节固定长度
# 日志配置
LOG_LEVEL=info
# 环境配置
NODE_ENV=production

43
backend/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# 多阶段构建:构建阶段
FROM node:18-alpine AS build-stage
WORKDIR /app
# 设置国内镜像源以加速依赖安装(可选,根据实际情况调整)
# RUN npm config set registry https://registry.npmmirror.com
# 复制package.json和package-lock.json
COPY package*.json ./
# 安装所有依赖(包括开发依赖)
RUN npm ci
# 复制源代码
COPY . .
# 编译TypeScript代码
RUN npm run build
# 多阶段构建:运行阶段
FROM node:18-alpine AS production-stage
WORKDIR /app
# 设置时区(可选,根据需要调整)
# RUN apk --no-cache add tzdata && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
# 复制package.json和package-lock.json
COPY package*.json ./
# 安装仅生产依赖
RUN npm ci --only=production
# 复制编译结果
COPY --from=build-stage /app/dist ./dist
# 复制环境变量示例文件(如果存在)
COPY .env.example .env
# 暴露端口
EXPOSE 3000
# 启动应用
CMD ["node", "dist/index.js"]

48
backend/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "game-operation-platform-backend",
"version": "1.0.0",
"description": "Game operation platform backend API",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "concurrently \"tsc --watch\" \"nodemon -q dist/index.js\"",
"typeorm": "ts-node ./node_modules/typeorm/cli.js",
"init:admin": "ts-node src/scripts/initAdmin.ts"
},
"keywords": [
"game",
"operation",
"platform",
"backend"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.13.2",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.9.7",
"typeorm": "^0.3.17"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/chai": "^5.2.3",
"@types/cors": "^2.8.17",
"@types/deep-eql": "^4.0.2",
"@types/estree": "^1.0.8",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.5",
"concurrently": "^9.2.1",
"nodemon": "^3.1.11",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,37 @@
import { DataSource } from 'typeorm'
import * as dotenv from 'dotenv'
// 加载环境变量
dotenv.config()
export const AppDataSource = new DataSource({
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
username: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || 'mhxy_web',
entities: [__dirname + '/../models/*.{ts,js}'],
migrations: [__dirname + '/../../migrations/*.{ts,js}'],
subscribers: [],
synchronize: true, // 生产环境建议设置为false
logging: ['error', 'warn', 'schema'], // 只记录错误、警告和架构变更,不记录查询日志
charset: 'utf8mb4_unicode_ci'
})
// 初始化数据库连接
export const initializeDatabase = async () => {
try {
console.log(`🔄 正在连接数据库 ${process.env.DB_HOST || 'localhost'}:${process.env.DB_PORT || '3306'}/${process.env.DB_NAME || 'mhxy_web'}...`)
await AppDataSource.initialize()
console.log('✅ 数据库连接成功')
} catch (error) {
console.error('❌ 数据库连接失败:', error)
console.error('📋 请检查以下配置:')
console.error(' • 数据库主机地址、端口是否正确')
console.error(' • 数据库用户名、密码是否正确')
console.error(' • 数据库服务是否正常运行')
console.error(' • 数据库是否已创建')
throw error
}
}

138
backend/src/index.ts Normal file
View File

@@ -0,0 +1,138 @@
import * as dotenv from 'dotenv'
import express from 'express'
import cors from 'cors'
import { initializeDatabase, AppDataSource } from './config/typeorm.config'
import authRoutes from './routes/auth.routes'
import userRoutes from './routes/user.routes'
import configRoutes from './routes/config.routes'
import gameRoutes from './routes/game.routes'
import errorHandler from './middleware/errorHandler'
import { Config } from './models/Config'
import { configService } from './services/ConfigService'
// 加载环境变量
dotenv.config()
// 创建Express应用
const app = express()
// 配置中间件
app.use(cors())
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
// 配置路由
app.use('/api/auth', authRoutes)
app.use('/api/users', userRoutes)
app.use('/api/config', configRoutes)
app.use('/api/game', gameRoutes)
// 错误处理中间件
app.use(errorHandler)
// 启动信息
console.log('🚀 正在启动梦幻西游Web管理系统后端服务...')
console.log(`📋 环境模式: ${process.env.NODE_ENV || 'development'}`)
console.log(`🔧 服务器配置: ${process.env.HOST || 'localhost'}:${process.env.PORT || 3001}`)
// 启动服务器
let PORT = process.env.PORT || 3001
let HOST = process.env.HOST || 'localhost'
/**
* 将.env文件中的配置同步到数据库中
* 仅在数据库中没有对应配置时才插入,保留数据库中已有的配置
*/
const syncEnvToDatabase = async () => {
try {
const configRepository = AppDataSource.getRepository(Config)
// 需要同步的配置项映射:.env键名 → 数据库键名
const envToDbMapping = {
'HOST': { key: 'server_host', description: '后端服务器主机地址' },
'PORT': { key: 'server_port', description: '后端服务器端口' },
'GAME_API_URL': { key: 'game_api_url', description: '游戏服务API地址' },
'GAME_PSK': { key: 'game_psk', description: '游戏服务端的PSK' },
'JWT_SECRET': { key: 'jwt_secret', description: 'JWT密钥' },
'JWT_EXPIRES_IN': { key: 'jwt_expires_in', description: 'JWT过期时间' },
'JWT_REFRESH_SECRET': { key: 'jwt_refresh_secret', description: 'JWT刷新令牌密钥' },
'JWT_REFRESH_EXPIRES_IN': { key: 'jwt_refresh_expires_in', description: 'JWT刷新令牌过期时间' }
}
// 获取数据库中已有的配置
const existingConfigs = await configRepository.find()
const existingConfigKeys = existingConfigs.map(config => config.key)
// 遍历需要同步的配置项,仅插入不存在的配置
let syncCount = 0
for (const [envKey, dbConfig] of Object.entries(envToDbMapping)) {
if (!existingConfigKeys.includes(dbConfig.key)) {
// 数据库中不存在该配置,从.env插入
await configRepository.insert({
key: dbConfig.key,
value: process.env[envKey] || '',
description: dbConfig.description
})
console.log(`✅ 从.env同步配置: ${envKey}${dbConfig.key}`)
syncCount++
}
}
if (syncCount > 0) {
console.log(`📊 成功同步 ${syncCount} 个配置项到数据库`)
} else {
console.log('📊 数据库配置已是最新,无需同步')
}
} catch (error) {
console.error('同步环境变量到数据库失败:', error)
}
}
const startServer = async () => {
try {
console.log('🔄 正在初始化数据库连接...')
// 初始化数据库连接
await initializeDatabase()
console.log('🔄 正在同步环境变量到数据库...')
// 将.env文件中的配置同步到数据库
await syncEnvToDatabase()
console.log('🔄 正在初始化配置服务...')
// 初始化配置服务,从数据库加载配置
await configService.initialize()
// 将数据库配置应用到环境变量,覆盖.env文件中的配置
configService.applyToEnv()
// 更新服务器配置
PORT = process.env.PORT || 3001
HOST = process.env.HOST || 'localhost'
// 启动服务器
app.listen(PORT, () => {
console.log('')
console.log('🎉 梦幻西游Web管理系统后端服务启动成功')
console.log(`🌐 服务地址: http://${HOST}:${PORT}`)
console.log(`📅 启动时间: ${new Date().toLocaleString('zh-CN')}`)
console.log('')
console.log('📋 已启用的API端点:')
console.log(' • 认证服务: /api/auth')
console.log(' • 用户管理: /api/users')
console.log(' • 系统配置: /api/config')
console.log(' • 游戏服务: /api/game')
console.log('')
console.log('✨ 系统已就绪,等待客户端连接...')
})
} catch (error) {
console.error('❌ 服务器启动失败:', error)
console.error('📋 请检查以下配置:')
console.error(' • 数据库连接配置是否正确')
console.error(' • 数据库服务是否正常运行')
console.error(' • 端口是否被占用')
process.exit(1)
}
}
// 启动服务器
startServer()

View File

@@ -0,0 +1,74 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { User } from '../models/User'
import { AppDataSource } from '../config/typeorm.config'
// 定义请求类型扩展
interface AuthRequest extends Request {
user?: User
}
// JWT认证中间件
const authMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
// 获取Authorization头
const authHeader = req.header('Authorization')
// 检查Authorization头是否存在且格式正确
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
message: '未授权,请提供有效的认证令牌'
})
}
// 提取令牌
const token = authHeader.replace('Bearer ', '')
// 验证令牌
const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as { userId: number }
// 查找用户
const userRepository = AppDataSource.getRepository(User)
const user = await userRepository.findOne({ where: { id: decoded.userId } })
if (!user) {
return res.status(401).json({
success: false,
message: '认证失败,用户不存在'
})
}
// 将用户信息添加到请求对象
req.user = user
// 继续处理请求
next()
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return res.status(401).json({
success: false,
message: '认证令牌已过期,请重新登录'
})
}
return res.status(401).json({
success: false,
message: '认证失败,请重新登录'
})
}
}
// 管理员权限中间件
const adminMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({
success: false,
message: '权限不足,需要管理员权限'
})
}
next()
}
export { authMiddleware, adminMiddleware }

View File

@@ -0,0 +1,38 @@
import { Request, Response, NextFunction } from 'express'
// 定义错误接口
interface AppError {
statusCode: number
message: string
isOperational?: boolean
stack?: string
}
// 错误处理中间件
const errorHandler = (err: AppError, req: Request, res: Response, next: NextFunction) => {
// 设置默认错误状态码和消息
const statusCode = err.statusCode || 500
const message = err.message || 'Internal Server Error'
// 构建错误响应
const errorResponse = {
success: false,
message,
// 在开发环境中包含堆栈信息
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
// 记录错误日志
console.error(`Error: ${message}`, {
statusCode,
path: req.path,
method: req.method,
ip: req.ip,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
})
// 返回错误响应
res.status(statusCode).json(errorResponse)
}
export default errorHandler

View File

@@ -0,0 +1,22 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'
@Entity()
export class Config {
@PrimaryGeneratedColumn()
id: number
@Column({ unique: true, length: 50 })
key: string
@Column({ type: 'text', nullable: true })
value: string
@Column({ length: 100, nullable: true })
description?: string
@CreateDateColumn()
createdAt: Date
@UpdateDateColumn()
updatedAt: Date
}

View File

@@ -0,0 +1,40 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'
export enum UserRole {
ADMIN = 'admin'
}
export enum UserStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE'
}
@Entity({ name: 'users' })
export class User {
@PrimaryGeneratedColumn()
id: number
@Column({ unique: true, length: 50 })
username: string
@Column({ length: 100 })
password: string
@Column({ unique: true, nullable: true, length: 100 })
email?: string
@Column({ unique: true, nullable: true, length: 20 })
phone?: string
@Column({ type: 'enum', enum: UserRole, default: UserRole.ADMIN })
role: UserRole
@Column({ type: 'enum', enum: UserStatus, default: UserStatus.ACTIVE })
status: UserStatus
@CreateDateColumn()
createdAt: Date
@UpdateDateColumn()
updatedAt: Date
}

View File

@@ -0,0 +1,195 @@
import { Router, Request, Response } from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { User, UserRole } from '../models/User'
import { AppDataSource } from '../config/typeorm.config'
const router = Router()
const userRepository = AppDataSource.getRepository(User)
// 注册路由
router.post('/register', async (req: Request, res: Response) => {
try {
const { username, password, email, phone, role } = req.body
// 检查用户名是否已存在
const existingUser = await userRepository.findOne({ where: { username } })
if (existingUser) {
return res.status(400).json({
success: false,
message: '用户名已存在'
})
}
// 检查邮箱是否已存在
if (email) {
const existingEmail = await userRepository.findOne({ where: { email } })
if (existingEmail) {
return res.status(400).json({
success: false,
message: '邮箱已被注册'
})
}
}
// 检查手机号是否已存在
if (phone) {
const existingPhone = await userRepository.findOne({ where: { phone } })
if (existingPhone) {
return res.status(400).json({
success: false,
message: '手机号已被注册'
})
}
}
// 加密密码
const hashedPassword = await bcrypt.hash(password, 12)
// 创建用户
const user = userRepository.create({
username,
password: hashedPassword,
email,
phone,
role: role || UserRole.ADMIN
})
await userRepository.save(user)
// 生成JWT令牌
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET as jwt.Secret,
{ expiresIn: (process.env.JWT_EXPIRES_IN || '24h') as jwt.SignOptions['expiresIn'] }
)
// 返回用户信息和令牌
const { password: _, ...userWithoutPassword } = user
res.status(201).json({
success: true,
message: '注册成功',
data: {
user: userWithoutPassword,
token
}
})
} catch (error) {
console.error('注册失败:', error)
res.status(500).json({
success: false,
message: '注册失败,请稍后重试'
})
}
})
// 登录路由
router.post('/login', async (req: Request, res: Response) => {
try {
const { username, password } = req.body
// 查找用户
const user = await userRepository.findOne({ where: { username } })
if (!user) {
return res.status(401).json({
success: false,
message: '用户名或密码错误'
})
}
// 验证密码
const isPasswordValid = await bcrypt.compare(password, user.password)
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: '用户名或密码错误'
})
}
// 生成JWT令牌
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET as jwt.Secret,
{ expiresIn: (process.env.JWT_EXPIRES_IN || '24h') as jwt.SignOptions['expiresIn'] }
)
// 返回用户信息和令牌
const { password: _, ...userWithoutPassword } = user
res.status(200).json({
success: true,
message: '登录成功',
data: {
user: userWithoutPassword,
token
}
})
} catch (error) {
console.error('登录失败:', error)
res.status(500).json({
success: false,
message: '登录失败,请稍后重试'
})
}
})
// 获取当前用户信息路由
router.get('/me', async (req: Request, res: Response) => {
try {
// 从Authorization头获取令牌
const authHeader = req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
message: '未授权,请提供有效的认证令牌'
})
}
const token = authHeader.replace('Bearer ', '')
// 验证令牌
const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as { userId: number }
// 查找用户
const user = await userRepository.findOne({ where: { id: decoded.userId } })
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
})
}
// 返回用户信息
const { password: _, ...userWithoutPassword } = user
res.status(200).json({
success: true,
data: userWithoutPassword
})
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return res.status(401).json({
success: false,
message: '认证令牌已过期,请重新登录'
})
}
console.error('获取用户信息失败:', error)
res.status(500).json({
success: false,
message: '获取用户信息失败,请稍后重试'
})
}
})
// 登出路由
router.post('/logout', async (req: Request, res: Response) => {
// 客户端需要自行删除令牌
res.status(200).json({
success: true,
message: '登出成功'
})
})
export default router

View File

@@ -0,0 +1,118 @@
import { Router, Request, Response } from 'express'
import { Config } from '../models/Config'
import { AppDataSource } from '../config/typeorm.config'
import { authMiddleware, adminMiddleware } from '../middleware/authMiddleware'
import { configService } from '../services/ConfigService'
const router = Router()
const configRepository = AppDataSource.getRepository(Config)
// 获取所有配置(需要管理员权限)
router.get('/', [authMiddleware, adminMiddleware], async (req: Request, res: Response) => {
try {
const configs = await configRepository.find()
// 转换为键值对格式
const configMap = configs.reduce((map, config) => {
map[config.key] = config.value
return map
}, {} as Record<string, string>)
res.status(200).json({
success: true,
data: configMap
})
} catch (error) {
console.error('获取配置失败:', error)
res.status(500).json({
success: false,
message: '获取配置失败,请稍后重试'
})
}
})
// 获取单个配置
router.get('/:key', [authMiddleware], async (req: Request, res: Response) => {
try {
const { key } = req.params
const config = await configRepository.findOne({ where: { key } })
if (!config) {
return res.status(404).json({
success: false,
message: '配置不存在'
})
}
res.status(200).json({
success: true,
data: {
[config.key]: config.value
}
})
} catch (error) {
console.error('获取配置失败:', error)
res.status(500).json({
success: false,
message: '获取配置失败,请稍后重试'
})
}
})
// 设置配置(需要管理员权限)
router.put('/:key', [authMiddleware, adminMiddleware], async (req: Request, res: Response) => {
try {
const { key } = req.params
const { value, description } = req.body
// 使用ConfigService更新配置会自动处理数据库更新、.env更新和环境变量应用
await configService.update(key, value, description)
res.status(200).json({
success: true,
message: '配置更新成功',
data: {
[key]: value
}
})
} catch (error) {
console.error('更新配置失败:', error)
res.status(500).json({
success: false,
message: '更新配置失败,请稍后重试'
})
}
})
// 删除配置(需要管理员权限)
router.delete('/:key', [authMiddleware, adminMiddleware], async (req: Request, res: Response) => {
try {
const { key } = req.params
// 查找配置
const config = await configRepository.findOne({ where: { key } })
if (!config) {
return res.status(404).json({
success: false,
message: '配置不存在'
})
}
await configRepository.remove(config)
res.status(200).json({
success: true,
message: '配置删除成功'
})
} catch (error) {
console.error('删除配置失败:', error)
res.status(500).json({
success: false,
message: '删除配置失败,请稍后重试'
})
}
})
export default router

View File

@@ -0,0 +1,153 @@
import { Router, Request, Response } from 'express'
import axios from 'axios'
import { authMiddleware } from '../middleware/authMiddleware'
import * as dotenv from 'dotenv'
// 加载环境变量
dotenv.config()
const router = Router()
// 游戏服务端API基础URL
const GAME_API_URL = process.env.GAME_API_URL || 'http://127.0.0.1:8080/tool/http'
// 检查游戏服务状态
router.get('/status', authMiddleware, async (req: Request, res: Response) => {
try {
// 获取PSK密钥
const GAME_PSK = process.env.GAME_PSK
if (!GAME_PSK) {
return res.status(500).json({
success: false,
message: '游戏服务端PSK密钥未配置'
})
}
// 发送测试请求到游戏服务端API
const response = await axios.post(GAME_API_URL, { code: 'account/get_account_list', page: 1, pageSize: 1 }, {
headers: {
'Content-Type': 'application/json',
'psk': GAME_PSK // 添加PSK认证头
},
timeout: 5000, // 设置5秒超时
proxy: false // 禁用代理,确保请求直接发送到目标服务器
})
return res.status(200).json({
success: true,
message: '游戏服务端连接成功',
data: {
status: 'online',
responseTime: response.config.timeout
}
})
} catch (error: unknown) {
console.error('游戏服务状态检查失败:', error)
if (axios.isAxiosError(error)) {
// Axios错误处理
if (error.code === 'ECONNREFUSED') {
return res.status(200).json({
success: false,
message: '游戏服务端连接失败',
data: {
status: 'offline',
error: '游戏服务端未响应,请检查游戏服务端是否正常运行'
}
})
} else if (error.code === 'ECONNABORTED') {
return res.status(200).json({
success: false,
message: '游戏服务端请求超时',
data: {
status: 'timeout',
error: '游戏服务端响应超时'
}
})
}
}
return res.status(200).json({
success: false,
message: '游戏服务端状态检查失败',
data: {
status: 'error',
error: '未知错误'
}
})
}
})
// 转发请求到游戏服务端API需要认证
router.post('/', authMiddleware, async (req: Request, res: Response) => {
try {
// 获取游戏服务端API路径和参数
const { path, params } = req.body
if (!path) {
return res.status(400).json({
success: false,
message: '缺少游戏服务端API路径'
})
}
// 获取PSK密钥
const GAME_PSK = process.env.GAME_PSK
if (!GAME_PSK) {
return res.status(500).json({
success: false,
message: '游戏服务端PSK密钥未配置'
})
}
// 构建完整的请求URL
const apiUrl = `${GAME_API_URL}`
// 发送请求到游戏服务端API禁用代理确保直接连接
const response = await axios.post(apiUrl, { code: path, ...params }, {
headers: {
'Content-Type': 'application/json',
'psk': GAME_PSK // 添加PSK认证头
},
timeout: 10000, // 设置10秒超时
proxy: false // 禁用代理,确保请求直接发送到目标服务器
})
// 返回游戏服务端API的响应
res.status(200).json({
success: true,
message: '游戏服务端API调用成功',
data: response.data
})
} catch (error: unknown) {
console.error('游戏服务端API调用失败:', error)
if (axios.isAxiosError(error)) {
// Axios错误处理
if (error.code === 'ECONNREFUSED') {
return res.status(503).json({
success: false,
message: '游戏服务端连接失败,请检查游戏服务端是否正常运行'
})
} else if (error.code === 'ECONNABORTED') {
return res.status(504).json({
success: false,
message: '游戏服务端API请求超时'
})
}
return res.status(error.response?.status || 500).json({
success: false,
message: error.response?.data?.message || '游戏服务端API调用失败',
data: error.response?.data
})
}
res.status(500).json({
success: false,
message: '游戏服务端API调用失败请稍后重试'
})
}
})
export default router

View File

@@ -0,0 +1,375 @@
import { Router, Request, Response } from 'express'
import { User, UserRole, UserStatus } from '../models/User'
import { AppDataSource } from '../config/typeorm.config'
import { authMiddleware, adminMiddleware } from '../middleware/authMiddleware'
import bcrypt from 'bcryptjs'
const router = Router()
const userRepository = AppDataSource.getRepository(User)
// 获取用户列表(需要管理员权限)
router.get('/', [authMiddleware, adminMiddleware], async (req: Request, res: Response) => {
try {
const { page = 1, limit = 10, role, status } = req.query
const skip = (parseInt(page as string) - 1) * parseInt(limit as string)
const take = parseInt(limit as string)
const whereCondition: any = {}
if (role && Object.values(UserRole).includes(role as UserRole)) {
whereCondition.role = role
}
if (status && Object.values(UserStatus).includes(status as UserStatus)) {
whereCondition.status = status
}
const [users, total] = await userRepository.findAndCount({
where: whereCondition,
skip,
take,
order: { createdAt: 'DESC' }
})
// 移除密码字段
const usersWithoutPassword = users.map(user => {
const { password, ...userData } = user
return userData
})
res.status(200).json({
success: true,
data: {
users: usersWithoutPassword,
total,
page: parseInt(page as string),
limit: parseInt(limit as string),
pages: Math.ceil(total / take)
}
})
} catch (error) {
console.error('获取用户列表失败:', error)
res.status(500).json({
success: false,
message: '获取用户列表失败,请稍后重试'
})
}
})
// 创建用户(需要管理员权限)
router.post('/', [authMiddleware, adminMiddleware], async (req: Request, res: Response) => {
try {
const { username, password, email, phone, role, status } = req.body
// 检查用户名是否已存在
const existingUser = await userRepository.findOne({ where: { username } })
if (existingUser) {
return res.status(400).json({
success: false,
message: '用户名已存在'
})
}
// 检查邮箱是否已存在
if (email) {
const existingEmail = await userRepository.findOne({ where: { email } })
if (existingEmail) {
return res.status(400).json({
success: false,
message: '邮箱已被注册'
})
}
}
// 检查手机号是否已存在
if (phone) {
const existingPhone = await userRepository.findOne({ where: { phone } })
if (existingPhone) {
return res.status(400).json({
success: false,
message: '手机号已被注册'
})
}
}
// 加密密码
const hashedPassword = await bcrypt.hash(password, 12)
// 创建新用户
const newUser = userRepository.create({
username,
password: hashedPassword,
email,
phone,
role: role || UserRole.ADMIN,
status: status || UserStatus.ACTIVE
})
await userRepository.save(newUser)
// 移除密码字段
const { password: _, ...userData } = newUser
res.status(201).json({
success: true,
message: '用户创建成功',
data: userData
})
} catch (error) {
console.error('创建用户失败:', error)
res.status(500).json({
success: false,
message: '创建用户失败,请稍后重试'
})
}
})
// 获取用户详情
router.get('/:id', authMiddleware, async (req: Request, res: Response) => {
try {
const { id } = req.params
const user = await userRepository.findOne({ where: { id: parseInt(id) } })
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
})
}
// 移除密码字段
const { password, ...userData } = user
res.status(200).json({
success: true,
data: userData
})
} catch (error) {
console.error('获取用户详情失败:', error)
res.status(500).json({
success: false,
message: '获取用户详情失败,请稍后重试'
})
}
})
// 更新用户信息
router.put('/:id', authMiddleware, async (req: Request, res: Response) => {
try {
const { id } = req.params
const { username, email, phone, role, status } = req.body
// 查找用户
const user = await userRepository.findOne({ where: { id: parseInt(id) } })
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
})
}
// 检查用户名是否已存在(排除当前用户)
if (username && username !== user.username) {
const existingUser = await userRepository.findOne({ where: { username } })
if (existingUser) {
return res.status(400).json({
success: false,
message: '用户名已存在'
})
}
}
// 检查邮箱是否已存在(排除当前用户)
if (email && email !== user.email) {
const existingEmail = await userRepository.findOne({ where: { email } })
if (existingEmail) {
return res.status(400).json({
success: false,
message: '邮箱已被注册'
})
}
}
// 检查手机号是否已存在(排除当前用户)
if (phone && phone !== user.phone) {
const existingPhone = await userRepository.findOne({ where: { phone } })
if (existingPhone) {
return res.status(400).json({
success: false,
message: '手机号已被注册'
})
}
}
// 更新用户信息
if (username) user.username = username
if (email) user.email = email
if (phone) user.phone = phone
if (role && Object.values(UserRole).includes(role)) {
// 只有管理员可以更改角色
if ((req as any).user?.role !== UserRole.ADMIN) {
return res.status(403).json({
success: false,
message: '权限不足,无法更改用户角色'
})
}
user.role = role
}
if (status && Object.values(UserStatus).includes(status)) {
// 只有管理员可以更改状态
if ((req as any).user?.role !== UserRole.ADMIN) {
return res.status(403).json({
success: false,
message: '权限不足,无法更改用户状态'
})
}
user.status = status
}
await userRepository.save(user)
// 移除密码字段
const { password, ...userData } = user
res.status(200).json({
success: true,
message: '用户信息更新成功',
data: userData
})
} catch (error) {
console.error('更新用户信息失败:', error)
res.status(500).json({
success: false,
message: '更新用户信息失败,请稍后重试'
})
}
})
// 更新用户密码
router.put('/:id/password', authMiddleware, async (req: Request, res: Response) => {
try {
const { id } = req.params
const { oldPassword, newPassword } = req.body
// 查找用户
const user = await userRepository.findOne({ where: { id: parseInt(id) } })
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
})
}
// 验证旧密码
if ((req as any).user?.role !== UserRole.ADMIN) {
// 非管理员需要验证旧密码
const isPasswordValid = await bcrypt.compare(oldPassword, user.password)
if (!isPasswordValid) {
return res.status(400).json({
success: false,
message: '旧密码错误'
})
}
}
// 加密新密码
const hashedPassword = await bcrypt.hash(newPassword, 12)
// 更新密码
user.password = hashedPassword
await userRepository.save(user)
res.status(200).json({
success: true,
message: '密码更新成功'
})
} catch (error) {
console.error('更新密码失败:', error)
res.status(500).json({
success: false,
message: '更新密码失败,请稍后重试'
})
}
})
// 批量更新用户状态(需要管理员权限)
router.put('/batch/status', [authMiddleware, adminMiddleware], async (req: Request, res: Response) => {
try {
const { ids, status } = req.body
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({
success: false,
message: '请提供有效的用户ID列表'
})
}
if (!status || !Object.values(UserStatus).includes(status as UserStatus)) {
return res.status(400).json({
success: false,
message: '请提供有效的用户状态'
})
}
// 不能修改当前登录用户的状态
const currentUserId = (req as any).user?.id
const filteredIds = ids.filter(id => id !== currentUserId)
if (filteredIds.length === 0) {
return res.status(400).json({
success: false,
message: '不能修改当前登录用户的状态'
})
}
await userRepository.update(filteredIds, { status })
res.status(200).json({
success: true,
message: `成功更新${filteredIds.length}个用户的状态`
})
} catch (error) {
console.error('批量更新用户状态失败:', error)
res.status(500).json({
success: false,
message: '批量更新用户状态失败,请稍后重试'
})
}
})
// 删除用户(需要管理员权限)
router.delete('/:id', [authMiddleware, adminMiddleware], async (req: Request, res: Response) => {
try {
const { id } = req.params
// 查找用户
const user = await userRepository.findOne({ where: { id: parseInt(id) } })
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
})
}
// 不能删除自己
if ((req as any).user?.id === user.id) {
return res.status(400).json({
success: false,
message: '不能删除当前登录的用户'
})
}
await userRepository.remove(user)
res.status(200).json({
success: true,
message: '用户删除成功'
})
} catch (error) {
console.error('删除用户失败:', error)
res.status(500).json({
success: false,
message: '删除用户失败,请稍后重试'
})
}
})
export default router

View File

@@ -0,0 +1,52 @@
import { AppDataSource } from '../config/typeorm.config'
import { User, UserRole } from '../models/User'
import bcrypt from 'bcryptjs'
// 初始化管理员用户
const initAdmin = async () => {
try {
// 连接数据库
await AppDataSource.initialize()
console.log('数据库连接成功')
const userRepository = AppDataSource.getRepository(User)
// 检查是否已存在管理员用户
const existingAdmin = await userRepository.findOne({ where: { username: 'admin' } })
if (existingAdmin) {
console.log('管理员用户已存在')
return
}
// 加密密码
const hashedPassword = await bcrypt.hash('admin123', 12)
// 创建管理员用户
const adminUser = userRepository.create({
username: 'admin',
password: hashedPassword,
role: UserRole.ADMIN
})
await userRepository.save(adminUser)
console.log('管理员用户创建成功')
console.log('用户名: admin')
console.log('密码: admin123')
// 断开数据库连接
await AppDataSource.destroy()
} catch (error) {
console.error('初始化管理员用户失败:', error)
process.exit(1)
}
}
// 执行初始化
export default initAdmin
// 直接执行脚本
if (require.main === module) {
initAdmin()
}

View File

@@ -0,0 +1,227 @@
import { AppDataSource } from '../config/typeorm.config'
import { Config } from '../models/Config'
import * as fs from 'fs'
import * as path from 'path'
/**
* 配置服务,用于从数据库中读取和管理配置
*/
export class ConfigService {
private configRepository = AppDataSource.getRepository(Config)
private configCache: Record<string, string> = {}
private envFilePath: string = path.resolve(__dirname, '../../.env')
/**
* 初始化配置服务
* 从数据库中加载所有配置项到缓存中
*/
async initialize() {
try {
console.log('🔄 正在从数据库加载系统配置...')
const configs = await this.configRepository.find()
this.configCache = configs.reduce((map, config) => {
map[config.key] = config.value
return map
}, {} as Record<string, string>)
console.log(`✅ 配置服务初始化成功,已加载 ${configs.length} 个配置项`)
// 显示关键配置信息(脱敏显示)
const importantKeys = ['server_host', 'server_port', 'game_api_url', 'game_psk']
importantKeys.forEach(key => {
if (this.configCache[key]) {
let displayValue = this.configCache[key]
// 对敏感信息进行脱敏处理
if (key.includes('secret')) {
displayValue = displayValue ? '*'.repeat(Math.min(displayValue.length, 8)) : '未设置'
}
console.log(` 📋 ${key}: ${displayValue}`)
}
})
} catch (error) {
console.error('❌ 配置服务初始化失败:', error)
throw error
}
}
/**
* 获取配置值
* @param key 配置键名
* @param defaultValue 默认值,当配置不存在时返回
* @returns 配置值
*/
get(key: string, defaultValue?: string): string {
return this.configCache[key] || defaultValue || ''
}
/**
* 获取所有配置
* @returns 所有配置项
*/
getAll(): Record<string, string> {
return { ...this.configCache }
}
/**
* 更新配置
* @param key 配置键名
* @param value 配置值
* @param description 配置描述
*/
async update(key: string, value: string, description?: string) {
try {
// 更新数据库
await this.configRepository.upsert(
{ key, value, description },
['key']
)
// 更新缓存
this.configCache[key] = value
// 更新.env文件
await this.updateEnvFile(key, value)
// 应用到环境变量
this.applyToEnvKey(key, value)
return true
} catch (error) {
console.error('更新配置失败:', error)
throw error
}
}
/**
* 将数据库配置应用到环境变量
* 覆盖process.env中的配置
*/
applyToEnv() {
console.log('🔄 正在应用数据库配置到环境变量...')
let appliedCount = 0
// 服务器配置
if (this.configCache.server_host) {
process.env.HOST = this.configCache.server_host
appliedCount++
}
if (this.configCache.server_port) {
process.env.PORT = this.configCache.server_port
appliedCount++
}
// 游戏服务API配置
if (this.configCache.game_api_url) {
process.env.GAME_API_URL = this.configCache.game_api_url
appliedCount++
}
// 游戏服务PSK配置
if (this.configCache.game_psk) {
process.env.GAME_PSK = this.configCache.game_psk
appliedCount++
}
// JWT配置
if (this.configCache.jwt_secret) {
process.env.JWT_SECRET = this.configCache.jwt_secret
appliedCount++
}
if (this.configCache.jwt_expires_in) {
process.env.JWT_EXPIRES_IN = this.configCache.jwt_expires_in
appliedCount++
}
if (this.configCache.jwt_refresh_secret) {
process.env.JWT_REFRESH_SECRET = this.configCache.jwt_refresh_secret
appliedCount++
}
if (this.configCache.jwt_refresh_expires_in) {
process.env.JWT_REFRESH_EXPIRES_IN = this.configCache.jwt_refresh_expires_in
appliedCount++
}
console.log(`✅ 数据库配置应用完成,已更新 ${appliedCount} 个环境变量`)
}
/**
* 将单个配置键应用到环境变量
* @param key 配置键名
* @param value 配置值
*/
private applyToEnvKey(key: string, value: string) {
// 配置键映射:数据库键名 → .env键名
const keyMapping: Record<string, string> = {
server_host: 'HOST',
server_port: 'PORT',
game_api_url: 'GAME_API_URL',
game_psk: 'GAME_PSK',
jwt_secret: 'JWT_SECRET',
jwt_expires_in: 'JWT_EXPIRES_IN',
jwt_refresh_secret: 'JWT_REFRESH_SECRET',
jwt_refresh_expires_in: 'JWT_REFRESH_EXPIRES_IN'
}
// 应用到环境变量
const envKey = keyMapping[key]
if (envKey) {
process.env[envKey] = value
console.log(`配置 ${key} 已应用到环境变量 ${envKey}=${value}`)
}
}
/**
* 更新.env文件中的配置项
* @param key 数据库中的配置键名
* @param value 配置值
*/
private async updateEnvFile(key: string, value: string) {
try {
// 配置键映射:数据库键名 → .env键名
const keyMapping: Record<string, string> = {
server_host: 'HOST',
server_port: 'PORT',
game_api_url: 'GAME_API_URL',
game_psk: 'GAME_PSK',
jwt_secret: 'JWT_SECRET',
jwt_expires_in: 'JWT_EXPIRES_IN',
jwt_refresh_secret: 'JWT_REFRESH_SECRET',
jwt_refresh_expires_in: 'JWT_REFRESH_EXPIRES_IN'
}
// 获取对应的.env键名
const envKey = keyMapping[key]
if (!envKey) {
console.log(` 配置 ${key} 无需更新到.env文件`)
return
}
// 读取.env文件内容
let envContent = ''
if (fs.existsSync(this.envFilePath)) {
envContent = fs.readFileSync(this.envFilePath, 'utf8')
}
// 构建新的配置行
const newConfigLine = `${envKey}=${value}`
const envKeyRegex = new RegExp(`^${envKey}=.*$`, 'gm')
// 更新或添加配置行
let updatedContent = ''
if (envKeyRegex.test(envContent)) {
// 替换现有配置行
updatedContent = envContent.replace(envKeyRegex, newConfigLine)
} else {
// 添加新配置行
updatedContent = envContent ? `${envContent}\n${newConfigLine}` : newConfigLine
}
// 写入.env文件
fs.writeFileSync(this.envFilePath, updatedContent, 'utf8')
console.log(` ✍️ .env文件已更新: ${envKey}=${value}`)
} catch (error) {
console.error('❌ 更新.env文件失败:', error)
throw error
}
}
}
// 导出单例实例
export const configService = new ConfigService()

18
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"strictPropertyInitialization": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

93
cz.config.js Normal file
View File

@@ -0,0 +1,93 @@
module.exports = {
// 提交信息的类型
types: [
{
value: 'feat',
name: 'feat: 新增功能',
emoji: '✨'
},
{
value: 'fix',
name: 'fix: 修复bug',
emoji: '🐛'
},
{
value: 'docs',
name: 'docs: 更新文档',
emoji: '📝'
},
{
value: 'style',
name: 'style: 代码样式(不影响功能)',
emoji: '💄'
},
{
value: 'refactor',
name: 'refactor: 代码重构',
emoji: '♻️'
},
{
value: 'perf',
name: 'perf: 性能优化',
emoji: '⚡️'
},
{
value: 'test',
name: 'test: 测试相关',
emoji: '🧪'
},
{
value: 'build',
name: 'build: 构建配置',
emoji: '🏗️'
},
{
value: 'ci',
name: 'ci: CI/CD配置',
emoji: '👷'
},
{
value: 'chore',
name: 'chore: 其他(不影响代码)',
emoji: '🔧'
},
{
value: 'revert',
name: 'revert: 回滚提交',
emoji: '⏪️'
}
],
// 提交信息的范围
scopes: [
{
value: 'frontend',
name: 'frontend: 前端'
},
{
value: 'backend',
name: 'backend: 后端'
},
{
value: 'docs',
name: 'docs: 文档'
},
{
value: 'other',
name: 'other: 其他'
}
],
// 允许自定义范围
allowCustomScopes: true,
// 允许空范围
allowEmptyScopes: true,
// 提交信息的主题长度限制
subjectLimit: 100,
// 主题末尾不允许有句号
subjectFullStop: false,
// 主题不区分大小写
subjectCase: false,
// 使用emoji
useEmoji: true,
// emoji放在类型后面
emojiAlign: 'right'
};

86
docker-compose.yml Normal file
View File

@@ -0,0 +1,86 @@
version: '1.0'
services:
# 前端服务
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
- backend
networks:
- mhxy_network
restart: unless-stopped
environment:
- NODE_ENV=production
# 后端服务
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3000:3000"
depends_on:
- database
networks:
- mhxy_network
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
- HOST=0.0.0.0
- DB_HOST=database
- DB_PORT=5432
- DB_NAME=mhxy_web
- DB_USER=postgres
- DB_PASSWORD=postgres
# 以下环境变量需要根据实际情况在.env文件中配置
# - JWT_SECRET=your_jwt_secret_key_here
# - JWT_EXPIRES_IN=24h
# - GAME_API_URL=http://your_game_server_url/tool/http
# - GAME_PSK=THIS_IS_A_32_BYTE_FIXED_PSK!!!!!
volumes:
# 可选:挂载日志目录(如果需要)
# - ./backend/logs:/app/logs
# 可选:挂载环境变量文件(如果需要)
- ./backend/.env:/app/.env
# 数据库服务
database:
image: mysql:8.4
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-password}
- MYSQL_DATABASE=${DB_NAME:-mhxy_web}
- MYSQL_USER=${DB_USER:-root}
- MYSQL_PASSWORD=${DB_PASSWORD:-password}
volumes:
# 持久化数据库数据
- mysql_data:/var/lib/mysql
# 挂载初始化SQL脚本
- ./sql/init_mysql.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- mhxy_network
restart: unless-stopped
# 可选:配置健康检查
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u ${DB_USER:-root} -p${DB_PASSWORD:-password}"]
interval: 5s
timeout: 5s
retries: 5
# 网络配置
networks:
mhxy_network:
driver: bridge
name: mhxy_network
# 数据卷配置
volumes:
mysql_data:
driver: local
name: mhxy_mysql_data

View File

@@ -0,0 +1,13 @@
# 生产环境配置
# 游戏服务端API配置使用Vite代理
VITE_GAME_API_URL=/game-api/tool/http
# 后端API基础URL
VITE_API_BASE_URL=/api
# 应用标题
VITE_APP_TITLE=梦幻西游Web管理系统
# 应用版本
VITE_APP_VERSION=1.0.0

33
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# 多阶段构建:构建阶段
FROM node:18-alpine AS build-stage
WORKDIR /app
# 设置国内镜像源以加速依赖安装(可选,根据实际情况调整)
# RUN npm config set registry https://registry.npmmirror.com
# 复制package.json和package-lock.json
COPY package*.json ./
# 安装依赖
RUN npm ci
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 多阶段构建:运行阶段
FROM nginx:1.23-alpine AS production-stage
# 复制构建结果到Nginx
COPY --from=build-stage /app/dist /usr/share/nginx/html
# 复制Nginx配置文件
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
# 启动Nginx
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>一体化游戏运营平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

43
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,43 @@
server {
listen 80;
server_name localhost;
# 配置静态资源服务
location / {
root /usr/share/nginx/html;
index index.html;
# 处理前端路由SPA模式
try_files $uri $uri/ /index.html;
}
# 配置API反向代理到后端服务
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 增加超时配置
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# 配置游戏API代理如果需要
location /game-api/ {
proxy_pass http://backend:3000/game-api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 增加超时配置
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# 配置404页面
error_page 404 /index.html;
}

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "game-operation-platform-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@remixicon/vue": "^4.7.0",
"axios": "^1.6.5",
"element-plus": "^2.5.1",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/chai": "^5.2.3",
"@types/deep-eql": "^4.0.2",
"@vitejs/plugin-vue": "^5.0.3",
"typescript": "^5.2.2",
"vite": "^5.0.11",
"vue-tsc": "^3.1.5"
}
}

37
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,37 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
// 应用启动时从localStorage恢复用户信息
onMounted(() => {
userStore.recoverUser()
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100%;
}
</style>

114
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,114 @@
import api from './index'
import { gameApi } from './index'
import type { LoginForm, LoginResponse, LogoutResponse } from '@/types/auth'
import { usePlayerStore } from '@/store/player'
/**
* 用户登录(运营管理系统)
* @param form 登录表单数据
* @returns 登录响应
*/
export const login = (form: LoginForm): Promise<LoginResponse> => {
return api.post('/auth/login', form)
}
/**
* 玩家登录游戏服务端API
* @param form 登录表单数据
* @returns 登录响应
*/
export const playerLogin = async (form: LoginForm): Promise<any> => {
// 按照游戏服务端API要求的格式发送请求
const requestData = {
code: 'auth/login',
username: form.username,
password: form.password
}
try {
const response = await gameApi.post('', requestData)
// 处理登录成功,保存玩家信息到独立存储
// 注意由于gameApi响应拦截器返回的是response.data所以response就是实际数据
if ((response as any)?.code === 200) {
const playerStore = usePlayerStore()
// 使用正确的响应数据结构
// 游戏服务端返回的data字段就是token
if (response.data) {
const token = response.data
const player = {
id: 0,
username: form.username,
role: 'player' as const,
status: 'ACTIVE' as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
// 保存token和玩家信息
playerStore.gameToken = token
playerStore.player = player
localStorage.setItem('gameToken', token)
localStorage.setItem('player', JSON.stringify(player))
}
}
return response
} catch (error) {
throw error
}
}
/**
* 用户登出(运营管理系统)
* @returns 登出响应
*/
export const logout = (): Promise<LogoutResponse> => {
return api.post('/auth/logout')
}
/**
* 玩家登出游戏服务端API
* @returns 登出响应
*/
export const playerLogout = async (): Promise<any> => {
// 按照游戏服务端API要求的格式发送请求
const requestData = {
code: 'auth/out_login'
}
try {
const response = await gameApi.post('', requestData)
return response
} catch (error) {
console.error('玩家登出API调用失败:', error)
return null
}
}
/**
* 获取当前玩家信息游戏服务端API
* @returns 玩家信息响应
*/
export const getPlayerInfo = async (): Promise<any> => {
// 按照游戏服务端API要求的格式发送请求
const requestData = {
code: 'account/get_account'
}
try {
const response = await gameApi.post('', requestData)
return response
} catch (error) {
console.error('获取玩家信息API调用失败:', error)
return null
}
}
/**
* 获取当前用户信息(运营管理系统)
* @returns 当前用户信息
*/
export const getCurrentUser = () => {
return api.get('/auth/me')
}

View File

@@ -0,0 +1,66 @@
import api from './index'
/**
* 获取所有配置项
* @returns 配置项列表
*/
export const getAllConfigs = () => {
return api.get('/config')
}
/**
* 获取单个配置项
* @param key 配置键名
* @returns 配置项详情
*/
export const getConfig = (key: string) => {
return api.get(`/config/${key}`)
}
/**
* 设置配置项
* @param data 配置数据
* @returns 设置结果
*/
export const addConfig = (data: { key: string; value: string; description?: string }) => {
return api.post('/config', data)
}
/**
* 更新配置项
* @param key 配置键名
* @param value 配置值
* @param description 配置描述(可选)
* @returns 更新结果
*/
export const updateConfig = (key: string, value: any, description?: string) => {
return api.put(`/config/${key}`, { value, description })
}
/**
* 删除配置项
* @param key 配置键名
* @returns 删除结果
*/
export const deleteConfig = (key: string) => {
return api.delete(`/config/${key}`)
}
/**
* 批量删除配置项
* @param ids 配置项ID列表
* @returns 删除结果
*/
export const batchDeleteConfigs = (ids: string[]) => {
return api.delete('/config/batch', { data: { ids } })
}
// 统一导出配置API
export const configApi = {
getAllConfigs,
getConfig,
addConfig,
updateConfig,
deleteConfig,
batchDeleteConfigs
}

31
frontend/src/api/game.ts Normal file
View File

@@ -0,0 +1,31 @@
import api, { gameApi } from './index'
/**
* 调用游戏服务端API直接调用
* @param path API路径
* @param data 请求数据
* @returns 响应结果
*/
export const callGameApi = (path: string, data: any) => {
return gameApi.post('', { code: path, ...data })
}
/**
* 通过后端转发调用游戏服务端API运营管理系统专用
* @param path API路径
* @param data 请求数据
* @returns 响应结果
*/
export const callGameApiThroughBackend = (path: string, data: any) => {
// 调用后端的/api/game接口由后端转发请求到游戏服务端
return api.post('/game', { path, params: data })
}
/**
* 获取游戏服务状态(通过后端)
* @returns 服务状态
*/
export const getGameServiceStatus = () => {
// 调用后端的/api/game/status接口
return api.get('/game/status')
}

152
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,152 @@
import axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { usePlayerStore } from '@/store/player'
// 配置默认值
const DEFAULT_BASE_URL = '/api'
const DEFAULT_TIMEOUT = 10000
// 创建axios实例用于调用后端API
const api: AxiosInstance = axios.create({
baseURL: DEFAULT_BASE_URL,
timeout: DEFAULT_TIMEOUT,
headers: {
'Content-Type': 'application/json'
},
// 允许跨域请求携带凭证
withCredentials: true
})
// 创建游戏服务端API专用Axios实例直接调用游戏服务端
const gameApi: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_GAME_API_URL || 'http://127.0.0.1:8080/tool/http',
timeout: 15000,
headers: {
'Content-Type': 'application/json'
},
// 允许跨域请求携带凭证
withCredentials: true
})
// 主API请求拦截器
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从store获取token
const userStore = useUserStore()
if (userStore.token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 游戏服务端API请求拦截器
gameApi.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从玩家存储获取token
const playerStore = usePlayerStore()
if (playerStore.gameToken) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${playerStore.gameToken}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 主API响应拦截器
api.interceptors.response.use(
(response: AxiosResponse) => {
return response.data
},
(error) => {
// 处理HTTP错误
let errorMessage = '网络错误,请稍后重试'
if (error.response) {
// 服务器返回错误状态码
const { status, data } = error.response
switch (status) {
case 400:
errorMessage = data.message || '请求参数错误'
break
case 401:
errorMessage = '登录已过期,请重新登录'
// 清除登录状态
const userStore = useUserStore()
userStore.logout()
// 根据当前路径跳转到对应的登录页面
const currentPath = window.location.pathname
console.log('401错误当前路径:', currentPath)
if (currentPath.startsWith('/admin')) {
console.log('跳转到管理员登录页')
window.location.href = '/admin/login'
} else {
console.log('跳转到玩家登录页')
window.location.href = '/player/login'
}
break
case 403:
errorMessage = '权限不足,无法访问该资源'
break
case 404:
errorMessage = '请求的资源不存在'
break
case 500:
errorMessage = '服务器内部错误,请稍后重试'
break
default:
errorMessage = data.message || `请求失败 (${status})`
}
} else if (error.request) {
// 请求已发送但未收到响应
errorMessage = '服务器无响应,请稍后重试'
}
console.log('API响应拦截器错误:', error)
ElMessage.error(errorMessage)
return Promise.reject(error)
}
)
// 游戏服务端API响应拦截器
gameApi.interceptors.response.use(
(response: AxiosResponse) => {
// 游戏服务端API的响应格式可能与运营管理系统不同
// 直接返回响应数据,由调用方处理
return response.data
},
(error) => {
// 处理游戏服务端API错误
let errorMessage = '游戏服务端请求失败,请稍后重试'
if (error.response) {
const { status, data } = error.response
if (status === 401) {
errorMessage = '登录已过期,请重新登录'
// 清除玩家登录状态
const playerStore = usePlayerStore()
playerStore.logout()
// 跳转到玩家登录页面
window.location.href = '/player/login'
} else {
errorMessage = data?.message || data?.error || errorMessage
}
} else if (error.request) {
// 请求已发送但未收到响应
errorMessage = '游戏服务端无响应,请稍后重试'
}
ElMessage.error(errorMessage)
return Promise.reject(error)
}
)
export default api
export { gameApi }

67
frontend/src/api/user.ts Normal file
View File

@@ -0,0 +1,67 @@
import api from './index'
import type { UserQueryParams, UpdateUserForm, ChangePasswordForm } from '@/types/user'
/**
* 获取用户列表
* @param params 查询参数
* @returns 用户列表
*/
export const getUserList = (params: UserQueryParams) => {
return api.get('/users', { params })
}
/**
* 获取用户详情
* @param id 用户ID
* @returns 用户详情
*/
export const getUserById = (id: number) => {
return api.get(`/users/${id}`)
}
/**
* 创建用户
* @param form 用户表单数据
* @returns 创建结果
*/
export const addUser = (form: any) => {
return api.post('/users', form)
}
/**
* 更新用户信息
* @param id 用户ID
* @param form 更新表单数据
* @returns 更新结果
*/
export const updateUser = (id: number, form: UpdateUserForm) => {
return api.put(`/users/${id}`, form)
}
/**
* 更新用户密码
* @param id 用户ID
* @param form 密码更新表单数据
* @returns 更新结果
*/
export const changePassword = (id: number, form: ChangePasswordForm) => {
return api.put(`/users/${id}/password`, form)
}
/**
* 删除用户
* @param id 用户ID
* @returns 删除结果
*/
export const deleteUser = (id: number) => {
return api.delete(`/users/${id}`)
}
/**
* 批量更新用户状态
* @param data 批量更新数据
* @returns 更新结果
*/
export const batchUpdateStatus = (data: { ids: number[], status: string }) => {
return api.put('/users/batch/status', data)
}

View File

@@ -0,0 +1,196 @@
/* 全局重置样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 14px;
color: #303133;
background-color: #f5f7fa;
}
/* 容器样式 */
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* 页面布局样式 */
.page-container {
height: 100%;
display: flex;
flex-direction: column;
}
/* 主内容区样式 */
.main-content {
flex: 1;
padding: 20px;
background-color: #fff;
margin: 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
/* 标题样式 */
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 20px;
color: #303133;
}
h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
}
h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #303133;
}
/* 按钮样式 */
.btn {
display: inline-block;
padding: 10px 20px;
background-color: #409eff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #66b1ff;
}
.btn-primary {
background-color: #409eff;
}
.btn-success {
background-color: #67c23a;
}
.btn-warning {
background-color: #e6a23c;
}
.btn-danger {
background-color: #f56c6c;
}
/* 表单样式 */
.form-item {
margin-bottom: 20px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #303133;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
transition: border-color 0.3s;
}
.form-control:focus {
outline: none;
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
/* 卡片样式 */
.card {
background-color: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 20px;
}
/* 表格样式 */
.table {
width: 100%;
border-collapse: collapse;
background-color: #fff;
}
.table th,
.table td {
padding: 12px;
border-bottom: 1px solid #ebeef5;
text-align: left;
}
.table th {
background-color: #f5f7fa;
font-weight: 600;
color: #303133;
}
.table tr:hover {
background-color: #f5f7fa;
}
/* 登录页面样式 */
.login-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #f5f7fa;
}
.login-form {
width: 400px;
padding: 40px;
background-color: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.login-title {
text-align: center;
font-size: 24px;
font-weight: 600;
margin-bottom: 30px;
color: #303133;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-form {
width: 90%;
padding: 20px;
}
.main-content {
margin: 10px;
padding: 15px;
}
}

View File

@@ -0,0 +1,174 @@
<template>
<el-header class="admin-header">
<div class="header-content">
<div class="header-left">
<!-- 侧边栏展开/折叠按钮 -->
<div class="sidebar-toggle-btn" @click="toggleSidebar">
<RiArrowLeftSLine v-if="!uiStore.isSidebarCollapsed" />
<RiArrowRightSLine v-else />
</div>
<div class="header-title">
<!-- 页面标题插槽允许自定义 -->
<slot name="header-title"></slot>
</div>
</div>
<div class="user-info">
<el-dropdown @command="handleCommand">
<span class="user-profile">
<RiUserLine />
<span class="username">{{ userStore.user?.username || '管理员' }}</span>
<RiArrowDownSLine class="el-icon--right" />
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-header>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useUIStore } from '@/store/ui'
import { ElMessage } from 'element-plus'
// 导入Remix Icon组件
import { RiUserLine, RiArrowDownSLine, RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/vue'
const router = useRouter()
const userStore = useUserStore()
const uiStore = useUIStore()
// 切换侧边栏折叠状态
const toggleSidebar = () => {
uiStore.toggleSidebar()
}
// 处理下拉菜单命令
const handleCommand = (command: string) => {
switch (command) {
case 'profile':
// TODO: 跳转到个人中心页面
ElMessage.info('个人中心功能开发中...')
break
case 'logout':
handleLogout()
break
default:
break
}
}
// 退出登录
const handleLogout = async () => {
try {
// 清除用户信息和token
userStore.logout()
// 显示成功消息
ElMessage.success('退出登录成功')
// 跳转到登录页面
router.push('/admin/login')
} catch (error) {
console.error('退出登录失败:', error)
ElMessage.error('退出登录失败')
}
}
</script>
<style scoped>
.admin-header {
background-color: #ffffff;
border-bottom: 1px solid #e4e7ed;
padding: 0;
height: 60px;
display: flex;
align-items: center;
}
.header-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-title {
flex: 1;
display: flex;
align-items: center;
}
.sidebar-toggle-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s ease;
}
.sidebar-toggle-btn:hover {
background-color: #f5f7fa;
}
.sidebar-toggle-btn svg {
font-size: 18px;
color: #606266;
}
.user-info {
display: flex;
align-items: center;
}
.user-profile {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.3s ease;
}
.user-profile:hover {
background-color: #f5f7fa;
}
.username {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.el-icon--right {
margin-left: 4px;
}
/* 响应式设计 */
@media screen and (max-width: 768px) {
.header-content {
padding: 0 15px;
}
.username {
display: none;
}
}
</style>

View File

@@ -0,0 +1,304 @@
<template>
<el-aside class="admin-sidebar" :width="uiStore.isSidebarCollapsed ? '80px' : '200px'">
<div class="sidebar-header" :class="{ 'sidebar-header-collapsed': uiStore.isSidebarCollapsed }">
<div class="logo-container" v-if="!uiStore.isSidebarCollapsed">
<RiGamepadFill />
<span class="logo-text">运营管理系统</span>
</div>
<div class="logo-mini" v-else>
<RiGamepadFill />
</div>
</div>
<el-menu
:default-active="activeMenu"
:default-openeds="openedMenus"
class="admin-menu"
:collapse="uiStore.isSidebarCollapsed"
:collapse-transition="false"
router
mode="vertical"
>
<el-menu-item index="/admin">
<RiDashboardLine />
<span>工作台</span>
</el-menu-item>
<el-sub-menu index="player-management">
<template #title>
<RiUserLine />
<span>玩家管理</span>
</template>
<el-menu-item index="/admin/players">
<RiUserLine />
<span>玩家列表</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="system-management">
<template #title>
<RiSettings2Line />
<span>系统管理</span>
</template>
<el-menu-item index="/admin/users">
<RiUserSettingsLine />
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/admin/config">
<RiSettings2Line />
<span>系统配置</span>
</el-menu-item>
<el-menu-item index="/admin/game">
<RiServerLine />
<span>游戏服务</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useUIStore } from '@/store/ui'
// 导入Remix Icon组件
import {
RiGamepadFill,
RiDashboardLine,
RiSettings2Line,
RiUserSettingsLine,
RiServerLine,
RiUserLine
} from '@remixicon/vue'
const route = useRoute()
const uiStore = useUIStore()
// 计算当前激活的菜单
const activeMenu = computed(() => {
return route.path
})
// 计算当前应该展开的子菜单
const openedMenus = computed(() => {
const path = route.path
const menus = []
// 根据当前路由判断应该展开的子菜单
if (path.startsWith('/admin/users') || path.startsWith('/admin/config') || path.startsWith('/admin/game')) {
menus.push('system-management')
}
// 玩家管理菜单展开条件
if (path.startsWith('/admin/players')) {
menus.push('player-management')
}
return menus
})
// 侧边栏折叠状态由顶部导航栏控制,不需要本地切换函数
</script>
<style scoped>
.admin-sidebar {
background-color: #ffffff;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
transition: width 0.3s ease;
}
.sidebar-header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid #e4e7ed;
background-color: #ffffff;
}
/* 侧边栏折叠时调整头部布局使logo居中 */
.sidebar-header-collapsed {
justify-content: center !important;
padding: 0 !important;
}
.logo-container {
display: flex;
align-items: center;
gap: 8px;
}
.logo-container i {
font-size: 24px;
color: #409eff;
}
.logo-text {
font-size: 16px;
font-weight: 600;
color: #303133;
white-space: nowrap;
}
.logo-mini {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-left: 0;
}
.logo-mini i {
font-size: 24px;
color: #409eff;
}
.admin-menu {
flex: 1;
border-right: none;
background-color: transparent;
margin-top: 16px;
}
.admin-menu .el-menu-item {
height: 50px;
line-height: 50px;
margin: 3px 8px;
border-radius: 8px;
padding: 0 16px;
transition: all 0.3s ease;
}
.admin-menu .el-menu-item:hover {
background-color: #f5f7fa;
}
.admin-menu .el-menu-item.is-active {
background-color: #e6f2ff;
color: #409eff;
font-weight: 500;
}
.admin-menu .el-menu-item i,
.admin-menu .el-menu-item svg {
font-size: 20px;
color: inherit;
width: 20px;
height: 20px;
text-align: center;
}
:deep(.el-menu--vertical) {
border-right: none;
}
:deep(.el-menu-item) {
display: flex;
align-items: center;
gap: 12px;
}
:deep(.el-sub-menu__title) {
display: flex;
align-items: center;
gap: 12px;
height: 50px;
line-height: 50px;
margin: 3px 8px;
border-radius: 8px;
padding: 0 16px;
transition: all 0.3s ease;
}
:deep(.el-sub-menu__title i),
:deep(.el-sub-menu__title svg) {
font-size: 20px;
width: 20px;
height: 20px;
text-align: center;
}
:deep(.el-sub-menu__title:hover) {
background-color: #f5f7fa;
}
/* 保持折叠状态下图标大小与展开时一致 */
:deep(.el-menu--collapse .el-menu-item) svg,
:deep(.el-menu--collapse .el-sub-menu__title) svg {
font-size: 20px !important;
width: 20px !important;
height: 20px !important;
line-height: 20px !important;
}
/* 确保折叠状态下图标居中显示 */
:deep(.el-menu--collapse .el-menu-item),
:deep(.el-menu--collapse .el-sub-menu__title) {
display: flex;
align-items: center;
justify-content: center;
padding: 0 !important;
}
/* 覆盖Element Plus折叠状态的默认样式 */
:deep(.el-menu--collapse) {
width: 80px;
}
:deep(.el-menu--collapse .el-menu-item),
:deep(.el-menu--collapse .el-sub-menu__title) {
width: 64px; /* 保持与菜单项相同的宽度,确保边框完整显示 */
margin: 3px 8px !important; /* 保持外边距,确保有空间显示边框 */
text-align: center;
}
/* 确保折叠状态下不显示文字 */
:deep(.el-menu--collapse .el-menu-item span),
:deep(.el-menu--collapse .el-sub-menu__title span) {
display: none;
}
/* 修复一级菜单展开箭头的垂直对齐问题 */
:deep(.el-sub-menu__title) {
display: flex;
align-items: center;
gap: 12px;
height: 50px;
line-height: 50px;
margin: 3px 8px;
border-radius: 8px;
padding: 0 16px;
transition: all 0.3s ease;
}
/* 确保展开箭头图标与文字在同一水平线上 */
:deep(.el-sub-menu__icon-arrow) {
position: static !important;
margin-left: auto !important;
margin-top: 0 !important;
transform: none !important;
vertical-align: middle !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
font-size: 18px !important;
line-height: 1 !important;
}
/* 侧边栏折叠状态下隐藏展开箭头 */
.el-menu--collapse :deep(.el-sub-menu__icon-arrow) {
display: none !important;
}
/* 当子菜单展开时的箭头旋转 */
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
transform: rotate(180deg) !important;
transition: transform 0.3s ease !important;
}
</style>

View File

@@ -0,0 +1,268 @@
<template>
<div class="tab-nav-container">
<div class="tab-nav-wrapper">
<div
v-for="tab in tabs"
:key="tab.path"
class="tab-item"
:class="{ 'active': tab.path === currentPath }"
@click="switchTab(tab)"
:draggable="tab.path !== '/admin'"
@dragstart="handleDragStart($event, tab)"
@dragover="handleDragOver($event)"
@drop="handleDrop($event, tab)"
>
<span class="tab-title">{{ tab.title }}</span>
<!-- 工作台标签没有关闭按钮 -->
<button
v-if="tab.path !== '/admin'"
class="tab-close-btn"
@click.stop="closeTab(tab)"
:title="`关闭 ${tab.title}`"
>
<RiCloseLine />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUIStore } from '@/store/ui'
import { ElMessage } from 'element-plus'
import { RiCloseLine } from '@remixicon/vue'
interface TabItem {
path: string
title: string
name?: string
}
const router = useRouter()
const route = useRoute()
const uiStore = useUIStore()
// 当前路径
const currentPath = computed(() => route.path)
// 标签列表
const tabs = computed(() => uiStore.tabs)
// 切换标签
const switchTab = (tab: TabItem) => {
if (tab.path !== currentPath.value) {
router.push(tab.path)
}
}
// 关闭标签
const closeTab = (tab: TabItem) => {
if (tabs.value.length <= 1) {
ElMessage.warning('至少需要保留一个标签页')
return
}
const tabIndex = tabs.value.findIndex(t => t.path === tab.path)
let nextPath = ''
// 确定关闭标签后的跳转路径
if (tab.path === currentPath.value) {
// 关闭当前激活的标签
if (tabIndex === tabs.value.length - 1) {
// 如果是最后一个标签,跳转到前一个
nextPath = tabs.value[tabIndex - 1].path
} else {
// 否则跳转到下一个
nextPath = tabs.value[tabIndex + 1].path
}
}
// 从标签列表中移除
uiStore.removeTab(tab.path)
// 如果需要跳转
if (nextPath) {
router.push(nextPath)
}
}
// 刷新当前标签功能已移除
// 拖拽相关功能
const draggedTab = ref<TabItem | null>(null)
const handleDragStart = (_: DragEvent, tab: TabItem) => {
draggedTab.value = tab
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
const handleDrop = (event: DragEvent, targetTab: TabItem) => {
event.preventDefault()
if (draggedTab.value && draggedTab.value.path !== targetTab.path) {
uiStore.reorderTabs(draggedTab.value.path, targetTab.path)
}
draggedTab.value = null
}
// 监听路由变化,确保标签列表与当前路由一致
watch(
() => route.path,
(newPath) => {
// 当路由变化时,确保当前路径在标签列表中
if (!tabs.value.some(tab => tab.path === newPath)) {
// 如果不在列表中,添加新标签
uiStore.addTab({
path: newPath,
title: route.meta.title as string || '未命名页面',
name: route.name as string || ''
})
}
},
{ immediate: true }
)
// 组件初始化时,确保工作台标签始终存在
onMounted(() => {
// 检查工作台标签是否存在
if (!tabs.value.some(tab => tab.path === '/admin')) {
// 如果不存在,添加工作台标签
uiStore.addTab({
path: '/admin',
title: '工作台',
name: 'admin'
})
}
})
</script>
<style scoped>
.tab-nav-container {
display: flex;
align-items: center;
height: 36px;
background-color: #ffffff;
border-bottom: 1px solid #e4e7ed;
padding: 0 16px;
overflow: hidden;
flex-shrink: 0; /* 防止被flex容器压缩 */
}
.tab-nav-wrapper {
display: flex;
align-items: center;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tab-nav-wrapper::-webkit-scrollbar {
display: none;
}
.tab-item {
display: flex;
align-items: center;
padding: 0 16px;
height: 32px;
margin-right: 2px;
background-color: #ffffff;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
position: relative;
}
.tab-item:hover {
background-color: #f0f0f0;
}
.tab-item.active {
background-color: #409eff;
color: #ffffff;
}
.tab-title {
font-size: 14px;
color: #606266;
margin-right: 8px;
}
.tab-item.active .tab-title {
color: #ffffff;
font-weight: 500;
}
.tab-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
background: transparent;
color: #606266;
cursor: pointer;
border-radius: 50%;
padding: 0;
font-size: 14px;
opacity: 1;
transition: all 0.2s ease;
}
.tab-item.active .tab-close-btn {
color: #ffffff;
}
.tab-close-btn:hover {
background-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
.tab-nav-right {
display: flex;
align-items: center;
margin-left: 16px;
}
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: #606266;
cursor: pointer;
border-radius: 4px;
padding: 0;
font-size: 14px;
transition: all 0.3s ease;
}
.refresh-btn:hover {
background-color: #ecf5ff;
color: #409eff;
}
/* 响应式设计 */
@media screen and (max-width: 768px) {
.tab-item {
padding: 0 12px;
margin-right: 1px;
}
.tab-title {
font-size: 13px;
}
}
</style>

33
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn' // 导入Element Plus中文语言包
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './assets/main.css'
import { useUserStore } from '@/store/user'
import { usePlayerStore } from '@/store/player'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// 配置Element Plus使用中文语言
app.use(ElementPlus, {
locale: zhCn // 设置中文语言包
})
// 应用启动时恢复用户状态
const userStore = useUserStore()
const playerStore = usePlayerStore()
// 恢复运营管理系统用户状态
userStore.recoverUser()
// 恢复玩家服务中心用户状态
playerStore.recoverPlayer()
app.mount('#app')

View File

@@ -0,0 +1,158 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw> = [
// 玩家服务中心路由
{
path: '/player/login',
name: 'PlayerLogin',
component: () => import('@/views/player/Login.vue'),
meta: { title: '登录' }
},
{
path: '/player',
name: 'PlayerHome',
component: () => import('@/views/player/Home.vue'),
meta: { title: '玩家服务中心', requiresAuth: true }
},
// 运营管理系统路由
{
path: '/admin/login',
name: 'AdminLogin',
component: () => import('@/views/admin/Login.vue'),
meta: { title: '登录' }
},
{
path: '/admin',
name: 'AdminHome',
component: () => import('@/views/admin/Home.vue'),
meta: { title: '工作台', requiresAuth: true, isAdmin: true }
},
{
path: '/admin/users',
name: 'UserManagement',
component: () => import('@/views/admin/UserManagement.vue'),
meta: { title: '用户管理', requiresAuth: true, isAdmin: true }
},
{
path: '/admin/config',
name: 'SystemConfig',
component: () => import('@/views/admin/SystemConfig.vue'),
meta: { title: '系统配置', requiresAuth: true, isAdmin: true }
},
{
path: '/admin/game',
name: 'GameService',
component: () => import('@/views/admin/GameService.vue'),
meta: { title: '游戏服务', requiresAuth: true, isAdmin: true }
},
{
path: '/admin/players',
name: 'PlayerList',
component: () => import('@/views/admin/PlayerList.vue'),
meta: { title: '玩家列表', requiresAuth: true, isAdmin: true }
},
// 默认路由重定向
{
path: '/',
redirect: '/player'
},
// 404路由
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue'),
meta: { title: '页面不存在' }
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL as string),
routes
})
// 路由守卫
router.beforeEach((to, _from, next) => {
// 设置页面标题
const pageTitle = to.meta.title as string || '首页'
let systemName = '梦幻西游一站式运营管理平台'
// 根据路由路径判断系统类型
if (to.path.startsWith('/admin')) {
systemName = '运营管理系统'
} else if (to.path.startsWith('/player')) {
systemName = '玩家服务中心'
}
document.title = `${pageTitle} - ${systemName}`
// 检查是否需要认证
if (to.meta.requiresAuth) {
if (to.path.startsWith('/admin')) {
// 运营管理系统路由 - 使用管理员认证
const token = localStorage.getItem('token')
const userStr = localStorage.getItem('user')
const user = userStr ? JSON.parse(userStr) : null
console.log('管理员认证检查:', {
path: to.path,
hasToken: !!token,
hasUser: !!user,
userRole: user?.role
})
if (!token || !user) {
// 管理员未登录,跳转到管理员登录页
console.log('管理员未登录,跳转到管理员登录页')
next('/admin/login')
} else if (to.meta.isAdmin && user.role !== 'admin') {
// 需要管理员权限,但不是管理员
console.log('权限不足,跳转到管理员首页')
next('/admin')
} else {
// 管理员已登录且权限符合,继续访问
console.log('管理员已登录且权限符合,继续访问')
next()
}
} else {
// 玩家服务中心路由 - 使用玩家认证
const gameToken = localStorage.getItem('gameToken')
const playerStr = localStorage.getItem('player')
const player = playerStr ? JSON.parse(playerStr) : null
console.log('玩家认证检查:', {
path: to.path,
hasGameToken: !!gameToken,
hasPlayer: !!player
})
if (!gameToken || !player) {
// 玩家未登录,跳转到玩家登录页
console.log('玩家未登录,跳转到玩家登录页')
next('/player/login')
} else {
// 玩家已登录,继续访问
console.log('玩家已登录,继续访问')
next()
}
}
} else {
// 不需要认证,直接访问
console.log('不需要认证,直接访问')
next()
}
})
// 路由后置守卫 - 用于标签导航管理
router.afterEach((to) => {
// 只在运营管理系统中添加标签
if (to.path.startsWith('/admin') && !to.path.startsWith('/admin/login')) {
// 由于路由守卫在Pinia实例创建之前执行我们需要在组件内部处理标签添加
// 标签添加逻辑已在TabNav组件中实现
}
})
export default router

View File

@@ -0,0 +1,72 @@
import { defineStore } from 'pinia'
import { configApi } from '@/api/config'
export const useConfigStore = defineStore('config', {
state: () => ({
configs: {} as Record<string, any>,
loading: false,
error: null as unknown
}),
actions: {
/**
* 获取所有配置
* @returns Promise<{ success: boolean; data: any }>
*/
async getAllConfigs() {
this.loading = true
this.error = null
try {
const response = await configApi.getAllConfigs()
this.configs = response.data
return response
} catch (error) {
this.error = error
throw error
} finally {
this.loading = false
}
},
/**
* 更新单个配置项
* @param configData 配置数据包含key和value字段
* @returns Promise<{ success: boolean; message: string }>
*/
async updateConfig(configData: { key: string; value: any }) {
this.loading = true
this.error = null
try {
const response = await configApi.updateConfig(configData.key, configData.value)
// 更新本地状态
this.configs[configData.key] = configData.value
return response
} catch (error) {
this.error = error
throw error
} finally {
this.loading = false
}
},
/**
* 获取单个配置项
* @param key 配置键
* @returns Promise<{ success: boolean; data: any }>
*/
async getConfig(key: string) {
this.loading = true
this.error = null
try {
const response = await configApi.getConfig(key)
return response
} catch (error) {
this.error = error
throw error
} finally {
this.loading = false
}
}
}
})

View File

@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
export default pinia

View File

@@ -0,0 +1,149 @@
import { defineStore } from 'pinia'
import type { User } from '@/types/user'
import { playerLogin, playerLogout, getPlayerInfo } from '@/api/auth'
import type { LoginForm } from '@/types/auth'
export const usePlayerStore = defineStore('player', {
state: () => ({
player: null as User | null,
gameToken: localStorage.getItem('gameToken') || '',
loading: false
}),
getters: {
isLoggedIn: (state) => !!state.gameToken,
playerName: (state) => state.player?.username || ''
},
actions: {
// 玩家登录游戏服务端API
async login(form: LoginForm) {
this.loading = true
try {
// 调用游戏服务端API登录
const response = await playerLogin(form)
// 检查响应是否成功游戏服务端返回code=200表示成功
if (response?.code === 200) {
// 保存游戏token和初始玩家信息
const token = response?.data
const player = {
id: 0, // 游戏服务端可能不返回id使用默认值
username: form.username,
role: 'player' as const, // 玩家角色固定为player
status: 'ACTIVE' as const, // 设置用户状态为激活
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
if (token) {
this.gameToken = token
this.player = player
// 保存到localStorage使用独立的key
localStorage.setItem('gameToken', token)
localStorage.setItem('player', JSON.stringify(player))
// 登录成功后调用获取玩家信息的API更新真实玩家信息
await this.getPlayerInfo()
} else {
throw new Error('登录失败未获取到token')
}
} else {
throw new Error(response?.message || '登录失败,请稍后重试')
}
return response
} catch (error) {
throw error
} finally {
this.loading = false
}
},
// 玩家登出
async logout() {
this.loading = true
try {
// 调用游戏服务端登出API
await playerLogout()
} catch (error) {
console.error('玩家登出API调用失败:', error)
} finally {
// 无论API调用是否成功都要清除玩家状态
this.gameToken = ''
this.player = null
localStorage.removeItem('gameToken')
localStorage.removeItem('player')
this.loading = false
}
},
// 从localStorage恢复玩家信息
recoverPlayer() {
const gameToken = localStorage.getItem('gameToken')
const playerStr = localStorage.getItem('player')
if (gameToken && playerStr) {
this.gameToken = gameToken
this.player = JSON.parse(playerStr)
}
},
// 获取当前玩家信息从游戏服务端API
async getPlayerInfo() {
// 只有在有游戏Token的情况下才调用API
if (!this.gameToken) return
this.loading = true
try {
const response = await getPlayerInfo()
// 检查响应是否成功游戏服务端返回code=200表示成功
if ((response as any)?.code === 200 && (response as any)?.success === true) {
// 从游戏服务端获取玩家信息
const playerData = (response as any)?.data
// 更新玩家信息
if (playerData && this.player) {
this.player = {
...this.player,
username: playerData.username || this.player.username,
// 可以根据游戏服务端返回的实际字段扩展
// 例如id: playerData.id, status: playerData.status等
}
// 更新本地存储
localStorage.setItem('player', JSON.stringify(this.player))
} else if (playerData && !this.player) {
// 如果本地没有玩家信息,创建新的玩家对象
this.player = {
id: 0, // 使用默认值或游戏服务端返回的ID
username: playerData.username || '',
role: 'player' as const,
status: 'ACTIVE' as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
// 更新本地存储
localStorage.setItem('player', JSON.stringify(this.player))
}
}
return response
} catch (error) {
console.error('获取玩家信息失败:', error)
return null
} finally {
this.loading = false
}
},
// 清除玩家状态不调用API
clearPlayer() {
this.gameToken = ''
this.player = null
localStorage.removeItem('gameToken')
localStorage.removeItem('player')
}
}
})

86
frontend/src/store/ui.ts Normal file
View File

@@ -0,0 +1,86 @@
import { defineStore } from 'pinia'
interface TabItem {
path: string
title: string
name?: string
}
export const useUIStore = defineStore('ui', {
state: () => ({
sidebarCollapsed: false, // 侧边栏折叠状态,默认展开
tabs: [] as TabItem[] // 标签列表
}),
getters: {
isSidebarCollapsed: (state) => state.sidebarCollapsed
},
actions: {
// 切换侧边栏折叠状态
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed
},
// 设置侧边栏折叠状态
setSidebarCollapsed(collapsed: boolean) {
this.sidebarCollapsed = collapsed
},
// 添加标签
addTab(tab: TabItem) {
// 检查标签是否已存在
const existingTab = this.tabs.find(t => t.path === tab.path)
if (!existingTab) {
// 如果是工作台标签,确保它始终在第一位
if (tab.path === '/admin') {
// 如果工作台标签不存在,添加到数组开头
this.tabs.unshift(tab)
} else {
// 其他标签添加到数组中,保持工作台在第一位
this.tabs.push(tab)
}
}
},
// 移除标签
removeTab(path: string) {
// 工作台标签不能被删除
if (path === '/admin') {
return
}
const index = this.tabs.findIndex(t => t.path === path)
if (index > -1) {
this.tabs.splice(index, 1)
}
},
// 重新排序标签
reorderTabs(fromPath: string, toPath: string) {
// 工作台标签不能被重新排序
if (fromPath === '/admin' || toPath === '/admin') {
return
}
const fromIndex = this.tabs.findIndex(t => t.path === fromPath)
const toIndex = this.tabs.findIndex(t => t.path === toPath)
if (fromIndex > -1 && toIndex > -1 && fromIndex !== toIndex) {
// 移动标签
const [removedTab] = this.tabs.splice(fromIndex, 1)
this.tabs.splice(toIndex, 0, removedTab)
}
},
// 清空所有标签(保留首页)
clearTabs(keepPath: string = '/admin') {
this.tabs = this.tabs.filter(t => t.path === keepPath)
},
// 设置标签列表
setTabs(tabs: TabItem[]) {
this.tabs = tabs
}
}
})

View File

@@ -0,0 +1,85 @@
import { defineStore } from 'pinia'
import type { User } from '@/types/user'
import { login, logout, playerLogout } from '@/api/auth'
import type { LoginForm } from '@/types/auth'
export const useUserStore = defineStore('user', {
state: () => ({
user: null as User | null,
token: localStorage.getItem('token') || '',
loading: false
}),
getters: {
isLoggedIn: (state) => !!state.token,
isAdmin: (state) => state.user?.role === 'admin',
userName: (state) => state.user?.username || ''
},
actions: {
// 用户登录(运营管理系统)
async login(form: LoginForm) {
this.loading = true
try {
const response = await login(form)
this.token = response.data.token
this.user = response.data.user
// 保存到localStorage
localStorage.setItem('token', response.data.token)
localStorage.setItem('user', JSON.stringify(response.data.user))
return response
} catch (error) {
throw error
} finally {
this.loading = false
}
},
// 设置用户信息
setUser(user: User) {
this.user = user
// 保存到localStorage
localStorage.setItem('user', JSON.stringify(user))
},
// 设置token
setToken(token: string) {
this.token = token
// 保存到localStorage
localStorage.setItem('token', token)
},
// 用户登出
async logout() {
try {
// 根据用户角色调用不同的登出接口
if (this.isAdmin) {
// 管理员调用运营管理系统的登出接口
await logout()
} else {
// 玩家调用游戏服务端的登出接口
await playerLogout()
}
} catch (error) {
console.error('登出失败:', error)
} finally {
// 清除状态
this.token = ''
this.user = null
// 清除localStorage
localStorage.removeItem('token')
localStorage.removeItem('user')
}
},
// 从localStorage恢复用户信息
recoverUser() {
const token = localStorage.getItem('token')
const userStr = localStorage.getItem('user')
if (token && userStr) {
this.token = token
this.user = JSON.parse(userStr)
}
}
}
})

View File

@@ -0,0 +1,41 @@
import type { User } from './user'
// 登录表单接口
export interface LoginForm {
username: string
password: string
}
// 注册表单接口
export interface RegisterForm {
username: string
password: string
confirmPassword: string
email?: string
phone?: string
}
// 登录响应接口
export interface LoginResponse {
success: boolean
message: string
data: {
token: string
user: User
}
}
// 注册响应接口
export interface RegisterResponse {
success: boolean
message: string
data: {
user: User
}
}
// 登出响应接口
export interface LogoutResponse {
success: boolean
message: string
}

View File

@@ -0,0 +1,22 @@
// 配置项接口
export interface Config {
id: number
key: string
value: string
description?: string
createdAt: string
updatedAt: string
}
// 配置查询参数接口
export interface ConfigQueryParams {
key?: string
description?: string
}
// 配置创建/更新表单接口
export interface ConfigForm {
key: string
value: string
description?: string
}

View File

@@ -0,0 +1,50 @@
// 用户角色类型
export type UserRole = 'admin' | 'player'
// 用户状态类型
export type UserStatus = 'ACTIVE' | 'INACTIVE'
// 用户信息接口
export interface User {
id: number
username: string
email?: string
phone?: string
role: UserRole
status: UserStatus
createdAt: string
updatedAt: string
}
// 用户查询参数接口
export interface UserQueryParams {
page?: number
limit?: number
username?: string
email?: string
phone?: string
role?: UserRole
status?: UserStatus
}
// 用户更新表单接口
export interface UpdateUserForm {
username?: string
email?: string
phone?: string
role?: UserRole
status?: UserStatus
}
// 密码更新表单接口
export interface ChangePasswordForm {
oldPassword: string
newPassword: string
confirmPassword: string
}
// 批量更新状态表单接口
export interface BatchUpdateStatusForm {
ids: number[]
status: UserStatus
}

View File

@@ -0,0 +1,123 @@
/**
* 格式化日期时间
* @param date 日期对象或日期字符串
* @param format 格式化字符串,默认 'YYYY-MM-DD HH:mm:ss'
* @returns 格式化后的日期字符串
*/
export const formatDate = (date: Date | string, format: string = 'YYYY-MM-DD HH:mm:ss'): string => {
const d = typeof date === 'string' ? new Date(date) : date
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/**
* 验证密码强度
* @param password 密码字符串
* @returns 密码强度结果对象
*/
export const validatePassword = (password: string): { strong: boolean; message: string } => {
if (password.length < 6) {
return { strong: false, message: '密码长度不能少于6位' }
}
if (password.length > 20) {
return { strong: false, message: '密码长度不能超过20位' }
}
if (!/[A-Za-z]/.test(password)) {
return { strong: false, message: '密码必须包含字母' }
}
if (!/[0-9]/.test(password)) {
return { strong: false, message: '密码必须包含数字' }
}
return { strong: true, message: '密码强度符合要求' }
}
/**
* 生成唯一ID
* @returns 唯一ID字符串
*/
export const generateId = (): string => {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
/**
* 防抖函数
* @param func 要执行的函数
* @param wait 等待时间(毫秒)
* @returns 防抖处理后的函数
*/
export const debounce = <T extends (...args: any[]) => any>(func: T, wait: number): ((...args: Parameters<T>) => void) => {
let timeout: ReturnType<typeof setTimeout> | null = null
return (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
func(...args)
}, wait)
}
}
/**
* 节流函数
* @param func 要执行的函数
* @param limit 限制时间(毫秒)
* @returns 节流处理后的函数
*/
export const throttle = <T extends (...args: any[]) => any>(func: T, limit: number): ((...args: Parameters<T>) => void) => {
let inThrottle: boolean = false
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args)
inThrottle = true
setTimeout(() => {
inThrottle = false
}, limit)
}
}
}
/**
* 深拷贝对象
* @param obj 要拷贝的对象
* @returns 拷贝后的对象
*/
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as any
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as any
}
if (typeof obj === 'object') {
const clonedObj = {} as T
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key as keyof T] = deepClone(obj[key as keyof T])
}
}
return clonedObj
}
return obj
}

View File

@@ -0,0 +1,75 @@
<template>
<div class="not-found-container">
<div class="not-found-content">
<h1 class="error-code">404</h1>
<h2 class="error-message">页面不存在</h2>
<p class="error-description">
抱歉您访问的页面不存在或已被移除
</p>
<div class="action-buttons">
<el-button type="primary" @click="goHome">返回首页</el-button>
<el-button @click="goBack">返回上一页</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
// 返回首页
const goHome = () => {
router.push('/')
}
// 返回上一页
const goBack = () => {
router.back()
}
</script>
<style scoped>
.not-found-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f7fa;
}
.not-found-content {
text-align: center;
padding: 50px;
background-color: white;
border-radius: 10px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.error-code {
font-size: 120px;
font-weight: bold;
color: #e74c3c;
margin: 0 0 20px;
}
.error-message {
font-size: 24px;
font-weight: 500;
color: #333;
margin: 0 0 15px;
}
.error-description {
font-size: 16px;
color: #666;
margin: 0 0 30px;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 20px;
}
</style>

View File

@@ -0,0 +1,638 @@
<template>
<el-container class="admin-container">
<!-- 侧边栏 -->
<AdminSidebar />
<!-- 主内容区 -->
<el-container direction="vertical">
<!-- 顶部导航栏 -->
<AdminHeader />
<!-- 标签式导航组件 -->
<TabNav />
<!-- 主内容区域 -->
<el-main class="admin-main">
<div class="page-container">
<!-- 页面标题 -->
<h2 class="page-title">游戏服务监控</h2>
<!-- 服务状态监控区域 -->
<div class="config-section">
<div class="section-header">
<h3>服务状态监控</h3>
<el-button type="primary" @click="refreshGameStatus">
刷新状态
</el-button>
</div>
<div class="status-grid">
<div class="status-info">
<el-descriptions :column="1" border>
<el-descriptions-item label="服务状态">
<el-tag :type="gameStatus.connected ? 'success' : 'danger'">
{{ gameStatus.connected ? '已连接' : '未连接' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="服务地址">
{{ gameStatus.serverUrl }}
</el-descriptions-item>
<el-descriptions-item label="最后检测时间">
{{ formatDate(gameStatus.lastCheckTime) }}
</el-descriptions-item>
<el-descriptions-item label="API调用次数">
{{ gameStatus.apiCallCount }}
</el-descriptions-item>
<el-descriptions-item label="平均响应时间">
{{ gameStatus.avgResponseTime }}ms
</el-descriptions-item>
</el-descriptions>
</div>
<div class="success-rate">
<div class="rate-chart">
<el-progress :percentage="gameStatus.responseSuccessRate" :stroke-width="24" />
<div class="rate-label">API成功率</div>
</div>
</div>
</div>
</div>
<!-- API调用测试区域 -->
<div class="config-section">
<div class="section-header">
<h3>API调用测试</h3>
</div>
<div class="form-content">
<el-form :model="apiTestForm" label-width="120px">
<el-form-item label="API路径">
<el-input v-model="apiTestForm.path" placeholder="如: /user/info" />
</el-form-item>
<el-form-item label="请求参数">
<el-input
v-model="apiTestForm.params"
type="textarea"
:rows="4"
placeholder='JSON格式参数如: {"userId": "123"}'
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="testApiCall">
发送请求
</el-button>
<el-button @click="clearApiTest">
清空
</el-button>
</el-form-item>
</el-form>
<!-- API响应结果 -->
<div class="api-response" v-if="apiResponse">
<h4>响应结果:</h4>
<el-tabs v-model="activeTab">
<el-tab-pane label="响应数据" name="data">
<pre>{{ formatJson(apiResponse.data) }}</pre>
</el-tab-pane>
<el-tab-pane label="响应头" name="headers">
<pre>{{ formatJson(apiResponse.headers) }}</pre>
</el-tab-pane>
<el-tab-pane label="请求信息" name="request">
<pre>{{ formatJson(apiResponse.requestInfo) }}</pre>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
<!-- API调用历史记录 -->
<div class="config-section">
<div class="section-header">
<h3>API调用历史</h3>
<el-button size="small" @click="clearHistory">
清空历史
</el-button>
</div>
<div class="table-content">
<el-table
:data="apiHistory"
border
stripe
style="width: 100%"
max-height="400"
>
<el-table-column prop="timestamp" label="调用时间" width="180" sortable />
<el-table-column prop="path" label="API路径" width="200" />
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 200 ? 'success' : 'danger'">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="responseTime" label="响应时间" width="100">
<template #default="scope">
<span>{{ scope.row.responseTime }}ms</span>
</template>
</el-table-column>
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="params" label="请求参数" min-width="200">
<template #default="scope">
<el-tooltip :content="formatJson(scope.row.params)" placement="top">
<span class="param-text">{{ truncateText(formatJson(scope.row.params), 50) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="response" label="响应数据" min-width="200">
<template #default="scope">
<el-tooltip :content="formatJson(scope.row.response)" placement="top">
<span class="response-text">{{ truncateText(formatJson(scope.row.response), 50) }}</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 服务配置 -->
<div class="config-section">
<div class="section-header">
<h3>服务配置</h3>
</div>
<div class="form-content">
<el-form :model="gameConfig" label-width="120px">
<el-form-item label="游戏服务URL">
<el-input v-model="gameConfig.serverUrl" placeholder="请输入游戏服务地址" />
</el-form-item>
<el-form-item label="连接超时时间">
<el-slider v-model="gameConfig.timeout" :min="1000" :max="10000" :step="500" />
<span style="margin-left: 10px;">{{ gameConfig.timeout }}ms</span>
</el-form-item>
<el-form-item label="重试次数">
<el-select v-model="gameConfig.retryCount" placeholder="选择重试次数">
<el-option label="0次" value="0" />
<el-option label="1次" value="1" />
<el-option label="2次" value="2" />
<el-option label="3次" value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="updateGameConfig">
更新配置
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import TabNav from '@/components/TabNav.vue'
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useConfigStore } from '@/store/config'
import { ElMessage } from 'element-plus'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
const router = useRouter()
const userStore = useUserStore()
const configStore = useConfigStore()
// 游戏服务状态
const gameStatus = ref({
connected: true,
serverUrl: 'http://localhost:8080/game-api',
lastCheckTime: new Date(),
apiCallCount: 1234,
avgResponseTime: 56,
responseSuccessRate: 98
})
// API调用测试表单
const apiTestForm = reactive({
path: '',
params: ''
})
// API响应数据类型定义
interface ApiResponse {
status: number
data: any
headers: Record<string, string>
requestInfo: {
path: string
method: string
params: any
timestamp: string
}
}
// API调用响应
const apiResponse = ref<ApiResponse | null>(null)
const activeTab = ref('data')
// API历史记录项类型定义
interface ApiHistoryItem {
timestamp: string
path: string
status: number
responseTime: number
userId: string
params: any
response: any
}
// API调用历史
const apiHistory = ref<ApiHistoryItem[]>([
{
timestamp: '2023-11-20 14:30:25',
path: '/user/info',
status: 200,
responseTime: 45,
userId: '123',
params: { userId: '123' },
response: { success: true, data: { userId: '123', username: 'test' } }
},
{
timestamp: '2023-11-20 14:25:18',
path: '/game/item',
status: 404,
responseTime: 120,
userId: '456',
params: { itemId: '789' },
response: { success: false, message: 'Item not found' }
},
{
timestamp: '2023-11-20 14:20:42',
path: '/user/login',
status: 200,
responseTime: 78,
userId: '',
params: { username: 'player1', password: '123456' },
response: { success: true, token: 'abcdef123456' }
}
])
// 游戏服务配置
const gameConfig = reactive({
serverUrl: 'http://localhost:8080/game-api',
timeout: 5000,
retryCount: 1
})
// 刷新游戏服务状态
const refreshGameStatus = () => {
gameStatus.value.lastCheckTime = new Date()
// 模拟检查游戏服务连接状态
setTimeout(() => {
// 随机模拟连接状态
gameStatus.value.connected = Math.random() > 0.1
if (gameStatus.value.connected) {
ElMessage.success('游戏服务连接正常')
} else {
ElMessage.error('游戏服务连接失败')
}
}, 1000)
}
// 测试API调用
const testApiCall = async () => {
if (!apiTestForm.path) {
ElMessage.warning('请输入API路径')
return
}
try {
let params = {}
if (apiTestForm.params) {
params = JSON.parse(apiTestForm.params)
}
const startTime = Date.now()
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000))
const endTime = Date.now()
const responseTime = endTime - startTime
const response = {
status: Math.random() > 0.2 ? 200 : 500,
data: Math.random() > 0.2 ? { success: true, message: 'API调用成功', data: params } : { success: false, message: 'API调用失败' },
headers: {
'Content-Type': 'application/json',
'Server': 'GameServer/1.0.0',
'X-Response-Time': `${responseTime}ms`
},
requestInfo: {
path: apiTestForm.path,
params: params,
method: 'POST',
timestamp: new Date().toISOString()
}
}
apiResponse.value = response
// 更新历史记录
apiHistory.value.unshift({
timestamp: formatDate(new Date()),
path: apiTestForm.path,
status: response.status,
responseTime: responseTime,
userId: String(userStore.user?.id || ''),
params: params,
response: response.data
})
// 限制历史记录数量
if (apiHistory.value.length > 100) {
apiHistory.value = apiHistory.value.slice(0, 100)
}
// 更新游戏状态统计
gameStatus.value.apiCallCount++
gameStatus.value.avgResponseTime = Math.round(
(gameStatus.value.avgResponseTime * (gameStatus.value.apiCallCount - 1) + responseTime) / gameStatus.value.apiCallCount
)
if (response.status === 200) {
ElMessage.success('API调用成功')
} else {
ElMessage.error('API调用失败')
}
} catch (error) {
console.error('API调用测试失败:', error)
ElMessage.error('API调用测试失败')
}
}
// 清空API测试表单
const clearApiTest = () => {
apiTestForm.path = ''
apiTestForm.params = ''
apiResponse.value = null
}
// 清空历史记录
const clearHistory = () => {
apiHistory.value = []
ElMessage.success('历史记录已清空')
}
// 更新游戏服务配置
const updateGameConfig = async () => {
try {
// 模拟保存配置
await configStore.updateConfig({
key: 'GAME_API_URL',
value: gameConfig.serverUrl
})
await configStore.updateConfig({
key: 'GAME_API_TIMEOUT',
value: gameConfig.timeout.toString()
})
await configStore.updateConfig({
key: 'GAME_API_RETRY_COUNT',
value: gameConfig.retryCount.toString()
})
ElMessage.success('配置更新成功')
gameStatus.value.serverUrl = gameConfig.serverUrl
refreshGameStatus()
} catch (error) {
console.error('配置更新失败:', error)
ElMessage.error('配置更新失败')
}
}
// 格式化日期
const formatDate = (date: Date): string => {
if (!date) return ''
return new Date(date).toLocaleString('zh-CN')
}
// 格式化JSON
const formatJson = (data: any): string => {
try {
if (typeof data === 'string') {
return JSON.stringify(JSON.parse(data), null, 2)
}
return JSON.stringify(data, null, 2)
} catch (e) {
return String(data)
}
}
// 截断文本
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
// 页面加载时检查登录状态
onMounted(async () => {
// 检查登录状态
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
// 检查管理员权限
if (!userStore.isAdmin) {
ElMessage.error('您没有管理员权限')
router.push('/')
return
}
// 加载游戏服务配置
try {
// 直接从configStore中获取所有配置避免多次API调用
await configStore.getAllConfigs()
// 从configStore的configs对象中获取具体配置值
const serverUrl = configStore.configs.GAME_API_URL
if (serverUrl) {
gameStatus.value.serverUrl = serverUrl
gameConfig.serverUrl = serverUrl
}
const timeout = configStore.configs.GAME_API_TIMEOUT
if (timeout) {
gameConfig.timeout = parseInt(timeout)
}
const retryCount = configStore.configs.GAME_API_RETRY_COUNT
if (retryCount) {
gameConfig.retryCount = parseInt(retryCount)
}
} catch (error) {
console.error('加载游戏服务配置失败:', error)
}
// 刷新游戏服务状态
refreshGameStatus()
})
</script>
<style scoped>
.admin-container {
height: 100vh;
background-color: #f5f7fa;
}
.admin-main {
padding: 20px;
background-color: #f5f7fa;
overflow-y: auto;
}
.page-container {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.page-title {
margin-bottom: 20px;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.config-section {
margin-bottom: 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding: 15px 0;
border-bottom: 1px solid #ebeef5;
}
.section-header h3 {
margin: 0;
color: #303133;
font-size: 16px;
font-weight: 600;
}
.status-grid {
display: flex;
justify-content: space-between;
align-items: center;
gap: 40px;
}
.status-info {
flex: 1;
}
.success-rate {
min-width: 200px;
}
.rate-chart {
text-align: center;
}
.rate-label {
margin-top: 10px;
color: #909399;
font-size: 14px;
}
.form-content {
padding: 20px 0;
}
.form-content :deep(.el-form-item) {
margin-bottom: 24px;
}
.table-content {
padding: 10px 0;
}
.api-response {
margin-top: 20px;
padding: 20px;
background-color: #f5f7fa;
border-radius: 6px;
}
.api-response h4 {
margin: 0 0 15px 0;
color: #303133;
font-size: 16px;
font-weight: 600;
}
.api-response pre {
background-color: #fff;
padding: 15px;
border-radius: 4px;
border: 1px solid #e4e7ed;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
margin: 0;
}
.param-text,
.response-text {
color: #606266;
font-size: 13px;
cursor: pointer;
}
.param-text:hover,
.response-text:hover {
color: #409eff;
}
@media (max-width: 768px) {
.admin-main {
padding: 15px;
}
.section-header {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.status-grid {
flex-direction: column;
gap: 20px;
}
.success-rate {
min-width: auto;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<el-container class="admin-container">
<!-- 侧边栏 -->
<AdminSidebar />
<!-- 主内容区域 -->
<el-container direction="vertical">
<!-- 顶部导航栏 -->
<AdminHeader />
<!-- 标签式导航组件 -->
<TabNav />
<!-- 主要内容 -->
<el-main class="admin-main">
<el-card class="welcome-card">
<template #header>
<div class="card-header">
<span>工作台</span>
<el-tag type="success">管理员</el-tag>
</div>
</template>
<div class="welcome-content">
<h3>欢迎回来{{ userStore.userInfo?.username || '管理员' }}</h3>
<p class="welcome-desc">这里是运营管理系统后台您可以管理用户配置系统和监控游戏服务</p>
<el-row :gutter="20" class="quick-stats">
<el-col :span="6">
<el-statistic title="在线用户" :value="128" />
</el-col>
<el-col :span="6">
<el-statistic title="今日登录" :value="89" />
</el-col>
<el-col :span="6">
<el-statistic title="系统配置" :value="12" />
</el-col>
<el-col :span="6">
<el-statistic title="服务状态" value="正常" />
</el-col>
</el-row>
</div>
</el-card>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { useUserStore } from '@/store/user'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
import TabNav from '@/components/TabNav.vue'
const userStore = useUserStore()
</script>
<style scoped>
.admin-container {
height: 100vh;
background-color: #f5f7fa;
}
.admin-header {
background-color: #ffffff;
border-bottom: 1px solid #e4e7ed;
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
padding: 0 24px;
}
.header-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.user-info {
display: flex;
align-items: center;
}
.user-profile {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: background-color 0.3s;
}
.user-profile:hover {
background-color: #f5f7fa;
}
.username {
margin: 0 8px;
font-size: 14px;
color: #606266;
}
.admin-main {
padding: 20px;
background-color: #f5f7fa;
}
.welcome-card {
max-width: 1200px;
margin: 0 auto;
border-radius: 8px;
border: none;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.welcome-content h3 {
margin: 0 0 16px 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.welcome-desc {
margin: 0 0 24px 0;
font-size: 14px;
color: #606266;
line-height: 1.6;
}
.quick-stats {
margin-top: 24px;
}
:deep(.el-statistic) {
text-align: center;
}
:deep(.el-statistic__head) {
margin-bottom: 8px;
color: #909399;
font-size: 14px;
}
:deep(.el-statistic__content) {
font-size: 24px;
font-weight: 600;
color: #303133;
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="login-container">
<div class="login-form">
<div class="login-header">
<h2>运营管理系统</h2>
<p>欢迎登录游戏运营平台后台</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-position="top"
>
<el-form-item label="管理员账号" prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入管理员账号"
size="large"
:prefix-icon="RiAdminLine"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入管理员密码"
show-password
size="large"
:prefix-icon="RiLockLine"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
@click="handleLogin"
size="large"
block
>
登录
</el-button>
</el-form-item>
<div class="login-footer">
<el-link type="primary" :underline="false">忘记密码</el-link>
<el-link type="primary" :underline="false">联系管理员</el-link>
</div>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import type { LoginForm } from '@/types/auth'
// 导入Remix Icon组件
import { RiAdminLine, RiLockLine } from '@remixicon/vue'
const router = useRouter()
const userStore = useUserStore()
// 登录表单引用
const loginFormRef = ref()
// 加载状态
const loading = ref(false)
// 登录表单
const loginForm = reactive<LoginForm>({
username: '',
password: ''
})
// 表单验证规则
const loginRules = {
username: [
{ required: true, message: '请输入管理员账号', trigger: 'blur' },
{ min: 3, max: 20, message: '账号长度在 3 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
]
}
// 处理登录
const handleLogin = async () => {
// 表单验证
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
loading.value = true
// 调用userStore的登录方法
await userStore.login(loginForm)
// 检查是否为管理员
if (!userStore.isAdmin) {
throw new Error('您不是管理员,无法登录运营管理系统')
}
ElMessage.success('登录成功')
// 跳转到管理员首页
router.push('/admin')
} catch (error: any) {
ElMessage.error(error?.message || '登录失败,请稍后重试')
console.error('登录失败:', error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #1890ff 0%, #52c41a 100%);
padding: 20px;
}
.login-form {
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.login-header p {
font-size: 14px;
color: #666;
}
.login-footer {
display: flex;
justify-content: space-between;
margin-top: 20px;
}
/* 输入框图标样式已通过Element Plus的prefix-icon属性自动处理 */
</style>

View File

@@ -0,0 +1,891 @@
<template>
<el-container class="admin-container">
<!-- 侧边栏 -->
<AdminSidebar />
<!-- 主内容区域 -->
<el-container direction="vertical">
<!-- 顶部导航栏 -->
<AdminHeader />
<!-- 标签式导航组件 -->
<TabNav />
<!-- 主要内容 -->
<el-main class="admin-main">
<div class="page-container">
<h2 class="page-title">玩家列表</h2>
<!-- 搜索筛选区 -->
<div class="search-container">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="玩家ID">
<el-input v-model="searchForm.playerId" placeholder="请输入玩家ID" clearable />
</el-form-item>
<el-form-item label="玩家昵称">
<el-input v-model="searchForm.nickname" placeholder="请输入玩家昵称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="在线" value="ONLINE" />
<el-option label="离线" value="OFFLINE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 玩家列表区 -->
<div class="list-container">
<div class="list-header">
<div class="header-left">
<el-button type="primary" @click="handleAddPlayer">新增玩家</el-button>
<el-button @click="handleExport">导出</el-button>
<el-button @click="handleImport">导入</el-button>
<el-button type="danger" @click="handleBatchDelete" :disabled="selectedPlayerIds.length === 0">批量删除</el-button>
</div>
</div>
<!-- 玩家表格 -->
<el-table
v-loading="loading"
:data="playerList"
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="120" align="center" />
<el-table-column prop="nickname" label="账号名称" />
<el-table-column prop="level" label="等级" width="80" align="center" />
<el-table-column prop="vipLevel" label="VIP等级" width="100" align="center" />
<el-table-column prop="lastLoginTime" label="最后登录时间" width="180" />
<el-table-column prop="status" label="状态" width="120" align="center">
<template #default="scope">
<el-tag
:type="scope.row.status === 'ONLINE' ? 'success' : 'info'"
>
{{ scope.row.status === 'ONLINE' ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" align="center">
<template #default="scope">
<el-button type="primary" size="small" @click="handleQueryRoles(scope.row)">查询角色</el-button>
<el-button type="primary" size="small" @click="handleEditPlayer(scope.row)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDeletePlayer(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 玩家表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
>
<el-form
ref="playerFormRef"
:model="playerForm"
:rules="formRules"
label-width="80px"
>
<el-form-item label="ID" prop="id">
<el-input v-model="playerForm.id" placeholder="请输入ID" :disabled="!isAdd" />
</el-form-item>
<el-form-item label="账号名称" prop="nickname">
<el-input v-model="playerForm.nickname" placeholder="请输入账号名称" />
</el-form-item>
<el-form-item label="等级" prop="level">
<el-input-number v-model="playerForm.level" :min="1" :max="100" />
</el-form-item>
<el-form-item label="VIP等级" prop="vipLevel">
<el-input-number v-model="playerForm.vipLevel" :min="0" :max="10" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="playerForm.status"
active-value="ONLINE"
inactive-value="OFFLINE"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSavePlayer">保存</el-button>
</span>
</template>
</el-dialog>
<!-- 角色查询弹窗 -->
<el-dialog
v-model="rolesDialogVisible"
:title="rolesDialogTitle"
width="800px"
destroy-on-close
@close="handleRolesDialogClose"
>
<el-collapse v-if="!rolesLoading">
<el-collapse-item
v-for="character in charactersList"
:key="character.id"
:title="`${character.id}-${character.extra_data.name || character.extra_data.名称 || '未知名称'}`"
>
<!-- 添加滚动条限制高度 -->
<div style="max-height: 500px; overflow-y: auto; padding-right: 10px;">
<el-descriptions :column="2" border>
<!-- 基本信息 -->
<el-descriptions-item label="账号ID">{{ character.uid }}</el-descriptions-item>
<el-descriptions-item label="角色ID">{{ character.id }}</el-descriptions-item>
<el-descriptions-item label="角色名称">{{ character.extra_data.name || character.extra_data.名称 || '未知名称' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ character.extra_data.gender || character.extra_data.性别 || '未知' }}</el-descriptions-item>
<el-descriptions-item label="种族">{{ character.extra_data.race || character.extra_data.种族 || '未知' }}</el-descriptions-item>
<el-descriptions-item label="门派">{{ character.extra_data.faction || character.extra_data.school || character.extra_data.门派 || '无门派' }}</el-descriptions-item>
<el-descriptions-item label="等级">{{ character.extra_data.level || character.extra_data.等级 || 0 }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ character.status }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ character.created_at }}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{ character.last_login }}</el-descriptions-item>
<!-- 基本属性 -->
<el-descriptions-item label="气血" :span="2">
{{ character.extra_data.max_qi || character.extra_data.max_health || character.extra_data.最大气血 || 0 }}/{{ character.extra_data.qi || character.extra_data.health || character.extra_data.气血 || 0 }}
</el-descriptions-item>
<el-descriptions-item label="魔法" :span="2">
{{ character.extra_data.max_magic || character.extra_data.最大魔法 || 0 }}/{{ character.extra_data.magic || character.extra_data.魔法 || 0 }}
</el-descriptions-item>
<el-descriptions-item label="伤害">{{ character.extra_data.damage || character.extra_data.伤害 || 0 }}</el-descriptions-item>
<el-descriptions-item label="防御">{{ character.extra_data.defense || character.extra_data.防御 || 0 }}</el-descriptions-item>
<el-descriptions-item label="命中">{{ character.extra_data.hit || character.extra_data.accuracy || character.extra_data.命中 || 0 }}</el-descriptions-item>
<el-descriptions-item label="速度">{{ character.extra_data.speed || character.extra_data.速度 || 0 }}</el-descriptions-item>
<el-descriptions-item label="灵力">{{ character.extra_data.spirit || character.extra_data.intelligence || character.extra_data.灵力 || 0 }}</el-descriptions-item>
<el-descriptions-item label="躲避">{{ character.extra_data.dodge || character.extra_data.躲避 || 0 }}</el-descriptions-item>
</el-descriptions>
<!-- 详细属性折叠面板 -->
<el-collapse>
<!-- 修炼信息 -->
<el-collapse-item title="修炼信息" v-if="character.extra_data.cultivation || character.extra_data.cultivation_info || character.extra_data.修炼">
<el-descriptions :column="3" border>
<!-- 根据API返回的实际字段名调整 -->
<el-descriptions-item label="攻击修炼">
{{ formatCultivation((character.extra_data.cultivation || {}).attack_cultivation || (character.extra_data.cultivation_info || {}).attack_cultivation || (character.extra_data.cultivation || {}).attack || (character.extra_data.修炼 || {}).attack_cultivation || (character.extra_data.修炼 || {}).攻击修炼 || []) }}
</el-descriptions-item>
<el-descriptions-item label="防御修炼">
{{ formatCultivation((character.extra_data.cultivation || {}).defense_cultivation || (character.extra_data.cultivation_info || {}).defense_cultivation || (character.extra_data.cultivation || {}).defense || (character.extra_data.修炼 || {}).defense_cultivation || (character.extra_data.修炼 || {}).防御修炼 || []) }}
</el-descriptions-item>
<el-descriptions-item label="法术修炼">
{{ formatCultivation((character.extra_data.cultivation || {}).spell_cultivation || (character.extra_data.cultivation_info || {}).spell_cultivation || (character.extra_data.cultivation || {}).magic || (character.extra_data.修炼 || {}).spell_cultivation || (character.extra_data.修炼 || {}).法术修炼 || []) }}
</el-descriptions-item>
<el-descriptions-item label="抗法修炼">
{{ formatCultivation((character.extra_data.cultivation || {}).resist_spell_cultivation || (character.extra_data.cultivation_info || {}).resist_spell_cultivation || (character.extra_data.cultivation || {}).resist_magic || (character.extra_data.修炼 || {}).resist_spell_cultivation || (character.extra_data.修炼 || {}).抗法修炼 || []) }}
</el-descriptions-item>
<el-descriptions-item label="猎术修炼">
{{ formatCultivation((character.extra_data.cultivation || {}).hunting_cultivation || (character.extra_data.cultivation_info || {}).hunting_cultivation || (character.extra_data.cultivation || {}).hunting || (character.extra_data.修炼 || {}).hunting_cultivation || (character.extra_data.修炼 || {}).猎术修炼 || []) }}
</el-descriptions-item>
<el-descriptions-item label="抗物理修炼">
{{ formatCultivation((character.extra_data.cultivation || {}).resist_physical_cultivation || (character.extra_data.cultivation_info || {}).resist_physical_cultivation || (character.extra_data.cultivation || {}).resist_physical || (character.extra_data.修炼 || {}).resist_physical_cultivation || (character.extra_data.修炼 || {}).抗物理修炼 || []) }}
</el-descriptions-item>
</el-descriptions>
</el-collapse-item>
<!-- 技能信息 -->
<el-collapse-item title="技能信息" v-if="character.extra_data.skills || character.extra_data.skill_list || character.extra_data.技能">
<el-descriptions :column="3" border>
<el-descriptions-item
v-for="(skill, key) in character.extra_data.skills || character.extra_data.skill_list || character.extra_data.技能"
:key="key"
:label="skill.name || skill.名称 || key"
>
{{ skill.level || skill.等级 || 0 }}
</el-descriptions-item>
</el-descriptions>
</el-collapse-item>
<!-- 门派信息 -->
<el-collapse-item title="门派信息" v-if="character.extra_data.faction || character.extra_data.school || character.extra_data.门派">
<el-descriptions :column="2" border>
<el-descriptions-item label="门派">{{ character.extra_data.faction || character.extra_data.school || character.extra_data.门派 || '无门派' }}</el-descriptions-item>
<el-descriptions-item label="当前称谓">{{ character.extra_data.current_title || character.extra_data.当前称谓 || '' }}</el-descriptions-item>
<el-descriptions-item label="门贡" :span="2">{{ character.extra_data.faction_contribution || character.extra_data.school_contribution || character.extra_data.门贡 || 0 }}</el-descriptions-item>
</el-descriptions>
</el-collapse-item>
</el-collapse>
</div>
</el-collapse-item>
</el-collapse>
<!-- 加载状态 -->
<el-skeleton :rows="5" animated v-else />
<!-- 无数据提示 -->
<div v-if="!rolesLoading && charactersList.length === 0" class="no-data">
<el-empty description="暂无角色数据" />
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="rolesDialogVisible = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
import TabNav from '@/components/TabNav.vue'
import { callGameApiThroughBackend } from '@/api/game'
// 搜索表单
const searchForm = reactive({
playerId: '',
nickname: '',
status: undefined
})
// 玩家列表(模拟数据)
const playerList = ref([])
const loading = ref(false)
// 分页
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 选中的玩家ID
const selectedPlayerIds = ref<number[]>([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('新增玩家')
const isAdd = ref(true)
// 玩家表单
const playerFormRef = ref()
const playerForm = reactive({
id: '',
nickname: '',
level: 1,
vipLevel: 0,
status: 'ONLINE' as const,
lastLoginTime: ''
})
// 表单验证规则
const formRules = reactive({
id: [
{ required: true, message: '请输入玩家ID', trigger: 'blur' },
{ min: 1, max: 20, message: '玩家ID长度在 1 到 20 个字符', trigger: 'blur' }
],
nickname: [
{ required: true, message: '请输入玩家昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '玩家昵称长度在 2 到 20 个字符', trigger: 'blur' }
],
level: [
{ required: true, message: '请输入等级', trigger: 'blur' }
]
})
// 角色查询相关
const rolesDialogVisible = ref(false) // 角色弹窗可见性
const rolesDialogTitle = ref('') // 角色弹窗标题
const charactersList = ref<any[]>([]) // 角色列表数据
const rolesLoading = ref(false) // 角色数据加载状态
const currentPlayer = ref<any>(null) // 当前查询的玩家信息
// 初始化
onMounted(() => {
loadPlayers()
})
// 加载玩家列表
const loadPlayers = async () => {
loading.value = true
try {
// 通过后端转发调用真实API获取玩家列表运营管理系统专用
const response = await callGameApiThroughBackend('account/get_account_list', {})
// 处理后端返回的数据结构响应拦截器已处理response 即为 response.data
const data = response as any
if (data && data.success && data.data) {
// 检查游戏服务端返回的数据结构
if (data.data.success && data.data.data) {
const userList = data.data.data.user_list || []
// 适配API返回的数据结构
const players = userList.map((user: any) => ({
id: user.id,
nickname: user.username,
level: 1, // API未返回等级信息默认为1
vipLevel: 0, // API未返回VIP等级信息默认为0
lastLoginTime: user.last_login || '未知',
status: 'OFFLINE' // API未返回在线状态默认为离线
}))
// 实现前端分页
const startIndex = (pagination.currentPage - 1) * pagination.pageSize
const endIndex = startIndex + pagination.pageSize
playerList.value = players.slice(startIndex, endIndex)
pagination.total = players.length
} else {
// 游戏服务端返回错误
const errorMsg = data.data.message || '游戏服务端返回数据格式错误'
console.error('获取玩家列表失败:', errorMsg)
ElMessage.error(errorMsg)
}
} else {
// 后端API调用失败
const errorMsg = data.message || '游戏服务端请求失败,请稍后重试'
console.error('获取玩家列表失败:', errorMsg)
ElMessage.error(errorMsg)
}
} catch (error: any) {
console.error('加载玩家列表失败:', error)
const errorMsg = error.message || '游戏服务端请求失败,请稍后重试'
ElMessage.error(errorMsg)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
// 模拟搜索功能
pagination.currentPage = 1
ElMessage.info('搜索功能开发中')
}
// 重置
const handleReset = () => {
searchForm.playerId = ''
searchForm.nickname = ''
searchForm.status = undefined
pagination.currentPage = 1
loadPlayers()
}
// 分页大小变化
const handleSizeChange = (size: number) => {
pagination.pageSize = size
loadPlayers()
}
// 当前页变化
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
loadPlayers()
}
// 选择变化
const handleSelectionChange = (selection: any[]) => {
selectedPlayerIds.value = selection.map(item => item.id)
}
// 新增玩家
const handleAddPlayer = () => {
dialogTitle.value = '新增玩家'
isAdd.value = true
resetForm()
dialogVisible.value = true
}
// 编辑玩家
const handleEditPlayer = (row: any) => {
dialogTitle.value = '编辑玩家'
isAdd.value = false
// 复制玩家信息到表单
Object.assign(playerForm, {
...row
})
dialogVisible.value = true
}
// 重置表单
const resetForm = () => {
if (playerFormRef.value) {
playerFormRef.value.resetFields()
}
Object.assign(playerForm, {
id: '',
nickname: '',
level: 1,
vipLevel: 0,
status: 'ONLINE',
lastLoginTime: ''
})
}
// 保存玩家
const handleSavePlayer = async () => {
if (!playerFormRef.value) return
try {
await playerFormRef.value.validate()
if (isAdd.value) {
ElMessage.info('新增玩家功能开发中')
} else {
ElMessage.info('更新玩家功能开发中')
dialogVisible.value = false
loadPlayers()
}
} catch (error) {
if (error !== false) {
console.error('保存玩家失败:', error)
ElMessage.error('保存玩家失败')
}
}
}
// 删除玩家
const handleDeletePlayer = (id: string) => {
ElMessageBox.confirm(`确定要删除ID为${id}的玩家吗?`, '提示', {
type: 'warning'
}).then(() => {
ElMessage.success('删除玩家成功')
loadPlayers()
}).catch(() => {
// 取消删除
})
}
// 批量删除玩家
const handleBatchDelete = async () => {
if (selectedPlayerIds.value.length === 0) {
ElMessage.warning('请选择要删除的玩家')
return
}
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedPlayerIds.value.length} 个玩家吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('批量删除成功')
loadPlayers()
selectedPlayerIds.value = []
} catch (error: any) {
if (error !== 'cancel') {
console.error('批量删除失败:', error)
ElMessage.error('批量删除失败')
}
}
}
// 导出玩家
const handleExport = () => {
ElMessage.info('导出功能开发中')
}
// 导入玩家
const handleImport = () => {
ElMessage.info('导入功能开发中')
}
// 格式化修炼数据
const formatCultivation = (cultivation: any) => {
if (!Array.isArray(cultivation) || cultivation.length === 0) {
return '0'
}
return cultivation[0] || '0'
}
// 角色弹窗关闭时的处理
const handleRolesDialogClose = () => {
// 重置角色列表数据
charactersList.value = []
// 重置当前查询的玩家信息
currentPlayer.value = null
// 重置弹窗标题
rolesDialogTitle.value = ''
}
// 查询角色
const handleQueryRoles = async (row: any) => {
if (!row || !row.id) {
ElMessage.error('无效的玩家信息')
return
}
// 保存当前查询的玩家信息
currentPlayer.value = row
// 显示加载状态
rolesLoading.value = true
try {
// 调用API获取角色数据
const response = await callGameApiThroughBackend('characters/get_characters', {
code: 'characters/get_characters',
uid: row.id
})
// 处理后端返回的数据结构响应拦截器已处理response 即为 response.data
const data = response as any
if (data && data.success && data.data) {
// 检查游戏服务端返回的数据结构
if (data.data.success && data.data.data) {
const charactersData = data.data.data
const characters = charactersData.characters_list || []
// 更新角色列表数据
charactersList.value = characters
// 设置弹窗标题
rolesDialogTitle.value = `${row.nickname}玩家的角色数据(共${characters.length}个角色)`
// 显示弹窗
rolesDialogVisible.value = true
} else {
// 游戏服务端返回错误
const errorMsg = data.data.message || '获取角色数据失败'
console.error('获取角色数据失败:', errorMsg)
ElMessage.error(errorMsg)
}
} else {
// 后端API调用失败
const errorMsg = data.message || '游戏服务端请求失败,请稍后重试'
console.error('获取角色数据失败:', errorMsg)
ElMessage.error(errorMsg)
}
} catch (error: any) {
console.error('获取角色数据失败:', error)
const errorMsg = error.message || '获取角色数据失败,请稍后重试'
ElMessage.error(errorMsg)
} finally {
// 隐藏加载状态
rolesLoading.value = false
}
}
</script>
<style scoped>
.admin-container {
height: 100vh;
background-color: #f5f7fa;
}
.admin-header {
background-color: #ffffff;
border-bottom: 1px solid #e4e7ed;
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
padding: 0 24px;
}
.header-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.user-info {
display: flex;
align-items: center;
}
.user-profile {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: background-color 0.3s;
}
.user-profile:hover {
background-color: #f5f7fa;
}
.username {
margin: 0 8px;
font-size: 14px;
color: #606266;
}
.admin-main {
padding: 20px;
background-color: #f5f7fa;
}
.page-container {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
h2 {
margin-bottom: 20px;
color: #303133;
}
/* 搜索筛选区样式 */
.search-container {
margin-bottom: 20px;
padding: 20px 15px;
background-color: #f0f9ff;
border-radius: 8px;
box-shadow: none;
display: flex;
align-items: center;
}
.search-form {
display: flex;
align-items: center;
width: 100%;
margin: 0;
}
/* 调整表单元素的默认样式,确保垂直居中 */
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 20px;
}
:deep(.el-form-item__content) {
margin-top: 0;
display: flex;
align-items: center;
}
/* 玩家列表区样式 */
.list-container {
background-color: #fff;
border-radius: 8px;
padding: 15px;
border: 1px solid #ebeef5;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.header-left {
display: flex;
gap: 10px;
}
/* 表格样式 */
:deep(.el-table) {
margin-bottom: 0;
border: none;
}
/* 移除表格所有默认边框 */
:deep(.el-table__inner-wrapper) {
border: none;
}
:deep(.el-table__body-wrapper) {
border: none;
}
:deep(.el-table)::before {
display: none;
}
:deep(.el-table)::after {
display: none;
}
:deep(.el-table__header-wrapper) {
border: none;
}
:deep(.el-table__header) {
border: none;
}
:deep(.el-table__body) {
border: none;
}
:deep(.el-table__footer-wrapper) {
border: none;
}
:deep(.el-table__footer) {
border: none;
}
:deep(.el-table__wrapper) {
border: none;
box-shadow: none;
}
:deep(.el-table__header th) {
border-bottom: none;
}
:deep(.el-table__body td) {
border-bottom: none;
}
:deep(.el-table__row) {
border-bottom: none;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 0;
padding-top: 15px;
border-top: 1px solid #ebeef5;
}
/* 状态标签样式 */
:deep(.el-tag) {
box-shadow: none;
}
/* 对话框样式 */
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 角色弹窗样式 */
:deep(.el-collapse) {
margin-bottom: 10px;
width: 100%;
box-sizing: border-box;
}
:deep(.el-collapse-item__header) {
font-weight: bold;
background-color: #f5f7fa;
padding: 12px 20px !important; /* 增加内边距 */
box-sizing: border-box;
word-break: break-word; /* 允许标题文字换行 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.el-collapse-item__content) {
padding: 10px 0 !important;
box-sizing: border-box;
}
/* 无数据提示样式 */
.no-data {
padding: 20px 0;
text-align: center;
}
/* 描述列表样式 */
:deep(.el-descriptions) {
width: 100%;
box-sizing: border-box;
}
:deep(.el-descriptions__table) {
width: 100%;
table-layout: fixed; /* 固定表格布局,防止列宽溢出 */
}
:deep(.el-descriptions__item) {
padding: 8px 12px !important;
box-sizing: border-box;
}
:deep(.el-descriptions__item-label) {
background-color: #fafafa;
font-weight: bold;
width: 120px; /* 固定标签宽度 */
word-break: keep-all;
text-align: left !important;
}
:deep(.el-descriptions__item-content) {
word-break: break-word; /* 允许内容换行 */
overflow-wrap: break-word;
width: calc(100% - 120px); /* 计算内容宽度 */
}
/* 修复折叠面板内部嵌套的折叠面板样式 */
:deep(.el-collapse .el-collapse) {
margin: 10px 0 0 0;
}
:deep(.el-collapse .el-collapse-item) {
margin-bottom: 8px; /* 增加折叠面板之间的间距 */
border: 1px solid #ebeef5;
border-radius: 4px;
overflow: hidden;
}
:deep(.el-collapse .el-collapse-item:last-child) {
margin-bottom: 0;
}
:deep(.el-collapse .el-collapse-item__header) {
padding: 10px 16px !important;
background-color: #f0f2f5;
font-size: 14px;
border-bottom: none;
}
:deep(.el-collapse .el-collapse-item__content) {
padding: 10px 0 !important;
background-color: #fff;
}
/* 修复内部描述列表样式 */
:deep(.el-collapse .el-descriptions) {
margin-top: 8px;
}
:deep(.el-collapse .el-descriptions__item) {
padding: 6px 10px !important;
}
:deep(.el-collapse .el-descriptions__item-label) {
width: 100px;
font-size: 13px;
}
:deep(.el-collapse .el-descriptions__item-content) {
font-size: 13px;
width: calc(100% - 100px);
}
</style>

View File

@@ -0,0 +1,404 @@
<template>
<el-container class="admin-container">
<!-- 侧边栏 -->
<AdminSidebar />
<!-- 主内容区 -->
<el-container direction="vertical">
<!-- 顶部导航栏 -->
<AdminHeader />
<!-- 标签式导航组件 -->
<TabNav />
<!-- 主内容区域 -->
<el-main class="admin-main">
<div class="page-container">
<!-- 页面标题 -->
<h2 class="page-title">系统配置</h2>
<!-- 配置表单 - Tabs标签页形式 -->
<div class="config-form-container">
<el-tabs v-model="activeTab" type="card">
<!-- 基础设置 -->
<el-tab-pane label="基础设置" name="basic">
<el-form :model="configForm" label-width="180px">
<el-form-item>
<template #label>
<span class="label-with-tooltip">
网站名称
<el-tooltip content="网站的对外显示名称,将出现在页面标题和页脚中" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.website_name" placeholder="请输入网站名称" style="width: 100%" />
</el-form-item>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
运营后台域名
<el-tooltip content="运营后台的访问域名,用于生成管理链接" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.admin_domain" placeholder="请输入运营后台域名" style="width: 100%" />
</el-form-item>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
玩家中心域名
<el-tooltip content="玩家中心的访问域名,用于生成玩家链接" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.player_domain" placeholder="请输入玩家中心域名" style="width: 100%" />
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 安全设置 -->
<el-tab-pane label="安全设置" name="security">
<el-form :model="configForm" label-width="180px">
<!-- 后端服务器配置 -->
<el-divider content-position="left">后端服务器配置</el-divider>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
服务器地址
<el-tooltip content="请填写本系统后端的服务器地址IP、域名均可" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.server_host" placeholder="请输入服务器IP或域名" style="width: 100%" />
</el-form-item>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
服务器端口
<el-tooltip content="请填写本系统后端的运行端口" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.server_port" placeholder="请输入服务器端口" style="width: 100%" />
</el-form-item>
<!-- 游戏服务API配置 -->
<el-divider content-position="left">游戏服务API配置</el-divider>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
游戏服务API地址
<el-tooltip content="请填写游戏服务端的API接口地址" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.game_api_url" placeholder="请输入游戏服务API接口地址" style="width: 100%" />
</el-form-item>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
游戏服务端PSK
<el-tooltip content="游戏服务端的PSK密钥用于API调用的身份验证" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.game_psk" placeholder="请输入游戏服务端PSK密钥" type="password" show-password style="width: 100%" />
</el-form-item>
<!-- JWT配置 -->
<el-divider content-position="left">JWT配置</el-divider>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
JWT密钥
<el-tooltip content="用于生成和验证JWT令牌的密钥建议使用32位随机字符串" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.jwt_secret" placeholder="请输入JWT密钥" type="password" show-password style="width: 100%" />
</el-form-item>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
JWT过期时间
<el-tooltip content="JWT令牌的有效期单位为秒默认24小时(86400秒)" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.jwt_expires_in" placeholder="请输入JWT过期时间" style="width: 100%" />
</el-form-item>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
JWT刷新令牌密钥
<el-tooltip content="用于生成和验证刷新令牌的密钥建议与JWT密钥不同" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.jwt_refresh_secret" placeholder="请输入JWT刷新令牌密钥" type="password" show-password style="width: 100%" />
</el-form-item>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
JWT刷新令牌过期时间
<el-tooltip content="刷新令牌的有效期单位为秒建议设置为7天(604800秒)" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-input v-model="configForm.jwt_refresh_expires_in" placeholder="请输入JWT刷新令牌过期时间" style="width: 100%" />
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 服务设置 -->
<el-tab-pane label="服务设置" name="service">
<el-form :model="configForm" label-width="180px">
<el-form-item>
<template #label>
<span class="label-with-tooltip">
服务运行状态
<el-tooltip content="当前系统服务的运行状态,可设置为运行中或维护中" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-select v-model="configForm.service_status" placeholder="请选择服务状态">
<el-option label="运行中" value="running" />
<el-option label="维护中" value="maintenance" />
</el-select>
</el-form-item>
<el-form-item>
<template #label>
<span class="label-with-tooltip">
维护模式开关
<el-tooltip content="开启后系统将进入维护模式,只允许管理员访问" placement="top">
<RiInformationFill class="info-icon" />
</el-tooltip>
</span>
</template>
<el-switch v-model="configForm.maintenance_mode" />
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<!-- 保存按钮 -->
<div class="form-footer">
<el-button type="primary" @click="handleSaveConfig" size="large">
保存配置
</el-button>
</div>
</div>
</div>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import TabNav from '@/components/TabNav.vue'
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useConfigStore } from '@/store/config'
import { useUserStore } from '@/store/user'
import { RiInformationFill } from '@remixicon/vue'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
const router = useRouter()
const configStore = useConfigStore()
const userStore = useUserStore()
// 当前激活的标签页
const activeTab = ref('basic')
// 配置表单数据
const configForm = reactive({
// 基础设置
website_name: '',
admin_domain: '',
player_domain: '',
// 安全设置
server_host: '',
server_port: '',
game_api_url: '',
game_psk: '',
jwt_secret: '',
jwt_expires_in: '',
jwt_refresh_secret: '',
jwt_refresh_expires_in: '',
// 服务设置
service_status: 'running',
maintenance_mode: false
})
// 加载系统配置
const loadSystemConfig = async () => {
try {
const response = await configStore.getAllConfigs()
// 将获取到的配置映射到表单中
if (response && response.success && response.data) {
const configData = response.data
Object.assign(configForm, configData)
}
} catch (error) {
console.error('获取系统配置失败:', error)
ElMessage.error('获取系统配置失败,请稍后重试')
}
}
// 保存系统配置
const handleSaveConfig = async () => {
try {
// 遍历配置表单,逐个保存配置项
for (const [key, value] of Object.entries(configForm)) {
await configStore.updateConfig({ key, value })
}
ElMessage.success('配置保存成功')
} catch (error) {
console.error('保存系统配置失败:', error)
ElMessage.error('保存系统配置失败,请稍后重试')
}
}
// 页面加载时检查登录状态
onMounted(async () => {
// 检查登录状态
if (!userStore.isLoggedIn) {
router.push('/admin/login')
return
}
// 检查管理员权限
if (!userStore.isAdmin) {
ElMessage.error('您没有权限访问此页面')
router.push('/admin')
return
}
// 加载系统配置
await loadSystemConfig()
})
</script>
<style scoped>
.admin-container {
height: 100vh;
background-color: #f5f7fa;
}
.admin-main {
padding: 20px;
background-color: #f5f7fa;
}
.page-container {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.page-title {
margin-bottom: 20px;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.config-form-container {
margin-bottom: 20px;
}
.config-form-container :deep(.el-tabs__content) {
padding: 20px 0;
}
.config-form-container :deep(.el-form-item) {
margin-bottom: 24px;
}
.config-form-container :deep(.el-divider--horizontal) {
margin: 20px 0;
}
.help-text {
margin-top: 8px;
color: #909399;
font-size: 12px;
}
.input-with-tooltip {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.input-with-tooltip .el-input {
flex: 1;
width: 100%;
}
.input-with-tooltip .el-input__wrapper {
width: 100% !important;
}
/* 游戏服务API地址输入框专用样式 */
.api-url-input {
width: 100% !important;
}
.api-url-input .el-input {
flex: 1 !important;
width: 100% !important;
}
.api-url-input .el-input__wrapper {
width: 100% !important;
}
.info-icon {
color: #909399;
cursor: help;
font-size: 14px;
margin-left: 6px;
vertical-align: middle;
width: 14px;
height: 14px;
}
.label-with-tooltip {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.form-footer {
margin-top: 30px;
display: flex;
justify-content: flex-end;
padding: 10px 0;
border-top: 1px solid #ebeef5;
}
</style>

View File

@@ -0,0 +1,615 @@
<template>
<el-container class="admin-container">
<!-- 侧边栏 -->
<AdminSidebar />
<!-- 主内容区域 -->
<el-container direction="vertical">
<!-- 顶部导航栏 -->
<AdminHeader />
<!-- 标签式导航组件 -->
<TabNav />
<!-- 主要内容 -->
<el-main class="admin-main">
<div class="page-container">
<h2 class="page-title">用户管理</h2>
<!-- 搜索筛选区 -->
<div class="search-container">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="searchForm.nickname" placeholder="请输入昵称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 用户列表区 -->
<div class="list-container">
<div class="list-header">
<div class="header-left">
<el-button type="primary" @click="handleAddUser">新增用户</el-button>
<el-button @click="handleExport">导出</el-button>
<el-button @click="handleImport">导入</el-button>
<el-button type="danger" @click="handleBatchDelete" :disabled="selectedUserIds.length === 0">批量删除</el-button>
</div>
</div>
<!-- 用户表格 -->
<el-table
v-loading="loading"
:data="userList"
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="nickname" label="昵称" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="roleName" label="角色" />
<el-table-column prop="status" label="状态" width="120" align="center">
<template #default="scope">
<el-tag
:type="scope.row.status === 'ACTIVE' ? 'success' : 'danger'"
>
{{ scope.row.status === 'ACTIVE' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="scope">
<el-button type="primary" size="small" @click="handleEditUser(scope.row)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDeleteUser(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 用户表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
>
<el-form
ref="userFormRef"
:model="userForm"
:rules="formRules"
label-width="80px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="userForm.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="userForm.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="userForm.password" type="password" placeholder="请输入密码" :disabled="!isAdd" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="userForm.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="角色" prop="roleId">
<el-select v-model="userForm.roleId" placeholder="请选择角色">
<el-option
v-for="role in roleList"
:key="role.id"
:label="role.name"
:value="role.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="userForm.status"
active-value="ACTIVE"
inactive-value="INACTIVE"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveUser">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
import TabNav from '@/components/TabNav.vue'
import * as userApi from '@/api/user'
// 搜索表单
const searchForm = reactive({
username: '',
nickname: '',
status: undefined
})
// 用户列表
const userList = ref([])
const loading = ref(false)
// 分页
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 选中的用户ID
const selectedUserIds = ref<number[]>([])
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('新增用户')
const isAdd = ref(true)
// 用户表单
const userFormRef = ref()
const userForm = reactive({
id: '',
username: '',
nickname: '',
password: '',
email: '',
roleId: '',
status: 'ACTIVE' as const
})
// 表单验证规则
const formRules = reactive({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
],
roleId: [
{ required: true, message: '请选择角色', trigger: 'change' }
]
})
// 角色列表(硬编码数据)
const roleList = ref([
{ id: 1, name: '管理员' },
{ id: 2, name: '普通用户' }
])
// 初始化
onMounted(() => {
loadUsers()
loadRoles()
})
// 加载用户列表
const loadUsers = async () => {
loading.value = true
try {
const params = {
page: pagination.currentPage,
pageSize: pagination.pageSize,
...searchForm
}
const response = await userApi.getUserList(params)
// 处理API响应适配后端返回的数据结构
const responseData = response?.data || response
userList.value = responseData?.users || []
pagination.total = responseData?.total || 0
} catch (error) {
console.error('加载用户列表失败:', error)
ElMessage.error('加载用户列表失败')
} finally {
loading.value = false
}
}
// 加载角色列表
const loadRoles = async () => {
// 角色列表暂时使用硬编码数据
// TODO: 实现角色管理API后替换为真实API调用
console.log('角色列表使用硬编码数据')
}
// 搜索
const handleSearch = () => {
pagination.currentPage = 1
loadUsers()
}
// 重置
const handleReset = () => {
searchForm.username = ''
searchForm.nickname = ''
searchForm.status = undefined
pagination.currentPage = 1
loadUsers()
}
// 分页大小变化
const handleSizeChange = (size: number) => {
pagination.pageSize = size
loadUsers()
}
// 当前页变化
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
loadUsers()
}
// 选择变化
const handleSelectionChange = (selection: any[]) => {
selectedUserIds.value = selection.map(item => item.id)
}
// 新增用户
const handleAddUser = () => {
dialogTitle.value = '新增用户'
isAdd.value = true
resetForm()
dialogVisible.value = true
}
// 编辑用户
const handleEditUser = (row: any) => {
dialogTitle.value = '编辑用户'
isAdd.value = false
// 复制用户信息到表单,确保状态字段正确映射
Object.assign(userForm, {
...row,
status: row.status || 'ACTIVE'
})
dialogVisible.value = true
}
// 重置表单
const resetForm = () => {
if (userFormRef.value) {
userFormRef.value.resetFields()
}
Object.assign(userForm, {
id: '',
username: '',
nickname: '',
password: '',
email: '',
roleId: '',
status: 'ACTIVE'
})
}
// 保存用户
const handleSaveUser = async () => {
if (!userFormRef.value) return
try {
await userFormRef.value.validate()
if (isAdd.value) {
// 新增用户功能暂未实现
ElMessage.info('新增用户功能开发中')
} else {
// 更新用户,状态字段已经是正确的格式
await userApi.updateUser(parseInt(userForm.id), userForm)
ElMessage.success('更新用户成功')
dialogVisible.value = false
loadUsers()
}
} catch (error) {
if (error !== false) {
console.error('保存用户失败:', error)
ElMessage.error('保存用户失败')
}
}
}
// 删除用户
const handleDeleteUser = (id: number) => {
ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
type: 'warning'
}).then(async () => {
try {
await userApi.deleteUser(id)
ElMessage.success('删除用户成功')
loadUsers()
} catch (error) {
ElMessage.error('删除用户失败')
}
}).catch(() => {
// 取消删除
})
}
// 批量删除用户
const handleBatchDelete = async () => {
if (selectedUserIds.value.length === 0) {
ElMessage.warning('请选择要删除的用户')
return
}
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedUserIds.value.length} 个用户吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
// 使用批量更新状态API替代批量删除
await userApi.batchUpdateStatus({
ids: selectedUserIds.value,
status: 'deleted'
})
ElMessage.success('批量删除成功')
loadUsers()
selectedUserIds.value = []
} catch (error: any) {
if (error !== 'cancel') {
console.error('批量删除失败:', error)
ElMessage.error('批量删除失败')
}
}
}
// 导出用户
const handleExport = () => {
ElMessage.info('导出功能开发中')
}
// 导入用户
const handleImport = () => {
ElMessage.info('导入功能开发中')
}
</script>
<style scoped>
.admin-container {
height: 100vh;
background-color: #f5f7fa;
}
.admin-header {
background-color: #ffffff;
border-bottom: 1px solid #e4e7ed;
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
padding: 0 24px;
}
.header-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.user-info {
display: flex;
align-items: center;
}
.user-profile {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
transition: background-color 0.3s;
}
.user-profile:hover {
background-color: #f5f7fa;
}
.username {
margin: 0 8px;
font-size: 14px;
color: #606266;
}
.admin-main {
padding: 20px;
background-color: #f5f7fa;
}
.page-container {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
h2 {
margin-bottom: 20px;
color: #303133;
}
/* 搜索筛选区样式 */
.search-container {
margin-bottom: 20px;
padding: 20px 15px;
background-color: #f0f9ff;
border-radius: 8px;
box-shadow: none;
display: flex;
align-items: center;
}
.search-form {
display: flex;
align-items: center;
width: 100%;
margin: 0;
}
/* 调整表单元素的默认样式,确保垂直居中 */
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 20px;
}
:deep(.el-form-item__content) {
margin-top: 0;
display: flex;
align-items: center;
}
/* 用户列表区样式 */
.list-container {
background-color: #fff;
border-radius: 8px;
padding: 15px;
border: 1px solid #ebeef5;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.header-left {
display: flex;
gap: 10px;
}
/* 表格样式 */
:deep(.el-table) {
margin-bottom: 0;
border: none;
}
/* 移除表格所有默认边框 */
:deep(.el-table__inner-wrapper) {
border: none;
}
:deep(.el-table__body-wrapper) {
border: none;
}
:deep(.el-table)::before {
display: none;
}
:deep(.el-table)::after {
display: none;
}
:deep(.el-table__header-wrapper) {
border: none;
}
:deep(.el-table__header) {
border: none;
}
:deep(.el-table__body) {
border: none;
}
:deep(.el-table__footer-wrapper) {
border: none;
}
:deep(.el-table__footer) {
border: none;
}
:deep(.el-table__wrapper) {
border: none;
box-shadow: none;
}
:deep(.el-table__header th) {
border-bottom: none;
}
:deep(.el-table__body td) {
border-bottom: none;
}
:deep(.el-table__row) {
border-bottom: none;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 0;
padding-top: 15px;
border-top: 1px solid #ebeef5;
}
/* 状态标签样式 */
:deep(.el-tag) {
box-shadow: none;
}
/* 对话框样式 */
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,429 @@
<template>
<div class="player-home">
<!-- 顶部导航栏 -->
<el-header>
<div class="header-content">
<div class="header-left">
<h1 class="logo">玩家服务中心</h1>
</div>
<div class="header-right">
<el-dropdown>
<span class="user-info">
<el-avatar size="large">
{{ userInfo.username?.charAt(0) || 'U' }}
</el-avatar>
<span>{{ userInfo.username || '用户' }}</span>
<RiArrowDownSLine />
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<RiUserLine />
个人中心
</el-dropdown-item>
<el-dropdown-item>
<RiSettingsLine />
账号设置
</el-dropdown-item>
<el-dropdown-divider />
<el-dropdown-item @click="handleLogout">
<RiSwitchLine />
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-header>
<!-- 主要内容区域 -->
<el-main>
<div class="dashboard">
<!-- 欢迎信息 -->
<div class="welcome-section">
<h2>欢迎回来{{ userInfo.username || '玩家' }}</h2>
<p>今天是 {{ formatDate(new Date()) }}</p>
</div>
<!-- 数据统计卡片 -->
<div class="stats-cards">
<el-card shadow="hover" class="stats-card">
<div class="stats-content">
<div class="stats-info">
<h3 class="stats-value">123</h3>
<p class="stats-label">游戏时长(小时)</p>
</div>
<div class="stats-icon">
<RiTimerLine style="color: #67c23a; font-size: 40px;" />
</div>
</div>
</el-card>
<el-card shadow="hover" class="stats-card">
<div class="stats-content">
<div class="stats-info">
<h3 class="stats-value">45</h3>
<p class="stats-label">游戏等级</p>
</div>
<div class="stats-icon">
<RiMedalLine style="color: #e6a23c; font-size: 40px;" />
</div>
</div>
</el-card>
<el-card shadow="hover" class="stats-card">
<div class="stats-content">
<div class="stats-info">
<h3 class="stats-value">678</h3>
<p class="stats-label">游戏金币</p>
</div>
<div class="stats-icon">
<RiCoinLine style="color: #f56c6c; font-size: 40px;" />
</div>
</div>
</el-card>
<el-card shadow="hover" class="stats-card">
<div class="stats-content">
<div class="stats-info">
<h3 class="stats-value">90</h3>
<p class="stats-label">成就解锁</p>
</div>
<div class="stats-icon">
<RiStarFill style="color: #909399; font-size: 40px;" />
</div>
</div>
</el-card>
</div>
<!-- 功能模块 -->
<div class="function-modules">
<el-card shadow="hover" class="module-card">
<div class="module-content">
<div class="module-icon">
<RiGamepadLine style="color: #409eff; font-size: 60px;" />
</div>
<h3 class="module-title">游戏信息</h3>
<p class="module-desc">查看游戏状态和更新日志</p>
<el-button type="primary" size="small">进入</el-button>
</div>
</el-card>
<el-card shadow="hover" class="module-card">
<div class="module-content">
<div class="module-icon">
<RiGiftLine style="color: #67c23a; font-size: 60px;" />
</div>
<h3 class="module-title">礼包中心</h3>
<p class="module-desc">领取游戏礼包和活动奖励</p>
<el-button type="primary" size="small">进入</el-button>
</div>
</el-card>
<el-card shadow="hover" class="module-card">
<div class="module-content">
<div class="module-icon">
<RiCoinLine style="color: #e6a23c; font-size: 60px;" />
</div>
<h3 class="module-title">充值中心</h3>
<p class="module-desc">充值游戏金币和会员服务</p>
<el-button type="primary" size="small">进入</el-button>
</div>
</el-card>
<el-card shadow="hover" class="module-card">
<div class="module-content">
<div class="module-icon">
<RiChat3Fill style="color: #f56c6c; font-size: 60px;" />
</div>
<h3 class="module-title">客服中心</h3>
<p class="module-desc">联系客服解决游戏问题</p>
<el-button type="primary" size="small">进入</el-button>
</div>
</el-card>
</div>
<!-- 最近活动 -->
<div class="activities-section">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<h3>最近活动</h3>
<el-button type="text">查看全部</el-button>
</div>
</template>
<el-timeline>
<el-timeline-item>
<template #dot>
<RiCalendarLine style="color: #e6a23c;" />
</template>
<div class="timeline-content">
<h4>国庆活动礼包</h4>
<p>2023-10-01 2023-10-07</p>
<p>参与国庆活动领取丰厚奖励</p>
<el-button type="primary" size="small">立即参与</el-button>
</div>
</el-timeline-item>
<el-timeline-item>
<template #dot>
<RiCalendarLine style="color: #409eff;" />
</template>
<div class="timeline-content">
<h4>版本更新公告</h4>
<p>2023-09-25</p>
<p>游戏版本更新至 v1.2.0新增多种玩法</p>
<el-button type="primary" size="small">查看详情</el-button>
</div>
</el-timeline-item>
<el-timeline-item>
<template #dot>
<RiCalendarLine style="color: #67c23a;" />
</template>
<div class="timeline-content">
<h4>新服开启</h4>
<p>2023-09-20</p>
<p>新服务器龙腾四海正式开启</p>
<el-button type="primary" size="small">进入新服</el-button>
</div>
</el-timeline-item>
</el-timeline>
</el-card>
</div>
</div>
</el-main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { usePlayerStore } from '@/store/player'
import { ElMessage, ElNotification } from 'element-plus'
import type { User } from '@/types/user'
// 导入Remix Icon组件
import {
RiArrowDownSLine,
RiUserLine,
RiSettingsLine,
RiSwitchLine,
RiTimerLine,
RiMedalLine,
RiStarFill,
RiGamepadLine,
RiGiftLine,
RiCoinLine,
RiChat3Fill,
RiCalendarLine
} from '@remixicon/vue'
const router = useRouter()
const playerStore = usePlayerStore()
// 玩家信息
const userInfo = computed(() => playerStore.player as User || { username: '' })
// 格式化日期
const formatDate = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const week = ['日', '一', '二', '三', '四', '五', '六'][date.getDay()]
return `${year}${month}${day}日 星期${week}`
}
// 退出登录
const handleLogout = async () => {
try {
// 调用playerStore的logout方法该方法会调用API并清理本地存储
await playerStore.logout()
ElMessage.success('退出登录成功')
router.push('/player/login')
} catch (error) {
console.error('退出登录失败:', error)
ElMessage.error('退出登录失败,请稍后重试')
}
}
// 页面加载时检查登录状态
onMounted(async () => {
if (!playerStore.isLoggedIn) {
router.push('/player/login')
return
}
// 获取最新的玩家信息
await playerStore.getPlayerInfo()
// 模拟获取用户游戏数据
ElNotification({
title: '欢迎回来',
message: '您有3个未读消息和1个新活动',
type: 'info'
})
})
</script>
<style scoped>
.player-home {
display: flex;
flex-direction: column;
height: 100vh;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
padding: 0 20px;
}
.logo {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0;
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
gap: 8px;
}
.dashboard {
padding: 20px;
}
.welcome-section {
margin-bottom: 30px;
}
.welcome-section h2 {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.welcome-section p {
font-size: 16px;
color: #666;
margin: 0;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stats-card {
transition: transform 0.3s ease;
}
.stats-card:hover {
transform: translateY(-5px);
}
.stats-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.stats-info {
flex: 1;
}
.stats-value {
font-size: 32px;
font-weight: 700;
color: #333;
margin: 0 0 8px 0;
}
.stats-label {
font-size: 14px;
color: #666;
margin: 0;
}
.stats-icon {
margin-left: 20px;
}
.function-modules {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.module-card {
transition: transform 0.3s ease;
}
.module-card:hover {
transform: translateY(-5px);
}
.module-content {
text-align: center;
padding: 30px 20px;
}
.module-icon {
margin-bottom: 20px;
}
.module-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 10px 0;
}
.module-desc {
font-size: 14px;
color: #666;
margin: 0 0 20px 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.timeline-content {
padding: 10px 0;
}
.timeline-content h4 {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 5px 0;
}
.timeline-content p {
font-size: 14px;
color: #666;
margin: 5px 0;
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="login-container">
<div class="login-form">
<div class="login-header">
<h2>一梦西游玩家服务中心</h2>
<p>欢迎登录一梦西游一站式玩家服务中心</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-position="top"
>
<el-form-item label="游戏账号" prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入游戏账号"
size="large"
:prefix-icon="RiUserLine"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入游戏密码"
size="large"
show-password
:prefix-icon="RiLockLine"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
@click="handleLogin"
size="large"
block
>
登录
</el-button>
</el-form-item>
<div class="login-footer">
<el-link type="primary" :underline="false">忘记密码</el-link>
<el-link type="primary" :underline="false">注册账号</el-link>
</div>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { playerLogin } from '@/api/auth'
import type { LoginForm } from '@/types/auth'
// 导入Remix Icon组件
import { RiUserLine, RiLockLine } from '@remixicon/vue'
const router = useRouter()
// 登录表单引用
const loginFormRef = ref()
// 加载状态
const loading = ref(false)
// 登录表单
const loginForm = reactive<LoginForm>({
username: '',
password: ''
})
// 表单验证规则
const loginRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
]
}
// 处理登录
const handleLogin = async () => {
// 表单验证
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
loading.value = true
// 调用游戏服务端API登录API层会自动处理存储
const response = await playerLogin(loginForm)
// 检查响应是否成功游戏服务端返回code=200表示成功
if (response?.code === 200 && response?.success === true) {
ElMessage.success('登录成功')
// 跳转到玩家首页
router.push('/player')
} else {
throw new Error(response?.message || '登录失败,请稍后重试')
}
} catch (error: any) {
ElMessage.error(error?.message || '登录失败,请稍后重试')
console.error('登录失败:', error)
console.log('详细错误信息:', JSON.stringify(error, null, 2))
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-form {
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.login-header p {
font-size: 14px;
color: #666;
}
.login-footer {
display: flex;
justify-content: space-between;
margin-top: 20px;
}
/* 输入框图标样式已通过Element Plus的prefix-icon属性自动处理 */
</style>

15
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
// 模块声明
declare module '@/store/config' {
export const useConfigStore: () => any
}
declare module '@/store/user' {
export const useUserStore: () => any
}
declare module '@/api/config' {
export const configApi: any
}

31
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

27
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
port: 5000,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
},
'/game-api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/game-api/, '')
}
}
}
})

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "mhxy_web",
"version": "1.0.0",
"description": "",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"commit": "git-cz"
},
"config": {
"commitizen": {
"path": "node_modules/cz-git"
}
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"commitizen": "^4.3.1",
"cz-git": "^1.12.0"
}
}

118
sql/init_mysql.sql Normal file
View File

@@ -0,0 +1,118 @@
-- 一体化游戏运营平台数据库初始化脚本MySQL 8.4兼容版)
-- 创建日期: 2024-05-20
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS mhxy_web DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 切换到mhxy_web数据库
USE mhxy_web;
-- 创建用户表
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NULL,
phone VARCHAR(20) UNIQUE NULL,
role ENUM('admin', 'player') DEFAULT 'admin' NOT NULL,
status ENUM('ACTIVE', 'INACTIVE') DEFAULT 'ACTIVE' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 创建配置表
CREATE TABLE IF NOT EXISTS config (
id INT AUTO_INCREMENT PRIMARY KEY,
`key` VARCHAR(50) UNIQUE NOT NULL,
value TEXT NULL,
description VARCHAR(100) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 创建管理员用户密码admin123已通过bcrypt加密
INSERT INTO users (username, password, role, status) VALUES (
'admin',
'$2a$12$A3KXVvye0Q1WMx3AjqRgb.ea3PTUpJHLyOBrm9Q8PAVMYVxNCvZrO',
'admin',
'ACTIVE'
) ON DUPLICATE KEY UPDATE id = id;
-- 插入初始配置
INSERT INTO config (`key`, `value`, description) VALUES
('game_api_url', 'http://127.0.0.1:8080/tool/http', '游戏服务端API地址'),
('max_login_attempts', '5', '最大登录尝试次数'),
('login_lock_time', '300', '登录锁定时间(秒)')
ON DUPLICATE KEY UPDATE id = id;
-- 插入系统配置
INSERT INTO config (`key`, `value`, description) VALUES
('website_name', '', '网站名称'),
('admin_domain', '', '运营后台域名'),
('player_domain', '', '玩家中心域名'),
('server_host', '', '后端服务器主机地址'),
('server_port', '', '后端服务器端口'),
('game_api_url', '', '游戏服务API地址'),
('game_psk', '', '游戏服务端的PSK'),
('jwt_secret', '', 'JWT密钥'),
('jwt_expires_in', '', 'JWT过期时间'),
('jwt_refresh_secret', '', 'JWT刷新令牌密钥'),
('jwt_refresh_expires_in', '', 'JWT刷新令牌过期时间'),
('service_status', 'running', '服务运行状态'),
('maintenance_mode', 'false', '维护模式开关')
ON DUPLICATE KEY UPDATE id = id;
-- 创建索引MySQL 8.4不支持IF NOT EXISTS语法使用IGNORE错误处理
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_users_username') > 0,
'SELECT "Index idx_users_username already exists" AS message;',
'CREATE INDEX idx_users_username ON users(username);'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_users_email') > 0,
'SELECT "Index idx_users_email already exists" AS message;',
'CREATE INDEX idx_users_email ON users(email);'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_users_phone') > 0,
'SELECT "Index idx_users_phone already exists" AS message;',
'CREATE INDEX idx_users_phone ON users(phone);'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_users_status') > 0,
'SELECT "Index idx_users_status already exists" AS message;',
'CREATE INDEX idx_users_status ON users(status);'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'config' AND INDEX_NAME = 'idx_config_key') > 0,
'SELECT "Index idx_config_key already exists" AS message;',
'CREATE INDEX idx_config_key ON config(`key`);'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 输出初始化完成信息
SELECT '数据库初始化完成!' AS message;
SELECT '管理员用户名: admin, 密码: admin123' AS admin_info;