feat: 添加系统管理功能(代理配置和API健康检查)
- 新增代理配置管理功能,支持HTTP代理配置(host、port、用户名、密码) - 代理配置存储在数据库中,支持实时生效,无需重启服务 - 代理配置变更时自动重连WebSocket连接(订单推送和跟单服务) - 新增API健康状态检查功能,监控Polymarket各API和Polygon RPC的可用性 - 优化API健康状态UI,支持响应式设计(移动端和桌面端) - 将Ethereum RPC相关配置和代码统一改为Polygon RPC - 修复代理检查时的SSL证书验证问题 - 优化代理配置:关闭代理时保留配置信息,避免重新输入
This commit is contained in:
@@ -25,6 +25,7 @@ import CopyTradingStatistics from './pages/CopyTradingStatistics'
|
||||
import CopyTradingBuyOrders from './pages/CopyTradingBuyOrders'
|
||||
import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
|
||||
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
import { apiService } from './services/api'
|
||||
@@ -226,6 +227,7 @@ function App() {
|
||||
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
|
||||
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
||||
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
|
||||
|
||||
{/* 默认重定向到登录页 */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
LinkOutlined,
|
||||
AppstoreOutlined,
|
||||
TeamOutlined,
|
||||
LogoutOutlined
|
||||
LogoutOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -101,6 +102,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <TeamOutlined />,
|
||||
label: '用户管理'
|
||||
},
|
||||
{
|
||||
key: '/system-settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统管理'
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Badge, Spin, Row, Col } from 'antd'
|
||||
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface ProxyConfig {
|
||||
id?: number
|
||||
type: string
|
||||
enabled: boolean
|
||||
host?: string
|
||||
port?: number
|
||||
username?: string
|
||||
subscriptionUrl?: string
|
||||
lastSubscriptionUpdate?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface ProxyCheckResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
latency?: number // 延迟(毫秒)
|
||||
}
|
||||
|
||||
interface ApiHealthStatus {
|
||||
name: string
|
||||
url: string
|
||||
status: string
|
||||
message: string
|
||||
responseTime?: number
|
||||
}
|
||||
|
||||
const SystemSettings: React.FC = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [checkResult, setCheckResult] = useState<ProxyCheckResponse | null>(null)
|
||||
const [currentConfig, setCurrentConfig] = useState<ProxyConfig | null>(null)
|
||||
const [apiHealthStatus, setApiHealthStatus] = useState<ApiHealthStatus[]>([])
|
||||
const [checkingApiHealth, setCheckingApiHealth] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
checkApiHealth()
|
||||
}, [])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await apiService.proxyConfig.get()
|
||||
if (response.data.code === 0) {
|
||||
const data = response.data.data
|
||||
setCurrentConfig(data)
|
||||
if (data) {
|
||||
form.setFieldsValue({
|
||||
enabled: data.enabled,
|
||||
host: data.host || '',
|
||||
port: data.port || undefined,
|
||||
username: data.username || '',
|
||||
password: '', // 密码不预填充
|
||||
})
|
||||
} else {
|
||||
form.resetFields()
|
||||
}
|
||||
} else {
|
||||
message.error(response.data.msg || '获取代理配置失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取代理配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.proxyConfig.saveHttp({
|
||||
enabled: values.enabled || false,
|
||||
host: values.host,
|
||||
port: values.port,
|
||||
username: values.username || undefined,
|
||||
password: values.password || undefined, // 如果密码为空,则不更新密码
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success('保存代理配置成功。新配置将立即生效,已建立的 WebSocket 连接需要重新连接才能使用新代理。')
|
||||
fetchConfig()
|
||||
setCheckResult(null) // 清除检查结果
|
||||
// 自动刷新 API 健康状态,验证新配置是否生效
|
||||
setTimeout(() => {
|
||||
checkApiHealth()
|
||||
}, 1000)
|
||||
} else {
|
||||
message.error(response.data.msg || '保存代理配置失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '保存代理配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheck = async () => {
|
||||
setChecking(true)
|
||||
setCheckResult(null)
|
||||
try {
|
||||
const response = await apiService.proxyConfig.check()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const result = response.data.data
|
||||
setCheckResult(result)
|
||||
if (result.success) {
|
||||
message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}`)
|
||||
} else {
|
||||
message.warning(`代理检查失败:${result.message}`)
|
||||
}
|
||||
} else {
|
||||
message.error(response.data.msg || '代理检查失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '代理检查失败')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkApiHealth = async () => {
|
||||
setCheckingApiHealth(true)
|
||||
try {
|
||||
const response = await apiService.proxyConfig.checkApiHealth()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setApiHealthStatus(response.data.data.apis)
|
||||
} else {
|
||||
message.error(response.data.msg || 'API 健康检查失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || 'API 健康检查失败')
|
||||
} finally {
|
||||
setCheckingApiHealth(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
if (status === 'success') {
|
||||
return '#52c41a'
|
||||
} else if (status === 'skipped') {
|
||||
return '#999'
|
||||
} else {
|
||||
return '#ff4d4f'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
if (status === 'success') {
|
||||
return '正常'
|
||||
} else if (status === 'skipped') {
|
||||
return '未配置'
|
||||
} else {
|
||||
return '异常'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Title level={2} style={{ margin: 0 }}>系统管理</Title>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<GlobalOutlined />
|
||||
<span>API 健康状态</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: '16px' }}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={checkApiHealth}
|
||||
loading={checkingApiHealth}
|
||||
size="small"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Spin spinning={checkingApiHealth}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{apiHealthStatus.map((item, index) => (
|
||||
<Col
|
||||
key={index}
|
||||
xs={24}
|
||||
sm={12}
|
||||
md={12}
|
||||
lg={8}
|
||||
xl={6}
|
||||
>
|
||||
{isMobile ? (
|
||||
// 移动端:一行显示,只保留名称、延迟、状态点(不显示文字)
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderLeft: `4px solid ${getStatusColor(item.status)}`,
|
||||
}}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Space>
|
||||
{item.responseTime !== undefined && item.responseTime !== null && (
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
<Text strong style={{ color: '#1890ff' }}>{item.responseTime}ms</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Badge
|
||||
status={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'default' : 'error'}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
// 桌面端:保持原有卡片布局
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderLeft: `4px solid ${getStatusColor(item.status)}`,
|
||||
height: '100%'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text strong style={{ fontSize: '14px' }}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Badge
|
||||
status={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'default' : 'error'}
|
||||
text={getStatusText(item.status)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
|
||||
{item.url}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{item.message && item.message !== '连接成功' && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text
|
||||
type={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'secondary' : 'danger'}
|
||||
style={{ fontSize: '13px' }}
|
||||
>
|
||||
{item.message}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.responseTime !== undefined && item.responseTime !== null && (
|
||||
<div style={{ marginTop: '8px', display: 'flex', alignItems: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
延迟: <Text strong style={{ color: '#1890ff' }}>{item.responseTime}ms</Text>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{apiHealthStatus.length === 0 && !checkingApiHealth && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
|
||||
<Text type="secondary">暂无 API 状态信息</Text>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Card title="代理设置" style={{ marginBottom: '16px' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item
|
||||
label="启用代理"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="代理主机"
|
||||
name="host"
|
||||
rules={[
|
||||
{ required: true, message: '请输入代理主机地址' },
|
||||
{ pattern: /^[\w\.-]+$/, message: '请输入有效的主机地址' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如:127.0.0.1 或 proxy.example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="代理端口"
|
||||
name="port"
|
||||
rules={[
|
||||
{ required: true, message: '请输入代理端口' },
|
||||
{ type: 'number', min: 1, max: 65535, message: '端口必须在 1-65535 之间' }
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如:8888"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="代理用户名(可选)"
|
||||
name="username"
|
||||
>
|
||||
<Input placeholder="如果代理需要认证,请输入用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="代理密码(可选)"
|
||||
name="password"
|
||||
help={currentConfig ? "留空则不更新密码,输入新密码则更新" : "如果代理需要认证,请输入密码"}
|
||||
>
|
||||
<Input.Password placeholder={currentConfig ? "留空则不更新密码" : "如果代理需要认证,请输入密码"} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={handleCheck}
|
||||
loading={checking}
|
||||
>
|
||||
检查代理
|
||||
</Button>
|
||||
{checkResult && (
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchConfig}
|
||||
>
|
||||
刷新配置
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{checkResult && (
|
||||
<Alert
|
||||
type={checkResult.success ? 'success' : 'error'}
|
||||
message={checkResult.success ? '代理检查成功' : '代理检查失败'}
|
||||
description={
|
||||
<div>
|
||||
<Text>{checkResult.message}</Text>
|
||||
{(checkResult.responseTime !== undefined || checkResult.latency !== undefined) && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<Text type="secondary">
|
||||
延迟: {(checkResult.latency ?? checkResult.responseTime) ?? 0}ms
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
style={{ marginTop: '16px' }}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemSettings
|
||||
|
||||
@@ -387,6 +387,65 @@ export const apiService = {
|
||||
*/
|
||||
list: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/orders/tracking', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 代理配置 API
|
||||
*/
|
||||
proxyConfig: {
|
||||
/**
|
||||
* 获取当前代理配置
|
||||
*/
|
||||
get: () =>
|
||||
apiClient.post<ApiResponse<any>>('/proxy-config/get', {}),
|
||||
|
||||
/**
|
||||
* 获取所有代理配置
|
||||
*/
|
||||
list: () =>
|
||||
apiClient.post<ApiResponse<any[]>>('/proxy-config/list', {}),
|
||||
|
||||
/**
|
||||
* 保存 HTTP 代理配置
|
||||
*/
|
||||
saveHttp: (data: {
|
||||
enabled: boolean
|
||||
host: string
|
||||
port: number
|
||||
username?: string
|
||||
password?: string
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<any>>('/proxy-config/http/save', data),
|
||||
|
||||
/**
|
||||
* 检查代理是否可用
|
||||
*/
|
||||
check: () =>
|
||||
apiClient.post<ApiResponse<{
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
}>>('/proxy-config/check', {}),
|
||||
|
||||
/**
|
||||
* 删除代理配置
|
||||
*/
|
||||
delete: (data: { id: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/proxy-config/delete', data),
|
||||
|
||||
/**
|
||||
* 检查所有 API 的健康状态
|
||||
*/
|
||||
checkApiHealth: () =>
|
||||
apiClient.post<ApiResponse<{
|
||||
apis: Array<{
|
||||
name: string
|
||||
url: string
|
||||
status: string
|
||||
message: string
|
||||
responseTime?: number
|
||||
}>
|
||||
}>>('/proxy-config/api-health-check', {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user