feat: 添加JWT登录鉴权和用户管理功能

- 后端功能:
  - 实现JWT登录鉴权,token有效期7天,超过1天自动刷新
  - 添加用户管理功能,支持创建、删除、修改密码
  - 首次创建的用户为默认账户,拥有管理权限
  - 实现密码重置功能,支持重置密钥和频率限制(1分钟最多3次)
  - 所有API接口需要JWT鉴权
  - WebSocket连接需要JWT鉴权,绑定用户身份

- 前端功能:
  - 添加登录页面和密码重置页面
  - 添加用户管理页面,默认账户可管理所有用户,普通用户只能查看和修改自己
  - 添加退出登录功能,带二次确认
  - 未登录时不建立WebSocket连接
  - API请求自动携带JWT token,认证失败自动跳转登录页

- 安全特性:
  - 密码使用BCrypt加密存储
  - 重置密码错误信息统一处理,避免信息泄露
  - 用户操作严格绑定JWT,防止数据篡改和越权
This commit is contained in:
WrBug
2025-12-03 00:24:46 +08:00
parent f1c9b34488
commit dd553ae160
30 changed files with 2402 additions and 47 deletions
+114 -33
View File
@@ -1,9 +1,12 @@
import { useEffect, useCallback } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { ConfigProvider, notification } from 'antd'
import { useEffect, useCallback, useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { ConfigProvider, notification, Spin } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import Layout from './components/Layout'
import Login from './pages/Login'
import ResetPassword from './pages/ResetPassword'
import AccountList from './pages/AccountList'
import UserList from './pages/UserList'
import AccountImport from './pages/AccountImport'
import AccountDetail from './pages/AccountDetail'
import AccountEdit from './pages/AccountEdit'
@@ -24,8 +27,30 @@ import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
import { apiService } from './services/api'
import { hasToken } from './utils'
/**
* 路由保护组件
*/
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation()
const isAuthPage = location.pathname === '/login' || location.pathname === '/reset-password'
if (isAuthPage) {
return <>{children}</>
}
if (!hasToken()) {
return <Navigate to="/login" replace />
}
return <Layout>{children}</Layout>
}
function App() {
const [isFirstUse, setIsFirstUse] = useState<boolean | null>(null)
const [checking, setChecking] = useState(true)
/**
* 获取订单类型文本
*/
@@ -100,17 +125,36 @@ function App() {
})
}, [getOrderTypeText])
// 应用启动时立即建立全局 WebSocket 连接
// 应用启动时检查是否首次使用
useEffect(() => {
// 立即建立连接(如果还未连接)
if (!wsManager.isConnected()) {
wsManager.connect()
const checkFirstUse = async () => {
try {
const response = await apiService.auth.checkFirstUse()
if (response.data.code === 0 && response.data.data) {
setIsFirstUse(response.data.data.isFirstUse)
}
} catch (error) {
console.error('检查首次使用失败:', error)
setIsFirstUse(false) // 出错时默认不是首次使用
} finally {
setChecking(false)
}
}
// 注意:应用不会卸载,所以不需要在 cleanup 中断开连接
// WebSocket 连接会在整个应用生命周期中保持,并自动重连
checkFirstUse()
}, [])
// 应用启动时立即建立全局 WebSocket 连接(仅在已登录时)
useEffect(() => {
// 只有在已登录且不是首次使用的情况下才建立WebSocket连接
if (!checking && isFirstUse === false && hasToken() && !wsManager.isConnected()) {
wsManager.connect()
} else if (!hasToken() && wsManager.isConnected()) {
// 如果未登录但WebSocket已连接,断开连接
wsManager.disconnect()
}
}, [checking, isFirstUse])
// 订阅订单推送并显示全局通知
useEffect(() => {
const unsubscribe = wsManager.subscribe('order', (data: OrderPushMessage) => {
@@ -122,33 +166,70 @@ function App() {
}
}, [handleOrderPush])
// 如果正在检查首次使用,显示加载中
if (checking) {
return (
<ConfigProvider locale={zhCN}>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh'
}}>
<Spin size="large" />
</div>
</ConfigProvider>
)
}
// 如果首次使用,直接跳转到重置密码页面
if (isFirstUse === true) {
return (
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Routes>
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="*" element={<Navigate to="/reset-password" replace />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
)
}
return (
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<AccountList />} />
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/import" element={<AccountImport />} />
<Route path="/accounts/detail" element={<AccountDetail />} />
<Route path="/accounts/edit" element={<AccountEdit />} />
<Route path="/leaders" element={<LeaderList />} />
<Route path="/leaders/add" element={<LeaderAdd />} />
<Route path="/leaders/edit" element={<LeaderEdit />} />
<Route path="/templates" element={<TemplateList />} />
<Route path="/templates/add" element={<TemplateAdd />} />
<Route path="/templates/edit/:id" element={<TemplateEdit />} />
<Route path="/copy-trading" element={<CopyTradingList />} />
<Route path="/copy-trading/add" element={<CopyTradingAdd />} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<CopyTradingStatistics />} />
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<CopyTradingBuyOrders />} />
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<CopyTradingSellOrders />} />
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<CopyTradingMatchedOrders />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/positions" element={<PositionList />} />
<Route path="/statistics" element={<Statistics />} />
</Routes>
</Layout>
<Routes>
{/* 公开路由(不需要鉴权) */}
<Route path="/login" element={<Login />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* 受保护的路由 */}
<Route path="/" element={<ProtectedRoute><AccountList /></ProtectedRoute>} />
<Route path="/accounts" element={<ProtectedRoute><AccountList /></ProtectedRoute>} />
<Route path="/accounts/import" element={<ProtectedRoute><AccountImport /></ProtectedRoute>} />
<Route path="/accounts/detail" element={<ProtectedRoute><AccountDetail /></ProtectedRoute>} />
<Route path="/accounts/edit" element={<ProtectedRoute><AccountEdit /></ProtectedRoute>} />
<Route path="/leaders" element={<ProtectedRoute><LeaderList /></ProtectedRoute>} />
<Route path="/leaders/add" element={<ProtectedRoute><LeaderAdd /></ProtectedRoute>} />
<Route path="/leaders/edit" element={<ProtectedRoute><LeaderEdit /></ProtectedRoute>} />
<Route path="/templates" element={<ProtectedRoute><TemplateList /></ProtectedRoute>} />
<Route path="/templates/add" element={<ProtectedRoute><TemplateAdd /></ProtectedRoute>} />
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
<Route path="/copy-trading/add" element={<ProtectedRoute><CopyTradingAdd /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
<Route path="/config" element={<ProtectedRoute><ConfigPage /></ProtectedRoute>} />
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
{/* 默认重定向到登录页 */}
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
)
+51 -5
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Layout as AntLayout, Menu, Drawer, Button } from 'antd'
import { Layout as AntLayout, Menu, Drawer, Button, Modal } from 'antd'
import { useMediaQuery } from 'react-responsive'
import {
WalletOutlined,
@@ -10,10 +10,14 @@ import {
MenuOutlined,
FileTextOutlined,
LinkOutlined,
AppstoreOutlined
AppstoreOutlined,
TeamOutlined,
LogoutOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
import { removeToken } from '../utils'
import { wsManager } from '../services/websocket'
const { Header, Content, Sider } = AntLayout
@@ -35,10 +39,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
// 获取当前应该打开的父菜单
const getInitialOpenKeys = (): string[] => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
return ['/copy-trading-management']
keys.push('/copy-trading-management')
}
return []
return keys
}
const [openKeys, setOpenKeys] = useState<string[]>(getInitialOpenKeys())
@@ -46,9 +51,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
// 当路径变化时,自动打开对应的父菜单
useEffect(() => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
setOpenKeys(['/copy-trading-management'])
keys.push('/copy-trading-management')
}
setOpenKeys(keys)
}, [location.pathname])
const menuItems: MenuProps['items'] = [
@@ -88,14 +95,53 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
key: '/statistics',
icon: <BarChartOutlined />,
label: '统计信息'
},
{
key: '/users',
icon: <TeamOutlined />,
label: '用户管理'
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录'
}
]
const handleLogout = () => {
removeToken()
// 断开 WebSocket 连接
wsManager.disconnect()
navigate('/login', { replace: true })
}
const handleLogoutConfirm = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出登录吗?',
okText: '确定',
cancelText: '取消',
onOk: () => {
handleLogout()
if (isMobile) {
setMobileMenuOpen(false)
}
}
})
}
const handleMenuClick = ({ key }: { key: string }) => {
// 如果是父菜单,不导航
if (key === '/copy-trading-management') {
return
}
// 处理退出登录
if (key === 'logout') {
handleLogoutConfirm()
return
}
navigate(key)
if (isMobile) {
setMobileMenuOpen(false)
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, message, Typography } from 'antd'
import { UserOutlined, LockOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { setToken } from '../utils'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const Login: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [form] = Form.useForm()
const handleLogin = async (values: { username: string; password: string }) => {
setLoading(true)
try {
const response = await apiService.auth.login(values)
if (response.data.code === 0 && response.data.data) {
const token = response.data.data.token
setToken(token)
message.success('登录成功')
// 跳转到首页
navigate('/')
} else {
message.error(response.data.msg || '登录失败')
}
} catch (error: any) {
console.error('登录失败:', error)
const errorMsg = error.response?.data?.msg || error.message || '登录失败'
message.error(errorMsg)
} finally {
setLoading(false)
}
}
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
padding: isMobile ? '20px' : '40px',
background: '#f0f2f5'
}}>
<Card
style={{
width: isMobile ? '100%' : '400px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<Title level={2} style={{ textAlign: 'center', marginBottom: '32px' }}>
</Title>
<Form
form={form}
onFinish={handleLogin}
layout="vertical"
size={isMobile ? 'large' : 'middle'}
>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
size={isMobile ? 'large' : 'middle'}
>
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default Login
+230
View File
@@ -0,0 +1,230 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, message, Typography, Alert, Progress } from 'antd'
import { LockOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
/**
* 计算密码强度
* @param password 密码
* @returns 强度等级 0-4 (0: 弱, 1: 较弱, 2: 中等, 3: 强, 4: 很强)
*/
const getPasswordStrength = (password: string): number => {
if (!password) return 0
if (password.length < 6) return 0
let strength = 0
// 长度加分
if (password.length >= 6) strength += 1
if (password.length >= 8) strength += 1
if (password.length >= 12) strength += 1
// 字符类型加分
if (/[a-z]/.test(password)) strength += 0.5
if (/[A-Z]/.test(password)) strength += 0.5
if (/\d/.test(password)) strength += 0.5
if (/[^a-zA-Z0-9]/.test(password)) strength += 0.5
return Math.min(4, Math.floor(strength))
}
/**
* 获取密码强度文本和颜色
*/
const getPasswordStrengthInfo = (strength: number): { text: string; color: string; percent: number } => {
switch (strength) {
case 0:
return { text: '弱', color: '#ff4d4f', percent: 25 }
case 1:
return { text: '较弱', color: '#ff7a45', percent: 50 }
case 2:
return { text: '中等', color: '#faad14', percent: 75 }
case 3:
return { text: '强', color: '#52c41a', percent: 100 }
case 4:
return { text: '很强', color: '#52c41a', percent: 100 }
default:
return { text: '弱', color: '#ff4d4f', percent: 0 }
}
}
const ResetPassword: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [passwordStrength, setPasswordStrength] = useState(0)
const [form] = Form.useForm()
const handleReset = async (values: {
resetKey: string
username: string
newPassword: string
confirmPassword: string
}) => {
if (values.newPassword !== values.confirmPassword) {
message.error('两次输入的密码不一致')
return
}
setLoading(true)
try {
const response = await apiService.auth.resetPassword({
resetKey: values.resetKey,
username: values.username,
newPassword: values.newPassword
})
if (response.data.code === 0) {
message.success('密码重置成功,请登录')
// 延迟跳转到登录页,让用户看到成功提示
setTimeout(() => {
navigate('/login', { replace: true })
}, 1000)
} else {
message.error(response.data.msg || '密码重置失败')
}
} catch (error: any) {
console.error('密码重置失败:', error)
const errorMsg = error.response?.data?.msg || error.message || '密码重置失败'
message.error(errorMsg)
} finally {
setLoading(false)
}
}
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
padding: isMobile ? '20px' : '40px',
background: '#f0f2f5'
}}>
<Card
style={{
width: isMobile ? '100%' : '500px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<Title level={2} style={{ textAlign: 'center', marginBottom: '16px' }}>
</Title>
<Alert
message="首次使用系统"
description="请使用管理员提供的重置密钥设置初始密码"
type="info"
showIcon
style={{ marginBottom: '24px' }}
/>
<Form
form={form}
onFinish={handleReset}
layout="vertical"
size={isMobile ? 'large' : 'middle'}
>
<Form.Item
name="resetKey"
label="重置密钥"
rules={[
{ required: true, message: '请输入重置密钥' }
]}
>
<Input
prefix={<KeyOutlined />}
placeholder="请输入重置密钥"
/>
</Form.Item>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: '请输入用户名' }
]}
>
<Input
prefix={<UserOutlined />}
placeholder="请输入用户名"
/>
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少6位' }
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="至少6位"
onChange={(e) => {
const strength = getPasswordStrength(e.target.value)
setPasswordStrength(strength)
}}
/>
</Form.Item>
{passwordStrength > 0 && (
<Form.Item>
<div style={{ marginTop: '-16px', marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span style={{ fontSize: '12px', color: '#666' }}></span>
<span style={{
fontSize: '12px',
fontWeight: 'bold',
color: getPasswordStrengthInfo(passwordStrength).color
}}>
{getPasswordStrengthInfo(passwordStrength).text}
</span>
</div>
<Progress
percent={getPasswordStrengthInfo(passwordStrength).percent}
strokeColor={getPasswordStrengthInfo(passwordStrength).color}
showInfo={false}
size="small"
/>
</div>
</Form.Item>
)}
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
}
})
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请再次输入密码"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
size={isMobile ? 'large' : 'middle'}
>
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default ResetPassword
+366
View File
@@ -0,0 +1,366 @@
import { useEffect, useState } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input } from 'antd'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
interface User {
id: number
username: string
isDefault: boolean
createdAt: number
updatedAt: number
}
const UserList: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [createModalVisible, setCreateModalVisible] = useState(false)
const [updatePasswordModalVisible, setUpdatePasswordModalVisible] = useState(false)
const [updateOwnPasswordModalVisible, setUpdateOwnPasswordModalVisible] = useState(false)
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [createForm] = Form.useForm()
const [updatePasswordForm] = Form.useForm()
const [updateOwnPasswordForm] = Form.useForm()
// 获取当前用户(判断是否是默认账户)
const currentUser = users.find(user => user.isDefault) || users[0]
const isDefaultUser = currentUser?.isDefault || false
const fetchUsers = async () => {
setLoading(true)
try {
const response = await apiService.users.list()
if (response.data.code === 0 && response.data.data) {
setUsers(response.data.data)
} else {
message.error(response.data.msg || '获取用户列表失败')
}
} catch (error: any) {
console.error('获取用户列表失败:', error)
const errorMsg = error.response?.data?.msg || error.message || '获取用户列表失败'
message.error(errorMsg)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchUsers()
}, [])
const handleCreate = async (values: { username: string; password: string }) => {
try {
const response = await apiService.users.create({
username: values.username,
password: values.password
})
if (response.data.code === 0) {
message.success('创建用户成功')
setCreateModalVisible(false)
createForm.resetFields()
fetchUsers()
} else {
message.error(response.data.msg || '创建用户失败')
}
} catch (error: any) {
console.error('创建用户失败:', error)
const errorMsg = error.response?.data?.msg || error.message || '创建用户失败'
message.error(errorMsg)
}
}
const handleUpdatePassword = async (values: { newPassword: string }) => {
if (!selectedUser) return
try {
const response = await apiService.users.updatePassword({
userId: selectedUser.id,
newPassword: values.newPassword
})
if (response.data.code === 0) {
message.success('更新密码成功')
setUpdatePasswordModalVisible(false)
setSelectedUser(null)
updatePasswordForm.resetFields()
fetchUsers()
} else {
message.error(response.data.msg || '更新密码失败')
}
} catch (error: any) {
console.error('更新密码失败:', error)
const errorMsg = error.response?.data?.msg || error.message || '更新密码失败'
message.error(errorMsg)
}
}
const handleUpdateOwnPassword = async (values: { newPassword: string }) => {
try {
const response = await apiService.users.updateOwnPassword({
newPassword: values.newPassword
})
if (response.data.code === 0) {
message.success('修改密码成功,请重新登录')
setUpdateOwnPasswordModalVisible(false)
updateOwnPasswordForm.resetFields()
// 延迟跳转到登录页
setTimeout(() => {
window.location.href = '/login'
}, 1000)
} else {
message.error(response.data.msg || '修改密码失败')
}
} catch (error: any) {
console.error('修改密码失败:', error)
const errorMsg = error.response?.data?.msg || error.message || '修改密码失败'
message.error(errorMsg)
}
}
const handleDelete = async (user: User) => {
try {
const response = await apiService.users.delete({ userId: user.id })
if (response.data.code === 0) {
message.success('删除用户成功')
fetchUsers()
} else {
message.error(response.data.msg || '删除用户失败')
}
} catch (error: any) {
console.error('删除用户失败:', error)
const errorMsg = error.response?.data?.msg || error.message || '删除用户失败'
message.error(errorMsg)
}
}
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80
},
{
title: '用户名',
dataIndex: 'username',
key: 'username'
},
{
title: '角色',
dataIndex: 'isDefault',
key: 'isDefault',
width: 100,
render: (isDefault: boolean) => (
<Tag color={isDefault ? 'red' : 'blue'}>
{isDefault ? '默认账户' : '普通用户'}
</Tag>
)
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (timestamp: number) => new Date(timestamp).toLocaleString('zh-CN')
},
{
title: '操作',
key: 'action',
width: 200,
render: (_: any, record: User) => {
// 如果是默认账户,可以管理所有用户
if (isDefaultUser) {
return (
<Space size="small">
{!record.isDefault && (
<>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => {
setSelectedUser(record)
setUpdatePasswordModalVisible(true)
}}
>
</Button>
<Popconfirm
title="确定要删除这个用户吗?"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
</>
)}
</Space>
)
} else {
// 非默认账户:只能看到自己的信息,不显示操作按钮(修改密码通过顶部按钮)
return null
}
}
}
]
return (
<div>
<Card>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Space>
<Button
icon={<EditOutlined />}
onClick={() => setUpdateOwnPasswordModalVisible(true)}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={fetchUsers}
loading={loading}
>
</Button>
{isDefaultUser && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalVisible(true)}
>
</Button>
)}
</Space>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
pageSize: isMobile ? 10 : 20,
showSizeChanger: !isMobile,
showTotal: (total) => `${total}`
}}
scroll={isMobile ? { x: 600 } : undefined}
/>
</Card>
{/* 创建用户弹窗 */}
<Modal
title="新增用户"
open={createModalVisible}
onCancel={() => {
setCreateModalVisible(false)
createForm.resetFields()
}}
onOk={() => createForm.submit()}
okText="创建"
cancelText="取消"
>
<Form
form={createForm}
onFinish={handleCreate}
layout="vertical"
>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: '请输入用户名' }
]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6位' }
]}
>
<Input.Password placeholder="至少6位" />
</Form.Item>
</Form>
</Modal>
{/* 修改密码弹窗(管理员修改其他用户密码) */}
<Modal
title="修改密码"
open={updatePasswordModalVisible}
onCancel={() => {
setUpdatePasswordModalVisible(false)
setSelectedUser(null)
updatePasswordForm.resetFields()
}}
onOk={() => updatePasswordForm.submit()}
okText="确定"
cancelText="取消"
>
<Form
form={updatePasswordForm}
onFinish={handleUpdatePassword}
layout="vertical"
>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少6位' }
]}
>
<Input.Password placeholder="至少6位" />
</Form.Item>
</Form>
</Modal>
{/* 修改我的密码弹窗(默认账户修改自己密码) */}
<Modal
title="修改我的密码"
open={updateOwnPasswordModalVisible}
onCancel={() => {
setUpdateOwnPasswordModalVisible(false)
updateOwnPasswordForm.resetFields()
}}
onOk={() => updateOwnPasswordForm.submit()}
okText="确定"
cancelText="取消"
>
<Form
form={updateOwnPasswordForm}
onFinish={handleUpdateOwnPassword}
layout="vertical"
>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少6位' }
]}
>
<Input.Password placeholder="至少6位" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default UserList
+88 -3
View File
@@ -1,5 +1,7 @@
import axios, { AxiosInstance } from 'axios'
import axios, { AxiosInstance, AxiosError } from 'axios'
import type { ApiResponse } from '../types'
import { getToken, setToken, removeToken } from '../utils'
import { wsManager } from './websocket'
/**
* API 基础配置
@@ -17,6 +19,11 @@ const apiClient: AxiosInstance = axios.create({
*/
apiClient.interceptors.request.use(
(config) => {
// 从 localStorage 读取 token 并添加到请求头
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
@@ -29,11 +36,31 @@ apiClient.interceptors.request.use(
*/
apiClient.interceptors.response.use(
(response) => {
// 检查响应头中是否有新的 token(自动刷新)
const newToken = response.headers['x-new-token']
if (newToken) {
setToken(newToken)
}
return response
},
(error) => {
(error: AxiosError<ApiResponse<any>>) => {
if (error.response) {
console.error('API 错误:', error.response.data)
const response = error.response
const data = response.data
// 检查是否是认证错误(2001-2999)
if (data && data.code >= 2001 && data.code < 3000) {
// 清除 token
removeToken()
// 断开 WebSocket 连接
wsManager.disconnect()
// 跳转到登录页(避免循环跳转)
if (window.location.pathname !== '/login' && window.location.pathname !== '/reset-password') {
window.location.href = '/login'
}
}
console.error('API 错误:', data)
} else if (error.request) {
console.error('网络错误:', error.request)
} else {
@@ -47,6 +74,64 @@ apiClient.interceptors.response.use(
* API 服务
*/
export const apiService = {
/**
* 用户管理 API
*/
users: {
/**
* 获取用户列表
*/
list: () =>
apiClient.post<ApiResponse<any[]>>('/users/list', {}),
/**
* 创建用户
*/
create: (data: { username: string; password: string }) =>
apiClient.post<ApiResponse<any>>('/users/create', data),
/**
* 更新用户密码
*/
updatePassword: (data: { userId: number; newPassword: string }) =>
apiClient.post<ApiResponse<void>>('/users/update-password', data),
/**
* 删除用户
*/
delete: (data: { userId: number }) =>
apiClient.post<ApiResponse<void>>('/users/delete', data),
/**
* 用户修改自己的密码
*/
updateOwnPassword: (data: { newPassword: string }) =>
apiClient.post<ApiResponse<void>>('/users/update-own-password', data)
},
/**
* 认证 API
*/
auth: {
/**
* 登录
*/
login: (data: { username: string; password: string }) =>
apiClient.post<ApiResponse<{ token: string }>>('/auth/login', data),
/**
* 重置密码
*/
resetPassword: (data: { resetKey: string; username: string; newPassword: string }) =>
apiClient.post<ApiResponse<void>>('/auth/reset-password', data),
/**
* 检查是否首次使用
*/
checkFirstUse: () =>
apiClient.post<ApiResponse<{ isFirstUse: boolean }>>('/auth/check-first-use', {})
},
/**
* 账户管理 API
*/
+29 -5
View File
@@ -53,6 +53,13 @@ class WebSocketManager {
* 连接 WebSocket(全局共享连接)
*/
connect(): void {
// 检查是否有token,未登录不允许连接
const token = this.getToken()
if (!token) {
console.log('[WebSocket] 未登录,不建立连接')
return
}
// 如果已经连接或正在连接,直接返回
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
return
@@ -104,8 +111,8 @@ class WebSocketManager {
this.isConnecting = false
this.notifyConnectionStatus(false)
this.stopPing()
// 自动重连(除非正在卸载)
if (!this.isUnmounting) {
// 自动重连(除非正在卸载或未登录
if (!this.isUnmounting && this.getToken()) {
this.scheduleReconnect()
}
}
@@ -113,8 +120,8 @@ class WebSocketManager {
console.error('[WebSocket] 创建连接失败:', error)
this.isConnecting = false
this.notifyConnectionStatus(false)
// 自动重连(除非正在卸载)
if (!this.isUnmounting) {
// 自动重连(除非正在卸载或未登录
if (!this.isUnmounting && this.getToken()) {
this.scheduleReconnect()
}
}
@@ -272,6 +279,11 @@ class WebSocketManager {
return
}
// 检查是否有token,未登录不重连
if (!this.getToken()) {
return
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
@@ -312,14 +324,26 @@ class WebSocketManager {
}
/**
* 获取 WebSocket URL
* 获取 WebSocket URL(带token认证)
*/
private getWebSocketUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const token = this.getToken()
if (token) {
// 通过查询参数传递token
return `${protocol}//${host}/ws?token=${encodeURIComponent(token)}`
}
return `${protocol}//${host}/ws`
}
/**
* 获取token(从localStorage
*/
private getToken(): string | null {
return localStorage.getItem('jwt_token')
}
/**
* 注册连接状态回调
*/
+34
View File
@@ -0,0 +1,34 @@
/**
* Token 管理工具
*/
const TOKEN_KEY = 'jwt_token'
/**
* 获取 token
*/
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY)
}
/**
* 保存 token
*/
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token)
}
/**
* 清除 token
*/
export const removeToken = (): void => {
localStorage.removeItem(TOKEN_KEY)
}
/**
* 检查是否有 token
*/
export const hasToken = (): boolean => {
return getToken() !== null
}
+8
View File
@@ -37,3 +37,11 @@ export {
isValidPrivateKey
} from './ethers'
// 统一导出 auth 相关工具函数
export {
getToken,
setToken,
removeToken,
hasToken
} from './auth'