1539 lines
62 KiB
JavaScript
1539 lines
62 KiB
JavaScript
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 0–100、24h 涨跌幅 25 表示 25%),不做 0–1 比例转换
|
||
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',
|
||
'MIN_RR_FOR_TP1',
|
||
'POSITION_SCALE_FACTOR',
|
||
])
|
||
// 配置项中文标签(便于识别)
|
||
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 中性',
|
||
MIN_RR_FOR_TP1: '第一目标最小盈亏比(相对止损)',
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: '允许 4H 中性时自动交易',
|
||
POSITION_SCALE_FACTOR: '仓位放大系数',
|
||
}
|
||
|
||
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 已移除,直接显示小数
|
||
])
|
||
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: 0.12,
|
||
MAX_TOTAL_POSITION_PERCENT: 0.45,
|
||
MAX_LEVERAGE: 20,
|
||
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,
|
||
MAX_POSITION_PERCENT: 0.12,
|
||
MAX_LEVERAGE: 20,
|
||
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_POSITION_PERCENT: 0.10,
|
||
MAX_LEVERAGE: 15,
|
||
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: '与 2026-02-12 策略一致:4H 中性不自动交易、做多 RSI≤65、第一目标止盈 20%、止损 3×ATR、盈亏比 3:1、同品种连亏冷却。',
|
||
configs: {
|
||
// 风险与止盈止损(与当前全局默认一致)
|
||
ATR_STOP_LOSS_MULTIPLIER: 3.0, // 3×ATR 减少噪音止损
|
||
STOP_LOSS_PERCENT: 0.12,
|
||
RISK_REWARD_RATIO: 3.0, // 盈亏比 3:1
|
||
TAKE_PROFIT_1_PERCENT: 0.2, // 第一目标止盈 20%
|
||
TAKE_PROFIT_PERCENT: 0.30, // 第二目标 30%
|
||
MIN_RR_FOR_TP1: 1.5, // 第一目标至少 1.5 倍止损距离
|
||
MIN_HOLD_TIME_SEC: 1800,
|
||
USE_FIXED_RISK_SIZING: true,
|
||
FIXED_RISK_PERCENT: 0.02, // 每笔风险 2%
|
||
USE_DYNAMIC_ATR_MULTIPLIER: false,
|
||
|
||
USE_TRAILING_STOP: true,
|
||
TRAILING_STOP_ACTIVATION: 0.20,
|
||
TRAILING_STOP_PROTECT: 0.10,
|
||
|
||
MAX_POSITION_PERCENT: 0.12, // 单笔最大 12% 可用资金,加大单笔盈利空间
|
||
MAX_TOTAL_POSITION_PERCENT: 0.45,
|
||
MAX_DAILY_ENTRIES: 8,
|
||
MAX_OPEN_POSITIONS: 4,
|
||
LEVERAGE: 10,
|
||
MAX_LEVERAGE: 20, // 动态杠杆上限 20,配合单笔仓位提高收益
|
||
MIN_LEVERAGE: 8, // 动态杠杆下限 8,与之前盈利阶段一致,避免被压到 2–4x
|
||
MAX_LEVERAGE_SMALL_CAP: 8, // 高波动币也允许 8x,与之前一致
|
||
USE_DYNAMIC_LEVERAGE: true,
|
||
|
||
MIN_VOLUME_24H: 10000000,
|
||
MIN_VOLATILITY: 0.02,
|
||
TOP_N_SYMBOLS: 20,
|
||
MAX_SCAN_SYMBOLS: 500,
|
||
MIN_SIGNAL_STRENGTH: 8,
|
||
EXCLUDE_MAJOR_COINS: true,
|
||
SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT: 15,
|
||
|
||
SCAN_INTERVAL: 1800,
|
||
PRIMARY_INTERVAL: '1h',
|
||
ENTRY_INTERVAL: '15m',
|
||
CONFIRM_INTERVAL: '4h',
|
||
|
||
AUTO_TRADE_ONLY_TRENDING: true,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: false, // 4H 中性不自动交易,提升质量
|
||
MAX_RSI_FOR_LONG: 65, // 做多不追高
|
||
MIN_RSI_FOR_SHORT: 30,
|
||
MAX_CHANGE_PERCENT_FOR_LONG: 25,
|
||
MAX_CHANGE_PERCENT_FOR_SHORT: 10,
|
||
|
||
SYMBOL_LOSS_COOLDOWN_ENABLED: true,
|
||
SYMBOL_MAX_CONSECUTIVE_LOSSES: 2,
|
||
SYMBOL_LOSS_COOLDOWN_SEC: 3600,
|
||
|
||
BETA_FILTER_ENABLED: true,
|
||
BETA_FILTER_THRESHOLD: -0.005,
|
||
ENTRY_SHORT_TREND_FILTER_ENABLED: true,
|
||
MAX_TREND_MOVE_BEFORE_ENTRY: 0.05,
|
||
},
|
||
},
|
||
more_opportunities: {
|
||
name: '增加机会(放宽过滤)',
|
||
desc: '放宽过滤条件以增加下单机会:允许 4H 中性、大盘共振阈值放宽到 -1%、做多 RSI≤70、做空 RSI≥25。适合想要更多交易机会时使用。',
|
||
configs: {
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: true, // 允许 4H 中性,增加机会
|
||
BETA_FILTER_THRESHOLD: -0.01, // 大盘共振阈值放宽到 -1%(从 -0.5% 放宽)
|
||
MAX_RSI_FOR_LONG: 70, // 做多 RSI 上限放宽到 70(从 65)
|
||
MIN_RSI_FOR_SHORT: 25, // 做空 RSI 下限放宽到 25(从 30),允许更多做空机会
|
||
ATR_STOP_LOSS_MULTIPLIER: 3.0,
|
||
RISK_REWARD_RATIO: 3.0,
|
||
TAKE_PROFIT_1_PERCENT: 0.2,
|
||
TAKE_PROFIT_PERCENT: 0.30,
|
||
MIN_RR_FOR_TP1: 1.5,
|
||
SYMBOL_LOSS_COOLDOWN_ENABLED: true,
|
||
SYMBOL_MAX_CONSECUTIVE_LOSSES: 2,
|
||
SYMBOL_LOSS_COOLDOWN_SEC: 3600,
|
||
BETA_FILTER_ENABLED: true, // 保持开启但放宽阈值
|
||
MIN_SIGNAL_STRENGTH: 8,
|
||
MAX_POSITION_PERCENT: 0.12,
|
||
MAX_LEVERAGE: 20,
|
||
},
|
||
},
|
||
profit_scale: {
|
||
name: '盈利放大(适度激进)',
|
||
desc: '在推荐策略基础上:仓位放大 1.25 倍、单笔上限 12%、杠杆上限 20、每笔风险 2.5%、最多 5 仓。盈利阶段可一键放大收益,仍受单笔上限约束。',
|
||
configs: {
|
||
POSITION_SCALE_FACTOR: 1.25,
|
||
MAX_POSITION_PERCENT: 0.12,
|
||
MAX_LEVERAGE: 20,
|
||
MAX_TOTAL_POSITION_PERCENT: 0.45,
|
||
FIXED_RISK_PERCENT: 0.025,
|
||
MAX_OPEN_POSITIONS: 5,
|
||
MAX_DAILY_ENTRIES: 10,
|
||
ATR_STOP_LOSS_MULTIPLIER: 3.0,
|
||
RISK_REWARD_RATIO: 3.0,
|
||
TAKE_PROFIT_1_PERCENT: 0.2,
|
||
TAKE_PROFIT_PERCENT: 0.30,
|
||
MIN_RR_FOR_TP1: 1.5,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: false,
|
||
MAX_RSI_FOR_LONG: 65,
|
||
MIN_RSI_FOR_SHORT: 30,
|
||
SYMBOL_LOSS_COOLDOWN_ENABLED: true,
|
||
SYMBOL_MAX_CONSECUTIVE_LOSSES: 2,
|
||
SYMBOL_LOSS_COOLDOWN_SEC: 3600,
|
||
BETA_FILTER_ENABLED: true,
|
||
MIN_SIGNAL_STRENGTH: 8,
|
||
},
|
||
},
|
||
classic_profit: {
|
||
name: '之前盈利风格',
|
||
desc: '对齐 2 月初有盈利时的下单方式:固定 8 倍杠杆、单笔 12%、每笔风险 2.5%,不做动态降杠杆,单笔保证金与盈利空间更接近当时。',
|
||
configs: {
|
||
LEVERAGE: 8,
|
||
USE_DYNAMIC_LEVERAGE: false, // 固定 8x,与之前一致
|
||
MIN_LEVERAGE: 8,
|
||
MAX_LEVERAGE: 20,
|
||
MAX_POSITION_PERCENT: 0.12,
|
||
MAX_TOTAL_POSITION_PERCENT: 0.45,
|
||
FIXED_RISK_PERCENT: 0.025, // 略高于 2%,单笔保证金更接近之前
|
||
MAX_LEVERAGE_SMALL_CAP: 8,
|
||
ATR_STOP_LOSS_MULTIPLIER: 3.0,
|
||
RISK_REWARD_RATIO: 3.0,
|
||
TAKE_PROFIT_1_PERCENT: 0.2,
|
||
TAKE_PROFIT_PERCENT: 0.30,
|
||
MIN_RR_FOR_TP1: 1.5,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: false,
|
||
MAX_RSI_FOR_LONG: 65,
|
||
MIN_RSI_FOR_SHORT: 30,
|
||
SYMBOL_LOSS_COOLDOWN_ENABLED: true,
|
||
SYMBOL_MAX_CONSECUTIVE_LOSSES: 2,
|
||
SYMBOL_LOSS_COOLDOWN_SEC: 3600,
|
||
BETA_FILTER_ENABLED: true,
|
||
MIN_SIGNAL_STRENGTH: 8,
|
||
},
|
||
},
|
||
classic_profit_amplify: {
|
||
name: '之前盈利·放大',
|
||
desc: '在「之前盈利风格」基础上把杠杆提到 10 倍:同保证金下名义更大,盈利与亏损都会按比例放大。适合想进一步放大收益时使用。',
|
||
configs: {
|
||
LEVERAGE: 10,
|
||
USE_DYNAMIC_LEVERAGE: false, // 固定 10x
|
||
MIN_LEVERAGE: 10,
|
||
MAX_LEVERAGE: 20,
|
||
MAX_POSITION_PERCENT: 0.12,
|
||
MAX_TOTAL_POSITION_PERCENT: 0.45,
|
||
FIXED_RISK_PERCENT: 0.025,
|
||
MAX_LEVERAGE_SMALL_CAP: 10,
|
||
ATR_STOP_LOSS_MULTIPLIER: 3.0,
|
||
RISK_REWARD_RATIO: 3.0,
|
||
TAKE_PROFIT_1_PERCENT: 0.2,
|
||
TAKE_PROFIT_PERCENT: 0.30,
|
||
MIN_RR_FOR_TP1: 1.5,
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: false,
|
||
MAX_RSI_FOR_LONG: 65,
|
||
MIN_RSI_FOR_SHORT: 30,
|
||
SYMBOL_LOSS_COOLDOWN_ENABLED: true,
|
||
SYMBOL_MAX_CONSECUTIVE_LOSSES: 2,
|
||
SYMBOL_LOSS_COOLDOWN_SEC: 3600,
|
||
BETA_FILTER_ENABLED: true,
|
||
MIN_SIGNAL_STRENGTH: 8,
|
||
},
|
||
},
|
||
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倍
|
||
}
|
||
},
|
||
script_v1: {
|
||
name: '高收益趋势 (脚本配置)',
|
||
desc: '原 update_global_config.py 脚本中的配置。高止盈(60%) + 紧止损(10%) + 移动止损保护。适合捕捉大单边行情。',
|
||
configs: {
|
||
TAKE_PROFIT_PERCENT: 60.0,
|
||
TAKE_PROFIT_1_PERCENT: 30.0,
|
||
STOP_LOSS_PERCENT: 10.0,
|
||
TRAILING_STOP_ACTIVATION: 30.0,
|
||
TRAILING_STOP_PROTECT: 5.0,
|
||
LEVERAGE: 4,
|
||
RISK_REWARD_RATIO: 3.0,
|
||
ATR_TAKE_PROFIT_MULTIPLIER: 4.5,
|
||
ATR_STOP_LOSS_MULTIPLIER: 1.5,
|
||
USE_FIXED_RISK_SIZING: true,
|
||
FIXED_RISK_PERCENT: 1.0, // 1%
|
||
MAX_LEVERAGE_SMALL_CAP: 4,
|
||
}
|
||
}
|
||
}
|
||
|
||
// 已知全局配置项默认值(兜底:后端未返回时前端仍能显示,避免看不到新配置项)
|
||
const KNOWN_GLOBAL_CONFIG_DEFAULTS = {
|
||
MAX_RSI_FOR_LONG: { value: 65, type: 'number', category: 'strategy', description: '做多时 RSI 超过此值则不开多(2026-02-12:65 避免追高)。' },
|
||
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.2, type: 'number', category: 'strategy', description: '分步止盈第一目标(保证金百分比,如 0.2=20%)。2026-02-12 提高以改善盈亏比。' },
|
||
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小时。' },
|
||
MIN_RR_FOR_TP1: { value: 1.5, type: 'number', category: 'strategy', description: '第一目标止盈相对止损的最小盈亏比(如 1.5 表示 TP1 至少为止损距离的 1.5 倍)。2026-02-12 新增。' },
|
||
POSITION_SCALE_FACTOR: { value: 1.0, type: 'number', category: 'risk', description: '仓位放大系数:1.0=正常,1.2=+20% 仓位,1.5=+50%,上限 2.0。盈利时适度调高可扩大收益,仍受单笔上限约束。' },
|
||
AUTO_TRADE_ALLOW_4H_NEUTRAL: { value: false, type: 'boolean', category: 'strategy', description: '是否允许 4H 趋势为中性时自动交易。关闭可减少震荡扫损、提升质量(建议关闭)。' },
|
||
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趋势”约束)。' },
|
||
TAKE_PROFIT_PERCENT: { value: 0.25, type: 'number', category: 'risk', description: '止盈百分比(保证金比例,如 25.0=25%)。' },
|
||
STOP_LOSS_PERCENT: { value: 0.08, type: 'number', category: 'risk', description: '止损百分比(保证金比例,如 8.0=8%)。' },
|
||
TRAILING_STOP_ACTIVATION: { value: 0.20, type: 'number', category: 'risk', description: '移动止损激活阈值(盈利达到此比例后激活,如 20.0=20%)。' },
|
||
TRAILING_STOP_PROTECT: { value: 0.10, type: 'number', category: 'risk', description: '移动止损保护阈值(回撤至此比例时触发止损,如 10.0=10%)。' },
|
||
LEVERAGE: { value: 10, type: 'number', category: 'risk', description: '基础杠杆倍数。' },
|
||
MAX_POSITION_PERCENT: { value: 0.12, type: 'number', category: 'risk', description: '单笔最大保证金占可用资金比例(如 0.12=12%),加大可提高单笔盈利。' },
|
||
MAX_LEVERAGE: { value: 20, type: 'number', category: 'risk', description: '动态杠杆上限(如 20 表示最高 20 倍),配合单笔仓位提高收益。' },
|
||
MIN_LEVERAGE: { value: 8, type: 'number', category: 'risk', description: '动态杠杆下限(如 8 表示不低于 8 倍)。之前盈利阶段多为 8x,避免被压到 2–4x 导致单笔盈利过少。' },
|
||
MAX_LEVERAGE_SMALL_CAP: { value: 8, type: 'number', category: 'risk', description: '高波动/小众币最大允许杠杆(与之前盈利阶段一致)。' },
|
||
RISK_REWARD_RATIO: { value: 3, type: 'number', category: 'risk', description: '盈亏比目标(用于计算动态止盈止损,建议 3:1)。' },
|
||
ATR_TAKE_PROFIT_MULTIPLIER: { value: 1.5, type: 'number', category: 'risk', description: 'ATR 止盈倍数。' },
|
||
ATR_STOP_LOSS_MULTIPLIER: { value: 3, type: 'number', category: 'risk', description: 'ATR 止损倍数(2026-02-12:3 减少噪音止损)。' },
|
||
USE_FIXED_RISK_SIZING: { value: false, type: 'boolean', category: 'risk', description: '是否使用固定风险仓位计算(基于止损距离和账户余额)。' },
|
||
FIXED_RISK_PERCENT: { value: 0.01, type: 'number', category: 'risk', description: '每笔交易固定风险百分比(如 0.01=1%)。' },
|
||
}
|
||
|
||
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 || '未知错误'))
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
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: '当前推荐' },
|
||
more_opportunities: { group: 'altcoin', tag: '增加机会' },
|
||
profit_scale: { group: 'altcoin', tag: '盈利放大' },
|
||
classic_profit: { group: 'altcoin', tag: '之前盈利' },
|
||
classic_profit_amplify: { group: 'altcoin', tag: '杠杆放大' },
|
||
swing: { group: 'backup', tag: '纯限价' },
|
||
strict: { group: 'backup', tag: '精选低频' },
|
||
conservative: { group: 'backup', tag: '保守' },
|
||
}
|
||
|
||
// 快速方案:当前推荐(含增加机会/盈利放大/之前盈利)+ 备选
|
||
const presetGroups = [
|
||
{
|
||
key: 'altcoin',
|
||
title: '当前推荐 · 山寨币策略',
|
||
desc: '与当前策略一致。想增加下单机会可点「增加机会」;盈利阶段可点「盈利放大」;想恢复 2 月初有盈利时的下单风格可点「之前盈利」;在之前盈利基础上再放大可点「杠杆放大」(10x)。',
|
||
presetKeys: ['altcoin', 'more_opportunities', 'profit_scale', 'classic_profit', 'classic_profit_amplify'],
|
||
},
|
||
{
|
||
key: 'backup',
|
||
title: '备选方案(纯限价 / 保守)',
|
||
desc: '需要更少出单或纯限价时可选:波段回归、精选低频、保守传统。',
|
||
presetKeys: ['swing', 'strict', 'conservative'],
|
||
},
|
||
]
|
||
|
||
|
||
|
||
|
||
|
||
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>:选「山寨币策略(当前推荐)」即可,已与当前策略(4H 中性关闭、RSI/止盈/止损/盈亏比)对齐。
|
||
</li>
|
||
<li>
|
||
<strong>若几乎不出单</strong>:可临时把 <code>AUTO_TRADE_ALLOW_4H_NEUTRAL</code> 打开(会增加 4H 震荡单,信号质量下降)。
|
||
</li>
|
||
<li>
|
||
<strong>要更少出单</strong>:选备选里的「波段回归」或「精选低频」。
|
||
</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
|