auto_trade_sys/frontend/src/components/GlobalConfig.jsx
薇薇安 9958af7c3f 1
2026-02-03 15:42:29 +08:00

1447 lines
57 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 } from 'react'
import { Link } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { api } from '../services/api'
import { selectIsAdmin } from '../store/appSlice'
import './GlobalConfig.css'
import './ConfigPanel.css' // 复用 ConfigPanel 的样式
// 复用 ConfigPanel 的 ConfigItem 组件
// 部分配置项使用“数值原样”(如 RSI 0100、24h 涨跌幅 25 表示 25%),不做 01 比例转换
const NUMBER_AS_IS_KEYS = new Set([
'MAX_RSI_FOR_LONG',
'MIN_RSI_FOR_SHORT',
'MAX_CHANGE_PERCENT_FOR_LONG',
'MAX_CHANGE_PERCENT_FOR_SHORT',
])
// 配置项中文标签(便于识别)
const KEY_LABELS = {
MAX_RSI_FOR_LONG: '做多 RSI 上限',
MIN_RSI_FOR_SHORT: '做空 RSI 下限',
MAX_CHANGE_PERCENT_FOR_LONG: '做多 24h 涨跌幅上限(%)',
MAX_CHANGE_PERCENT_FOR_SHORT: '做空 24h 涨跌幅上限(%)',
TAKE_PROFIT_1_PERCENT: '第一目标止盈(%)',
SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT: '智能补单候选数',
SYMBOL_LOSS_COOLDOWN_ENABLED: '连续亏损冷却',
SYMBOL_MAX_CONSECUTIVE_LOSSES: '连续亏损次数阈值',
SYMBOL_LOSS_COOLDOWN_SEC: '冷却时间(秒)',
RSI_EXTREME_REVERSE_ENABLED: 'RSI 极端反向(超买反空/超卖反多)',
RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H: 'RSI 反向仅允许 4H 中性',
}
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 isNumberAsIs = NUMBER_AS_IS_KEYS.has(label)
const displayLabel = KEY_LABELS[label] || label
const formatPercent = (n) => {
if (typeof n !== 'number' || isNaN(n)) return ''
return n.toFixed(4).replace(/\.?0+$/, '')
}
const getInitialDisplayValue = (val) => {
if (config.type === 'number' && isNumberAsIs) {
if (val === null || val === undefined || val === '') return ''
const numVal = typeof val === 'string' ? parseFloat(val) : val
return isNaN(numVal) ? '' : String(numVal)
}
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) {
return formatPercent(numVal <= 0.05 ? numVal * 100 : numVal)
}
return formatPercent(numVal <= 1 ? numVal : numVal / 100)
}
return val === null || val === undefined ? '' : val
}
const [localValue, setLocalValue] = useState(getInitialDisplayValue(config.value))
const [isEditing, setIsEditing] = useState(false)
useEffect(() => {
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 (isNumberAsIs) {
const numVal = parseFloat(localValue)
if (isNaN(numVal)) {
setLocalValue(getInitialDisplayValue(config.value))
setIsEditing(false)
return
}
finalValue = numVal
} else if (isPercentKey) {
const numVal = parseFloat(localValue)
if (isNaN(numVal)) {
setLocalValue(getInitialDisplayValue(config.value))
setIsEditing(false)
return
}
if (numVal < 0 || numVal > 1) {
setLocalValue(getInitialDisplayValue(config.value))
setIsEditing(false)
return
}
finalValue = numVal
} 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>{displayLabel}</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' && !isNumberAsIs) {
if (isPercentKey) {
const numValue = parseFloat(newValue)
if (newValue !== '' && !isNaN(numValue) && (numValue < 0 || numValue > 1)) {
return
}
}
}
handleChange(newValue)
}}
onBlur={handleBlur}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleBlur()
}
}}
disabled={disabled}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
)}
{/* ⚠️ 简化:去掉%符号直接显示小数0.30 */}
</div>
{config.description && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>{config.description}</div>
)}
</div>
)
}
const GlobalConfig = () => {
const isAdmin = useSelector(selectIsAdmin)
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState('')
const [busy, setBusy] = useState(false)
// 系统控制相关
const [systemStatus, setSystemStatus] = useState(null)
const [backendStatus, setBackendStatus] = useState(null)
const [servicesSummary, setServicesSummary] = useState(null)
const [systemBusy, setSystemBusy] = useState(false)
// 预设方案相关
const [configs, setConfigs] = useState({})
const [saving, setSaving] = useState(false)
// 配置快照相关
const [showSnapshot, setShowSnapshot] = useState(false)
const [snapshotText, setSnapshotText] = useState('')
const [snapshotIncludeSecrets, setSnapshotIncludeSecrets] = useState(false)
const [snapshotBusy, setSnapshotBusy] = useState(false)
// 新增:搜索和 Tab 状态
const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('all')
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倍
}
},
altcoin: {
name: '山寨币狙击(高盈亏比)',
desc: '专为山寨币设计宽止损2.0倍ATR+12%固定、合理盈亏比3:1、移动止损保护利润、严格成交量过滤≥3000万美元。2026-01-27优化让收益率真实胜率正常化。期望胜率40%+盈亏比1.5:1+。',
configs: {
// 风险控制(最关键)- 2026-01-27优化让收益率真实胜率正常化
ATR_STOP_LOSS_MULTIPLIER: 1.5, // ATR止损1.5倍2026-01-27优化收紧止损减少单笔亏损
STOP_LOSS_PERCENT: 12.0, // 固定止损12%(收紧止损,减少单笔亏损)
RISK_REWARD_RATIO: 3.0, // 盈亏比3:1降低更容易触发保证胜率
ATR_TAKE_PROFIT_MULTIPLIER: 2.0, // ATR止盈2.0倍2026-01-27优化降低止盈目标更容易触发
TAKE_PROFIT_PERCENT: 20.0, // 固定止盈20%(降低止盈目标,更容易触发,提升止盈单比例)
MIN_HOLD_TIME_SEC: 0, // 取消持仓锁(山寨币变化快)
USE_FIXED_RISK_SIZING: true, // 固定风险
FIXED_RISK_PERCENT: 1.0, // 每笔最多亏1%
USE_DYNAMIC_ATR_MULTIPLIER: false, // 不使用动态ATR
// 移动止损(必须开启)- 2026-01-27优化与第一目标止盈一致
USE_TRAILING_STOP: true, // 启用移动止损保护利润
TRAILING_STOP_ACTIVATION: 20.0, // 盈利20%后激活(与第一目标止盈一致)
TRAILING_STOP_PROTECT: 10.0, // 保护10%利润(给回撤足够空间)
// 仓位管理
MAX_POSITION_PERCENT: 1.5, // 单笔1.5%(山寨币不加仓)
MAX_TOTAL_POSITION_PERCENT: 12.0, // 总仓位12%
MAX_DAILY_ENTRIES: 8, // 每日最多8笔增加交易频率
MAX_OPEN_POSITIONS: 4, // 最多4个持仓
LEVERAGE: 8, // 基础杠杆8倍
MAX_LEVERAGE: 12, // 最大杠杆12倍
USE_DYNAMIC_LEVERAGE: false, // 不使用动态杠杆
// 品种筛选(流动性为王)
MIN_VOLUME_24H: 30000000, // 24H成交额≥3000万美元
MIN_VOLUME_24H_STRICT: 50000000, // 严格过滤≥5000万
MIN_VOLATILITY: 3.0, // 最小波动率3%
TOP_N_SYMBOLS: 8, // 选择信号最强的8个给更多选择余地避免错过好机会
MAX_SCAN_SYMBOLS: 250, // 扫描前250个增加覆盖率从27.6%提升到46.0%
MIN_SIGNAL_STRENGTH: 5, // 信号强度≥5MACD金叉/死叉已足够,配合其他筛选)
EXCLUDE_MAJOR_COINS: true, // 排除主流币BTC、ETH、BNB等专注于山寨币
// 时间框架
SCAN_INTERVAL: 1800, // 扫描间隔30分钟增加交易机会
PRIMARY_INTERVAL: '4h', // 主周期4小时
ENTRY_INTERVAL: '1h', // 入场周期1小时
CONFIRM_INTERVAL: '1d', // 确认周期日线
// 智能入场
SMART_ENTRY_ENABLED: true, // 开启智能入场
ENTRY_SYMBOL_COOLDOWN_SEC: 1800, // 币种冷却30分钟
ENTRY_MAX_DRIFT_PCT_TRENDING: 0.8, // 追价偏离0.8%
ENTRY_MAX_DRIFT_PCT_RANGING: 0.3, // 震荡偏离0.3%
// 交易控制
AUTO_TRADE_ONLY_TRENDING: true, // 只做趋势市
AUTO_TRADE_ALLOW_4H_NEUTRAL: true, // 允许4H中性提高交易频率宽止损+高盈亏比已考虑低胜率)
},
},
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倍
}
}
}
// 已知全局配置项默认值(兜底:后端未返回时前端仍能显示,避免看不到新配置项)
const KNOWN_GLOBAL_CONFIG_DEFAULTS = {
MAX_RSI_FOR_LONG: { value: 70, type: 'number', category: 'strategy', description: '做多时 RSI 超过此值则不开多避免超买区追多。2026-01-31新增。' },
MAX_CHANGE_PERCENT_FOR_LONG: { value: 25, type: 'number', category: 'strategy', description: '做多时 24h 涨跌幅超过此值则不开多(避免追大涨)。单位:百分比数值,如 25 表示 25%。' },
MIN_RSI_FOR_SHORT: { value: 30, type: 'number', category: 'strategy', description: '做空时 RSI 低于此值则不做空避免深超卖反弹。2026-01-31新增。' },
MAX_CHANGE_PERCENT_FOR_SHORT: { value: 10, type: 'number', category: 'strategy', description: '做空时 24h 涨跌幅超过此值则不做空24h 仍大涨时不做空)。单位:百分比数值。' },
TAKE_PROFIT_1_PERCENT: { value: 0.15, type: 'number', category: 'strategy', description: '分步止盈第一目标(保证金百分比,如 0.15=15%。第一目标触发后了结50%仓位。' },
SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT: { value: 8, type: 'number', category: 'scan', description: '智能补单:多返回的候选数量,冷却时仍可尝试后续交易对。' },
SYMBOL_LOSS_COOLDOWN_ENABLED: { value: true, type: 'boolean', category: 'strategy', description: '是否启用同一交易对连续亏损后的冷却。' },
SYMBOL_MAX_CONSECUTIVE_LOSSES: { value: 2, type: 'number', category: 'strategy', description: '最大允许连续亏损次数(超过则禁止交易该交易对一段时间)。' },
SYMBOL_LOSS_COOLDOWN_SEC: { value: 3600, type: 'number', category: 'strategy', description: '连续亏损后的冷却时间默认1小时。' },
BETA_FILTER_ENABLED: { value: true, type: 'boolean', category: 'strategy', description: '大盘共振过滤BTC/ETH 下跌时屏蔽多单。' },
BETA_FILTER_THRESHOLD: { value: -0.005, type: 'number', category: 'strategy', description: '大盘共振阈值(比例,如 -0.005 表示 -0.5%)。' },
RSI_EXTREME_REVERSE_ENABLED: { value: false, type: 'boolean', category: 'strategy', description: '开启后:原信号做多但 RSI 超买(≥做多上限)时改为做空;原信号做空但 RSI 超卖(≤做空下限)时改为做多。属均值回归思路,可填补超买超卖时不下单的空置;默认关闭。' },
RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H: { value: true, type: 'boolean', category: 'strategy', description: '建议开启:仅在 4H 趋势为中性时允许 RSI 反向单,避免在强趋势里逆势抄底/摸顶,降低风险。关闭则反向可与 4H 同向仍受“禁止逆4H趋势”约束。' },
}
const loadConfigs = async () => {
try {
// 管理员全局配置:从独立的全局配置表读取,不依赖任何 account
if (isAdmin) {
const data = await api.getGlobalConfigs()
// 兜底:若后端未返回新配置项(如未重启),用已知默认值合并,确保前端能看到
const merged = { ...(data || {}) }
Object.keys(KNOWN_GLOBAL_CONFIG_DEFAULTS).forEach(key => {
if (!(key in merged)) {
merged[key] = KNOWN_GLOBAL_CONFIG_DEFAULTS[key]
}
})
console.log('Loaded global configs:', Object.keys(merged).length, 'keys')
setConfigs(merged)
} else {
// 非管理员不应该访问这个页面
setConfigs({})
}
} catch (error) {
console.error('Failed to load global configs:', error)
setConfigs({})
}
}
const loadSystemStatus = async () => {
try {
const res = await api.getTradingSystemStatus()
setSystemStatus(res)
try {
const servicesRes = await api.get('/system/trading/services')
setServicesSummary(servicesRes.data.summary)
} catch (e) {
// Services summary failed
}
} catch (error) {
// 静默失败
}
}
const loadBackendStatus = async () => {
try {
const res = await api.getBackendStatus()
setBackendStatus(res)
} catch (error) {
// 静默失败
}
}
useEffect(() => {
const init = async () => {
if (isAdmin) {
// 加载全局配置(独立于账户)
await Promise.allSettled([
loadConfigs(),
loadSystemStatus(),
loadBackendStatus()
])
}
setLoading(false)
}
init()
// 只有管理员才轮询系统状态
if (isAdmin) {
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 handleStopBackend = async () => {
if (!window.confirm('警告:确定要停止后端服务吗?\n\n停止后 Web 界面将立即无法访问!\n必须手动登录服务器执行 ./start.sh 才能恢复。')) return
setSystemBusy(true)
setMessage('')
try {
const res = await api.stopBackend()
setMessage(res.message || '后端服务已停止')
alert('后端服务已停止,页面将不再响应。请手动去服务器启动。')
} 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 handleStopAllTrading = async () => {
if (!window.confirm('确定要停止【所有账号】的交易进程吗?所有账号将停止自动交易!')) return
setSystemBusy(true)
setMessage('')
try {
const res = await api.stopAllTradingSystems({ prefix: 'auto_sys_acc', include_default: true })
setMessage(`已发起批量停止:共 ${res.count} 个,成功 ${res.ok},失败 ${res.failed}`)
await loadSystemStatus()
} 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 handleConfigUpdate = async (key, value, config) => {
// 检查是否有实质变化
if (config.value === value) return
// 格式化显示值用于确认弹窗
let displayOld = config.value
let displayNew = value
// 如果是百分比相关的,尝试转回百分数显示,更直观
const isPercent = key.includes('PERCENT') || key.includes('PCT')
if (isPercent && typeof value === 'number' && Math.abs(value) <= 1) {
// 简单判断:如果原值是小数且 key 含 PERCENT可能是小数存储。
// 这里为了保险,直接显示原始值和新值,管理员自己判断。
// 或者简单转一下
displayOld = `${config.value} (${(config.value * 100).toFixed(2)}%)`
displayNew = `${value} (${(value * 100).toFixed(2)}%)`
}
const confirmMsg = `确定修改配置项【${key}】吗?\n\n原值: ${displayOld}\n新值: ${displayNew}\n\n修改将立即生效。`
if (!window.confirm(confirmMsg)) {
// 如果用户取消,理论上 UI 应该回滚。
// 但 ConfigItem 内部状态已经变了onBlur 触发)。
// 触发一次重载配置可以强制 UI 回滚
await loadConfigs()
return
}
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)
}
}
// 配置快照函数
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 loadUsersAndAccounts = async () => {
if (!isAdmin) return
try {
setBusy(true)
const [users, accounts] = await Promise.all([
api.getUsersDetailed ? api.getUsersDetailed() : api.get('/admin/users/detailed').then(r => r.data),
api.getAccounts(),
])
setUsersDetailed(Array.isArray(users) ? users : [])
setAccountsAdmin(Array.isArray(accounts) ? accounts : [])
const initMap = {}
;(Array.isArray(users) ? users : []).forEach(u => {
initMap[u.id] = ''
})
setLinkAccountMap(initMap)
} catch (e) {
setMessage(e?.message || '加载失败')
} finally {
setBusy(false)
}
}
useEffect(() => {
if (isAdmin) loadUsersAndAccounts()
}, [isAdmin])
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 = {
altcoin: { group: 'altcoin', tag: '山寨币专用' },
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: 'altcoin',
title: '⭐ 山寨币高盈亏比狙击策略',
desc: '专为山寨币设计宽止损2.0×ATR+ 高盈亏比4:1+ 移动止损 + 严格流动性筛选。目标胜率35%,期望值+0.75%/笔。',
presetKeys: ['altcoin'],
},
{
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'],
},
]
const handleGrant = async (userId) => {
const aid = parseInt(String(linkAccountMap[userId] || ''), 10)
if (!Number.isFinite(aid) || aid <= 0) return
try {
setBusy(true)
await api.grantUserAccount(userId, aid, linkRole)
setMessage('已关联账号')
await loadUsersAndAccounts()
} catch (e) {
setMessage(e?.message || '关联失败')
} finally {
setBusy(false)
}
}
const handleRevoke = async (userId, accountId) => {
try {
setBusy(true)
await api.revokeUserAccount(userId, accountId)
setMessage('已取消关联')
await loadUsersAndAccounts()
} catch (e) {
setMessage(e?.message || '取消失败')
} finally {
setBusy(false)
}
}
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-indicators" style={{ display: 'flex', gap: '15px', fontSize: '14px', alignItems: 'center', marginLeft: '20px' }}>
<div className="status-item" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: backendStatus?.running ? '#4caf50' : '#f44336', display: 'inline-block' }}></span>
<span style={{ fontWeight: 500 }}>后端: {backendStatus?.running ? '运行中' : '停止'}</span>
</div>
{servicesSummary && (
<div className="status-item" style={{ display: 'flex', alignItems: 'center', gap: '6px', borderLeft: '1px solid #ddd', paddingLeft: '15px' }}>
<span style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#2196f3', display: 'inline-block' }}></span>
<span style={{ fontWeight: 500 }}>交易服务: </span>
<span style={{ fontWeight: 'bold' }}>{servicesSummary.total}</span>
<span style={{ color: '#666' }}></span>
<span style={{ color: '#4caf50', marginLeft: '8px', fontSize: '0.9em' }}> 运行 {servicesSummary.running}</span>
<span style={{ color: '#f44336', marginLeft: '8px', fontSize: '0.9em' }}> 停止 {servicesSummary.stopped}</span>
{servicesSummary.unknown > 0 && <span style={{ color: '#999', marginLeft: '8px', fontSize: '0.9em' }}> 未知 {servicesSummary.unknown}</span>}
</div>
)}
</div>
</div>
<div className="system-control-group">
<h4 className="control-group-title" style={{ margin: '10px 0 8px', fontSize: '14px', color: '#666' }}>后端服务管理</h4>
<div className="system-actions">
<button
type="button"
className="system-btn primary"
onClick={handleRestartBackend}
disabled={systemBusy}
title="通过 backend/restart.sh 重启后端uvicorn。重启期间接口会短暂不可用。"
>
重启后端服务
</button>
<button
type="button"
className="system-btn danger"
onClick={handleStopBackend}
disabled={systemBusy}
title="停止后端服务(需要手动去服务器启动)"
>
停止后端服务
</button>
</div>
</div>
<div className="system-control-group" style={{ marginTop: '15px' }}>
<h4 className="control-group-title" style={{ margin: '10px 0 8px', fontSize: '14px', color: '#666' }}>系统数据维护</h4>
<div className="system-actions">
<button
type="button"
className="system-btn warning"
onClick={handleClearCache}
disabled={systemBusy}
title="清理Redis配置缓存并从数据库回灌。这将影响后端服务和所有交易进程。切换API Key或修改配置后建议执行。"
>
清除全局缓存
</button>
<span style={{ fontSize: '12px', color: '#888', alignSelf: 'center', marginLeft: '10px' }}>
* 影响后端服务及所有交易账号修改配置后请点击
</span>
</div>
</div>
<div className="system-control-group" style={{ marginTop: '15px' }}>
<h4 className="control-group-title" style={{ margin: '10px 0 8px', fontSize: '14px', color: '#666' }}>交易服务管理</h4>
<div className="system-actions">
<button
type="button"
className="system-btn primary"
onClick={handleRestartAllTrading}
disabled={systemBusy}
title="批量重启所有账号交易进程auto_sys_acc*),用于代码升级后统一生效"
>
重启所有账号交易
</button>
<button
type="button"
className="system-btn danger"
onClick={handleStopAllTrading}
disabled={systemBusy}
title="批量停止所有账号交易进程auto_sys_acc*"
>
停止所有账号交易
</button>
</div>
</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>
{/* 搜索和筛选栏 */}
<div className="config-toolbar" style={{ marginBottom: '20px', display: 'flex', flexDirection: 'column', gap: '15px' }}>
{/* 搜索框 */}
<div className="search-bar" style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<input
type="text"
placeholder="🔍 搜索配置项 Key、描述或中文名..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '10px 12px',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '14px'
}}
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
style={{
position: 'absolute',
right: '10px',
border: 'none',
background: 'none',
cursor: 'pointer',
color: '#999',
fontSize: '16px'
}}
>
</button>
)}
</div>
{/* Tabs */}
<div className="config-tabs" style={{ display: 'flex', gap: '10px', overflowX: 'auto', paddingBottom: '5px' }}>
{[
{ key: 'all', label: '全部' },
{ key: 'risk', label: '风险控制' },
{ key: 'strategy', label: '策略参数' },
{ key: 'scan', label: '市场扫描' },
{ key: 'position', label: '仓位控制' },
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`tab-btn ${activeTab === tab.key ? 'active' : ''}`}
style={{
padding: '8px 16px',
borderRadius: '20px',
border: activeTab === tab.key ? '1px solid #007bff' : '1px solid #eee',
background: activeTab === tab.key ? '#e7f1ff' : '#f9f9f9',
color: activeTab === tab.key ? '#007bff' : '#666',
cursor: 'pointer',
fontWeight: activeTab === tab.key ? '600' : 'normal',
whiteSpace: 'nowrap',
transition: 'all 0.2s'
}}
>
{tab.label}
</button>
))}
</div>
</div>
{Object.keys(configs).length > 0 ? (
(() => {
const configCategories = {
'risk': '风险控制',
'strategy': '策略参数',
'scan': '市场扫描',
'position': '仓位控制',
}
// 过滤逻辑
const filteredConfigs = Object.entries(configs).filter(([key, config]) => {
// 1. 基础过滤排除非对象、风险旋钮、API Key
if (!config || typeof config !== 'object') 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
if (key === 'BINANCE_API_KEY' || key === 'BINANCE_API_SECRET' || key === 'USE_TESTNET') return false
// 2. Tab 过滤
if (activeTab !== 'all' && config.category !== activeTab) return false
// 3. 搜索过滤
if (searchTerm) {
const lowerTerm = searchTerm.toLowerCase()
const matchKey = key.toLowerCase().includes(lowerTerm)
const matchDesc = (config.description || '').toLowerCase().includes(lowerTerm)
const matchLabel = (KEY_LABELS[key] || '').toLowerCase().includes(lowerTerm)
if (!matchKey && !matchDesc && !matchLabel) return false
}
return true
})
if (filteredConfigs.length === 0) {
return <div style={{ textAlign: 'center', color: '#999', padding: '30px', background: '#f5f5f5', borderRadius: '8px' }}>未找到匹配的配置项</div>
}
// 分组渲染
// 如果是 'all' Tab按 Category 分组
// 如果是特定 Tab直接渲染或者也分组只有一个组
const groupsToRender = activeTab === 'all'
? Object.keys(configCategories)
: [activeTab]
// 将 filteredConfigs 转为 Map 或方便查找的结构,或者每次 filter
return groupsToRender.map(category => {
const categoryLabel = configCategories[category] || category
const groupConfigs = filteredConfigs.filter(([_, cfg]) => cfg.category === category)
if (groupConfigs.length === 0) return null
return (
<div key={category} style={{ marginBottom: '24px' }}>
<h4 style={{ marginBottom: '12px', fontSize: '16px', fontWeight: '600', borderLeft: '4px solid #007bff', paddingLeft: '10px', color: '#333' }}>
{categoryLabel}
</h4>
<div className="config-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
{groupConfigs.map(([key, config]) => (
<ConfigItem
key={key}
label={key}
config={config}
onUpdate={(val) => handleConfigUpdate(key, val, config)}
disabled={saving}
/>
))}
</div>
</div>
)
})
})()
) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
{loading ? '加载配置中...' : '暂无配置项'}
</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