diff --git a/backend/api/routes/system.py b/backend/api/routes/system.py index 3bcbb7c..48ff2e5 100644 --- a/backend/api/routes/system.py +++ b/backend/api/routes/system.py @@ -895,6 +895,79 @@ async def trading_restart(_admin: Dict[str, Any] = Depends(require_system_admin) raise HTTPException(status_code=500, detail=f"supervisorctl restart 失败: {e}") +@router.post("/trading/stop-all") +async def trading_stop_all( + _admin: Dict[str, Any] = Depends(require_system_admin), + prefix: str = "auto_sys_acc", + include_default: bool = False, +) -> Dict[str, Any]: + """ + 一键停止所有账号交易进程(supervisor)。 + """ + try: + prefix = (prefix or "auto_sys_acc").strip() + if not prefix: + prefix = "auto_sys_acc" + + # 先读取全量 status,拿到有哪些进程 + status_all = _run_supervisorctl(["status"]) + names = _list_supervisor_process_names(status_all) + + targets: list[str] = [] + for n in names: + if n.startswith(prefix): + targets.append(n) + + if include_default: + default_prog = _get_program_name() + if default_prog and default_prog not in targets and default_prog in names: + targets.append(default_prog) + + if not targets: + return { + "message": "未找到可停止的交易进程", + "prefix": prefix, + "include_default": include_default, + "count": 0, + "targets": [], + "status_all": status_all, + } + + results: list[Dict[str, Any]] = [] + ok = 0 + failed = 0 + for prog in targets: + try: + out = _run_supervisorctl(["stop", prog]) + raw = _run_supervisorctl(["status", prog]) + running, pid, state = _parse_supervisor_status(raw) + results.append( + { + "program": prog, + "ok": True, + "output": out, + "status": {"running": running, "pid": pid, "state": state, "raw": raw}, + } + ) + ok += 1 + except Exception as e: + failed += 1 + results.append({"program": prog, "ok": False, "error": str(e)}) + + return { + "message": "已发起批量停止", + "prefix": prefix, + "include_default": include_default, + "count": len(targets), + "ok": ok, + "failed": failed, + "targets": targets, + "results": results, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"批量停止失败: {e}") + + @router.post("/trading/restart-all") async def trading_restart_all( _admin: Dict[str, Any] = Depends(require_system_admin), diff --git a/frontend/src/components/GlobalConfig.css b/frontend/src/components/GlobalConfig.css index 4841c8a..4dc17c5 100644 --- a/frontend/src/components/GlobalConfig.css +++ b/frontend/src/components/GlobalConfig.css @@ -165,3 +165,125 @@ background: #ffebee; color: #c62828; } + +/* System Control Section */ +.system-section { + border-top: 4px solid #1976d2; +} + +.system-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.system-header h3 { + margin: 0; +} + +.system-status { + display: flex; + align-items: center; + gap: 12px; +} + +.system-status-badge { + padding: 4px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; +} + +.system-status-badge.running { + background: #e8f5e9; + color: #2e7d32; + border: 1px solid #c8e6c9; +} + +.system-status-badge.stopped { + background: #ffebee; + color: #c62828; + border: 1px solid #ffcdd2; +} + +.system-status-meta { + font-size: 12px; + color: #666; + font-family: monospace; +} + +.system-control-group { + background: #f8f9fa; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + border: 1px solid #eee; +} + +.control-group-title { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: #555; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.system-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.system-btn { + padding: 8px 16px; + border: 1px solid #ddd; + background: white; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + color: #333; +} + +.system-btn:hover:not(:disabled) { + background: #f5f5f5; + border-color: #bbb; +} + +.system-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.system-btn.primary { + background: #1976d2; + color: white; + border-color: #1565c0; +} + +.system-btn.primary:hover:not(:disabled) { + background: #1565c0; +} + +.system-btn.danger { + background: #dc3545; + color: white; + border-color: #dc3545; +} + +.system-btn.danger:hover:not(:disabled) { + background: #c82333; + border-color: #bd2130; +} + +.system-hint { + font-size: 13px; + color: #666; + margin-top: 12px; + padding: 8px 12px; + background: #fff3e0; + border-radius: 4px; + border: 1px solid #ffe0b2; +} diff --git a/frontend/src/components/GlobalConfig.jsx b/frontend/src/components/GlobalConfig.jsx index 6b7f02f..7ad4d22 100644 --- a/frontend/src/components/GlobalConfig.jsx +++ b/frontend/src/components/GlobalConfig.jsx @@ -212,6 +212,10 @@ const GlobalConfig = () => { 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', @@ -679,6 +683,21 @@ const GlobalConfig = () => { } } + 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] @@ -761,6 +780,57 @@ const GlobalConfig = () => { } } + // 单个配置更新(带确认) + 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原值: ${config.value}\n新值: ${value}\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' @@ -1110,78 +1180,85 @@ const GlobalConfig = () => {

系统控制

- {systemStatus?.running ? '运行中' : '未运行'} + 交易系统 {systemStatus?.running ? '运行中' : '未运行'} {systemStatus?.pid ? PID: {systemStatus.pid} : null} - {systemStatus?.program ? 程序: {systemStatus.program} : null} - {systemStatus?.meta?.requested_at ? 上次重启: {systemStatus.meta.requested_at} : null} + + + 后端服务 {backendStatus?.running ? '运行中' : '未知'} + + {backendStatus?.pid ? PID: {backendStatus.pid} : null}
-
- - - - - - + +
+

后端服务管理

+
+ + +
-
- - 后端 {backendStatus?.running ? '运行中' : '未知'} - - {backendStatus?.pid ? PID: {backendStatus.pid} : null} - {backendStatus?.meta?.requested_at ? 上次重启: {backendStatus.meta.requested_at} : null} + +
+

交易服务管理

+
+ + + + +
+
- 建议流程:先更新配置里的 Key → 点击"清除缓存" → 点击"重启交易系统",确保不再使用旧账号下单。 + 建议流程:先更新配置里的 Key → 点击"清除缓存" → 点击"重启所有账号交易",确保不再使用旧账号下单。
)} @@ -1269,64 +1346,137 @@ const GlobalConfig = () => { 修改全局策略配置,所有普通用户账号将使用这些配置(风险旋钮除外)

+ + {/* 搜索和筛选栏 */} +
+ {/* 搜索框 */} +
+ setSearchTerm(e.target.value)} + style={{ + width: '100%', + padding: '10px 12px', + borderRadius: '6px', + border: '1px solid #ddd', + fontSize: '14px' + }} + /> + {searchTerm && ( + + )} +
+ + {/* Tabs */} +
+ {[ + { key: 'all', label: '全部' }, + { key: 'risk', label: '风险控制' }, + { key: 'strategy', label: '策略参数' }, + { key: 'scan', label: '市场扫描' }, + { key: 'position', label: '仓位控制' }, + ].map(tab => ( + + ))} +
+
+ {Object.keys(configs).length > 0 ? ( (() => { const configCategories = { - 'scan': '市场扫描', - 'position': '仓位控制', 'risk': '风险控制', 'strategy': '策略参数', + 'scan': '市场扫描', + 'position': '仓位控制', } - return Object.entries(configCategories).map(([category, label]) => { - const categoryConfigs = Object.entries(configs).filter(([key, config]) => { - // 确保config是对象且有category字段 - if (!config || typeof config !== 'object') { - console.warn(`Config ${key} is not an object:`, config) - return false - } - if (!config.category || 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 - console.log(`Category ${category} (${label}): ${categoryConfigs.length} configs`, categoryConfigs.map(([k]) => k)) + + // 过滤逻辑 + 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
未找到匹配的配置项
+ } + + // 分组渲染 + // 如果是 '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 (
-

{label}

+

+ {categoryLabel} +

- {categoryConfigs.map(([key, config]) => ( + {groupConfigs.map(([key, config]) => ( { - 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) - } - }} + onUpdate={(val) => handleConfigUpdate(key, val, config)} disabled={saving} /> ))} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 3253ea4..8114494 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -612,6 +612,24 @@ export const api = { return response.json(); }, + stopAllTradingSystems: async (opts = {}) => { + const params = new URLSearchParams() + if (opts?.prefix) params.set('prefix', String(opts.prefix)) + if (opts?.include_default) params.set('include_default', 'true') + const url = params.toString() + ? `${buildUrl('/api/system/trading/stop-all')}?${params.toString()}` + : buildUrl('/api/system/trading/stop-all') + const response = await fetch(url, { + method: 'POST', + headers: withAccountHeaders({ 'Content-Type': 'application/json' }), + }) + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '批量停止失败' })) + throw new Error(error.detail || '批量停止失败') + } + return response.json() + }, + restartAllTradingSystems: async (opts = {}) => { const params = new URLSearchParams() if (opts?.prefix) params.set('prefix', String(opts.prefix))