From 4a97b964ac7aba4d0f58a77a7dbb4765f13e53c0 Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Mon, 22 Dec 2025 23:51:21 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .commitlintrc.js | 3 + .cz-config.js | 107 +++ .czrc | 75 ++ .gitignore | 90 ++ README.md | 174 ++++ backend/.env.example | 26 + backend/Dockerfile | 43 + backend/package.json | 48 ++ backend/src/config/typeorm.config.ts | 37 + backend/src/index.ts | 138 +++ backend/src/middleware/authMiddleware.ts | 74 ++ backend/src/middleware/errorHandler.ts | 38 + backend/src/models/Config.ts | 22 + backend/src/models/User.ts | 40 + backend/src/routes/auth.routes.ts | 195 +++++ backend/src/routes/config.routes.ts | 118 +++ backend/src/routes/game.routes.ts | 153 ++++ backend/src/routes/user.routes.ts | 375 ++++++++ backend/src/scripts/initAdmin.ts | 52 ++ backend/src/services/ConfigService.ts | 227 +++++ backend/tsconfig.json | 18 + cz.config.js | 93 ++ docker-compose.yml | 86 ++ frontend/.env.production.example | 13 + frontend/Dockerfile | 33 + frontend/index.html | 13 + frontend/nginx.conf | 43 + frontend/package.json | 27 + frontend/src/App.vue | 37 + frontend/src/api/auth.ts | 114 +++ frontend/src/api/config.ts | 66 ++ frontend/src/api/game.ts | 31 + frontend/src/api/index.ts | 152 ++++ frontend/src/api/user.ts | 67 ++ frontend/src/assets/main.css | 196 +++++ frontend/src/components/AdminHeader.vue | 174 ++++ frontend/src/components/AdminSidebar.vue | 304 +++++++ frontend/src/components/TabNav.vue | 268 ++++++ frontend/src/main.ts | 33 + frontend/src/router/index.ts | 158 ++++ frontend/src/store/config.ts | 72 ++ frontend/src/store/index.ts | 5 + frontend/src/store/player.ts | 149 ++++ frontend/src/store/ui.ts | 86 ++ frontend/src/store/user.ts | 85 ++ frontend/src/types/auth.ts | 41 + frontend/src/types/config.ts | 22 + frontend/src/types/user.ts | 50 ++ frontend/src/utils/helpers.ts | 123 +++ frontend/src/views/NotFoundView.vue | 75 ++ frontend/src/views/admin/GameService.vue | 638 ++++++++++++++ frontend/src/views/admin/Home.vue | 162 ++++ frontend/src/views/admin/Login.vue | 165 ++++ frontend/src/views/admin/PlayerList.vue | 891 ++++++++++++++++++++ frontend/src/views/admin/SystemConfig.vue | 404 +++++++++ frontend/src/views/admin/UserManagement.vue | 615 ++++++++++++++ frontend/src/views/player/Home.vue | 429 ++++++++++ frontend/src/views/player/Login.vue | 169 ++++ frontend/src/vite-env.d.ts | 15 + frontend/tsconfig.json | 31 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 27 + package.json | 28 + sql/init_mysql.sql | 118 +++ 64 files changed, 8371 insertions(+) create mode 100644 .commitlintrc.js create mode 100644 .cz-config.js create mode 100644 .czrc create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/config/typeorm.config.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/middleware/authMiddleware.ts create mode 100644 backend/src/middleware/errorHandler.ts create mode 100644 backend/src/models/Config.ts create mode 100644 backend/src/models/User.ts create mode 100644 backend/src/routes/auth.routes.ts create mode 100644 backend/src/routes/config.routes.ts create mode 100644 backend/src/routes/game.routes.ts create mode 100644 backend/src/routes/user.routes.ts create mode 100644 backend/src/scripts/initAdmin.ts create mode 100644 backend/src/services/ConfigService.ts create mode 100644 backend/tsconfig.json create mode 100644 cz.config.js create mode 100644 docker-compose.yml create mode 100644 frontend/.env.production.example create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/config.ts create mode 100644 frontend/src/api/game.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/user.ts create mode 100644 frontend/src/assets/main.css create mode 100644 frontend/src/components/AdminHeader.vue create mode 100644 frontend/src/components/AdminSidebar.vue create mode 100644 frontend/src/components/TabNav.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/store/config.ts create mode 100644 frontend/src/store/index.ts create mode 100644 frontend/src/store/player.ts create mode 100644 frontend/src/store/ui.ts create mode 100644 frontend/src/store/user.ts create mode 100644 frontend/src/types/auth.ts create mode 100644 frontend/src/types/config.ts create mode 100644 frontend/src/types/user.ts create mode 100644 frontend/src/utils/helpers.ts create mode 100644 frontend/src/views/NotFoundView.vue create mode 100644 frontend/src/views/admin/GameService.vue create mode 100644 frontend/src/views/admin/Home.vue create mode 100644 frontend/src/views/admin/Login.vue create mode 100644 frontend/src/views/admin/PlayerList.vue create mode 100644 frontend/src/views/admin/SystemConfig.vue create mode 100644 frontend/src/views/admin/UserManagement.vue create mode 100644 frontend/src/views/player/Home.vue create mode 100644 frontend/src/views/player/Login.vue create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 package.json create mode 100644 sql/init_mysql.sql diff --git a/.commitlintrc.js b/.commitlintrc.js new file mode 100644 index 0000000..c34aa79 --- /dev/null +++ b/.commitlintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['@commitlint/config-conventional'] +}; diff --git a/.cz-config.js b/.cz-config.js new file mode 100644 index 0000000..2665458 --- /dev/null +++ b/.cz-config.js @@ -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: "" +}; \ No newline at end of file diff --git a/.czrc b/.czrc new file mode 100644 index 0000000..33f51da --- /dev/null +++ b/.czrc @@ -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" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a56dfa5 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7086d0e --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..14821a3 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6dbb646 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..b438af3 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/config/typeorm.config.ts b/backend/src/config/typeorm.config.ts new file mode 100644 index 0000000..e8d47ef --- /dev/null +++ b/backend/src/config/typeorm.config.ts @@ -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 + } +} \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..9c30152 --- /dev/null +++ b/backend/src/index.ts @@ -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() \ No newline at end of file diff --git a/backend/src/middleware/authMiddleware.ts b/backend/src/middleware/authMiddleware.ts new file mode 100644 index 0000000..1574fd7 --- /dev/null +++ b/backend/src/middleware/authMiddleware.ts @@ -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 } \ No newline at end of file diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..af26a45 --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -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 \ No newline at end of file diff --git a/backend/src/models/Config.ts b/backend/src/models/Config.ts new file mode 100644 index 0000000..d3910c9 --- /dev/null +++ b/backend/src/models/Config.ts @@ -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 +} \ No newline at end of file diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts new file mode 100644 index 0000000..3411391 --- /dev/null +++ b/backend/src/models/User.ts @@ -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 +} \ No newline at end of file diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..131c7d1 --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/config.routes.ts b/backend/src/routes/config.routes.ts new file mode 100644 index 0000000..0d00187 --- /dev/null +++ b/backend/src/routes/config.routes.ts @@ -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) + + 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 \ No newline at end of file diff --git a/backend/src/routes/game.routes.ts b/backend/src/routes/game.routes.ts new file mode 100644 index 0000000..96a71b7 --- /dev/null +++ b/backend/src/routes/game.routes.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts new file mode 100644 index 0000000..d4ad1f5 --- /dev/null +++ b/backend/src/routes/user.routes.ts @@ -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 \ No newline at end of file diff --git a/backend/src/scripts/initAdmin.ts b/backend/src/scripts/initAdmin.ts new file mode 100644 index 0000000..1366979 --- /dev/null +++ b/backend/src/scripts/initAdmin.ts @@ -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() +} \ No newline at end of file diff --git a/backend/src/services/ConfigService.ts b/backend/src/services/ConfigService.ts new file mode 100644 index 0000000..89e919a --- /dev/null +++ b/backend/src/services/ConfigService.ts @@ -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 = {} + 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) + 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 { + 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 = { + 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 = { + 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() diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..156ac9e --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/cz.config.js b/cz.config.js new file mode 100644 index 0000000..944c049 --- /dev/null +++ b/cz.config.js @@ -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' +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..331d305 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/.env.production.example b/frontend/.env.production.example new file mode 100644 index 0000000..f77f515 --- /dev/null +++ b/frontend/.env.production.example @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c460de9 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..10debdd --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 一体化游戏运营平台 + + +
+ + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..42ef616 --- /dev/null +++ b/frontend/nginx.conf @@ -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; +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a8d72e0 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..54dec9d --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,37 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..b702e8b --- /dev/null +++ b/frontend/src/api/auth.ts @@ -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 => { + return api.post('/auth/login', form) +} + +/** + * 玩家登录(游戏服务端API) + * @param form 登录表单数据 + * @returns 登录响应 + */ +export const playerLogin = async (form: LoginForm): Promise => { + // 按照游戏服务端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 => { + return api.post('/auth/logout') +} + +/** + * 玩家登出(游戏服务端API) + * @returns 登出响应 + */ +export const playerLogout = async (): Promise => { + // 按照游戏服务端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 => { + // 按照游戏服务端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') +} \ No newline at end of file diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 0000000..a97a3f9 --- /dev/null +++ b/frontend/src/api/config.ts @@ -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 +} diff --git a/frontend/src/api/game.ts b/frontend/src/api/game.ts new file mode 100644 index 0000000..22a4f0b --- /dev/null +++ b/frontend/src/api/game.ts @@ -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') +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..3a70b0b --- /dev/null +++ b/frontend/src/api/index.ts @@ -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 } \ No newline at end of file diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts new file mode 100644 index 0000000..91ba8bc --- /dev/null +++ b/frontend/src/api/user.ts @@ -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) +} diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..1209f25 --- /dev/null +++ b/frontend/src/assets/main.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/components/AdminHeader.vue b/frontend/src/components/AdminHeader.vue new file mode 100644 index 0000000..d87e443 --- /dev/null +++ b/frontend/src/components/AdminHeader.vue @@ -0,0 +1,174 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/AdminSidebar.vue b/frontend/src/components/AdminSidebar.vue new file mode 100644 index 0000000..14fdd6e --- /dev/null +++ b/frontend/src/components/AdminSidebar.vue @@ -0,0 +1,304 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/TabNav.vue b/frontend/src/components/TabNav.vue new file mode 100644 index 0000000..99a7921 --- /dev/null +++ b/frontend/src/components/TabNav.vue @@ -0,0 +1,268 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..fa0004a --- /dev/null +++ b/frontend/src/main.ts @@ -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') \ No newline at end of file diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..838362c --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,158 @@ +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' + +const routes: Array = [ + // 玩家服务中心路由 + { + 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 \ No newline at end of file diff --git a/frontend/src/store/config.ts b/frontend/src/store/config.ts new file mode 100644 index 0000000..383f1fd --- /dev/null +++ b/frontend/src/store/config.ts @@ -0,0 +1,72 @@ +import { defineStore } from 'pinia' +import { configApi } from '@/api/config' + +export const useConfigStore = defineStore('config', { + state: () => ({ + configs: {} as Record, + 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 + } + } + } +}) diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts new file mode 100644 index 0000000..4c236a1 --- /dev/null +++ b/frontend/src/store/index.ts @@ -0,0 +1,5 @@ +import { createPinia } from 'pinia' + +export const pinia = createPinia() + +export default pinia \ No newline at end of file diff --git a/frontend/src/store/player.ts b/frontend/src/store/player.ts new file mode 100644 index 0000000..7de508a --- /dev/null +++ b/frontend/src/store/player.ts @@ -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') + } + } +}) \ No newline at end of file diff --git a/frontend/src/store/ui.ts b/frontend/src/store/ui.ts new file mode 100644 index 0000000..4c874fe --- /dev/null +++ b/frontend/src/store/ui.ts @@ -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 + } + } +}) \ No newline at end of file diff --git a/frontend/src/store/user.ts b/frontend/src/store/user.ts new file mode 100644 index 0000000..a5f9e9a --- /dev/null +++ b/frontend/src/store/user.ts @@ -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) + } + } + } +}) \ No newline at end of file diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..a5c1dc8 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -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 +} \ No newline at end of file diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts new file mode 100644 index 0000000..48db064 --- /dev/null +++ b/frontend/src/types/config.ts @@ -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 +} diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts new file mode 100644 index 0000000..97f85de --- /dev/null +++ b/frontend/src/types/user.ts @@ -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 +} \ No newline at end of file diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts new file mode 100644 index 0000000..b5054e1 --- /dev/null +++ b/frontend/src/utils/helpers.ts @@ -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 = any>(func: T, wait: number): ((...args: Parameters) => void) => { + let timeout: ReturnType | null = null + + return (...args: Parameters) => { + if (timeout) { + clearTimeout(timeout) + } + timeout = setTimeout(() => { + func(...args) + }, wait) + } +} + +/** + * 节流函数 + * @param func 要执行的函数 + * @param limit 限制时间(毫秒) + * @returns 节流处理后的函数 + */ +export const throttle = any>(func: T, limit: number): ((...args: Parameters) => void) => { + let inThrottle: boolean = false + + return (...args: Parameters) => { + if (!inThrottle) { + func(...args) + inThrottle = true + setTimeout(() => { + inThrottle = false + }, limit) + } + } +} + +/** + * 深拷贝对象 + * @param obj 要拷贝的对象 + * @returns 拷贝后的对象 + */ +export const deepClone = (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 +} diff --git a/frontend/src/views/NotFoundView.vue b/frontend/src/views/NotFoundView.vue new file mode 100644 index 0000000..a4784f6 --- /dev/null +++ b/frontend/src/views/NotFoundView.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/views/admin/GameService.vue b/frontend/src/views/admin/GameService.vue new file mode 100644 index 0000000..ed83799 --- /dev/null +++ b/frontend/src/views/admin/GameService.vue @@ -0,0 +1,638 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/Home.vue b/frontend/src/views/admin/Home.vue new file mode 100644 index 0000000..7c78cdf --- /dev/null +++ b/frontend/src/views/admin/Home.vue @@ -0,0 +1,162 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/Login.vue b/frontend/src/views/admin/Login.vue new file mode 100644 index 0000000..6eb4588 --- /dev/null +++ b/frontend/src/views/admin/Login.vue @@ -0,0 +1,165 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/PlayerList.vue b/frontend/src/views/admin/PlayerList.vue new file mode 100644 index 0000000..6a0e93e --- /dev/null +++ b/frontend/src/views/admin/PlayerList.vue @@ -0,0 +1,891 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/SystemConfig.vue b/frontend/src/views/admin/SystemConfig.vue new file mode 100644 index 0000000..80989ed --- /dev/null +++ b/frontend/src/views/admin/SystemConfig.vue @@ -0,0 +1,404 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/UserManagement.vue b/frontend/src/views/admin/UserManagement.vue new file mode 100644 index 0000000..6015f54 --- /dev/null +++ b/frontend/src/views/admin/UserManagement.vue @@ -0,0 +1,615 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/player/Home.vue b/frontend/src/views/player/Home.vue new file mode 100644 index 0000000..d28c929 --- /dev/null +++ b/frontend/src/views/player/Home.vue @@ -0,0 +1,429 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/player/Login.vue b/frontend/src/views/player/Login.vue new file mode 100644 index 0000000..116b128 --- /dev/null +++ b/frontend/src/views/player/Login.vue @@ -0,0 +1,169 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..344a218 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,15 @@ +/// + +// 模块声明 +declare module '@/store/config' { + export const useConfigStore: () => any +} + +declare module '@/store/user' { + export const useUserStore: () => any +} + +declare module '@/api/config' { + export const configApi: any +} + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..b29bb1c --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..2a7375e --- /dev/null +++ b/frontend/vite.config.ts @@ -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/, '') + } + } + } +}) \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd08f41 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/sql/init_mysql.sql b/sql/init_mysql.sql new file mode 100644 index 0000000..e852c28 --- /dev/null +++ b/sql/init_mysql.sql @@ -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;