Compare commits

...

2 Commits

59 changed files with 2330 additions and 0 deletions

107
.cz-config.js Normal file
View File

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

75
.czrc Normal file
View File

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

130
README.md Normal file
View File

@@ -0,0 +1,130 @@
# 梦幻西游一站式运营管理平台
## 项目结构
```
JGE-RS-SL-WEB/
├── backend/ # NestJS后端
│ ├── src/
│ │ ├── auth/ # 后台认证模块
│ │ ├── player/ # 玩家服务模块
│ │ ├── common/ # 公共模块(过滤器、拦截器、管道)
│ │ ├── entities/ # 数据库实体
│ │ └── config/ # 配置文件
│ └── database/ # 数据库初始化脚本
└── frontend/ # React前端
├── src/
│ ├── pages/ # 页面组件
│ ├── components/ # 公共组件
│ ├── services/ # API服务
│ ├── stores/ # 状态管理
│ └── router/ # 路由配置
```
## 环境要求
- Node.js 22.x
- MySQL 8.4
- npm 10.x
## 快速开始
### 1. 数据库初始化
1. 创建数据库:
```sql
CREATE DATABASE jge_rs_sl_web DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
2. 导入初始化脚本:
```bash
mysql -u root -p jge_rs_sl_web < backend/database/init.sql
```
3. 默认管理员账号:
- 用户名admin
- 密码admin123
### 2. 后端启动
1. 配置环境变量:
编辑 `backend/.env` 文件,修改数据库连接信息:
```
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=your_password
DB_DATABASE=jge_rs_sl_web
JWT_SECRET=your_jwt_secret_key_change_in_production
JWT_EXPIRES_IN=24h
PORT=3000
NODE_ENV=development
```
2. 安装依赖并启动:
```bash
cd backend
npm install
npm run start:dev
```
后端服务将在 http://localhost:3000 启动
### 3. 前端启动
1. 安装依赖并启动:
```bash
cd frontend
npm install
npm run dev
```
前端服务将在 http://localhost:5173 启动
## 功能模块
### 玩家服务中心 (/player)
- 登录页面
- 控制台布局Header、Content、Footer
- 控制台主页
### 运营管理系统后台 (/admin)
- 登录页面JWT认证
- 后台布局Header、Sider、Content、Footer
- 工作台页面
## 技术栈
### 后端
- NestJS 11.x
- TypeORM
- MySQL 8.4
- JWT认证
- Passport
- Bcrypt密码加密
### 前端
- React 19.x
- TypeScript
- Vite
- Ant Design 6.1.2
- React Router
- Zustand状态管理
- Axios
## API接口
### 后台认证
- POST /admin/auth/login - 后台登录
- POST /admin/auth/logout - 后台登出
### 玩家认证
- POST /player/auth/login - 玩家登录
- POST /player/auth/logout - 玩家登出
## 开发说明
- 后端代码遵循NestJS最佳实践
- 前端代码严格遵循Ant Design官方标准
- JWT Token存储在内存中不使用localStorage/sessionStorage
- 所有API响应格式统一

4
backend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
backend/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

30
backend/database/init.sql Normal file
View File

@@ -0,0 +1,30 @@
-- 梦幻西游一站式运营管理平台 - 数据库初始化脚本
-- MySQL 8.4 版本
-- 创建时间2025-12-27
-- 设置字符集
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- 后台用户表
CREATE TABLE IF NOT EXISTS admin_users (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(50) UNIQUE NOT NULL COMMENT '用户名',
password_hash CHAR(60) NOT NULL COMMENT '密码哈希(bcrypt)',
role ENUM('super_admin', 'operator', 'viewer') NOT NULL DEFAULT 'viewer' COMMENT '角色super_admin-超级管理员, operator-操作员, viewer-查看者',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台用户表';
-- 插入默认超级管理员账号
-- 用户名admin
-- 密码admin123
-- 注意密码哈希需要使用bcrypt生成以下是admin123的bcrypt哈希值10轮
INSERT INTO admin_users (username, password_hash, role) VALUES
('admin', '$2b$10$Q1RH29Lsi4y/uq9ZIej1a.nRUv/7gNgdnga.tVStXGARE/J0rrF5K', 'super_admin');
-- 验证插入结果
SELECT * FROM admin_users;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -0,0 +1,10 @@
-- 更新管理员密码
-- 用户名admin
-- 密码admin123456
-- 新的bcrypt哈希值10轮
UPDATE admin_users
SET password_hash = '$2b$10$t1yFXiPAfHUJONTjxnFYae3Q4petGiD3swtAJC2mtYNJcujxu0raa'
WHERE username = 'admin';
-- 验证更新结果
SELECT id, username, password_hash, role FROM admin_users;

35
backend/eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

86
backend/package.json Normal file
View File

@@ -0,0 +1,86 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"mysql2": "^3.16.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

33
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { PlayerModule } from './player/player.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306', 10),
username: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_DATABASE || 'jge_rs_sl_web',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: process.env.NODE_ENV === 'development',
logging: process.env.NODE_ENV === 'development',
charset: 'utf8mb4',
}),
AuthModule,
PlayerModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -0,0 +1,21 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { LoginResponseDto } from './dto/login-response.dto';
@Controller('admin/auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto): Promise<LoginResponseDto> {
return this.authService.login(loginDto);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(): Promise<{ message: string }> {
return { message: '登出成功' };
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { AdminUser } from '../entities/admin-user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([AdminUser]),
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'your_jwt_secret_key_change_in_production',
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '24h' } as any,
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { AdminUser } from '../entities/admin-user.entity';
import { LoginDto } from './dto/login.dto';
import { LoginResponseDto } from './dto/login-response.dto';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(AdminUser)
private adminUserRepository: Repository<AdminUser>,
private jwtService: JwtService,
) {}
async login(loginDto: LoginDto): Promise<LoginResponseDto> {
const { username, password } = loginDto;
const user = await this.adminUserRepository.findOne({
where: { username },
});
if (!user) {
throw new UnauthorizedException('用户名或密码错误');
}
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('用户名或密码错误');
}
const payload = { sub: user.id, username: user.username, role: user.role };
return {
accessToken: this.jwtService.sign(payload),
userId: user.id,
username: user.username,
role: user.role,
};
}
async validateUser(userId: number): Promise<AdminUser | null> {
return this.adminUserRepository.findOne({
where: { id: userId },
});
}
}

View File

@@ -0,0 +1,9 @@
export class LoginResponseDto {
accessToken: string;
userId: number;
username: string;
role: string;
}

View File

@@ -0,0 +1,12 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsNotEmpty({ message: '用户名不能为空' })
@IsString({ message: '用户名必须是字符串' })
username: string;
@IsNotEmpty({ message: '密码不能为空' })
@IsString({ message: '密码必须是字符串' })
@MinLength(6, { message: '密码长度至少为6位' })
password: string;
}

View File

@@ -0,0 +1,31 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message:
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || exception.message,
};
response.status(status).json(errorResponse);
}
}

View File

@@ -0,0 +1,34 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response<T> {
statusCode: number;
message: string;
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, Response<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
return next.handle().pipe(
map((data) => ({
statusCode: context.switchToHttp().getResponse().statusCode,
message: 'success',
data,
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@@ -0,0 +1,22 @@
import { ValidationPipe, ValidationError } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
export class CustomValidationPipe extends ValidationPipe {
constructor() {
super({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: (errors: ValidationError[]) => {
const messages = errors.map((error) => {
return Object.values(error.constraints || {}).join(', ');
});
return new BadRequestException({
statusCode: 400,
message: messages.join('; '),
error: 'Bad Request',
});
},
});
}
}

View File

@@ -0,0 +1,22 @@
import { DataSource, DataSourceOptions } from 'typeorm';
import { config } from 'dotenv';
config();
export const dataSourceOptions: DataSourceOptions = {
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306', 10),
username: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_DATABASE || 'jge_rs_sl_web',
entities: ['dist/**/*.entity{.ts,.js}'],
migrations: ['dist/migrations/*{.ts,.js}'],
synchronize: process.env.NODE_ENV === 'development',
logging: process.env.NODE_ENV === 'development',
charset: 'utf8mb4',
};
const dataSource = new DataSource(dataSourceOptions);
export default dataSource;

View File

@@ -0,0 +1,34 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
export enum AdminRole {
SUPER_ADMIN = 'super_admin',
OPERATOR = 'operator',
VIEWER = 'viewer',
}
@Entity('admin_users')
export class AdminUser {
@PrimaryGeneratedColumn({ type: 'bigint', comment: '用户ID' })
id: number;
@Column({ type: 'varchar', length: 50, unique: true, comment: '用户名' })
@Index()
username: string;
@Column({ type: 'char', length: 60, comment: '密码哈希(bcrypt)' })
passwordHash: string;
@Column({
type: 'enum',
enum: AdminRole,
default: AdminRole.VIEWER,
comment: '角色super_admin-超级管理员, operator-操作员, viewer-查看者',
})
role: AdminRole;
@CreateDateColumn({ type: 'datetime', comment: '创建时间' })
createdAt: Date;
@UpdateDateColumn({ type: 'datetime', comment: '更新时间' })
updatedAt: Date;
}

22
backend/src/main.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { CustomValidationPipe } from './common/pipes/validation.pipe';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: true,
credentials: true,
});
app.useGlobalPipes(new CustomValidationPipe());
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@@ -0,0 +1,21 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Headers } from '@nestjs/common';
import { PlayerService } from './player.service';
import { LoginDto } from '../auth/dto/login.dto';
@Controller('player/auth')
export class PlayerController {
constructor(private playerService: PlayerService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto): Promise<any> {
return this.playerService.login(loginDto);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(@Headers('authorization') authHeader: string): Promise<any> {
const token = authHeader?.replace('Bearer ', '');
return this.playerService.logout(token);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { PlayerController } from './player.controller';
import { PlayerService } from './player.service';
@Module({
imports: [
HttpModule.register({
proxy: false,
}),
],
controllers: [PlayerController],
providers: [PlayerService],
})
export class PlayerModule {}

View File

@@ -0,0 +1,61 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { LoginDto } from '../auth/dto/login.dto';
@Injectable()
export class PlayerService {
private readonly officialApiBaseUrl = process.env.GAME_API_BASE_URL || 'https://api.example.com';
constructor(private readonly httpService: HttpService) {}
async login(loginDto: LoginDto): Promise<any> {
try {
const response = await firstValueFrom(
this.httpService.post(`${this.officialApiBaseUrl}?code=auth/login`, loginDto),
);
const data = (response as any).data;
if (!data.success) {
throw new HttpException(
data.message || '登录失败',
HttpStatus.UNAUTHORIZED,
);
}
return {
accessToken: data.data.token,
userId: data.data.userId,
username: data.data.username,
role: 'player',
};
} catch (error) {
console.error('Player login error:', error);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
(error as any).response?.data?.message || (error as any).response?.data || '登录失败',
(error as any).response?.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async logout(token: string): Promise<any> {
try {
const response = await firstValueFrom(
this.httpService.post(`${this.officialApiBaseUrl}?code=auth/out_login`, {}, {
headers: {
Authorization: `Bearer ${token}`,
},
}),
);
return (response as any).data;
} catch (error) {
throw new HttpException(
(error as any).response?.data || '登出失败',
(error as any).response?.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"antd": "^6.1.2",
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.11.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

35
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,14 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
const AdminAuthRoute = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/admin/login" replace />;
}
return <Outlet />;
};
export default AdminAuthRoute;

View File

@@ -0,0 +1,135 @@
import { useState } from 'react';
import { Layout, Menu, Button, theme } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
DashboardOutlined,
UserOutlined,
LogoutOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import { adminAuthService } from '../services/adminAuthService';
import { message } from 'antd';
const { Header, Sider, Content, Footer } = Layout;
const AdminLayout = () => {
const [collapsed, setCollapsed] = useState(false);
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
const navigate = useNavigate();
const location = useLocation();
const adminUser = useAuthStore((state) => state.adminUser);
const logout = useAuthStore((state) => state.logout);
const handleLogout = async () => {
try {
await adminAuthService.logout();
localStorage.removeItem('adminToken');
logout();
message.success('登出成功');
navigate('/admin/login');
} catch (error) {
message.error('登出失败');
}
};
const menuItems = [
{
key: '/admin/dashboard',
icon: <DashboardOutlined />,
label: '工作台',
},
{
key: 'user',
icon: <UserOutlined />,
label: '用户管理',
children: [
{
key: '/admin/users',
label: '用户列表',
},
],
},
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed}>
<div
style={{
height: 32,
margin: 16,
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 'bold',
}}
>
{collapsed ? '运营' : '运营管理系统'}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header
style={{
padding: 0,
background: colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingRight: 24,
}}
>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
fontSize: '16px',
width: 64,
height: 64,
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span>, {adminUser?.username}</span>
<Button
type="text"
icon={<LogoutOutlined />}
onClick={handleLogout}
>
退
</Button>
</div>
</Header>
<Content
style={{
margin: '24px 16px',
padding: 24,
minHeight: 280,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<Outlet />
</Content>
<Footer style={{ textAlign: 'center' }}>
西 ©2025 Created by JGE
</Footer>
</Layout>
</Layout>
);
};
export default AdminLayout;

View File

@@ -0,0 +1,14 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
const PlayerAuthRoute = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/player/login" replace />;
}
return <Outlet />;
};
export default PlayerAuthRoute;

View File

@@ -0,0 +1,116 @@
import { Layout, Menu, Button, Dropdown } from 'antd';
import {
HomeOutlined,
UserOutlined,
LogoutOutlined,
DownOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { message } from 'antd';
import { playerAuthService } from '../services/playerAuthService';
const { Header, Content, Footer } = Layout;
const PlayerLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const handleLogout = async () => {
try {
await playerAuthService.logout();
localStorage.removeItem('playerToken');
message.success('登出成功');
navigate('/player/login');
} catch (error) {
message.error('登出失败');
}
};
const menuItems = [
{
key: '/player/dashboard',
icon: <HomeOutlined />,
label: '首页',
},
{
key: '/player/profile',
icon: <UserOutlined />,
label: '个人中心',
},
];
const userMenuItems = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: '#001529',
padding: '0 24px',
}}
>
<div
style={{
color: 'white',
fontSize: '20px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span>西</span>
</div>
<Menu
theme="dark"
mode="horizontal"
selectedKeys={[location.pathname]}
items={menuItems}
style={{ flex: 1, minWidth: 0, justifyContent: 'center' }}
onClick={({ key }) => navigate(key)}
/>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button
type="text"
style={{ color: 'white' }}
icon={<DownOutlined />}
>
</Button>
</Dropdown>
</Header>
<Content
style={{
padding: '24px 50px',
minHeight: 'calc(100vh - 128px)',
background: '#f0f2f5',
}}
>
<div style={{ padding: 24, minHeight: 380, background: 'white', borderRadius: 8 }}>
<Outlet />
</div>
</Content>
<Footer
style={{
textAlign: 'center',
background: '#001529',
color: 'rgba(255, 255, 255, 0.65)',
}}
>
西 ©2025 Created by JGE
</Footer>
</Layout>
);
};
export default PlayerLayout;

68
frontend/src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

15
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import './index.css';
import router from './router';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ConfigProvider locale={zhCN}>
<RouterProvider router={router} />
</ConfigProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,64 @@
import { Card, Row, Col, Statistic } from 'antd';
import {
UserOutlined,
ShoppingOutlined,
DollarOutlined,
TrophyOutlined,
} from '@ant-design/icons';
const AdminDashboard = () => {
return (
<div>
<h2 style={{ marginBottom: 24 }}></h2>
<Row gutter={16}>
<Col span={6}>
<Card>
<Statistic
title="总用户数"
value={11280}
prefix={<UserOutlined />}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="今日订单"
value={93}
prefix={<ShoppingOutlined />}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="今日收入"
value={11280}
prefix={<DollarOutlined />}
precision={2}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="活动参与"
value={93}
prefix={<TrophyOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
</Row>
<Card style={{ marginTop: 24 }} title="系统信息">
<p>线</p>
<p>2025-12-27 12:00:00</p>
</Card>
</div>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,95 @@
import { useState } from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { adminAuthService, type LoginRequest } from '../../services/adminAuthService';
import { useAuthStore } from '../../stores/authStore';
const AdminLogin = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const onFinish = async (values: LoginRequest) => {
setLoading(true);
try {
const response = await adminAuthService.login(values);
setAuth(
{
id: response.userId,
username: response.username,
role: response.role,
},
response.accessToken
);
localStorage.setItem('adminToken', response.accessToken);
message.success('登录成功');
navigate('/admin/dashboard');
} catch (error) {
message.error(error instanceof Error ? error.message : '登录失败');
} finally {
setLoading(false);
}
};
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Card
title="运营管理系统后台"
style={{
width: 400,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度至少为6位' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default AdminLogin;

View File

@@ -0,0 +1,122 @@
import { Card, Row, Col, Statistic, List, Avatar, Button } from 'antd';
import {
TrophyOutlined,
GiftOutlined,
ClockCircleOutlined,
StarOutlined,
} from '@ant-design/icons';
const PlayerDashboard = () => {
const notices = [
{
title: '系统维护通知',
description: '系统将于2025-12-28 02:00-04:00进行维护请提前做好准备。',
time: '2025-12-27 10:00',
},
{
title: '新年活动开启',
description: '新年活动已正式开启,参与活动可获得丰厚奖励!',
time: '2025-12-26 18:00',
},
{
title: '版本更新公告',
description: 'v2.5.0版本已更新,新增多项功能优化。',
time: '2025-12-25 09:00',
},
];
return (
<div>
<h2 style={{ marginBottom: 24 }}></h2>
<Row gutter={16}>
<Col span={6}>
<Card>
<Statistic
title="角色等级"
value={100}
suffix="级"
prefix={<StarOutlined />}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="累计充值"
value={12800}
prefix={<GiftOutlined />}
precision={2}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="在线时长"
value={365}
suffix="小时"
prefix={<ClockCircleOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="成就点数"
value={2560}
prefix={<TrophyOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 24 }}>
<Col span={12}>
<Card title="系统公告">
<List
itemLayout="horizontal"
dataSource={notices}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar icon={<TrophyOutlined />} />}
title={item.title}
description={
<div>
<p>{item.description}</p>
<small style={{ color: '#999' }}>{item.time}</small>
</div>
}
/>
</List.Item>
)}
/>
</Card>
</Col>
<Col span={12}>
<Card title="快捷入口">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Button type="primary" block size="large">
</Button>
<Button block size="large">
</Button>
<Button block size="large">
</Button>
<Button block size="large">
</Button>
</div>
</Card>
</Col>
</Row>
</div>
);
};
export default PlayerDashboard;

View File

@@ -0,0 +1,92 @@
import { useState } from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { playerAuthService, type LoginRequest } from '../../services/playerAuthService';
import { useAuthStore } from '../../stores/authStore';
const PlayerLogin = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const onFinish = async (values: LoginRequest) => {
setLoading(true);
try {
const response = await playerAuthService.login(values);
localStorage.setItem('playerToken', response.accessToken);
setAuth({
id: response.userId,
username: response.username,
role: response.role,
}, response.accessToken);
message.success('登录成功');
navigate('/player/dashboard');
} catch (error) {
message.error(error instanceof Error ? error.message : '登录失败');
} finally {
setLoading(false);
}
};
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Card
title="玩家服务中心"
style={{
width: 400,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度至少为6位' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default PlayerLogin;

View File

@@ -0,0 +1,72 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import AdminLayout from '../components/AdminLayout';
import PlayerLayout from '../components/PlayerLayout';
import AdminAuthRoute from '../components/AdminAuthRoute';
import PlayerAuthRoute from '../components/PlayerAuthRoute';
import AdminLogin from '../pages/admin/AdminLogin';
import AdminDashboard from '../pages/admin/AdminDashboard';
import PlayerLogin from '../pages/player/PlayerLogin';
import PlayerDashboard from '../pages/player/PlayerDashboard';
const router = createBrowserRouter([
{
path: '/',
element: <Navigate to="/player/login" replace />,
},
{
path: '/admin',
children: [
{
path: 'login',
element: <AdminLogin />,
},
{
element: <AdminAuthRoute />,
children: [
{
element: <AdminLayout />,
children: [
{
path: 'dashboard',
element: <AdminDashboard />,
},
{
index: true,
element: <Navigate to="/admin/dashboard" replace />,
},
],
},
],
},
],
},
{
path: '/player',
children: [
{
path: 'login',
element: <PlayerLogin />,
},
{
element: <PlayerAuthRoute />,
children: [
{
element: <PlayerLayout />,
children: [
{
path: 'dashboard',
element: <PlayerDashboard />,
},
{
index: true,
element: <Navigate to="/player/dashboard" replace />,
},
],
},
],
},
],
},
]);
export default router;

View File

@@ -0,0 +1,25 @@
import api from '../utils/api';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
userId: number;
username: string;
role: string;
}
export const adminAuthService = {
async login(data: LoginRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/admin/auth/login', data);
return response.data;
},
async logout(): Promise<{ message: string }> {
const response = await api.post<{ message: string }>('/admin/auth/logout');
return response.data;
},
};

View File

@@ -0,0 +1,25 @@
import api from '../utils/api';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
userId: number;
username: string;
role: string;
}
export const playerAuthService = {
async login(data: LoginRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/player/auth/login', data);
return response.data;
},
async logout(): Promise<{ message: string }> {
const response = await api.post<{ message: string }>('/player/auth/logout');
return response.data;
},
};

View File

@@ -0,0 +1,33 @@
import { create } from 'zustand';
interface AdminUser {
id: number;
username: string;
role: string;
}
interface AuthState {
isAuthenticated: boolean;
adminUser: AdminUser | null;
token: string | null;
setAuth: (user: AdminUser, token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
adminUser: null,
token: null,
setAuth: (user, token) =>
set({
isAuthenticated: true,
adminUser: user,
token,
}),
logout: () =>
set({
isAuthenticated: false,
adminUser: null,
token: null,
}),
}));

49
frontend/src/utils/api.ts Normal file
View File

@@ -0,0 +1,49 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use(
(config) => {
const adminToken = localStorage.getItem('adminToken');
const playerToken = localStorage.getItem('playerToken');
const token = adminToken || playerToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
api.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
console.error('API Error:', error);
let message = '请求失败';
if (error.response) {
const data = error.response.data;
if (data) {
message = data.message || data.error || message;
}
} else if (error.request) {
message = '网络连接失败,请检查后端服务是否启动';
} else {
message = error.message || message;
}
return Promise.reject(new Error(message));
}
);
export default api;

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

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

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "jge-rs-sl-web",
"version": "1.0.0",
"description": "梦幻西游一站式运营管理平台",
"scripts": {
"cz": "git-cz",
"commit": "git-cz"
},
"config": {
"commitizen": {
"path": "node_modules/cz-git",
"useConfig": "./.cz-config.js"
}
},
"devDependencies": {
"commitizen": "^4.3.1",
"cz-git": "^1.11.0"
}
}