auto_trade_sys/frontend/src/components/GlobalConfig.jsx
薇薇安 0c489bfdee a
2026-01-23 14:59:57 +08:00

1467 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_STOP_LOSS_MULTIPLIER: 2.5, // 放宽止损至2.5倍ATR提升胜率
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
RISK_REWARD_RATIO: 1.5, // 配合止盈倍数
TAKE_PROFIT_PERCENT: 25.0,
MIN_HOLD_TIME_SEC: 1800,
SMART_ENTRY_ENABLED: false,
USE_DYNAMIC_ATR_MULTIPLIER: false, // 关闭动态调整使用固定2.5倍
},
},
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_STOP_LOSS_MULTIPLIER: 2.5, // 放宽止损至2.5倍ATR提升胜率
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
RISK_REWARD_RATIO: 1.5, // 配合止盈倍数
TAKE_PROFIT_PERCENT: 25.0,
MIN_HOLD_TIME_SEC: 1800,
USE_DYNAMIC_ATR_MULTIPLIER: false, // 关闭动态调整使用固定2.5倍
},
},
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_STOP_LOSS_MULTIPLIER: 2.5, // 放宽止损至2.5倍ATR提升胜率
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
RISK_REWARD_RATIO: 1.5, // 配合止盈倍数
TAKE_PROFIT_PERCENT: 25.0,
MIN_HOLD_TIME_SEC: 1800,
USE_DYNAMIC_ATR_MULTIPLIER: false, // 关闭动态调整使用固定2.5倍
},
},
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_STOP_LOSS_MULTIPLIER: 2.5, // 放宽止损至2.5倍ATR提升胜率
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
RISK_REWARD_RATIO: 1.5, // 配合止盈倍数
TAKE_PROFIT_PERCENT: 25.0,
MIN_HOLD_TIME_SEC: 1800,
USE_DYNAMIC_ATR_MULTIPLIER: false, // 关闭动态调整使用固定2.5倍
},
},
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_STOP_LOSS_MULTIPLIER: 2.5, // 放宽止损至2.5倍ATR提升胜率
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
RISK_REWARD_RATIO: 1.5, // 配合止盈倍数
MIN_HOLD_TIME_SEC: 1800,
USE_DYNAMIC_ATR_MULTIPLIER: false, // 关闭动态调整使用固定2.5倍
}
},
balanced: {
name: '平衡配置',
desc: '推荐使用平衡频率和质量止损止盈适中盈亏比1.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_STOP_LOSS_MULTIPLIER: 2.5, // 放宽止损至2.5倍ATR提升胜率
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
RISK_REWARD_RATIO: 1.5, // 配合止盈倍数
MIN_HOLD_TIME_SEC: 1800,
USE_DYNAMIC_ATR_MULTIPLIER: false, // 关闭动态调整使用固定2.5倍
}
},
aggressive: {
name: '激进高频',
desc: '晚间波动大时使用交易频率高止损较紧但止盈合理盈亏比1.5: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_STOP_LOSS_MULTIPLIER: 2.5, // 放宽止损至2.5倍ATR提升胜率
ATR_TAKE_PROFIT_MULTIPLIER: 1.5,
RISK_REWARD_RATIO: 1.5, // 配合止盈倍数
MIN_HOLD_TIME_SEC: 1800,
USE_DYNAMIC_ATR_MULTIPLIER: false, // 关闭动态调整使用固定2.5倍
}
}
}
// 所有函数定义(必须在 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
if (isAdmin) {
const data = await api.getGlobalConfigs()
setConfigs(data)
} else {
// 非管理员不应该访问这个页面
setConfigs({})
}
} catch (error) {
console.error('Failed to load global configs:', error)
setConfigs({})
}
}
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) {
// 加载全局配置(独立于账户)
loadConfigs().catch(() => {})
loadConfigMeta().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)
// 管理员全局配置使用独立的全局配置API
let response
if (isAdmin) {
response = await api.updateGlobalConfigsBatch(configItems)
} else {
// 非管理员不应该访问这个页面,但为了安全还是处理一下
throw new Error('只有管理员可以修改全局配置')
}
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) => {
// 管理员全局配置:从独立的全局配置表读取
let data
if (isAdmin) {
data = await api.getGlobalConfigs()
} 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>
}
// 管理员全局配置页面:不依赖任何 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('')
if (!isAdmin) {
setMessage('只有管理员可以修改全局配置')
return
}
await api.updateGlobalConfigsBatch([{
key,
value,
type: config.type,
category: config.category,
description: config.description
}])
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