1488 lines
53 KiB
JavaScript
1488 lines
53 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { useSelector } from 'react-redux'
|
||
import { api } from '../services/api'
|
||
import { selectCurrentUser, selectIsAdmin } from '../store/appSlice'
|
||
import './GlobalConfig.css'
|
||
import './ConfigPanel.css' // 复用 ConfigPanel 的样式
|
||
|
||
// 复用 ConfigPanel 的 ConfigItem 组件
|
||
const ConfigItem = ({ label, config, onUpdate, disabled }) => {
|
||
const isPercentKey = label.includes('PERCENT') || label.includes('PCT')
|
||
const PCT_LIKE_KEYS = new Set([
|
||
'LIMIT_ORDER_OFFSET_PCT',
|
||
'ENTRY_MAX_DRIFT_PCT_TRENDING',
|
||
'ENTRY_MAX_DRIFT_PCT_RANGING',
|
||
'FIXED_RISK_PERCENT', // 固定风险百分比,已经是小数形式(0.02 = 2%)
|
||
])
|
||
const isPctLike = PCT_LIKE_KEYS.has(label)
|
||
const isRatioPercentKey = isPercentKey && !isPctLike
|
||
|
||
const formatPercent = (n) => {
|
||
if (typeof n !== 'number' || isNaN(n)) return ''
|
||
return n.toFixed(4).replace(/\.?0+$/, '')
|
||
}
|
||
|
||
const getInitialDisplayValue = (val) => {
|
||
if (config.type === 'number' && isPercentKey) {
|
||
if (val === null || val === undefined || val === '') {
|
||
return ''
|
||
}
|
||
const numVal = typeof val === 'string' ? parseFloat(val) : val
|
||
if (isNaN(numVal)) {
|
||
return ''
|
||
}
|
||
if (isPctLike) {
|
||
const pctNum = numVal <= 0.05 ? numVal * 100 : numVal
|
||
return formatPercent(pctNum)
|
||
}
|
||
const percent = numVal <= 1 ? numVal * 100 : numVal
|
||
return formatPercent(percent)
|
||
}
|
||
return val === null || val === undefined ? '' : val
|
||
}
|
||
|
||
const [value, setValue] = useState(config.value)
|
||
const [localValue, setLocalValue] = useState(getInitialDisplayValue(config.value))
|
||
const [isEditing, setIsEditing] = useState(false)
|
||
|
||
useEffect(() => {
|
||
setValue(config.value)
|
||
setIsEditing(false)
|
||
setLocalValue(getInitialDisplayValue(config.value))
|
||
}, [config.value])
|
||
|
||
const handleChange = (newValue) => {
|
||
setLocalValue(newValue)
|
||
setIsEditing(true)
|
||
}
|
||
|
||
const handleBlur = () => {
|
||
if (!isEditing) return
|
||
let finalValue = localValue
|
||
if (config.type === 'number') {
|
||
if (isPercentKey) {
|
||
const numVal = parseFloat(localValue)
|
||
if (isNaN(numVal)) {
|
||
setLocalValue(getInitialDisplayValue(config.value))
|
||
setIsEditing(false)
|
||
return
|
||
}
|
||
if (isPctLike) {
|
||
finalValue = numVal <= 1 ? numVal / 100 : numVal / 100
|
||
} else {
|
||
finalValue = numVal <= 100 ? numVal / 100 : numVal / 100
|
||
}
|
||
} else {
|
||
finalValue = parseFloat(localValue)
|
||
if (isNaN(finalValue)) {
|
||
setLocalValue(getInitialDisplayValue(config.value))
|
||
setIsEditing(false)
|
||
return
|
||
}
|
||
}
|
||
} else if (config.type === 'boolean') {
|
||
finalValue = localValue === 'true' || localValue === true
|
||
} else {
|
||
finalValue = localValue
|
||
}
|
||
onUpdate(finalValue)
|
||
setIsEditing(false)
|
||
}
|
||
|
||
const displayValue = isEditing ? localValue : getInitialDisplayValue(config.value)
|
||
|
||
return (
|
||
<div className="config-item">
|
||
<div className="config-item-header">
|
||
<label>{label}</label>
|
||
</div>
|
||
<div className="config-input-wrapper">
|
||
{config.type === 'boolean' ? (
|
||
<select
|
||
value={String(config.value || false)}
|
||
onChange={(e) => {
|
||
const boolValue = e.target.value === 'true'
|
||
onUpdate(boolValue)
|
||
}}
|
||
disabled={disabled}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="true">是</option>
|
||
<option value="false">否</option>
|
||
</select>
|
||
) : (
|
||
<input
|
||
type="text"
|
||
inputMode={config.type === 'number' ? 'decimal' : 'text'}
|
||
value={displayValue === '' ? '' : String(displayValue)}
|
||
onChange={(e) => {
|
||
let newValue = e.target.value
|
||
if (config.type === 'number') {
|
||
if (isPercentKey) {
|
||
const numValue = parseFloat(newValue)
|
||
const maxAllowed = isPctLike ? 1 : 100
|
||
if (newValue !== '' && !isNaN(numValue) && (numValue < 0 || numValue > maxAllowed)) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
handleChange(newValue)
|
||
}}
|
||
onBlur={handleBlur}
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter') {
|
||
handleBlur()
|
||
}
|
||
}}
|
||
disabled={disabled}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
)}
|
||
{isPercentKey && <span style={{ marginLeft: '8px', color: '#666' }}>%</span>}
|
||
</div>
|
||
{config.description && (
|
||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>{config.description}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const GlobalConfig = () => {
|
||
const currentUser = useSelector(selectCurrentUser)
|
||
const isAdmin = useSelector(selectIsAdmin)
|
||
|
||
const [users, setUsers] = useState([])
|
||
const [accounts, setAccounts] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [message, setMessage] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [selectedUserId, setSelectedUserId] = useState(null)
|
||
const [showUserForm, setShowUserForm] = useState(false)
|
||
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user', status: 'active' })
|
||
const [editingUserId, setEditingUserId] = useState(null)
|
||
|
||
// 系统控制相关
|
||
const [systemStatus, setSystemStatus] = useState(null)
|
||
const [backendStatus, setBackendStatus] = useState(null)
|
||
const [systemBusy, setSystemBusy] = useState(false)
|
||
|
||
// 预设方案相关
|
||
const [configs, setConfigs] = useState({})
|
||
const [saving, setSaving] = useState(false)
|
||
const [configMeta, setConfigMeta] = useState(null)
|
||
|
||
// 配置快照相关
|
||
const [showSnapshot, setShowSnapshot] = useState(false)
|
||
const [snapshotText, setSnapshotText] = useState('')
|
||
const [snapshotIncludeSecrets, setSnapshotIncludeSecrets] = useState(false)
|
||
const [snapshotBusy, setSnapshotBusy] = useState(false)
|
||
|
||
const PCT_LIKE_KEYS = new Set([
|
||
'LIMIT_ORDER_OFFSET_PCT',
|
||
'ENTRY_MAX_DRIFT_PCT_TRENDING',
|
||
'ENTRY_MAX_DRIFT_PCT_RANGING',
|
||
])
|
||
|
||
// isAdmin 已从 Redux 获取,无需重复定义
|
||
|
||
// 预设方案配置(必须在函数定义之前,常量定义)
|
||
const presets = {
|
||
swing: {
|
||
name: '波段回归(推荐)',
|
||
desc: '根治高频与追价:关闭智能入场,回归"纯限价 + 30分钟扫描 + 更高信号门槛"的低频波段。建议先跑20-30单再评估。',
|
||
configs: {
|
||
SCAN_INTERVAL: 1800,
|
||
TOP_N_SYMBOLS: 8,
|
||
MAX_POSITION_PERCENT: 2.0,
|
||
MAX_TOTAL_POSITION_PERCENT: 20.0,
|
||
MIN_POSITION_PERCENT: 0.0,
|
||
MIN_SIGNAL_STRENGTH: 8,
|
||
USE_TRAILING_STOP: false,
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
|
||
TAKE_PROFIT_PERCENT: 25.0,
|
||
MIN_HOLD_TIME_SEC: 1800,
|
||
SMART_ENTRY_ENABLED: false,
|
||
},
|
||
},
|
||
fill: {
|
||
name: '成交优先(更少漏单)',
|
||
desc: '优先解决"挂单NEW→超时撤单→没成交"的问题:解锁自动交易过滤 + 保守智能入场(限制追价步数与追价上限),在趋势强时允许可控的市价兜底。',
|
||
configs: {
|
||
SCAN_INTERVAL: 1800,
|
||
TOP_N_SYMBOLS: 6,
|
||
MIN_SIGNAL_STRENGTH: 7,
|
||
AUTO_TRADE_ONLY_TRENDING: false,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: true,
|
||
SMART_ENTRY_ENABLED: true,
|
||
LIMIT_ORDER_OFFSET_PCT: 0.1,
|
||
ENTRY_CONFIRM_TIMEOUT_SEC: 120,
|
||
ENTRY_CHASE_MAX_STEPS: 2,
|
||
ENTRY_STEP_WAIT_SEC: 20,
|
||
ENTRY_MARKET_FALLBACK_AFTER_SEC: 60,
|
||
ENTRY_MAX_DRIFT_PCT_TRENDING: 0.3,
|
||
ENTRY_MAX_DRIFT_PCT_RANGING: 0.15,
|
||
USE_TRAILING_STOP: false,
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
|
||
TAKE_PROFIT_PERCENT: 25.0,
|
||
MIN_HOLD_TIME_SEC: 1800,
|
||
},
|
||
},
|
||
strict: {
|
||
name: '精选低频(高胜率倾向)',
|
||
desc: '更偏"少单、质量优先":仅趋势行情自动交易 + 4H中性不自动下单 + 更高信号门槛。仍保持较贴近的限价偏移,减少"完全成交不了"。',
|
||
configs: {
|
||
SCAN_INTERVAL: 1800,
|
||
TOP_N_SYMBOLS: 6,
|
||
MIN_SIGNAL_STRENGTH: 8,
|
||
AUTO_TRADE_ONLY_TRENDING: true,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: false,
|
||
SMART_ENTRY_ENABLED: false,
|
||
LIMIT_ORDER_OFFSET_PCT: 0.1,
|
||
ENTRY_CONFIRM_TIMEOUT_SEC: 180,
|
||
USE_TRAILING_STOP: false,
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
|
||
TAKE_PROFIT_PERCENT: 25.0,
|
||
MIN_HOLD_TIME_SEC: 1800,
|
||
},
|
||
},
|
||
steady: {
|
||
name: '稳定出单(均衡收益/频率)',
|
||
desc: '在"会下单"的基础上略提高出单频率:更短扫描间隔 + 更宽松门槛 + 保守智能入场(追价受限),适合想要稳定有单但不想回到高频。',
|
||
configs: {
|
||
SCAN_INTERVAL: 900,
|
||
TOP_N_SYMBOLS: 8,
|
||
MIN_SIGNAL_STRENGTH: 6,
|
||
AUTO_TRADE_ONLY_TRENDING: false,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: true,
|
||
SMART_ENTRY_ENABLED: true,
|
||
LIMIT_ORDER_OFFSET_PCT: 0.12,
|
||
ENTRY_CONFIRM_TIMEOUT_SEC: 120,
|
||
ENTRY_CHASE_MAX_STEPS: 3,
|
||
ENTRY_STEP_WAIT_SEC: 15,
|
||
ENTRY_MARKET_FALLBACK_AFTER_SEC: 45,
|
||
ENTRY_MAX_DRIFT_PCT_TRENDING: 0.4,
|
||
ENTRY_MAX_DRIFT_PCT_RANGING: 0.2,
|
||
USE_TRAILING_STOP: false,
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
|
||
TAKE_PROFIT_PERCENT: 25.0,
|
||
MIN_HOLD_TIME_SEC: 1800,
|
||
},
|
||
},
|
||
conservative: {
|
||
name: '保守配置',
|
||
desc: '适合新手,风险较低,止损止盈较宽松,避免被正常波动触发',
|
||
configs: {
|
||
SCAN_INTERVAL: 900,
|
||
MIN_CHANGE_PERCENT: 2.0,
|
||
MIN_SIGNAL_STRENGTH: 5,
|
||
TOP_N_SYMBOLS: 10,
|
||
MAX_SCAN_SYMBOLS: 150,
|
||
MIN_VOLATILITY: 0.02,
|
||
STOP_LOSS_PERCENT: 10.0,
|
||
TAKE_PROFIT_PERCENT: 25.0,
|
||
MIN_STOP_LOSS_PRICE_PCT: 2.0,
|
||
MIN_TAKE_PROFIT_PRICE_PCT: 3.0,
|
||
USE_TRAILING_STOP: false,
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
|
||
MIN_HOLD_TIME_SEC: 1800
|
||
}
|
||
},
|
||
balanced: {
|
||
name: '平衡配置',
|
||
desc: '推荐使用,平衡频率和质量,止损止盈适中(盈亏比2.5:1)',
|
||
configs: {
|
||
SCAN_INTERVAL: 600,
|
||
MIN_CHANGE_PERCENT: 1.5,
|
||
MIN_SIGNAL_STRENGTH: 4,
|
||
TOP_N_SYMBOLS: 12,
|
||
MAX_SCAN_SYMBOLS: 250,
|
||
MIN_VOLATILITY: 0.018,
|
||
STOP_LOSS_PERCENT: 8.0,
|
||
TAKE_PROFIT_PERCENT: 25.0,
|
||
MIN_STOP_LOSS_PRICE_PCT: 2.0,
|
||
MIN_TAKE_PROFIT_PRICE_PCT: 3.0,
|
||
USE_TRAILING_STOP: false,
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
|
||
MIN_HOLD_TIME_SEC: 1800
|
||
}
|
||
},
|
||
aggressive: {
|
||
name: '激进高频',
|
||
desc: '晚间波动大时使用,交易频率高,止损较紧但止盈合理(盈亏比3:1)',
|
||
configs: {
|
||
SCAN_INTERVAL: 300,
|
||
MIN_CHANGE_PERCENT: 1.0,
|
||
MIN_SIGNAL_STRENGTH: 3,
|
||
TOP_N_SYMBOLS: 18,
|
||
MAX_SCAN_SYMBOLS: 350,
|
||
MIN_VOLATILITY: 0.015,
|
||
STOP_LOSS_PERCENT: 5.0,
|
||
TAKE_PROFIT_PERCENT: 25.0,
|
||
MIN_STOP_LOSS_PRICE_PCT: 1.5,
|
||
MIN_TAKE_PROFIT_PRICE_PCT: 2.0,
|
||
USE_TRAILING_STOP: false,
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
|
||
MIN_HOLD_TIME_SEC: 1800
|
||
}
|
||
}
|
||
}
|
||
|
||
// 所有函数定义(必须在 useEffect 之前)
|
||
const loadUsers = async () => {
|
||
try {
|
||
const list = await api.getUsers()
|
||
setUsers(Array.isArray(list) ? list : [])
|
||
} catch (error) {
|
||
setMessage('加载用户列表失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadAccounts = async () => {
|
||
try {
|
||
const list = await api.getAccounts()
|
||
setAccounts(Array.isArray(list) ? list : [])
|
||
} catch (error) {
|
||
console.error('加载账号列表失败:', error)
|
||
}
|
||
}
|
||
|
||
const loadConfigMeta = async () => {
|
||
try {
|
||
const m = await api.getConfigMeta()
|
||
setConfigMeta(m || null)
|
||
} catch (e) {
|
||
// 静默失败,可能是权限问题
|
||
console.error('loadConfigMeta failed:', e)
|
||
setConfigMeta(null)
|
||
}
|
||
}
|
||
|
||
const loadConfigs = async () => {
|
||
try {
|
||
// 管理员全局配置:始终使用全局策略账号的配置,不依赖当前 account
|
||
// 即使 configMeta 还没加载完成,也使用默认值 1
|
||
if (isAdmin) {
|
||
const globalAccountId = configMeta?.global_strategy_account_id
|
||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
||
: 1 // 如果 configMeta 还没加载,使用默认值 1
|
||
const data = await api.getGlobalConfigs(globalAccountId)
|
||
setConfigs(data)
|
||
} else {
|
||
// 非管理员:使用默认方式(会受当前 account 影响,但非管理员不应该访问这个页面)
|
||
const data = await api.getConfigs()
|
||
setConfigs(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load configs:', error)
|
||
// 如果加载失败,尝试使用默认值 1 重试一次
|
||
if (isAdmin) {
|
||
try {
|
||
const data = await api.getGlobalConfigs(1)
|
||
setConfigs(data)
|
||
} catch (retryError) {
|
||
console.error('Retry load configs with accountId=1 failed:', retryError)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadSystemStatus = async () => {
|
||
try {
|
||
const res = await api.getTradingSystemStatus()
|
||
setSystemStatus(res)
|
||
} catch (error) {
|
||
// 静默失败
|
||
}
|
||
}
|
||
|
||
const loadBackendStatus = async () => {
|
||
try {
|
||
const res = await api.getBackendStatus()
|
||
setBackendStatus(res)
|
||
} catch (error) {
|
||
// 静默失败
|
||
}
|
||
}
|
||
|
||
// 检测当前配置匹配哪个预设方案
|
||
const detectCurrentPreset = () => {
|
||
if (!configs || Object.keys(configs).length === 0) return null
|
||
|
||
for (const [presetKey, preset] of Object.entries(presets)) {
|
||
let match = true
|
||
for (const [key, expectedValue] of Object.entries(preset.configs)) {
|
||
const currentConfig = configs[key]
|
||
if (!currentConfig) {
|
||
match = false
|
||
break
|
||
}
|
||
|
||
let currentValue = currentConfig.value
|
||
if (key.includes('PERCENT') || key.includes('PCT')) {
|
||
if (PCT_LIKE_KEYS.has(key)) {
|
||
currentValue = currentValue <= 0.05 ? currentValue * 100 : currentValue
|
||
} else {
|
||
currentValue = currentValue * 100
|
||
}
|
||
}
|
||
|
||
if (typeof expectedValue === 'number' && typeof currentValue === 'number') {
|
||
if (Math.abs(currentValue - expectedValue) > 0.01) {
|
||
match = false
|
||
break
|
||
}
|
||
} else if (currentValue !== expectedValue) {
|
||
match = false
|
||
break
|
||
}
|
||
}
|
||
|
||
if (match) {
|
||
return presetKey
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadUsers()
|
||
loadAccounts()
|
||
// 只有管理员才加载配置和系统状态
|
||
if (isAdmin) {
|
||
// 立即加载 configs(不等待 configMeta,使用默认值 1)
|
||
// 同时加载 configMeta,加载完成后会触发重新加载 configs(如果 global_strategy_account_id 不是 1)
|
||
loadConfigs().catch(() => {})
|
||
loadConfigMeta()
|
||
.then(() => {
|
||
// configMeta 加载完成后,如果 global_strategy_account_id 不是 1,重新加载 configs
|
||
// 这样可以确保使用正确的全局策略账号ID
|
||
const globalAccountId = configMeta?.global_strategy_account_id
|
||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
||
: 1
|
||
// 如果 globalAccountId 不是 1,说明之前加载的是默认值,需要重新加载
|
||
if (globalAccountId !== 1) {
|
||
loadConfigs().catch(() => {})
|
||
}
|
||
})
|
||
.catch(() => {}) // 静默失败
|
||
loadSystemStatus().catch(() => {}) // 静默失败
|
||
loadBackendStatus().catch(() => {}) // 静默失败
|
||
|
||
const timer = setInterval(() => {
|
||
loadSystemStatus().catch(() => {})
|
||
loadBackendStatus().catch(() => {})
|
||
}, 3000)
|
||
return () => clearInterval(timer)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isAdmin])
|
||
|
||
// 系统控制函数
|
||
const handleClearCache = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.clearSystemCache()
|
||
setMessage(res.message || '缓存已清理')
|
||
await loadConfigs()
|
||
await loadSystemStatus()
|
||
} catch (error) {
|
||
setMessage('清理缓存失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleStartTrading = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.startTradingSystem()
|
||
setMessage(res.message || '交易系统已启动')
|
||
await loadSystemStatus()
|
||
} catch (error) {
|
||
setMessage('启动失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleStopTrading = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.stopTradingSystem()
|
||
setMessage(res.message || '交易系统已停止')
|
||
await loadSystemStatus()
|
||
} catch (error) {
|
||
setMessage('停止失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleRestartTrading = async () => {
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.restartTradingSystem()
|
||
setMessage(res.message || '交易系统已重启')
|
||
await loadSystemStatus()
|
||
} catch (error) {
|
||
setMessage('重启失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleRestartBackend = async () => {
|
||
if (!window.confirm('确定要重启后端服务吗?重启期间页面接口会短暂不可用(约 3-10 秒)。')) return
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.restartBackend()
|
||
setMessage(res.message || '已发起后端重启')
|
||
setTimeout(() => {
|
||
loadBackendStatus()
|
||
}, 4000)
|
||
} catch (error) {
|
||
setMessage('重启后端失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleRestartAllTrading = async () => {
|
||
if (!window.confirm('确定要重启【所有账号】的交易进程吗?这会让所有用户的交易服务短暂中断(约 3-10 秒),用于升级代码后统一生效。')) return
|
||
setSystemBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const res = await api.restartAllTradingSystems({ prefix: 'auto_sys_acc', do_update: true })
|
||
setMessage(`已发起批量重启:共 ${res.count} 个,成功 ${res.ok},失败 ${res.failed}`)
|
||
} catch (e) {
|
||
setMessage('批量重启失败: ' + (e?.message || '未知错误'))
|
||
} finally {
|
||
setSystemBusy(false)
|
||
}
|
||
}
|
||
|
||
|
||
const applyPreset = async (presetKey) => {
|
||
const preset = presets[presetKey]
|
||
if (!preset) return
|
||
|
||
setSaving(true)
|
||
setMessage('')
|
||
try {
|
||
const configItems = Object.entries(preset.configs).map(([key, value]) => {
|
||
const config = configs[key]
|
||
if (!config) {
|
||
let type = 'number'
|
||
let category = 'risk'
|
||
if (typeof value === 'boolean') {
|
||
type = 'boolean'
|
||
category = 'strategy'
|
||
}
|
||
if (key.startsWith('ENTRY_') || key.startsWith('SMART_ENTRY_') || key === 'SMART_ENTRY_ENABLED') {
|
||
type = typeof value === 'boolean' ? 'boolean' : 'number'
|
||
category = 'strategy'
|
||
} else if (key.startsWith('AUTO_TRADE_')) {
|
||
type = typeof value === 'boolean' ? 'boolean' : 'number'
|
||
category = 'strategy'
|
||
} else if (key === 'LIMIT_ORDER_OFFSET_PCT') {
|
||
type = 'number'
|
||
category = 'strategy'
|
||
} else if (key.includes('PERCENT') || key.includes('PCT')) {
|
||
type = 'number'
|
||
if (key.includes('STOP_LOSS') || key.includes('TAKE_PROFIT')) {
|
||
category = 'risk'
|
||
} else if (key.includes('POSITION')) {
|
||
category = 'position'
|
||
} else {
|
||
category = 'scan'
|
||
}
|
||
} else if (key === 'MIN_VOLATILITY') {
|
||
type = 'number'
|
||
category = 'scan'
|
||
} else if (typeof value === 'number') {
|
||
type = 'number'
|
||
category = 'scan'
|
||
}
|
||
|
||
return {
|
||
key,
|
||
value: (key.includes('PERCENT') || key.includes('PCT')) && !PCT_LIKE_KEYS.has(key) ? value / 100 : value,
|
||
type,
|
||
category,
|
||
description: `预设方案配置项:${key}`
|
||
}
|
||
}
|
||
return {
|
||
key,
|
||
value: (key.includes('PERCENT') || key.includes('PCT')) && !PCT_LIKE_KEYS.has(key) ? value / 100 : value,
|
||
type: config.type,
|
||
category: config.category,
|
||
description: config.description
|
||
}
|
||
}).filter(Boolean)
|
||
|
||
// 管理员全局配置:始终使用全局策略账号,即使 configMeta 还没加载也使用默认值 1
|
||
let response
|
||
if (isAdmin) {
|
||
const globalAccountId = configMeta?.global_strategy_account_id
|
||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
||
: 1
|
||
response = await api.updateGlobalConfigsBatch(configItems, globalAccountId)
|
||
} else {
|
||
// 非管理员不应该访问这个页面,但为了安全还是处理一下
|
||
response = await api.updateConfigsBatch(configItems)
|
||
}
|
||
setMessage(response.message || `已应用${preset.name}`)
|
||
if (response.note) {
|
||
setTimeout(() => {
|
||
setMessage(response.note)
|
||
}, 2000)
|
||
}
|
||
await loadConfigs()
|
||
} catch (error) {
|
||
setMessage('应用预设失败: ' + error.message)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
// 配置快照函数
|
||
const isSecretKey = (key) => {
|
||
return key === 'BINANCE_API_KEY' || key === 'BINANCE_API_SECRET'
|
||
}
|
||
|
||
const maskSecret = (val) => {
|
||
const s = val === null || val === undefined ? '' : String(val)
|
||
if (!s) return ''
|
||
if (s.length <= 8) return '****'
|
||
return `${s.slice(0, 4)}...${s.slice(-4)}`
|
||
}
|
||
|
||
const toDisplayValueForSnapshot = (key, value) => {
|
||
if (value === null || value === undefined) return value
|
||
if (typeof value === 'number' && (key.includes('PERCENT') || key.includes('PCT'))) {
|
||
if (PCT_LIKE_KEYS.has(key)) {
|
||
return value <= 0.05 ? value * 100 : value
|
||
}
|
||
return value < 1 ? value * 100 : value
|
||
}
|
||
return value
|
||
}
|
||
|
||
const buildConfigSnapshot = async (includeSecrets) => {
|
||
// 管理员全局配置:始终使用全局策略账号的配置,即使 configMeta 还没加载也使用默认值 1
|
||
let data
|
||
if (isAdmin) {
|
||
const globalAccountId = configMeta?.global_strategy_account_id
|
||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
||
: 1
|
||
data = await api.getGlobalConfigs(globalAccountId)
|
||
} else {
|
||
data = await api.getConfigs()
|
||
}
|
||
const now = new Date()
|
||
|
||
const categoryMap = {
|
||
scan: '市场扫描',
|
||
position: '仓位控制',
|
||
risk: '风险控制',
|
||
strategy: '策略参数',
|
||
api: 'API配置',
|
||
}
|
||
|
||
const entries = Object.entries(data || {}).map(([key, cfg]) => {
|
||
const rawVal = cfg?.value
|
||
const valMasked = isSecretKey(key) && !includeSecrets ? maskSecret(rawVal) : rawVal
|
||
const displayVal = toDisplayValueForSnapshot(key, valMasked)
|
||
return {
|
||
key,
|
||
category: cfg?.category || '',
|
||
category_label: categoryMap[cfg?.category] || cfg?.category || '',
|
||
type: cfg?.type || '',
|
||
value: valMasked,
|
||
display_value: displayVal,
|
||
description: cfg?.description || '',
|
||
}
|
||
})
|
||
|
||
entries.sort((a, b) => {
|
||
const ca = a.category_label || a.category || ''
|
||
const cb = b.category_label || b.category || ''
|
||
if (ca !== cb) return ca.localeCompare(cb)
|
||
return a.key.localeCompare(b.key)
|
||
})
|
||
|
||
// 临时获取当前配置以检测预设
|
||
const tempConfigs = data || {}
|
||
let detectedPreset = null
|
||
for (const [presetKey, preset] of Object.entries(presets)) {
|
||
let match = true
|
||
for (const [key, expectedValue] of Object.entries(preset.configs)) {
|
||
const currentConfig = tempConfigs[key]
|
||
if (!currentConfig) {
|
||
match = false
|
||
break
|
||
}
|
||
let currentValue = currentConfig.value
|
||
if (key.includes('PERCENT') || key.includes('PCT')) {
|
||
if (PCT_LIKE_KEYS.has(key)) {
|
||
currentValue = currentValue <= 0.05 ? currentValue * 100 : currentValue
|
||
} else {
|
||
currentValue = currentValue * 100
|
||
}
|
||
}
|
||
if (typeof expectedValue === 'number' && typeof currentValue === 'number') {
|
||
if (Math.abs(currentValue - expectedValue) > 0.01) {
|
||
match = false
|
||
break
|
||
}
|
||
} else if (currentValue !== expectedValue) {
|
||
match = false
|
||
break
|
||
}
|
||
}
|
||
if (match) {
|
||
detectedPreset = presetKey
|
||
break
|
||
}
|
||
}
|
||
|
||
const snapshot = {
|
||
fetched_at: now.toISOString(),
|
||
note: 'display_value 对 PERCENT/PCT 做了百分比换算;敏感字段可选择脱敏/明文。',
|
||
preset_detected: detectedPreset,
|
||
system_status: systemStatus ? {
|
||
running: !!systemStatus.running,
|
||
pid: systemStatus.pid || null,
|
||
program: systemStatus.program || null,
|
||
state: systemStatus.state || null,
|
||
} : null,
|
||
configs: entries,
|
||
}
|
||
|
||
return JSON.stringify(snapshot, null, 2)
|
||
}
|
||
|
||
const openSnapshot = async (includeSecrets) => {
|
||
setSnapshotBusy(true)
|
||
setMessage('')
|
||
try {
|
||
const text = await buildConfigSnapshot(includeSecrets)
|
||
setSnapshotText(text)
|
||
setShowSnapshot(true)
|
||
} catch (e) {
|
||
setMessage('生成配置快照失败: ' + (e?.message || '未知错误'))
|
||
} finally {
|
||
setSnapshotBusy(false)
|
||
}
|
||
}
|
||
|
||
const copySnapshot = async () => {
|
||
try {
|
||
if (navigator?.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(snapshotText || '')
|
||
setMessage('已复制配置快照到剪贴板')
|
||
} else {
|
||
setMessage('当前浏览器不支持剪贴板 API,可手动全选复制')
|
||
}
|
||
} catch (e) {
|
||
setMessage('复制失败: ' + (e?.message || '未知错误'))
|
||
}
|
||
}
|
||
|
||
const downloadSnapshot = () => {
|
||
try {
|
||
const blob = new Blob([snapshotText || ''], { type: 'application/json;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `config-snapshot-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(url)
|
||
} catch (e) {
|
||
setMessage('下载失败: ' + (e?.message || '未知错误'))
|
||
}
|
||
}
|
||
|
||
const handleCreateUser = async () => {
|
||
if (!newUser.username || !newUser.password) {
|
||
setMessage('用户名和密码不能为空')
|
||
return
|
||
}
|
||
setBusy(true)
|
||
setMessage('')
|
||
try {
|
||
await api.createUser(newUser)
|
||
setMessage('用户创建成功')
|
||
setShowUserForm(false)
|
||
setNewUser({ username: '', password: '', role: 'user', status: 'active' })
|
||
await loadUsers()
|
||
} catch (error) {
|
||
setMessage('创建用户失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleUpdateUserPassword = async (userId) => {
|
||
const passwordInput = document.querySelector(`input[data-user-id="${userId}"]`)
|
||
const password = passwordInput?.value
|
||
if (!password) {
|
||
setMessage('密码不能为空')
|
||
return
|
||
}
|
||
setBusy(true)
|
||
setMessage('')
|
||
try {
|
||
await api.updateUserPassword(userId, password)
|
||
setMessage('密码更新成功')
|
||
setEditingUserId(null)
|
||
if (passwordInput) passwordInput.value = ''
|
||
await loadUsers()
|
||
} catch (error) {
|
||
setMessage('更新密码失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleUpdateUserRole = async (userId, role) => {
|
||
setBusy(true)
|
||
setMessage('')
|
||
try {
|
||
await api.updateUserRole(userId, role)
|
||
setMessage('角色更新成功')
|
||
await loadUsers()
|
||
} catch (error) {
|
||
setMessage('更新角色失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleUpdateUserStatus = async (userId, status) => {
|
||
setBusy(true)
|
||
setMessage('')
|
||
try {
|
||
await api.updateUserStatus(userId, status)
|
||
setMessage('状态更新成功')
|
||
await loadUsers()
|
||
} catch (error) {
|
||
setMessage('更新状态失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="global-config">加载中...</div>
|
||
}
|
||
|
||
// 简单计算:全局策略账号ID(在 render 时计算)
|
||
const globalStrategyAccountId = configMeta?.global_strategy_account_id
|
||
? parseInt(String(configMeta?.global_strategy_account_id || '1'), 10)
|
||
: 1
|
||
// 管理员全局配置页面:不依赖当前 account,直接管理全局策略账号
|
||
const isGlobalStrategyAccount = isAdmin
|
||
|
||
// 简单计算:当前预设(直接在 render 时计算,不使用 useMemo)
|
||
let currentPreset = null
|
||
if (configs && Object.keys(configs).length > 0 && presets) {
|
||
try {
|
||
// 直接内联检测逻辑,避免函数调用
|
||
for (const [presetKey, preset] of Object.entries(presets)) {
|
||
let match = true
|
||
for (const [key, expectedValue] of Object.entries(preset.configs)) {
|
||
const currentConfig = configs[key]
|
||
if (!currentConfig) {
|
||
match = false
|
||
break
|
||
}
|
||
let currentValue = currentConfig.value
|
||
if (key.includes('PERCENT') || key.includes('PCT')) {
|
||
if (PCT_LIKE_KEYS.has(key)) {
|
||
currentValue = currentValue <= 0.05 ? currentValue * 100 : currentValue
|
||
} else {
|
||
currentValue = currentValue * 100
|
||
}
|
||
}
|
||
if (typeof expectedValue === 'number' && typeof currentValue === 'number') {
|
||
if (Math.abs(currentValue - expectedValue) > 0.01) {
|
||
match = false
|
||
break
|
||
}
|
||
} else if (currentValue !== expectedValue) {
|
||
match = false
|
||
break
|
||
}
|
||
}
|
||
if (match) {
|
||
currentPreset = presetKey
|
||
break
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('detectCurrentPreset error:', e)
|
||
}
|
||
}
|
||
|
||
const presetUiMeta = {
|
||
swing: { group: 'limit', tag: '纯限价' },
|
||
strict: { group: 'limit', tag: '纯限价' },
|
||
fill: { group: 'smart', tag: '智能入场' },
|
||
steady: { group: 'smart', tag: '智能入场' },
|
||
conservative: { group: 'legacy', tag: '传统' },
|
||
balanced: { group: 'legacy', tag: '传统' },
|
||
aggressive: { group: 'legacy', tag: '高频实验' },
|
||
}
|
||
|
||
const presetGroups = [
|
||
{
|
||
key: 'limit',
|
||
title: 'A. 纯限价(SMART_ENTRY_ENABLED=false)',
|
||
desc: '只下 1 次限价单,未在确认时间内成交就撤单跳过。更控频、更接近"波段",但更容易出现 NEW→撤单。',
|
||
presetKeys: ['swing', 'strict'],
|
||
},
|
||
{
|
||
key: 'smart',
|
||
title: 'B. 智能入场(SMART_ENTRY_ENABLED=true)',
|
||
desc: '限价回调 + 受限追价 +(趋势强时)可控市价兜底。更少漏单,但必须限制追价步数与偏离上限,避免回到高频追价。',
|
||
presetKeys: ['fill', 'steady'],
|
||
},
|
||
{
|
||
key: 'legacy',
|
||
title: 'C. 传统 / 实验(不建议长期)',
|
||
desc: '这组更多用于对比或临时实验(频率更高/更容易过度交易),建议在稳定盈利前谨慎使用。',
|
||
presetKeys: ['conservative', 'balanced', 'aggressive'],
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="global-config">
|
||
<div className="global-config-header">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<h2>全局配置</h2>
|
||
<p>管理用户、账号和全局策略配置</p>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '12px' }}>
|
||
<button
|
||
type="button"
|
||
className="guide-link snapshot-btn"
|
||
onClick={() => openSnapshot(snapshotIncludeSecrets)}
|
||
disabled={snapshotBusy}
|
||
title="导出当前全量配置(用于分析)"
|
||
>
|
||
{snapshotBusy ? '生成中...' : '查看整体配置'}
|
||
</button>
|
||
<Link to="/config/guide" className="guide-link">📖 配置说明</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{message && (
|
||
<div className={`message ${message.includes('失败') ? 'error' : 'success'}`}>
|
||
{message}
|
||
</div>
|
||
)}
|
||
|
||
{/* 系统控制 */}
|
||
{isAdmin && (
|
||
<section className="global-section system-section">
|
||
<div className="system-header">
|
||
<h3>系统控制</h3>
|
||
<div className="system-status">
|
||
<span className={`system-status-badge ${systemStatus?.running ? 'running' : 'stopped'}`}>
|
||
{systemStatus?.running ? '运行中' : '未运行'}
|
||
</span>
|
||
{systemStatus?.pid ? <span className="system-status-meta">PID: {systemStatus.pid}</span> : null}
|
||
{systemStatus?.program ? <span className="system-status-meta">程序: {systemStatus.program}</span> : null}
|
||
{systemStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {systemStatus.meta.requested_at}</span> : null}
|
||
</div>
|
||
</div>
|
||
<div className="system-actions">
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
onClick={handleClearCache}
|
||
disabled={systemBusy}
|
||
title="清理Redis配置缓存并从数据库回灌。切换API Key后建议先点这里,再重启交易系统。"
|
||
>
|
||
清除缓存
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
onClick={handleStopTrading}
|
||
disabled={systemBusy || systemStatus?.running === false}
|
||
title="通过 supervisorctl 停止交易系统"
|
||
>
|
||
停止
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn"
|
||
onClick={handleStartTrading}
|
||
disabled={systemBusy || systemStatus?.running === true}
|
||
title="通过 supervisorctl 启动交易系统"
|
||
>
|
||
启动
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
onClick={handleRestartTrading}
|
||
disabled={systemBusy}
|
||
title="通过 supervisorctl 重启交易系统(建议切换API Key后使用)"
|
||
>
|
||
重启交易系统
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
onClick={handleRestartAllTrading}
|
||
disabled={systemBusy}
|
||
title="批量重启所有账号交易进程(auto_sys_acc*),用于代码升级后统一生效"
|
||
>
|
||
重启所有账号交易
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="system-btn primary"
|
||
onClick={handleRestartBackend}
|
||
disabled={systemBusy}
|
||
title="通过 backend/restart.sh 重启后端(uvicorn)。重启期间接口会短暂不可用。"
|
||
>
|
||
重启后端服务
|
||
</button>
|
||
</div>
|
||
<div className="system-status" style={{ marginTop: '10px' }}>
|
||
<span className={`system-status-badge ${backendStatus?.running ? 'running' : 'stopped'}`}>
|
||
后端 {backendStatus?.running ? '运行中' : '未知'}
|
||
</span>
|
||
{backendStatus?.pid ? <span className="system-status-meta">PID: {backendStatus.pid}</span> : null}
|
||
{backendStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {backendStatus.meta.requested_at}</span> : null}
|
||
</div>
|
||
<div className="system-hint">
|
||
建议流程:先更新配置里的 Key → 点击"清除缓存" → 点击"重启交易系统",确保不再使用旧账号下单。
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
|
||
|
||
{/* 预设方案快速切换(仅管理员 + 全局策略账号) */}
|
||
{isAdmin && isGlobalStrategyAccount && (
|
||
<section className="global-section preset-section">
|
||
<div className="preset-header">
|
||
<h3>快速切换方案</h3>
|
||
<div className="current-preset-status">
|
||
<span className="status-label">当前方案:</span>
|
||
<span className={`status-badge ${currentPreset ? 'preset' : 'custom'}`}>
|
||
{currentPreset && presets && presets[currentPreset] ? presets[currentPreset].name : '自定义'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="preset-guide">
|
||
<div className="preset-guide-title">怎么选更不迷糊</div>
|
||
<ul className="preset-guide-list">
|
||
<li>
|
||
<strong>先选入场机制</strong>:纯限价(更控频但可能撤单) vs 智能入场(更少漏单但需限制追价)。
|
||
</li>
|
||
<li>
|
||
<strong>再看"会不会下单"</strong>:如果你发现几乎不出单,优先把 <code>AUTO_TRADE_ONLY_TRENDING</code> 关掉、把 <code>AUTO_TRADE_ALLOW_4H_NEUTRAL</code> 打开。
|
||
</li>
|
||
<li>
|
||
<strong>最后再微调</strong>:想更容易成交 → 调小 <code>LIMIT_ORDER_OFFSET_PCT</code>、调大 <code>ENTRY_CONFIRM_TIMEOUT_SEC</code>。
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="preset-groups">
|
||
{presetGroups.map((g) => (
|
||
<div key={g.key} className="preset-group">
|
||
<div className="preset-group-header">
|
||
<div className="preset-group-title">{g.title}</div>
|
||
<div className="preset-group-desc">{g.desc}</div>
|
||
</div>
|
||
<div className="preset-buttons">
|
||
{g.presetKeys
|
||
.filter((k) => presets && presets[k])
|
||
.map((k) => {
|
||
const preset = presets && presets[k] ? presets[k] : null
|
||
if (!preset) return null
|
||
const meta = presetUiMeta && presetUiMeta[k] ? presetUiMeta[k] : { group: g.key, tag: '' }
|
||
return (
|
||
<button
|
||
key={k}
|
||
className={`preset-btn ${currentPreset === k ? 'active' : ''}`}
|
||
onClick={() => {
|
||
if (typeof applyPreset === 'function') {
|
||
applyPreset(k)
|
||
}
|
||
}}
|
||
disabled={saving}
|
||
title={preset.desc}
|
||
>
|
||
<div className="preset-name">
|
||
{preset.name}
|
||
{meta.tag ? (
|
||
<span className={`preset-tag preset-tag--${meta.group}`}>{meta.tag}</span>
|
||
) : null}
|
||
{currentPreset === k ? <span className="active-indicator">✓</span> : null}
|
||
</div>
|
||
<div className="preset-desc">{preset.desc}</div>
|
||
</button>
|
||
)
|
||
})
|
||
.filter(Boolean)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* 全局策略配置项编辑(仅管理员) */}
|
||
{isAdmin && (
|
||
<section className="global-section config-section">
|
||
<div className="section-header">
|
||
<h3>全局策略配置</h3>
|
||
<p style={{ fontSize: '14px', color: '#666', marginTop: '8px' }}>
|
||
修改全局策略配置,所有普通用户账号将使用这些配置(风险旋钮除外)
|
||
</p>
|
||
</div>
|
||
{Object.keys(configs).length > 0 ? (
|
||
(() => {
|
||
const configCategories = {
|
||
'scan': '市场扫描',
|
||
'position': '仓位控制',
|
||
'risk': '风险控制',
|
||
'strategy': '策略参数',
|
||
}
|
||
return Object.entries(configCategories).map(([category, label]) => {
|
||
const categoryConfigs = Object.entries(configs).filter(([key, config]) => {
|
||
if (config.category !== category) return false
|
||
// 排除风险旋钮(这些由用户自己控制)
|
||
const RISK_KNOBS_KEYS = ['MIN_MARGIN_USDT', 'MIN_POSITION_PERCENT', 'MAX_POSITION_PERCENT',
|
||
'MAX_TOTAL_POSITION_PERCENT', 'AUTO_TRADE_ENABLED', 'MAX_OPEN_POSITIONS', 'MAX_DAILY_ENTRIES']
|
||
if (RISK_KNOBS_KEYS.includes(key)) return false
|
||
// 排除API密钥(在账号管理中)
|
||
if (key === 'BINANCE_API_KEY' || key === 'BINANCE_API_SECRET' || key === 'USE_TESTNET') return false
|
||
return true
|
||
})
|
||
if (categoryConfigs.length === 0) return null
|
||
return (
|
||
<div key={category} style={{ marginBottom: '24px' }}>
|
||
<h4 style={{ marginBottom: '12px', fontSize: '16px', fontWeight: '600' }}>{label}</h4>
|
||
<div className="config-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
|
||
{categoryConfigs.map(([key, config]) => (
|
||
<ConfigItem
|
||
key={key}
|
||
label={key}
|
||
config={config}
|
||
onUpdate={async (value) => {
|
||
try {
|
||
setSaving(true)
|
||
setMessage('')
|
||
// 管理员始终使用全局策略账号,即使 configMeta 还没加载也使用默认值 1
|
||
const globalAccountId = isAdmin
|
||
? (configMeta?.global_strategy_account_id
|
||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
||
: 1)
|
||
: null
|
||
if (!isAdmin || !globalAccountId) {
|
||
setMessage('只有管理员可以修改全局配置')
|
||
return
|
||
}
|
||
await api.updateGlobalConfigsBatch([{
|
||
key,
|
||
value,
|
||
type: config.type,
|
||
category: config.category,
|
||
description: config.description
|
||
}], globalAccountId)
|
||
setMessage(`已更新 ${key}`)
|
||
await loadConfigs()
|
||
} catch (error) {
|
||
setMessage('更新配置失败: ' + error.message)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}}
|
||
disabled={saving}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
})()
|
||
) : (
|
||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||
{loading ? '加载配置中...' : '暂无配置项'}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
|
||
|
||
{/* 用户管理 */}
|
||
<section className="global-section">
|
||
<div className="section-header">
|
||
<h3>用户管理</h3>
|
||
<button
|
||
type="button"
|
||
className="btn-primary"
|
||
onClick={() => setShowUserForm(!showUserForm)}
|
||
disabled={busy}
|
||
>
|
||
{showUserForm ? '取消' : '+ 创建用户'}
|
||
</button>
|
||
</div>
|
||
|
||
{showUserForm && (
|
||
<div className="form-card">
|
||
<h4>创建新用户</h4>
|
||
<div className="form-group">
|
||
<label>用户名</label>
|
||
<input
|
||
type="text"
|
||
value={newUser.username}
|
||
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
|
||
placeholder="输入用户名"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>密码</label>
|
||
<input
|
||
type="password"
|
||
value={newUser.password}
|
||
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
||
placeholder="输入密码"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>角色</label>
|
||
<select
|
||
value={newUser.role}
|
||
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
|
||
>
|
||
<option value="user">普通用户</option>
|
||
<option value="admin">管理员</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>状态</label>
|
||
<select
|
||
value={newUser.status}
|
||
onChange={(e) => setNewUser({ ...newUser, status: e.target.value })}
|
||
>
|
||
<option value="active">启用</option>
|
||
<option value="disabled">禁用</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-actions">
|
||
<button type="button" className="btn-primary" onClick={handleCreateUser} disabled={busy}>
|
||
创建
|
||
</button>
|
||
<button type="button" onClick={() => setShowUserForm(false)} disabled={busy}>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="table-container">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>用户名</th>
|
||
<th>角色</th>
|
||
<th>状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((user) => (
|
||
<tr key={user.id}>
|
||
<td>{user.id}</td>
|
||
<td>{user.username}</td>
|
||
<td>
|
||
<select
|
||
value={user.role || 'user'}
|
||
onChange={(e) => handleUpdateUserRole(user.id, e.target.value)}
|
||
disabled={busy}
|
||
>
|
||
<option value="user">普通用户</option>
|
||
<option value="admin">管理员</option>
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<select
|
||
value={user.status || 'active'}
|
||
onChange={(e) => handleUpdateUserStatus(user.id, e.target.value)}
|
||
disabled={busy}
|
||
>
|
||
<option value="active">启用</option>
|
||
<option value="disabled">禁用</option>
|
||
</select>
|
||
</td>
|
||
<td>
|
||
{editingUserId === user.id ? (
|
||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||
<input
|
||
type="password"
|
||
data-user-id={user.id}
|
||
placeholder="新密码"
|
||
style={{ padding: '4px 8px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '14px' }}
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter') {
|
||
handleUpdateUserPassword(user.id)
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="btn-primary"
|
||
onClick={() => handleUpdateUserPassword(user.id)}
|
||
disabled={busy}
|
||
>
|
||
保存
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setEditingUserId(null)
|
||
const input = document.querySelector(`input[data-user-id="${user.id}"]`)
|
||
if (input) input.value = ''
|
||
}}
|
||
disabled={busy}
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingUserId(user.id)}
|
||
disabled={busy}
|
||
>
|
||
修改密码
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 账号管理 */}
|
||
<section className="global-section">
|
||
<div className="section-header">
|
||
<h3>账号管理</h3>
|
||
<button
|
||
type="button"
|
||
className="btn-primary"
|
||
onClick={loadAccounts}
|
||
disabled={busy}
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
|
||
<div className="table-container">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>名称</th>
|
||
<th>状态</th>
|
||
<th>测试网</th>
|
||
<th>API Key</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{accounts.map((account) => (
|
||
<tr key={account.id}>
|
||
<td>{account.id}</td>
|
||
<td>{account.name || '未命名'}</td>
|
||
<td>
|
||
<span className={`status-badge ${account.status === 'active' ? 'active' : 'disabled'}`}>
|
||
{account.status === 'active' ? '启用' : '禁用'}
|
||
</span>
|
||
</td>
|
||
<td>{account.use_testnet ? '是' : '否'}</td>
|
||
<td>{account.has_api_key ? '已配置' : '未配置'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 配置快照 Modal */}
|
||
{showSnapshot && (
|
||
<div className="snapshot-modal-overlay" onClick={() => setShowSnapshot(false)} role="presentation">
|
||
<div className="snapshot-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="snapshot-modal-header">
|
||
<div>
|
||
<h3>当前整体配置快照</h3>
|
||
<div className="snapshot-hint">
|
||
默认脱敏 BINANCE_API_KEY/SECRET。你可以选择明文后重新生成再复制/下载。
|
||
</div>
|
||
</div>
|
||
<button type="button" className="snapshot-close" onClick={() => setShowSnapshot(false)}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
|
||
<div className="snapshot-toolbar">
|
||
<label className="snapshot-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={snapshotIncludeSecrets}
|
||
onChange={async (e) => {
|
||
const checked = e.target.checked
|
||
setSnapshotIncludeSecrets(checked)
|
||
await openSnapshot(checked)
|
||
}}
|
||
/>
|
||
显示敏感信息(明文)
|
||
</label>
|
||
|
||
<div className="snapshot-actions">
|
||
<button type="button" className="system-btn" onClick={copySnapshot}>
|
||
复制
|
||
</button>
|
||
<button type="button" className="system-btn primary" onClick={downloadSnapshot}>
|
||
下载 JSON
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<pre className="snapshot-pre">{snapshotText}</pre>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default GlobalConfig
|