项目初始化

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

View File

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

33
frontend/Dockerfile Normal file
View File

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

13
frontend/index.html Normal file
View File

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

43
frontend/nginx.conf Normal file
View File

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

27
frontend/package.json Normal file
View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

31
frontend/tsconfig.json Normal file
View File

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

View File

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

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

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