feat: 实现RPC节点管理功能
- 后端功能: - 添加RPC节点配置管理(增删改查、优先级调整) - 实现节点健康检查和自动故障转移 - 修复Retrofit baseUrl处理问题(使用拦截器动态替换URL) - 添加RPC节点可用性验证(创建前验证) - 默认节点作为兜底,不返回给前端,始终排在最后 - API健康检查使用动态获取的可用节点 - 前端功能: - 添加RPC节点设置页面 - 支持添加、删除、检查节点 - 支持调整节点优先级 - 完整的多语言支持(中文简体/繁体、英文) - 添加菜单项多语言key - 数据库: - 添加RPC节点配置表迁移脚本
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Table, Button, Space, Badge, message, Popconfirm, Tag } from 'antd'
|
||||
import { UpOutlined, DownOutlined, DeleteOutlined, ReloadOutlined, PlusOutlined, ApiOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import type { RpcNodeConfig } from '../types'
|
||||
import AddRpcNodeModal from '../components/AddRpcNodeModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const RpcNodeSettings: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [nodes, setNodes] = useState<RpcNodeConfig[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes()
|
||||
}, [])
|
||||
|
||||
const fetchNodes = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.rpcNodes.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setNodes(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.fetchFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckAllHealth = async () => {
|
||||
setChecking(true)
|
||||
try {
|
||||
const response = await apiService.rpcNodes.checkHealth({})
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.checkHealthSuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.checkHealthFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.checkHealthFailed'))
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const response = await apiService.rpcNodes.delete({ id })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.deleteSuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.deleteFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMovePriority = async (id: number, direction: 'up' | 'down') => {
|
||||
const index = nodes.findIndex(n => n.id === id)
|
||||
if (index === -1) return
|
||||
|
||||
if (direction === 'up' && index === 0) return
|
||||
if (direction === 'down' && index === nodes.length - 1) return
|
||||
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1
|
||||
const newPriority = nodes[targetIndex].priority
|
||||
|
||||
try {
|
||||
const response = await apiService.rpcNodes.updatePriority({ id, priority: newPriority })
|
||||
if (response.data.code === 0) {
|
||||
// 同时更新另一个节点的优先级
|
||||
await apiService.rpcNodes.updatePriority({
|
||||
id: nodes[targetIndex].id,
|
||||
priority: nodes[index].priority
|
||||
})
|
||||
message.success(t('rpcNodeSettings.adjustPrioritySuccess'))
|
||||
fetchNodes()
|
||||
} else {
|
||||
message.error(response.data.msg || t('rpcNodeSettings.adjustPriorityFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('rpcNodeSettings.adjustPriorityFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('rpcNodeSettings.priority'),
|
||||
dataIndex: 'priority',
|
||||
width: 120,
|
||||
render: (_: any, record: RpcNodeConfig, index: number) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UpOutlined />}
|
||||
onClick={() => handleMovePriority(record.id, 'up')}
|
||||
disabled={index === 0}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownOutlined />}
|
||||
onClick={() => handleMovePriority(record.id, 'down')}
|
||||
disabled={index === nodes.length - 1}
|
||||
/>
|
||||
<span>{index + 1}</span>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.providerType'),
|
||||
dataIndex: 'providerType',
|
||||
width: 120,
|
||||
render: (type: string) => <Tag color="blue">{type}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.name'),
|
||||
dataIndex: 'name',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.status'),
|
||||
dataIndex: 'lastCheckStatus',
|
||||
width: 100,
|
||||
render: (status: string | undefined) => {
|
||||
const statusMap = {
|
||||
HEALTHY: { status: 'success' as const, text: t('rpcNodeSettings.statusHealthy') },
|
||||
UNHEALTHY: { status: 'error' as const, text: t('rpcNodeSettings.statusUnhealthy') },
|
||||
UNKNOWN: { status: 'default' as const, text: t('rpcNodeSettings.statusUnknown') }
|
||||
}
|
||||
const config = statusMap[status as keyof typeof statusMap] || statusMap.UNKNOWN
|
||||
return <Badge status={config.status} text={config.text} />
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.responseTime'),
|
||||
dataIndex: 'responseTimeMs',
|
||||
width: 100,
|
||||
render: (time: number | undefined) => time ? `${time}ms` : '-'
|
||||
},
|
||||
{
|
||||
title: t('rpcNodeSettings.actions'),
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_: any, record: RpcNodeConfig) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await apiService.rpcNodes.checkHealth({ id: record.id })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('rpcNodeSettings.checkSuccess'))
|
||||
fetchNodes()
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(t('rpcNodeSettings.checkFailed'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('rpcNodeSettings.check')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('rpcNodeSettings.deleteConfirm')}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText={t('rpcNodeSettings.deleteConfirmOk')}
|
||||
cancelText={t('rpcNodeSettings.deleteConfirmCancel')}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('rpcNodeSettings.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ApiOutlined />
|
||||
<span>{t('rpcNodeSettings.title')}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleCheckAllHealth}
|
||||
loading={checking}
|
||||
>
|
||||
{t('rpcNodeSettings.batchCheck')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setModalVisible(true)}
|
||||
>
|
||||
{t('rpcNodeSettings.addNode')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={nodes}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<AddRpcNodeModal
|
||||
visible={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onSuccess={() => {
|
||||
setModalVisible(false)
|
||||
fetchNodes()
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default RpcNodeSettings
|
||||
@@ -887,6 +887,7 @@ const SystemSettings: React.FC = () => {
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user