Compare commits

..

6 Commits

Author SHA1 Message Date
Stev_Wang
c962fac20e chore: 📌 细微调整 2026-01-04 11:18:00 +08:00
Stev_Wang
b2ca0d86f2 chore: 📌 细节调整 2026-01-03 21:07:04 +08:00
Stev_Wang
5b8999b188 chore: 📌 优化后台风格显示 2026-01-03 20:11:05 +08:00
Stev_Wang
a950d1d526 feat: 添加用户管理页 2026-01-03 19:25:56 +08:00
Stev_Wang
cb5088115a fix: 🐛 修复玩家和管理员登录后刷新自动退出的bug优 2025-12-27 21:12:05 +08:00
Stev_Wang
598cd3026b refactor: ♻️ 使用Ant Design全局化配置 2025-12-27 20:31:42 +08:00
25 changed files with 906 additions and 105 deletions

6
.gitignore vendored
View File

@@ -58,14 +58,14 @@ Thumbs.db
*.temp
.cache/
# Frontend specific
# Frontend specific (React + Vite)
frontend/dist/
frontend/node_modules/
frontend/.env
frontend/.env.local
frontend/.env.*.local
# Backend specific
# Backend specific (NestJS)
backend/dist/
backend/node_modules/
backend/.env
@@ -73,7 +73,7 @@ backend/.env.local
backend/.env.*.local
backend/test-game-server.js
# Package manager files
# Package manager lock files
package-lock.json
yarn.lock
pnpm-lock.yaml

View File

@@ -0,0 +1,52 @@
import { Controller, Get, Post, Put, Delete, Body, Param, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
import { AdminUsersService } from './admin-users.service';
import { AdminRole } from '../entities/admin-user.entity';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@Controller('admin/admin-users')
@UseGuards(JwtAuthGuard)
export class AdminUsersController {
constructor(private adminUsersService: AdminUsersService) {}
@Get()
@HttpCode(HttpStatus.OK)
async findAll() {
return this.adminUsersService.findAll();
}
@Get(':id')
@HttpCode(HttpStatus.OK)
async findOne(@Param('id') id: string) {
return this.adminUsersService.findOne(Number(id));
}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() createAdminUserDto: {
username: string;
password: string;
role: AdminRole;
}) {
return this.adminUsersService.create(createAdminUserDto);
}
@Put(':id')
@HttpCode(HttpStatus.OK)
async update(
@Param('id') id: string,
@Body() updateAdminUserDto: {
username?: string;
password?: string;
role?: AdminRole;
},
) {
return this.adminUsersService.update(Number(id), updateAdminUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
async remove(@Param('id') id: string) {
await this.adminUsersService.remove(Number(id));
return { message: '删除成功' };
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { AdminUser } from '../entities/admin-user.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([AdminUser]), AuthModule],
controllers: [AdminUsersController],
providers: [AdminUsersService],
exports: [AdminUsersService],
})
export class AdminUsersModule {}

View File

@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AdminUser, AdminRole } from '../entities/admin-user.entity';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AdminUsersService {
constructor(
@InjectRepository(AdminUser)
private adminUserRepository: Repository<AdminUser>,
) {}
async findAll(): Promise<AdminUser[]> {
return this.adminUserRepository.find({
select: ['id', 'username', 'role', 'createdAt', 'updatedAt'],
order: { createdAt: 'DESC' },
});
}
async findOne(id: number): Promise<AdminUser | null> {
return this.adminUserRepository.findOne({
where: { id },
select: ['id', 'username', 'role', 'createdAt', 'updatedAt'],
});
}
async findByUsername(username: string): Promise<AdminUser | null> {
return this.adminUserRepository.findOne({
where: { username },
});
}
async create(createAdminUserDto: {
username: string;
password: string;
role: AdminRole;
}): Promise<AdminUser> {
const hashedPassword = await bcrypt.hash(createAdminUserDto.password, 10);
const adminUser = this.adminUserRepository.create({
username: createAdminUserDto.username,
passwordHash: hashedPassword,
role: createAdminUserDto.role,
});
return this.adminUserRepository.save(adminUser);
}
async update(id: number, updateAdminUserDto: {
username?: string;
password?: string;
role?: AdminRole;
}): Promise<AdminUser | null> {
const adminUser = await this.findOne(id);
if (!adminUser) {
return null;
}
if (updateAdminUserDto.username) {
adminUser.username = updateAdminUserDto.username;
}
if (updateAdminUserDto.password) {
adminUser.passwordHash = await bcrypt.hash(updateAdminUserDto.password, 10);
}
if (updateAdminUserDto.role) {
adminUser.role = updateAdminUserDto.role;
}
return this.adminUserRepository.save(adminUser);
}
async remove(id: number): Promise<boolean> {
const result = await this.adminUserRepository.delete(id);
return (result.affected ?? 0) > 0;
}
}

View File

@@ -5,6 +5,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { PlayerModule } from './player/player.module';
import { AdminUsersModule } from './admin-users/admin-users.module';
@Module({
imports: [
@@ -26,6 +27,7 @@ import { PlayerModule } from './player/player.module';
}),
AuthModule,
PlayerModule,
AdminUsersModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -17,6 +17,6 @@ import { AdminUser } from '../entities/admin-user.entity';
],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,30 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader) {
throw new UnauthorizedException('未提供认证令牌');
}
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) {
throw new UnauthorizedException('无效的认证令牌格式');
}
try {
const payload = await this.jwtService.verifyAsync(token);
request.user = payload;
return true;
} catch (error) {
throw new UnauthorizedException('无效的认证令牌');
}
}
}

View File

@@ -10,7 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"antd": "^6.1.2",
"antd": "6.1.3",
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",

View File

@@ -3,8 +3,9 @@ import { useAuthStore } from '../stores/authStore';
const AdminAuthRoute = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const authType = useAuthStore((state) => state.authType);
if (!isAuthenticated) {
if (!isAuthenticated || authType !== 'admin') {
return <Navigate to="/admin/login" replace />;
}

View File

@@ -1,16 +1,18 @@
import { useState } from 'react';
import { Layout, Menu, Button, theme } from 'antd';
import { useState, useMemo } from 'react';
import { Layout, Menu, Button, theme, App } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
DashboardOutlined,
UserOutlined,
SettingOutlined,
LogoutOutlined,
SunOutlined,
MoonOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import { useThemeStore } from '../stores/themeStore';
import { adminAuthService } from '../services/adminAuthService';
import { message } from 'antd';
const { Header, Sider, Content, Footer } = Layout;
@@ -23,15 +25,41 @@ const AdminLayout = () => {
const location = useLocation();
const adminUser = useAuthStore((state) => state.adminUser);
const logout = useAuthStore((state) => state.logout);
const { message } = App.useApp();
const { themeMode, toggleTheme } = useThemeStore();
// 跟踪用户是否手动修改过 openKeys
const [userInteracted, setUserInteracted] = useState(false);
// 用户手动设置的 openKeys
const [userOpenKeys, setUserOpenKeys] = useState<string[]>([]);
// 根据当前路径计算应该展开的菜单
const defaultOpenKeys = useMemo(() => {
const path = location.pathname;
if (path.startsWith('/admin/users')) {
return ['user'];
}
return [];
}, [location.pathname]);
// 最终使用的 openKeys如果用户手动修改过使用用户的设置否则使用默认值
const openKeys = useMemo(() => {
return userInteracted ? userOpenKeys : defaultOpenKeys;
}, [userInteracted, userOpenKeys, defaultOpenKeys]);
// 处理菜单展开/收起
const handleOpenChange = (keys: string[]) => {
setUserInteracted(true);
setUserOpenKeys(keys);
};
const handleLogout = async () => {
try {
await adminAuthService.logout();
localStorage.removeItem('adminToken');
logout();
message.success('登出成功');
navigate('/admin/login');
} catch (error) {
} catch {
message.error('登出失败');
}
};
@@ -44,12 +72,12 @@ const AdminLayout = () => {
},
{
key: 'user',
icon: <UserOutlined />,
label: '用户管理',
icon: <SettingOutlined />,
label: '系统管理',
children: [
{
key: '/admin/users',
label: '用户列表',
label: '用户管理',
},
],
},
@@ -57,26 +85,35 @@ const AdminLayout = () => {
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed}>
<Sider
trigger={null}
collapsible
collapsed={collapsed}
style={{
background: themeMode === 'dark' ? '#001529' : '#ffffff',
}}
>
<div
style={{
height: 32,
margin: 16,
background: 'rgba(255, 255, 255, 0.2)',
background: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.05)',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
color: themeMode === 'dark' ? 'white' : '#1890ff',
fontWeight: 'bold',
}}
>
{collapsed ? '运营' : '运营管理系统'}
</div>
<Menu
theme="dark"
theme={themeMode === 'dark' ? 'dark' : 'light'}
mode="inline"
selectedKeys={[location.pathname]}
openKeys={openKeys}
onOpenChange={handleOpenChange}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
@@ -100,14 +137,23 @@ const AdminLayout = () => {
fontSize: '16px',
width: 64,
height: 64,
outline: 'none',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Button
type="text"
icon={themeMode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
onClick={toggleTheme}
style={{ outline: 'none' }}
title={themeMode === 'dark' ? '切换到亮色主题' : '切换到暗色主题'}
/>
<span>, {adminUser?.username}</span>
<Button
type="text"
icon={<LogoutOutlined />}
onClick={handleLogout}
style={{ outline: 'none' }}
>
退
</Button>
@@ -124,7 +170,12 @@ const AdminLayout = () => {
>
<Outlet />
</Content>
<Footer style={{ textAlign: 'center' }}>
<Footer
style={{
textAlign: 'center',
background: themeMode === 'dark' ? '#001529' : '#ffffff',
}}
>
西 ©2025 Created by JGE
</Footer>
</Layout>

View File

@@ -3,8 +3,25 @@ import { useAuthStore } from '../stores/authStore';
const PlayerAuthRoute = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const authType = useAuthStore((state) => state.authType);
const setAuth = useAuthStore((state) => state.setAuth);
if (!isAuthenticated) {
// 检查是否有playerToken
const playerToken = localStorage.getItem('playerToken');
const playerUserStr = localStorage.getItem('playerUser');
// 如果有playerToken但authType不是player说明需要重新加载玩家认证
if (playerToken && playerUserStr && authType !== 'player') {
try {
const playerUser = JSON.parse(playerUserStr);
setAuth(playerUser, playerToken, 'player');
} catch (e) {
console.error('解析playerUser失败:', e);
}
}
// 如果没有playerToken重定向到登录页
if (!playerToken) {
return <Navigate to="/player/login" replace />;
}

View File

@@ -1,4 +1,4 @@
import { Layout, Menu, Button, Dropdown } from 'antd';
import { Layout, Menu, Button, Dropdown, theme, App } from 'antd';
import {
HomeOutlined,
UserOutlined,
@@ -6,19 +6,21 @@ import {
DownOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { message } from 'antd';
import { playerAuthService } from '../services/playerAuthService';
const { Header, Content, Footer } = Layout;
const PlayerLayout = () => {
const {
token: { colorBgContainer, colorBgLayout },
} = theme.useToken();
const navigate = useNavigate();
const location = useLocation();
const { message } = App.useApp();
const handleLogout = async () => {
try {
await playerAuthService.logout();
localStorage.removeItem('playerToken');
message.success('登出成功');
navigate('/player/login');
} catch (error) {
@@ -54,46 +56,73 @@ const PlayerLayout = () => {
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
justifyContent: 'center',
background: '#001529',
padding: '0',
height: 64,
}}
>
{/* 导航条内容容器 */}
<div
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
maxWidth: 1200,
padding: '0 24px',
}}
>
{/* LOGO标题位 */}
<div
style={{
color: 'white',
color: '#ffffff',
fontSize: '20px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: 12,
flexShrink: 0,
}}
>
<span>西</span>
</div>
{/* 导航菜单位 */}
<div
style={{
flex: 1,
display: 'flex',
justifyContent: 'center',
margin: '0 24px',
}}
>
<Menu
theme="dark"
mode="horizontal"
selectedKeys={[location.pathname]}
items={menuItems}
style={{ flex: 1, minWidth: 0, justifyContent: 'center' }}
style={{ flex: 1, minWidth: 0, justifyContent: 'center', background: 'transparent' }}
onClick={({ key }) => navigate(key)}
/>
</div>
{/* 个人中心菜单位 */}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button
type="text"
style={{ color: 'white' }}
style={{ color: '#ffffff', flexShrink: 0 }}
icon={<DownOutlined />}
>
</Button>
</Dropdown>
</div>
</Header>
<Content
style={{
padding: '24px 50px',
minHeight: 'calc(100vh - 128px)',
background: '#f0f2f5',
background: colorBgContainer,
}}
>
<div style={{ padding: 24, minHeight: 380, background: 'white', borderRadius: 8 }}>
@@ -103,7 +132,7 @@ const PlayerLayout = () => {
<Footer
style={{
textAlign: 'center',
background: '#001529',
background: colorBgLayout,
color: 'rgba(255, 255, 255, 0.65)',
}}
>

View File

@@ -0,0 +1,34 @@
import { ReactNode, useMemo } from 'react';
import { ConfigProvider, theme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { useThemeStore } from '../stores/themeStore';
import { getThemeByMode } from '../theme';
interface ThemeProviderProps {
children: ReactNode;
}
// 主题提供者组件
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
const { themeMode } = useThemeStore();
// 根据主题模式获取主题配置和算法
const currentTheme = useMemo(() => {
const customTheme = getThemeByMode(themeMode);
const algorithm = themeMode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm;
return {
algorithm,
...customTheme,
};
}, [themeMode]);
return (
<ConfigProvider
locale={zhCN}
theme={currentTheme}
>
{children}
</ConfigProvider>
);
};

View File

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

View File

@@ -17,7 +17,7 @@ const AdminDashboard = () => {
title="总用户数"
value={11280}
prefix={<UserOutlined />}
valueStyle={{ color: '#3f8600' }}
styles={{ content: { color: '#3f8600' } }}
/>
</Card>
</Col>
@@ -27,7 +27,7 @@ const AdminDashboard = () => {
title="今日订单"
value={93}
prefix={<ShoppingOutlined />}
valueStyle={{ color: '#cf1322' }}
styles={{ content: { color: '#cf1322' } }}
/>
</Card>
</Col>
@@ -38,7 +38,7 @@ const AdminDashboard = () => {
value={11280}
prefix={<DollarOutlined />}
precision={2}
valueStyle={{ color: '#1890ff' }}
styles={{ content: { color: '#1890ff' } }}
/>
</Card>
</Col>
@@ -48,7 +48,7 @@ const AdminDashboard = () => {
title="活动参与"
value={93}
prefix={<TrophyOutlined />}
valueStyle={{ color: '#722ed1' }}
styles={{ content: { color: '#722ed1' } }}
/>
</Card>
</Col>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { Form, Input, Button, Card, App } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { adminAuthService, type LoginRequest } from '../../services/adminAuthService';
@@ -9,6 +9,7 @@ const AdminLogin = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const { message } = App.useApp();
const onFinish = async (values: LoginRequest) => {
setLoading(true);
@@ -20,9 +21,9 @@ const AdminLogin = () => {
username: response.username,
role: response.role,
},
response.accessToken
response.accessToken,
'admin'
);
localStorage.setItem('adminToken', response.accessToken);
message.success('登录成功');
navigate('/admin/dashboard');
} catch (error) {

View File

@@ -0,0 +1,249 @@
import { useState, useEffect } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
message,
Popconfirm,
Tag,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { adminUsersService, AdminRole } from '../../services/adminUsersService';
import type { AdminUser, CreateAdminUserDto, UpdateAdminUserDto } from '../../services/adminUsersService';
const AdminUsers = () => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [form] = Form.useForm();
const roleOptions = [
{ label: '超级管理员', value: AdminRole.SUPER_ADMIN },
{ label: '操作员', value: AdminRole.OPERATOR },
{ label: '查看者', value: AdminRole.VIEWER },
];
const roleLabels: Record<AdminRole, string> = {
[AdminRole.SUPER_ADMIN]: '超级管理员',
[AdminRole.OPERATOR]: '操作员',
[AdminRole.VIEWER]: '查看者',
};
const roleColors: Record<AdminRole, string> = {
[AdminRole.SUPER_ADMIN]: 'red',
[AdminRole.OPERATOR]: 'blue',
[AdminRole.VIEWER]: 'green',
};
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
setLoading(true);
try {
const data = await adminUsersService.findAll();
setUsers(data);
} catch (error) {
message.error('获取用户列表失败');
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setEditingUser(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (user: AdminUser) => {
setEditingUser(user);
form.setFieldsValue({
username: user.username,
role: user.role,
});
setModalVisible(true);
};
const handleDelete = async (id: number) => {
try {
await adminUsersService.remove(id);
message.success('删除成功');
fetchUsers();
} catch (error) {
message.error('删除失败');
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (editingUser) {
const updateData: UpdateAdminUserDto = {
username: values.username,
role: values.role,
};
if (values.password) {
updateData.password = values.password;
}
await adminUsersService.update(editingUser.id, updateData);
message.success('更新成功');
} else {
const createData: CreateAdminUserDto = {
username: values.username,
password: values.password,
role: values.role,
};
await adminUsersService.create(createData);
message.success('创建成功');
}
setModalVisible(false);
form.resetFields();
fetchUsers();
} catch (error) {
message.error(editingUser ? '更新失败' : '创建失败');
}
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户名',
dataIndex: 'username',
key: 'username',
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role: AdminRole) => (
<Tag color={roleColors[role]}>{roleLabels[role]}</Tag>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
render: (_: any, record: AdminUser) => (
<Space>
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Popconfirm
title="确定要删除这个用户吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
/>
<Modal
title={editingUser ? '编辑用户' : '添加用户'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => {
setModalVisible(false);
form.resetFields();
}}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical">
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' },
{ max: 50, message: '用户名最多50个字符' },
]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={editingUser ? [] : [
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' },
]}
extra={editingUser ? '留空则不修改密码' : ''}
>
<Input.Password placeholder={editingUser ? '留空则不修改密码' : '请输入密码'} />
</Form.Item>
<Form.Item
label="角色"
name="role"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select placeholder="请选择角色" options={roleOptions} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminUsers;

View File

@@ -1,4 +1,4 @@
import { Card, Row, Col, Statistic, List, Avatar, Button } from 'antd';
import { Card, Row, Col, Statistic, Avatar, Button } from 'antd';
import {
TrophyOutlined,
GiftOutlined,
@@ -36,7 +36,7 @@ const PlayerDashboard = () => {
value={100}
suffix="级"
prefix={<StarOutlined />}
valueStyle={{ color: '#3f8600' }}
styles={{ content: { color: '#3f8600' } }}
/>
</Card>
</Col>
@@ -47,7 +47,7 @@ const PlayerDashboard = () => {
value={12800}
prefix={<GiftOutlined />}
precision={2}
valueStyle={{ color: '#cf1322' }}
styles={{ content: { color: '#cf1322' } }}
/>
</Card>
</Col>
@@ -58,7 +58,7 @@ const PlayerDashboard = () => {
value={365}
suffix="小时"
prefix={<ClockCircleOutlined />}
valueStyle={{ color: '#1890ff' }}
styles={{ content: { color: '#1890ff' } }}
/>
</Card>
</Col>
@@ -68,7 +68,7 @@ const PlayerDashboard = () => {
title="成就点数"
value={2560}
prefix={<TrophyOutlined />}
valueStyle={{ color: '#722ed1' }}
styles={{ content: { color: '#722ed1' } }}
/>
</Card>
</Col>
@@ -76,24 +76,33 @@ const PlayerDashboard = () => {
<Row gutter={16} style={{ marginTop: 24 }}>
<Col span={12}>
<Card title="系统公告">
<List
itemLayout="horizontal"
dataSource={notices}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar icon={<TrophyOutlined />} />}
title={item.title}
description={
<div>
<p>{item.description}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{notices.map((item, index) => (
<div
key={index}
style={{
display: 'flex',
alignItems: 'flex-start',
padding: '12px 0',
borderBottom: index !== notices.length - 1 ? '1px solid #f0f0f0' : 'none',
}}
>
<Avatar
icon={<TrophyOutlined />}
style={{ marginRight: 12, flexShrink: 0 }}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 500, marginBottom: 4 }}>
{item.title}
</div>
<div style={{ color: '#666', marginBottom: 4 }}>
{item.description}
</div>
<small style={{ color: '#999' }}>{item.time}</small>
</div>
}
/>
</List.Item>
)}
/>
</div>
))}
</div>
</Card>
</Col>
<Col span={12}>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { Form, Input, Button, Card, App } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { playerAuthService, type LoginRequest } from '../../services/playerAuthService';
@@ -9,17 +9,17 @@ const PlayerLogin = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const { message } = App.useApp();
const onFinish = async (values: LoginRequest) => {
setLoading(true);
try {
const response = await playerAuthService.login(values);
localStorage.setItem('playerToken', response.accessToken);
setAuth({
id: response.userId,
username: response.username,
role: response.role,
}, response.accessToken);
}, response.accessToken, 'player');
message.success('登录成功');
navigate('/player/dashboard');
} catch (error) {

View File

@@ -5,6 +5,7 @@ import AdminAuthRoute from '../components/AdminAuthRoute';
import PlayerAuthRoute from '../components/PlayerAuthRoute';
import AdminLogin from '../pages/admin/AdminLogin';
import AdminDashboard from '../pages/admin/AdminDashboard';
import AdminUsers from '../pages/admin/AdminUsers';
import PlayerLogin from '../pages/player/PlayerLogin';
import PlayerDashboard from '../pages/player/PlayerDashboard';
@@ -30,6 +31,10 @@ const router = createBrowserRouter([
path: 'dashboard',
element: <AdminDashboard />,
},
{
path: 'users',
element: <AdminUsers />,
},
{
index: true,
element: <Navigate to="/admin/dashboard" replace />,

View File

@@ -0,0 +1,54 @@
import api from '../utils/api';
export enum AdminRole {
SUPER_ADMIN = 'super_admin',
OPERATOR = 'operator',
VIEWER = 'viewer',
}
export interface AdminUser {
id: number;
username: string;
role: AdminRole;
createdAt: string;
updatedAt: string;
}
export interface CreateAdminUserDto {
username: string;
password: string;
role: AdminRole;
}
export interface UpdateAdminUserDto {
username?: string;
password?: string;
role?: AdminRole;
}
export const adminUsersService = {
async findAll(): Promise<AdminUser[]> {
const response = await api.get<AdminUser[]>('/admin/admin-users');
return response.data;
},
async findOne(id: number): Promise<AdminUser> {
const response = await api.get<AdminUser>(`/admin/admin-users/${id}`);
return response.data;
},
async create(data: CreateAdminUserDto): Promise<AdminUser> {
const response = await api.post<AdminUser>('/admin/admin-users', data);
return response.data;
},
async update(id: number, data: UpdateAdminUserDto): Promise<AdminUser> {
const response = await api.put<AdminUser>(`/admin/admin-users/${id}`, data);
return response.data;
},
async remove(id: number): Promise<{ message: string }> {
const response = await api.delete<{ message: string }>(`/admin/admin-users/${id}`);
return response.data;
},
};

View File

@@ -6,28 +6,82 @@ interface AdminUser {
role: string;
}
type AuthType = 'admin' | 'player';
interface AuthState {
isAuthenticated: boolean;
adminUser: AdminUser | null;
token: string | null;
setAuth: (user: AdminUser, token: string) => void;
authType: AuthType | null;
setAuth: (user: AdminUser, token: string, type: AuthType) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
adminUser: null,
token: null,
setAuth: (user, token) =>
export const useAuthStore = create<AuthState>((set) => {
// 初始化时从localStorage加载
const savedAdminToken = localStorage.getItem('adminToken');
const savedPlayerToken = localStorage.getItem('playerToken');
const savedAdminUserStr = localStorage.getItem('adminUser');
const savedPlayerUserStr = localStorage.getItem('playerUser');
let savedUser: AdminUser | null = null;
let savedToken: string | null = null;
let savedAuthType: AuthType | null = null;
// 优先加载管理员认证
if (savedAdminToken && savedAdminUserStr) {
try {
savedUser = JSON.parse(savedAdminUserStr);
savedToken = savedAdminToken;
savedAuthType = 'admin';
} catch (e) {
console.error('解析adminUser失败:', e);
}
}
// 如果没有管理员认证,则加载玩家认证
if (!savedToken && savedPlayerToken && savedPlayerUserStr) {
try {
savedUser = JSON.parse(savedPlayerUserStr);
savedToken = savedPlayerToken;
savedAuthType = 'player';
} catch (e) {
console.error('解析playerUser失败:', e);
}
}
return {
isAuthenticated: !!savedToken,
adminUser: savedUser,
token: savedToken,
authType: savedAuthType,
setAuth: (user, token, type) => {
// 根据类型保存到不同的localStorage key
if (type === 'admin') {
localStorage.setItem('adminToken', token);
localStorage.setItem('adminUser', JSON.stringify(user));
} else {
localStorage.setItem('playerToken', token);
localStorage.setItem('playerUser', JSON.stringify(user));
}
set({
isAuthenticated: true,
adminUser: user,
token,
}),
logout: () =>
authType: type,
});
},
logout: () => {
// 清除所有认证数据
localStorage.removeItem('adminToken');
localStorage.removeItem('adminUser');
localStorage.removeItem('playerToken');
localStorage.removeItem('playerUser');
set({
isAuthenticated: false,
adminUser: null,
token: null,
}),
}));
authType: null,
});
},
};
});

View File

@@ -0,0 +1,34 @@
import { create } from 'zustand';
// 主题类型
export type ThemeMode = 'light' | 'dark';
// 从 localStorage 读取主题模式
const getInitialThemeMode = (): ThemeMode => {
const savedTheme = localStorage.getItem('adminThemeMode');
if (savedTheme === 'light' || savedTheme === 'dark') {
return savedTheme;
}
return 'dark'; // 默认暗色主题
};
// 主题状态接口
interface ThemeState {
themeMode: ThemeMode;
toggleTheme: () => void;
setTheme: (mode: ThemeMode) => void;
}
// 主题状态管理
export const useThemeStore = create<ThemeState>((set) => ({
themeMode: getInitialThemeMode(),
toggleTheme: () => set((state) => {
const newMode = state.themeMode === 'light' ? 'dark' : 'light';
localStorage.setItem('adminThemeMode', newMode);
return { themeMode: newMode };
}),
setTheme: (mode) => {
localStorage.setItem('adminThemeMode', mode);
set({ themeMode: mode });
},
}));

View File

@@ -0,0 +1,85 @@
// 全局主题配置
import type { ThemeConfig } from 'antd';
import type { ThemeMode } from '../stores/themeStore';
// 亮色主题配置
export const lightTheme: ThemeConfig = {
token: {
// 主色调
colorPrimary: '#1890ff',
colorSuccess: '#52c41a',
colorWarning: '#faad14',
colorError: '#ff4d4f',
colorInfo: '#1890ff',
// 字体设置
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
fontSize: 14,
// 圆角
borderRadius: 3,
},
components: {
// Layout 组件主题
Layout: {
headerBg: '#ffffff', // 顶部导航背景色(亮色)
footerBg: '#ffffff', // 页脚背景色(亮色)
siderBg: '#ffffff', // 侧边栏背景色(亮色)
},
// Menu 组件主题
Menu: {
itemSelectedBg: '#e6f7ff', // 菜单激活背景色(亮色)
itemHoverBg: 'rgba(24, 144, 255, 0.1)', // 菜单悬停背景色(亮色)
itemColor: 'rgba(0, 0, 0, 0.65)', // 菜单项文字颜色(亮色)
itemSelectedColor: '#1890ff', // 菜单激活文字颜色(亮色)
itemHoverColor: '#1890ff', // 菜单悬停文字颜色(亮色)
},
},
};
// 暗色主题配置
export const darkTheme: ThemeConfig = {
token: {
// 主色调
colorPrimary: '#1890ff',
colorSuccess: '#52c41a',
colorWarning: '#faad14',
colorError: '#ff4d4f',
colorInfo: '#1890ff',
// 字体设置
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
fontSize: 14,
// 圆角
borderRadius: 3,
},
components: {
// Layout 组件主题
Layout: {
headerBg: '#001529', // 顶部导航和侧边栏背景色(暗色)
footerBg: '#001529', // 页脚背景色(暗色)
siderBg: '#001529', // 侧边栏背景色(暗色)
},
// Menu 组件主题
Menu: {
darkItemSelectedBg: '#1890ff', // 菜单激活背景色(暗色)
darkItemHoverBg: 'rgba(24, 144, 255, 0.2)', // 菜单悬停背景色(暗色)
darkItemColor: 'rgba(255, 255, 255, 0.65)', // 菜单项文字颜色(暗色)
darkItemSelectedColor: '#fff', // 菜单激活文字颜色(暗色)
darkItemHoverColor: '#fff', // 菜单悬停文字颜色(暗色)
},
},
};
// 根据主题模式获取主题配置
export const getThemeByMode = (mode: ThemeMode): ThemeConfig => {
return mode === 'light' ? lightTheme : darkTheme;
};
// 默认主题配置(向后兼容)
export const appTheme: ThemeConfig = lightTheme;

View File

@@ -15,5 +15,8 @@
"devDependencies": {
"commitizen": "^4.3.1",
"cz-git": "^1.11.0"
},
"dependencies": {
"@ant-design/icons": "^6.1.0"
}
}