feat: ✨ 运营管理系统后台登录鉴权及后台框架开发
This commit is contained in:
96
sql/init_admin_users.sql
Normal file
96
sql/init_admin_users.sql
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- 梦幻西游一站式运营管理系统 - 后台用户表初始化脚本
|
||||||
|
-- 适用于 MYSQL 8.4
|
||||||
|
-- 创建日期: 2025-12-12
|
||||||
|
-- 描述: 创建后台管理员用户相关数据表
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- 选择数据库
|
||||||
|
USE mhxy_web;
|
||||||
|
|
||||||
|
-- 创建后台管理员用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '管理员ID',
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
|
||||||
|
password VARCHAR(255) NOT NULL COMMENT '密码(加密存储)',
|
||||||
|
real_name VARCHAR(100) COMMENT '真实姓名',
|
||||||
|
email VARCHAR(100) COMMENT '邮箱地址',
|
||||||
|
phone VARCHAR(20) COMMENT '联系电话',
|
||||||
|
status TINYINT DEFAULT 1 COMMENT '账号状态: 0-禁用, 1-启用',
|
||||||
|
is_super_admin TINYINT DEFAULT 0 COMMENT '是否超级管理员: 0-否, 1-是',
|
||||||
|
last_login_time DATETIME COMMENT '最后登录时间',
|
||||||
|
last_login_ip VARCHAR(45) COMMENT '最后登录IP',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
|
||||||
|
-- 创建索引
|
||||||
|
INDEX idx_username (username),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台管理员用户表';
|
||||||
|
|
||||||
|
-- 创建管理员操作日志表
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
|
||||||
|
admin_user_id INT NOT NULL COMMENT '管理员ID',
|
||||||
|
operation_type VARCHAR(50) NOT NULL COMMENT '操作类型',
|
||||||
|
operation_desc TEXT COMMENT '操作描述',
|
||||||
|
request_method VARCHAR(10) COMMENT '请求方法',
|
||||||
|
request_url VARCHAR(255) COMMENT '请求URL',
|
||||||
|
request_params TEXT COMMENT '请求参数',
|
||||||
|
response_status INT COMMENT '响应状态码',
|
||||||
|
ip_address VARCHAR(45) COMMENT 'IP地址',
|
||||||
|
user_agent TEXT COMMENT '用户代理',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
|
||||||
|
-- 创建外键约束
|
||||||
|
FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- 创建索引
|
||||||
|
INDEX idx_admin_user_id (admin_user_id),
|
||||||
|
INDEX idx_operation_type (operation_type),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员操作日志表';
|
||||||
|
|
||||||
|
-- 创建系统配置表
|
||||||
|
CREATE TABLE IF NOT EXISTS system_config (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
|
||||||
|
config_key VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键',
|
||||||
|
config_value TEXT COMMENT '配置值',
|
||||||
|
config_desc VARCHAR(255) COMMENT '配置描述',
|
||||||
|
config_group VARCHAR(50) DEFAULT 'default' COMMENT '配置分组',
|
||||||
|
is_editable TINYINT DEFAULT 1 COMMENT '是否可编辑: 0-否, 1-是',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
|
||||||
|
-- 创建索引
|
||||||
|
INDEX idx_config_key (config_key),
|
||||||
|
INDEX idx_config_group (config_group)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';
|
||||||
|
|
||||||
|
-- 插入初始超级管理员账号(密码为: admin123,实际部署时需要加密)
|
||||||
|
INSERT INTO admin_users (username, password, real_name, email, status, is_super_admin) VALUES
|
||||||
|
('admin', '$2b$10$rOzK5V4yR7YqE6XoV4Wz9ODz8rN2L5A3C7B1F9H6I4J2K3L5M6N7O8P', '系统管理员', 'admin@mhxy.com', 1, 1);
|
||||||
|
|
||||||
|
-- 说明: 上面插入的密码是 'admin123' 的BCrypt加密结果
|
||||||
|
-- 生产环境中应该使用更复杂的密码
|
||||||
|
-- 加密密码生成示例(实际使用时需要在应用中处理):
|
||||||
|
-- const bcrypt = require('bcrypt');
|
||||||
|
-- const hashedPassword = await bcrypt.hash('admin123', 10);
|
||||||
|
|
||||||
|
-- 插入系统默认配置
|
||||||
|
INSERT INTO system_config (config_key, config_value, config_desc, config_group) VALUES
|
||||||
|
('site_name', '梦幻西游一站式运营管理系统', '网站名称', 'basic'),
|
||||||
|
('site_description', '梦幻西游游戏运营管理后台系统', '网站描述', 'basic'),
|
||||||
|
('admin_session_timeout', '7200', '管理员会话超时时间(秒)', 'security'),
|
||||||
|
('max_login_attempts', '5', '最大登录尝试次数', 'security'),
|
||||||
|
('enable_captcha', '1', '是否启用验证码', 'security');
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 使用说明:
|
||||||
|
-- 1. 在MySQL 8.4中执行此脚本
|
||||||
|
-- 2. 确保数据库存在: CREATE DATABASE IF NOT EXISTS mhxy_web;
|
||||||
|
-- 3. 执行脚本: source /path/to/init_admin_users.sql;
|
||||||
|
-- 4. 初始管理员账号: admin / admin123
|
||||||
|
-- 5. 部署到生产环境时,请修改默认密码
|
||||||
|
-- =====================================================
|
||||||
39
src/App.tsx
39
src/App.tsx
@@ -4,34 +4,47 @@ import { ConfigProvider } from 'antd';
|
|||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import PlayerLogin from './pages/PlayerLogin';
|
import PlayerLogin from './pages/PlayerLogin';
|
||||||
import AdminLogin from './pages/AdminLogin';
|
import AdminLogin from './pages/AdminLogin';
|
||||||
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import AdminLayout from './layouts/AdminLayout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主应用组件
|
* 主应用组件
|
||||||
* 配置了路由系统,支持两个登录页面:
|
* 配置了路由系统,支持:
|
||||||
* - /player - 玩家服务中心登录页面
|
* - /player - 玩家服务中心登录页面
|
||||||
* - /admin - 运营管理系统后台登录页面
|
* - /admin - 运营管理系统后台登录页面
|
||||||
|
* - /admin/dashboard - 后台管理系统工作台
|
||||||
* - / - 首页(导航页面)
|
* - / - 首页(导航页面)
|
||||||
*/
|
*/
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ConfigProvider locale={zhCN}>
|
<ConfigProvider locale={zhCN}>
|
||||||
<Router>
|
<AuthProvider>
|
||||||
<Routes>
|
<Router>
|
||||||
{/* 首页导航 */}
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
{/* 首页导航 */}
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
|
||||||
{/* 玩家服务中心登录页面 */}
|
{/* 玩家服务中心登录页面 */}
|
||||||
<Route path="/player" element={<PlayerLogin />} />
|
<Route path="/player" element={<PlayerLogin />} />
|
||||||
|
|
||||||
{/* 运营管理系统后台登录页面 */}
|
{/* 运营管理系统后台登录页面 */}
|
||||||
<Route path="/admin" element={<AdminLogin />} />
|
<Route path="/admin" element={<AdminLogin />} />
|
||||||
|
|
||||||
{/* 默认重定向到首页 */}
|
{/* 后台管理系统路由(需要认证) */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="/admin/dashboard" element={
|
||||||
</Routes>
|
<AdminLayout>
|
||||||
</Router>
|
<AdminDashboard />
|
||||||
|
</AdminLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* 默认重定向到首页 */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
186
src/contexts/AuthContext.tsx
Normal file
186
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
// 管理员用户接口
|
||||||
|
export interface AdminUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
real_name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
status: number;
|
||||||
|
is_super_admin: number;
|
||||||
|
last_login_time: string;
|
||||||
|
last_login_ip: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证上下文接口
|
||||||
|
interface AuthContextType {
|
||||||
|
user: AdminUser | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建认证上下文
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// 模拟API调用
|
||||||
|
const mockApiCall = {
|
||||||
|
login: async (username: string, password: string): Promise<AdminUser> => {
|
||||||
|
// 模拟网络延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// 模拟验证逻辑
|
||||||
|
if (username === 'admin' && password === 'admin123') {
|
||||||
|
const mockUser: AdminUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
real_name: '系统管理员',
|
||||||
|
email: 'admin@mhxy.com',
|
||||||
|
phone: '13800138000',
|
||||||
|
status: 1,
|
||||||
|
is_super_admin: 1,
|
||||||
|
last_login_time: new Date().toISOString(),
|
||||||
|
last_login_ip: '127.0.0.1',
|
||||||
|
created_at: '2025-12-12T00:00:00.000Z',
|
||||||
|
updated_at: '2025-12-12T00:00:00.000Z'
|
||||||
|
};
|
||||||
|
return mockUser;
|
||||||
|
} else {
|
||||||
|
throw new Error('用户名或密码错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证提供者组件
|
||||||
|
* @param children - 子组件
|
||||||
|
*/
|
||||||
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<AdminUser | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 检查本地存储中的用户信息
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuthStatus = () => {
|
||||||
|
try {
|
||||||
|
const storedUser = localStorage.getItem('admin_user');
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
|
||||||
|
if (storedUser && token) {
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查认证状态失败:', error);
|
||||||
|
// 清除无效的本地存储
|
||||||
|
localStorage.removeItem('admin_user');
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuthStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录方法
|
||||||
|
* @param username - 用户名
|
||||||
|
* @param password - 密码
|
||||||
|
* @returns Promise<boolean> - 登录是否成功
|
||||||
|
*/
|
||||||
|
const login = async (username: string, password: string): Promise<boolean> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const loggedInUser = await mockApiCall.login(username, password);
|
||||||
|
|
||||||
|
// 存储用户信息和token
|
||||||
|
const token = `mock_token_${loggedInUser.id}_${Date.now()}`;
|
||||||
|
localStorage.setItem('admin_user', JSON.stringify(loggedInUser));
|
||||||
|
localStorage.setItem('admin_token', token);
|
||||||
|
|
||||||
|
setUser(loggedInUser);
|
||||||
|
message.success(`欢迎回来,${loggedInUser.real_name}!`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '登录失败,请重试');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出方法
|
||||||
|
*/
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('admin_user');
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
setUser(null);
|
||||||
|
message.info('已安全登出');
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
login,
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用认证上下文的Hook
|
||||||
|
* @returns AuthContextType
|
||||||
|
*/
|
||||||
|
export const useAuth = (): AuthContextType => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth 必须在 AuthProvider 内部使用');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员权限验证组件
|
||||||
|
* @param children - 子组件
|
||||||
|
* @param fallback - 未认证时的回退组件
|
||||||
|
*/
|
||||||
|
export const AdminProtectedRoute: React.FC<{
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}> = ({ children, fallback }) => {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh'
|
||||||
|
}}>
|
||||||
|
正在验证身份...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return fallback || <div>请先登录</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthContext;
|
||||||
440
src/layouts/AdminLayout.tsx
Normal file
440
src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Layout, Menu, Avatar, Dropdown, Button, theme, Space, Typography, Badge, Drawer } from 'antd';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
|
import {
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
BellOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
MobileOutlined,
|
||||||
|
FileTextOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import TabNavigation from './TabNavigation';
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// 定义菜单项类型
|
||||||
|
interface MenuItem {
|
||||||
|
key: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
children?: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单项配置
|
||||||
|
*/
|
||||||
|
const menuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: '/admin/dashboard',
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
label: '工作台'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: '系统管理',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/admin/system/users',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: '用户管理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/system/roles',
|
||||||
|
icon: <SafetyOutlined />,
|
||||||
|
label: '角色管理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/system/permissions',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: '权限管理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/system/config',
|
||||||
|
icon: <ToolOutlined />,
|
||||||
|
label: '系统配置'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'game',
|
||||||
|
icon: <PlayCircleOutlined />,
|
||||||
|
label: '游戏管理',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/admin/game/servers',
|
||||||
|
icon: <MobileOutlined />,
|
||||||
|
label: '服务器管理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/game/goods',
|
||||||
|
icon: <DollarOutlined />,
|
||||||
|
label: '道具管理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/game/announcement',
|
||||||
|
icon: <BellOutlined />,
|
||||||
|
label: '公告管理'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'finance',
|
||||||
|
icon: <DollarOutlined />,
|
||||||
|
label: '财务管理',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/admin/finance/recharge',
|
||||||
|
icon: <DollarOutlined />,
|
||||||
|
label: '充值记录'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/finance/order',
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
label: '订单管理'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'report',
|
||||||
|
icon: <BarChartOutlined />,
|
||||||
|
label: '数据统计',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/admin/report/user',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: '用户统计'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/report/finance',
|
||||||
|
icon: <DollarOutlined />,
|
||||||
|
label: '财务统计'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/report/game',
|
||||||
|
icon: <PlayCircleOutlined />,
|
||||||
|
label: '游戏统计'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员后台主布局组件
|
||||||
|
* 采用 Header - Sider - Content 布局
|
||||||
|
*/
|
||||||
|
const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false);
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
// 响应式检测
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
if (window.innerWidth >= 768) {
|
||||||
|
setMobileDrawerVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIsMobile();
|
||||||
|
window.addEventListener('resize', checkIsMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkIsMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理用户下拉菜单点击
|
||||||
|
*/
|
||||||
|
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'profile':
|
||||||
|
navigate('/admin/profile');
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
navigate('/admin/settings');
|
||||||
|
break;
|
||||||
|
case 'logout':
|
||||||
|
logout();
|
||||||
|
navigate('/admin', { replace: true });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户下拉菜单配置
|
||||||
|
*/
|
||||||
|
const userMenuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: '个人资料'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: '系统设置'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出登录'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理菜单点击
|
||||||
|
*/
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
navigate(key);
|
||||||
|
// 在移动端点击菜单后关闭抽屉
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileDrawerVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取当前选中的菜单项
|
||||||
|
const getSelectedKeys = () => {
|
||||||
|
const pathname = location.pathname;
|
||||||
|
// 查找匹配的菜单项
|
||||||
|
const findSelectedKey = (items: MenuItem[], path: string): string[] => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.key === path) {
|
||||||
|
return [item.key];
|
||||||
|
}
|
||||||
|
if (item.children) {
|
||||||
|
const result = findSelectedKey(item.children, path);
|
||||||
|
if (result.length > 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return findSelectedKey(menuItems as MenuItem[], pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取展开的菜单项
|
||||||
|
const getOpenKeys = () => {
|
||||||
|
const pathname = location.pathname;
|
||||||
|
// 查找需要展开的父级菜单
|
||||||
|
const findOpenKeys = (items: MenuItem[], path: string): string[] => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.children) {
|
||||||
|
const hasSelectedChild = item.children.some((child: MenuItem) =>
|
||||||
|
child.key === path || findOpenKeys([child], path).length > 0
|
||||||
|
);
|
||||||
|
if (hasSelectedChild) {
|
||||||
|
return [item.key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return findOpenKeys(menuItems as MenuItem[], pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 侧边栏内容
|
||||||
|
const siderContent = (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Logo 区域 */}
|
||||||
|
<div style={{
|
||||||
|
height: '64px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
padding: collapsed ? '0' : '0 24px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
background: token.colorBgContainer
|
||||||
|
}}>
|
||||||
|
{!collapsed ? (
|
||||||
|
<Space size="small">
|
||||||
|
<div style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
background: 'linear-gradient(45deg, #1890ff, #722ed1)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}>
|
||||||
|
<SafetyOutlined />
|
||||||
|
</div>
|
||||||
|
<Text strong style={{ fontSize: '16px', color: token.colorText }}>
|
||||||
|
运营管理系统
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
background: 'linear-gradient(45deg, #1890ff, #722ed1)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}>
|
||||||
|
<SafetyOutlined />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 菜单区域 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={getSelectedKeys()}
|
||||||
|
defaultOpenKeys={getOpenKeys()}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
borderRight: 0,
|
||||||
|
background: 'transparent'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
{/* 侧边栏 - 桌面端 */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Sider
|
||||||
|
trigger={null}
|
||||||
|
collapsible
|
||||||
|
collapsed={collapsed}
|
||||||
|
width={240}
|
||||||
|
style={{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderRight: `1px solid ${token.colorBorderSecondary}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{siderContent}
|
||||||
|
</Sider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 侧边栏 - 移动端 Drawer */}
|
||||||
|
<Drawer
|
||||||
|
title="菜单"
|
||||||
|
placement="left"
|
||||||
|
onClose={() => setMobileDrawerVisible(false)}
|
||||||
|
open={mobileDrawerVisible}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
{siderContent}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
{/* 头部 */}
|
||||||
|
<Header style={{
|
||||||
|
padding: '0 24px',
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100
|
||||||
|
}}>
|
||||||
|
{/* 左侧控制区 */}
|
||||||
|
<Space>
|
||||||
|
{/* 折叠按钮 */}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 移动端菜单按钮 */}
|
||||||
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MenuFoldOutlined />}
|
||||||
|
onClick={() => setMobileDrawerVisible(true)}
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 右侧功能区 */}
|
||||||
|
<Space size="middle">
|
||||||
|
{/* 通知铃铛 */}
|
||||||
|
<Badge count={3} size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<BellOutlined />}
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* 用户信息 */}
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
|
||||||
|
placement="bottomRight"
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<Space style={{ cursor: 'pointer' }}>
|
||||||
|
<Avatar
|
||||||
|
size="small"
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
style={{ backgroundColor: token.colorPrimary }}
|
||||||
|
/>
|
||||||
|
<Text>{user?.real_name || user?.username}</Text>
|
||||||
|
</Space>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{/* 标签导航 */}
|
||||||
|
<TabNavigation />
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<Content style={{
|
||||||
|
margin: '16px',
|
||||||
|
padding: '24px',
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderRadius: token.borderRadiusLG,
|
||||||
|
minHeight: 'calc(100vh - 64px - 48px - 32px)',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminLayout;
|
||||||
269
src/layouts/TabNavigation.tsx
Normal file
269
src/layouts/TabNavigation.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Tabs, Button, Space, Dropdown, Typography, theme } from 'antd';
|
||||||
|
import type { TabsProps } from 'antd';
|
||||||
|
import {
|
||||||
|
MoreOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
BellOutlined,
|
||||||
|
MobileOutlined,
|
||||||
|
FileTextOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签页项接口
|
||||||
|
*/
|
||||||
|
export interface TabItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
closable?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签式导航组件
|
||||||
|
* 在主页面中的顶部以浏览器标签的信息展现已经打开的页面
|
||||||
|
*/
|
||||||
|
const TabNavigation: React.FC = () => {
|
||||||
|
const [tabItems, setTabItems] = useState<TabItem[]>([]);
|
||||||
|
const [activeKey, setActiveKey] = useState<string>('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由到标签配置的映射
|
||||||
|
*/
|
||||||
|
const routeConfig: Record<string, { label: string; icon: React.ReactNode; closable: boolean }> = {
|
||||||
|
'/admin/dashboard': { label: '工作台', icon: <DashboardOutlined />, closable: false },
|
||||||
|
'/admin/system/users': { label: '用户管理', icon: <TeamOutlined />, closable: true },
|
||||||
|
'/admin/system/roles': { label: '角色管理', icon: <SafetyOutlined />, closable: true },
|
||||||
|
'/admin/system/permissions': { label: '权限管理', icon: <UserOutlined />, closable: true },
|
||||||
|
'/admin/system/config': { label: '系统配置', icon: <ToolOutlined />, closable: true },
|
||||||
|
'/admin/game/servers': { label: '服务器管理', icon: <MobileOutlined />, closable: true },
|
||||||
|
'/admin/game/goods': { label: '道具管理', icon: <DollarOutlined />, closable: true },
|
||||||
|
'/admin/game/announcement': { label: '公告管理', icon: <BellOutlined />, closable: true },
|
||||||
|
'/admin/finance/recharge': { label: '充值记录', icon: <DollarOutlined />, closable: true },
|
||||||
|
'/admin/finance/order': { label: '订单管理', icon: <FileTextOutlined />, closable: true },
|
||||||
|
'/admin/report/user': { label: '用户统计', icon: <TeamOutlined />, closable: true },
|
||||||
|
'/admin/report/finance': { label: '财务统计', icon: <DollarOutlined />, closable: true },
|
||||||
|
'/admin/report/game': { label: '游戏统计', icon: <PlayCircleOutlined />, closable: true },
|
||||||
|
'/admin/profile': { label: '个人资料', icon: <UserOutlined />, closable: true },
|
||||||
|
'/admin/settings': { label: '系统设置', icon: <SettingOutlined />, closable: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化和更新标签页
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
const config = routeConfig[currentPath];
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
setTabItems(prevItems => {
|
||||||
|
// 检查是否已存在该标签
|
||||||
|
const existingIndex = prevItems.findIndex(item => item.key === currentPath);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// 标签已存在,只更新激活状态
|
||||||
|
const newItems = [...prevItems];
|
||||||
|
setActiveKey(currentPath);
|
||||||
|
return newItems;
|
||||||
|
} else {
|
||||||
|
// 添加新标签
|
||||||
|
const newItem: TabItem = {
|
||||||
|
key: currentPath,
|
||||||
|
label: config.label,
|
||||||
|
icon: config.icon,
|
||||||
|
closable: config.closable
|
||||||
|
};
|
||||||
|
|
||||||
|
const newItems = [...prevItems, newItem];
|
||||||
|
setActiveKey(currentPath);
|
||||||
|
return newItems;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭标签页
|
||||||
|
* @param targetKey - 要关闭的标签key
|
||||||
|
*/
|
||||||
|
const handleClose = (targetKey: string) => {
|
||||||
|
setTabItems(prevItems => {
|
||||||
|
const itemIndex = prevItems.findIndex(item => item.key === targetKey);
|
||||||
|
if (itemIndex === -1) return prevItems;
|
||||||
|
|
||||||
|
const newItems = prevItems.filter(item => item.key !== targetKey);
|
||||||
|
|
||||||
|
// 如果关闭的是当前激活的标签
|
||||||
|
if (targetKey === activeKey) {
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
// 激活策略:优先激活右侧标签,否则激活左侧
|
||||||
|
const newActiveIndex = itemIndex < newItems.length ? itemIndex : itemIndex - 1;
|
||||||
|
const newActiveKey = newItems[newActiveIndex]?.key;
|
||||||
|
if (newActiveKey) {
|
||||||
|
setActiveKey(newActiveKey);
|
||||||
|
navigate(newActiveKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有标签了,返回工作台
|
||||||
|
setActiveKey('');
|
||||||
|
navigate('/admin/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理标签点击
|
||||||
|
* @param key - 标签key
|
||||||
|
*/
|
||||||
|
const handleTabClick = (key: string) => {
|
||||||
|
if (key !== activeKey) {
|
||||||
|
setActiveKey(key);
|
||||||
|
navigate(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有标签项的TabsProps配置
|
||||||
|
*/
|
||||||
|
const getTabItems = (): TabsProps['items'] => {
|
||||||
|
return tabItems.map(item => ({
|
||||||
|
key: item.key,
|
||||||
|
label: (
|
||||||
|
<Space size="small">
|
||||||
|
{item.icon}
|
||||||
|
<span style={{ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
closable: item.closable !== false,
|
||||||
|
onClick: () => handleTabClick(item.key)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭其他标签
|
||||||
|
*/
|
||||||
|
const closeOthers = () => {
|
||||||
|
const currentItem = tabItems.find(item => item.key === activeKey);
|
||||||
|
if (currentItem && currentItem.closable !== false) {
|
||||||
|
setTabItems([currentItem]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭所有标签
|
||||||
|
*/
|
||||||
|
const closeAll = () => {
|
||||||
|
const defaultItem = tabItems.find(item => !item.closable);
|
||||||
|
if (defaultItem) {
|
||||||
|
setTabItems([defaultItem]);
|
||||||
|
setActiveKey(defaultItem.key);
|
||||||
|
navigate(defaultItem.key);
|
||||||
|
} else {
|
||||||
|
setTabItems([]);
|
||||||
|
setActiveKey('');
|
||||||
|
navigate('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更多操作下拉菜单
|
||||||
|
*/
|
||||||
|
const moreMenuItems = [
|
||||||
|
{
|
||||||
|
key: 'close-others',
|
||||||
|
label: '关闭其他',
|
||||||
|
onClick: closeOthers,
|
||||||
|
disabled: tabItems.filter(item => item.closable !== false).length <= 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'close-all',
|
||||||
|
label: '关闭所有',
|
||||||
|
onClick: closeAll,
|
||||||
|
disabled: tabItems.filter(item => item.closable !== false).length === 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 如果没有标签页,不渲染
|
||||||
|
if (tabItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 24px 0',
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
{/* 标签页区域 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<Tabs
|
||||||
|
type="editable-card"
|
||||||
|
activeKey={activeKey}
|
||||||
|
items={getTabItems()}
|
||||||
|
onEdit={(targetKey, action) => {
|
||||||
|
if (action === 'remove') {
|
||||||
|
handleClose(targetKey as string);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={handleTabClick}
|
||||||
|
hideAdd
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
margin: 0
|
||||||
|
}}
|
||||||
|
tabBarStyle={{
|
||||||
|
margin: 0,
|
||||||
|
background: 'transparent'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 更多操作按钮 */}
|
||||||
|
{tabItems.filter(item => item.closable !== false).length > 0 && (
|
||||||
|
<div style={{ marginLeft: '8px' }}>
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: moreMenuItems,
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
const action = moreMenuItems.find(item => item.key === key);
|
||||||
|
action?.onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placement="bottomRight"
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MoreOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{ height: '32px' }}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TabNavigation;
|
||||||
441
src/pages/AdminDashboard.tsx
Normal file
441
src/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
Progress,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Button,
|
||||||
|
Tag,
|
||||||
|
theme
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
FireOutlined,
|
||||||
|
ShoppingCartOutlined,
|
||||||
|
NotificationOutlined,
|
||||||
|
RiseOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟数据接口
|
||||||
|
*/
|
||||||
|
interface DashboardStats {
|
||||||
|
totalUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
todayRevenue: number;
|
||||||
|
monthlyRevenue: number;
|
||||||
|
totalServers: number;
|
||||||
|
onlineServers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickAction {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentActivity {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
action: string;
|
||||||
|
time: string;
|
||||||
|
status: 'success' | 'warning' | 'error' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemAlert {
|
||||||
|
id: string;
|
||||||
|
type: 'info' | 'warning' | 'error';
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
resolved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认工作台页面
|
||||||
|
* 管理员登录后的首页
|
||||||
|
*/
|
||||||
|
const AdminDashboard: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
const dashboardStats: DashboardStats = {
|
||||||
|
totalUsers: 125840,
|
||||||
|
activeUsers: 8756,
|
||||||
|
todayRevenue: 125680.50,
|
||||||
|
monthlyRevenue: 3756000.00,
|
||||||
|
totalServers: 24,
|
||||||
|
onlineServers: 23
|
||||||
|
};
|
||||||
|
|
||||||
|
// 快速操作
|
||||||
|
const quickActions: QuickAction[] = [
|
||||||
|
{
|
||||||
|
title: '用户管理',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
color: '#1890ff',
|
||||||
|
action: () => console.log('跳转到用户管理')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '服务器监控',
|
||||||
|
icon: <FireOutlined />,
|
||||||
|
color: '#fa541c',
|
||||||
|
action: () => console.log('跳转到服务器监控')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '订单处理',
|
||||||
|
icon: <ShoppingCartOutlined />,
|
||||||
|
color: '#52c41a',
|
||||||
|
action: () => console.log('跳转到订单处理')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '系统公告',
|
||||||
|
icon: <NotificationOutlined />,
|
||||||
|
color: '#722ed1',
|
||||||
|
action: () => console.log('跳转到系统公告')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 最近活动数据
|
||||||
|
const recentActivities: RecentActivity[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
user: '张三',
|
||||||
|
action: '完成了用户充值审核',
|
||||||
|
time: '2分钟前',
|
||||||
|
status: 'success'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
user: '李四',
|
||||||
|
action: '提交了服务器维护申请',
|
||||||
|
time: '5分钟前',
|
||||||
|
status: 'warning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
user: '王五',
|
||||||
|
action: '处理了玩家投诉',
|
||||||
|
time: '10分钟前',
|
||||||
|
status: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
user: '赵六',
|
||||||
|
action: '更新了游戏道具信息',
|
||||||
|
time: '15分钟前',
|
||||||
|
status: 'success'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
user: '孙七',
|
||||||
|
action: '删除了异常订单',
|
||||||
|
time: '20分钟前',
|
||||||
|
status: 'error'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 系统告警数据
|
||||||
|
const systemAlerts: SystemAlert[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'warning',
|
||||||
|
message: '服务器 CPU 使用率超过 80%',
|
||||||
|
time: '5分钟前',
|
||||||
|
resolved: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'info',
|
||||||
|
message: '今日玩家活跃度较昨日增长 15%',
|
||||||
|
time: '10分钟前',
|
||||||
|
resolved: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'error',
|
||||||
|
message: '支付接口响应异常',
|
||||||
|
time: '30分钟前',
|
||||||
|
resolved: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 获取状态图标
|
||||||
|
const getStatusIcon = (status: RecentActivity['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircleOutlined style={{ color: '#52c41a' }} />;
|
||||||
|
case 'warning':
|
||||||
|
return <WarningOutlined style={{ color: '#faad14' }} />;
|
||||||
|
case 'error':
|
||||||
|
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />;
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return <ClockCircleOutlined style={{ color: '#1890ff' }} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取告警标签颜色
|
||||||
|
const getAlertColor = (type: SystemAlert['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
case 'warning':
|
||||||
|
return 'warning';
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return 'processing';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0' }}>
|
||||||
|
{/* 欢迎标题 */}
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<Title level={2} style={{ margin: 0, color: token.colorText }}>
|
||||||
|
欢迎回来,{user?.real_name || user?.username}!
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
今天是 {new Date().toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'long'
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="总用户数"
|
||||||
|
value={dashboardStats.totalUsers}
|
||||||
|
prefix={<UserOutlined style={{ color: '#1890ff' }} />}
|
||||||
|
suffix="人"
|
||||||
|
styles={{ content: { color: '#1890ff' } }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
较上月增长 <Text type="success">+12.5%</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="在线用户"
|
||||||
|
value={dashboardStats.activeUsers}
|
||||||
|
prefix={<TeamOutlined style={{ color: '#52c41a' }} />}
|
||||||
|
suffix="人"
|
||||||
|
styles={{ content: { color: '#52c41a' } }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round((dashboardStats.activeUsers / dashboardStats.totalUsers) * 100)}
|
||||||
|
size="small"
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor="#52c41a"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="今日收入"
|
||||||
|
value={dashboardStats.todayRevenue}
|
||||||
|
prefix={<DollarOutlined style={{ color: '#fa8c16' }} />}
|
||||||
|
suffix="元"
|
||||||
|
precision={2}
|
||||||
|
styles={{ content: { color: '#fa8c16' } }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
较昨日 <Text type="success">+8.3%</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="月度收入"
|
||||||
|
value={dashboardStats.monthlyRevenue}
|
||||||
|
prefix={<RiseOutlined style={{ color: '#722ed1' }} />}
|
||||||
|
suffix="元"
|
||||||
|
precision={2}
|
||||||
|
styles={{ content: { color: '#722ed1' } }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
目标完成度 <Text type="success">85.6%</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{/* 快速操作 */}
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<Card title="快速操作" style={{ height: '100%' }}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{quickActions.map((action, index) => (
|
||||||
|
<Col xs={12} key={index}>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
icon={action.icon}
|
||||||
|
style={{
|
||||||
|
height: '80px',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderColor: action.color,
|
||||||
|
color: action.color
|
||||||
|
}}
|
||||||
|
onClick={action.action}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '18px', marginBottom: '4px' }}>
|
||||||
|
{action.icon}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '12px' }}>{action.title}</span>
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 最近活动 */}
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<Card title="最近活动" style={{ height: '100%' }}>
|
||||||
|
<div style={{ maxHeight: '300px', overflow: 'auto' }}>
|
||||||
|
{recentActivities.map((item) => (
|
||||||
|
<div key={item.id} style={{
|
||||||
|
padding: '12px 0',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<div style={{ marginTop: '2px' }}>
|
||||||
|
{getStatusIcon(item.status)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '4px'
|
||||||
|
}}>
|
||||||
|
<Text strong style={{ fontSize: '13px' }}>{item.user}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>{item.time}</Text>
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
{item.action}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
||||||
|
<Button type="link" size="small">查看全部</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 系统状态 */}
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<Card title="系统状态" style={{ height: '100%' }}>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||||
|
<Text>服务器在线率</Text>
|
||||||
|
<Text strong>
|
||||||
|
{Math.round((dashboardStats.onlineServers / dashboardStats.totalServers) * 100)}%
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round((dashboardStats.onlineServers / dashboardStats.totalServers) * 100)}
|
||||||
|
strokeColor={dashboardStats.onlineServers === dashboardStats.totalServers ? '#52c41a' : '#faad14'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<Text strong style={{ fontSize: '13px' }}>系统告警</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ maxHeight: '200px', overflow: 'auto' }}>
|
||||||
|
{systemAlerts.map((alert) => (
|
||||||
|
<div key={alert.id} style={{ padding: '8px 0', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Tag color={getAlertColor(alert.type)} style={{ margin: 0 }}>
|
||||||
|
{alert.type === 'error' ? '错误' : alert.type === 'warning' ? '警告' : '信息'}
|
||||||
|
</Tag>
|
||||||
|
<Text style={{ fontSize: '12px', flex: 1 }}>
|
||||||
|
{alert.message}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: '11px' }}>
|
||||||
|
{alert.time}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
||||||
|
<Button type="link" size="small">查看全部告警</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 底部信息 */}
|
||||||
|
<Row style={{ marginTop: '24px' }}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Card>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Space>
|
||||||
|
<TrophyOutlined style={{ color: '#faad14', fontSize: '18px' }} />
|
||||||
|
<div>
|
||||||
|
<Text strong>本月运营之星</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
李明 - 完成订单处理 1,256 单
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
系统版本:v1.0.0 | 最后更新:2025-12-12
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminDashboard;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Form, Input, Button, Card, Typography, message, Space, Checkbox } from 'antd';
|
import { Form, Input, Button, Card, Typography, message, Space, Checkbox } from 'antd';
|
||||||
import { UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons';
|
import { UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -11,11 +12,20 @@ const { Title, Text } = Typography;
|
|||||||
*/
|
*/
|
||||||
const AdminLogin: React.FC = () => {
|
const AdminLogin: React.FC = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login, isAuthenticated } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// 如果已登录,重定向到管理后台
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/admin/dashboard', { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理登录表单提交
|
* 处理登录表单提交
|
||||||
* @param values - 表单值(故意未使用)
|
* @param values - 表单值
|
||||||
*/
|
*/
|
||||||
const handleLogin = async (values: {
|
const handleLogin = async (values: {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -24,19 +34,13 @@ const AdminLogin: React.FC = () => {
|
|||||||
}) => {
|
}) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 模拟登录请求
|
const success = await login(values.username, values.password);
|
||||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
|
||||||
|
|
||||||
// 这里应该是实际的API调用
|
if (success) {
|
||||||
// const response = await loginAdmin(values);
|
navigate('/admin/dashboard', { replace: true });
|
||||||
|
}
|
||||||
// 模拟登录成功
|
} catch (error) {
|
||||||
message.success('登录成功!欢迎进入运营管理系统!');
|
// 错误已在AuthContext中处理
|
||||||
navigate('/admin/dashboard');
|
|
||||||
} catch {
|
|
||||||
// 故意使用 values 参数以避免 TypeScript 未使用警告
|
|
||||||
void values;
|
|
||||||
message.error('登录失败,请检查用户名和密码!');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user