This commit is contained in:
薇薇安 2026-01-22 11:24:57 +08:00
parent 28bce8f02b
commit 3bac042273
4 changed files with 991 additions and 369 deletions

View File

@ -10,23 +10,54 @@ import AccountSelector from './components/AccountSelector'
import GlobalConfig from './components/GlobalConfig' import GlobalConfig from './components/GlobalConfig'
import Login from './components/Login' import Login from './components/Login'
import { api, clearAuthToken, clearCurrentAccountId, setCurrentAccountId, getCurrentAccountId } from './services/api' import { api, clearAuthToken, clearCurrentAccountId, setCurrentAccountId, getCurrentAccountId } from './services/api'
const VIEWING_USER_ID_KEY = 'ats_viewing_user_id'
import './App.css' import './App.css'
function App() { function App() {
const [me, setMe] = useState(null) const [me, setMe] = useState(null)
const [checking, setChecking] = useState(true) const [checking, setChecking] = useState(true)
const [users, setUsers] = useState([])
const [viewingUserId, setViewingUserId] = useState(() => {
// localStorageID
const stored = localStorage.getItem(VIEWING_USER_ID_KEY)
if (stored) {
const parsed = parseInt(stored, 10)
if (Number.isFinite(parsed) && parsed > 0) return parsed
}
return null
})
const [showUserPopover, setShowUserPopover] = useState(false)
const userPopoverRef = React.useRef(null)
const refreshMe = async () => { const refreshMe = async () => {
try { try {
const u = await api.me() const u = await api.me()
setMe(u) setMe(u)
const isAdmin = (u?.role || '') === 'admin'
//
if (isAdmin) {
try {
const userList = await api.getUsers()
setUsers(Array.isArray(userList) ? userList : [])
// viewingUserId
const currentUserId = parseInt(String(u?.id || ''), 10)
if (!viewingUserId || !userList.some((user) => parseInt(String(user?.id || ''), 10) === viewingUserId)) {
setViewingUserId(currentUserId)
localStorage.setItem(VIEWING_USER_ID_KEY, String(currentUserId))
}
} catch (e) {
console.error('加载用户列表失败:', e)
}
}
// admin/沿 accountId // admin/沿 accountId
try { try {
const list = await api.getAccounts() const list = await api.getAccounts()
const accounts = Array.isArray(list) ? list : [] const accounts = Array.isArray(list) ? list : []
const active = accounts.filter((a) => String(a?.status || 'active') === 'active') const active = accounts.filter((a) => String(a?.status || 'active') === 'active')
const isAdmin = (u?.role || '') === 'admin'
let target = null let target = null
if (isAdmin) { if (isAdmin) {
@ -53,6 +84,34 @@ function App() {
setChecking(false) setChecking(false)
} }
} }
// popover
React.useEffect(() => {
const handleClickOutside = (event) => {
if (userPopoverRef.current && !userPopoverRef.current.contains(event.target)) {
setShowUserPopover(false)
}
}
if (showUserPopover) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [showUserPopover])
//
const currentViewingUser = users.find((u) => parseInt(String(u?.id || ''), 10) === viewingUserId)
const effectiveUserId = isAdmin && viewingUserId ? viewingUserId : parseInt(String(me?.id || ''), 10)
const handleSwitchUser = (userId) => {
const nextUserId = parseInt(String(userId || ''), 10)
if (Number.isFinite(nextUserId) && nextUserId > 0) {
setViewingUserId(nextUserId)
localStorage.setItem(VIEWING_USER_ID_KEY, String(nextUserId))
setShowUserPopover(false)
// accountAccountSelector
window.dispatchEvent(new CustomEvent('ats:user:switched', { detail: { userId: nextUserId } }))
}
}
useEffect(() => { useEffect(() => {
refreshMe() refreshMe()
@ -82,7 +141,7 @@ function App() {
<div className="nav-container"> <div className="nav-container">
<div className="nav-left"> <div className="nav-left">
<h1 className="nav-title">自动交易系统</h1> <h1 className="nav-title">自动交易系统</h1>
<AccountSelector currentUser={me} /> <AccountSelector currentUser={me} viewingUserId={effectiveUserId} />
</div> </div>
<div className="nav-links"> <div className="nav-links">
<Link to="/">仪表板</Link> <Link to="/">仪表板</Link>
@ -97,15 +156,88 @@ function App() {
) : null} ) : null}
</div> </div>
<div className="nav-user"> <div className="nav-user">
<span className="nav-user-name"> {isAdmin ? (
{me?.username ? me.username : 'user'}{isAdmin ? '(管理员)' : ''} <div className="nav-user-switcher" ref={userPopoverRef} style={{ position: 'relative', display: 'inline-block' }}>
</span> <button
type="button"
className="nav-user-switch-btn"
onClick={() => setShowUserPopover(!showUserPopover)}
title="切换查看的用户视角"
style={{
background: 'transparent',
border: 'none',
color: 'white',
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
<span>👤 {currentViewingUser?.username || me?.username || '选择用户'}</span>
{currentViewingUser?.role === 'admin' ? '(管理员)' : ''}
<span></span>
</button>
{showUserPopover && (
<div className="nav-user-popover" style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
background: 'white',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
minWidth: '200px',
maxWidth: '300px',
zIndex: 1000,
overflow: 'hidden'
}}>
<div style={{ padding: '12px 16px', background: '#f5f5f5', borderBottom: '1px solid #e0e0e0', fontWeight: 600, fontSize: '14px', color: '#333' }}>
切换用户视角
</div>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{users.map((u) => (
<div
key={u.id}
onClick={() => handleSwitchUser(u.id)}
style={{
padding: '12px 16px',
cursor: 'pointer',
transition: 'background-color 0.2s',
borderBottom: '1px solid #f0f0f0',
background: viewingUserId === u.id ? '#e3f2fd' : 'white'
}}
onMouseEnter={(e) => {
if (viewingUserId !== u.id) e.currentTarget.style.background = '#f5f5f5'
}}
onMouseLeave={(e) => {
if (viewingUserId !== u.id) e.currentTarget.style.background = 'white'
}}
>
<div style={{ fontWeight: 500, fontSize: '14px', color: '#333', marginBottom: '4px' }}>
{u.username || 'user'} {u.role === 'admin' ? '(管理员)' : ''}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{u.status === 'active' ? '启用' : '禁用'}
</div>
</div>
))}
</div>
</div>
)}
</div>
) : (
<span className="nav-user-name">
{me?.username ? me.username : 'user'}
</span>
)}
<button <button
type="button" type="button"
className="nav-logout" className="nav-logout"
onClick={() => { onClick={() => {
clearAuthToken() clearAuthToken()
clearCurrentAccountId() clearCurrentAccountId()
localStorage.removeItem(VIEWING_USER_ID_KEY)
setMe(null) setMe(null)
setChecking(false) setChecking(false)
}} }}
@ -120,7 +252,7 @@ function App() {
<Routes> <Routes>
<Route path="/" element={<StatsDashboard />} /> <Route path="/" element={<StatsDashboard />} />
<Route path="/recommendations" element={<Recommendations />} /> <Route path="/recommendations" element={<Recommendations />} />
<Route path="/config" element={<ConfigPanel currentUser={me} />} /> <Route path="/config" element={<ConfigPanel currentUser={me} viewingUserId={effectiveUserId} />} />
<Route path="/config/guide" element={<ConfigGuide />} /> <Route path="/config/guide" element={<ConfigGuide />} />
<Route path="/trades" element={<TradeList />} /> <Route path="/trades" element={<TradeList />} />
<Route path="/global-config" element={isAdmin ? <GlobalConfig currentUser={me} /> : <div style={{ padding: '24px' }}>无权限</div>} /> <Route path="/global-config" element={isAdmin ? <GlobalConfig currentUser={me} /> : <div style={{ padding: '24px' }}>无权限</div>} />

View File

@ -3,42 +3,45 @@ import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api'
const VIEWING_USER_ID_KEY = 'ats_viewing_user_id' const VIEWING_USER_ID_KEY = 'ats_viewing_user_id'
const AccountSelector = ({ onChanged, currentUser }) => { const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUserId }) => {
const [accounts, setAccounts] = useState([]) const [accounts, setAccounts] = useState([])
const [accountId, setAccountId] = useState(getCurrentAccountId()) const [accountId, setAccountId] = useState(getCurrentAccountId())
const [users, setUsers] = useState([])
const [viewingUserId, setViewingUserId] = useState(() => {
// localStorageID
const stored = localStorage.getItem(VIEWING_USER_ID_KEY)
if (stored) {
const parsed = parseInt(stored, 10)
if (Number.isFinite(parsed) && parsed > 0) return parsed
}
return parseInt(String(currentUser?.id || ''), 10) || null
})
const [showUserPopover, setShowUserPopover] = useState(false)
const popoverRef = useRef(null)
const isAdmin = (currentUser?.role || '') === 'admin' const isAdmin = (currentUser?.role || '') === 'admin'
// // prop使user_id
const effectiveUserId = isAdmin ? viewingUserId : parseInt(String(currentUser?.id || ''), 10) const effectiveUserId = propViewingUserId || parseInt(String(currentUser?.id || ''), 10)
// //
useEffect(() => { useEffect(() => {
if (isAdmin) { const handleUserSwitched = (e) => {
api.getUsers() const userId = e?.detail?.userId
.then((list) => { if (userId && isAdmin) {
setUsers(Array.isArray(list) ? list : []) //
// viewingUserId api.getUserAccounts(userId)
const currentUserId = parseInt(String(currentUser?.id || ''), 10) .then((list) => {
if (viewingUserId && !list.some((u) => parseInt(String(u?.id || ''), 10) === viewingUserId)) { const accountsList = (Array.isArray(list) ? list : []).map((item) => ({
setViewingUserId(currentUserId) id: item.account_id || item.id,
localStorage.setItem(VIEWING_USER_ID_KEY, String(currentUserId)) name: item.account_name || item.name || '',
} status: item.account_status || item.status || 'active',
}) role: item.role || 'viewer',
.catch(() => setUsers([])) user_id: item.user_id
}))
setAccounts(accountsList)
// active
const firstActive = accountsList.find((a) => String(a?.status || 'active') === 'active') || accountsList[0]
if (firstActive) {
const nextAccountId = parseInt(String(firstActive.id || ''), 10)
if (Number.isFinite(nextAccountId) && nextAccountId > 0) {
setAccountId(nextAccountId)
}
}
})
.catch(() => setAccounts([]))
}
} }
}, [isAdmin, currentUser?.id, viewingUserId]) window.addEventListener('ats:user:switched', handleUserSwitched)
return () => window.removeEventListener('ats:user:switched', handleUserSwitched)
}, [isAdmin])
// effectiveUserId // effectiveUserId
useEffect(() => { useEffect(() => {
@ -82,19 +85,6 @@ const AccountSelector = ({ onChanged, currentUser }) => {
} }
} }
}, [isAdmin, effectiveUserId]) }, [isAdmin, effectiveUserId])
// popover
useEffect(() => {
const handleClickOutside = (event) => {
if (popoverRef.current && !popoverRef.current.contains(event.target)) {
setShowUserPopover(false)
}
}
if (showUserPopover) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [showUserPopover])
useEffect(() => { useEffect(() => {
setCurrentAccountId(accountId) setCurrentAccountId(accountId)
@ -125,56 +115,8 @@ const AccountSelector = ({ onChanged, currentUser }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [optionsKey, accountId]) }, [optionsKey, accountId])
const currentViewingUser = users.find((u) => parseInt(String(u?.id || ''), 10) === viewingUserId)
const handleSwitchUser = (userId) => {
const nextUserId = parseInt(String(userId || ''), 10)
if (Number.isFinite(nextUserId) && nextUserId > 0) {
setViewingUserId(nextUserId)
localStorage.setItem(VIEWING_USER_ID_KEY, String(nextUserId))
setShowUserPopover(false)
//
setAccountId(null)
}
}
return ( return (
<div className="nav-account"> <div className="nav-account">
{isAdmin ? (
<div className="nav-user-switcher" ref={popoverRef} style={{ position: 'relative' }}>
<button
type="button"
className="nav-user-switch-btn"
onClick={() => setShowUserPopover(!showUserPopover)}
title="切换查看的用户视角"
>
👤 {currentViewingUser?.username || '选择用户'}
{currentViewingUser?.role === 'admin' ? '(管理员)' : ''}
<span style={{ marginLeft: '4px' }}></span>
</button>
{showUserPopover && (
<div className="nav-user-popover">
<div className="popover-header">切换用户视角</div>
<div className="popover-list">
{users.map((u) => (
<div
key={u.id}
className={`popover-item ${viewingUserId === u.id ? 'active' : ''}`}
onClick={() => handleSwitchUser(u.id)}
>
<div className="popover-item-name">
{u.username || 'user'} {u.role === 'admin' ? '(管理员)' : ''}
</div>
<div className="popover-item-meta">
{u.status === 'active' ? '启用' : '禁用'}
</div>
</div>
))}
</div>
</div>
)}
</div>
) : null}
<span className="nav-account-label">账号</span> <span className="nav-account-label">账号</span>
<select <select
className="nav-account-select" className="nav-account-select"

View File

@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api' import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api'
import './ConfigPanel.css' import './ConfigPanel.css'
const ConfigPanel = ({ currentUser }) => { const ConfigPanel = ({ currentUser, viewingUserId }) => {
const [configs, setConfigs] = useState({}) const [configs, setConfigs] = useState({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -255,16 +255,24 @@ const ConfigPanel = ({ currentUser }) => {
const loadCurrentAccountMeta = async () => { const loadCurrentAccountMeta = async () => {
try { try {
// 使getAccounts
// status
const list = await api.getAccounts() const list = await api.getAccounts()
const accounts = Array.isArray(list) ? list : [] const accounts = Array.isArray(list) ? list : []
const meta = accounts.find((a) => parseInt(String(a?.id || '0'), 10) === parseInt(String(accountId || '0'), 10)) const meta = accounts.find((a) => parseInt(String(a?.id || '0'), 10) === parseInt(String(accountId || '0'), 10))
// statusmetastatus'active'
if (meta) {
meta.status = meta.status || 'active'
}
setCurrentAccountMeta(meta || null) setCurrentAccountMeta(meta || null)
// testnet // testnet
if (meta && !credForm.api_key && !credForm.api_secret) { if (meta && !credForm.api_key && !credForm.api_secret) {
setCredForm((prev) => ({ ...prev, use_testnet: !!meta.use_testnet })) setCredForm((prev) => ({ ...prev, use_testnet: !!meta.use_testnet }))
} }
return meta
} catch (e) { } catch (e) {
setCurrentAccountMeta(null) setCurrentAccountMeta(null)
return null
} }
} }
@ -429,18 +437,10 @@ const ConfigPanel = ({ currentUser }) => {
loadConfigMeta() loadConfigMeta()
loadConfigs() loadConfigs()
checkFeasibility() checkFeasibility()
if (isAdmin) {
loadSystemStatus()
loadBackendStatus()
}
loadAccountTradingStatus() loadAccountTradingStatus()
loadCurrentAccountMeta() loadCurrentAccountMeta()
const timer = setInterval(() => { const timer = setInterval(() => {
if (isAdmin) {
loadSystemStatus()
loadBackendStatus()
}
loadAccountTradingStatus() loadAccountTradingStatus()
loadCurrentAccountMeta() loadCurrentAccountMeta()
}, 3000) }, 3000)
@ -467,19 +467,16 @@ const ConfigPanel = ({ currentUser }) => {
} }
} }
// // account meta
useEffect(() => { useEffect(() => {
setCurrentAccountId(accountId) setCurrentAccountId(accountId)
setMessage('') setMessage('')
setLoading(true) setLoading(true)
loadConfigs() loadCurrentAccountMeta().then(() => {
checkFeasibility() loadConfigs()
if (isAdmin) { checkFeasibility()
loadSystemStatus() loadAccountTradingStatus()
loadBackendStatus() })
}
loadAccountTradingStatus()
loadCurrentAccountMeta()
}, [accountId]) }, [accountId])
// setInterval // setInterval
@ -488,10 +485,11 @@ const ConfigPanel = ({ currentUser }) => {
const next = parseInt(String(e?.detail?.accountId || ''), 10) const next = parseInt(String(e?.detail?.accountId || ''), 10)
if (Number.isFinite(next) && next > 0 && next !== accountId) { if (Number.isFinite(next) && next > 0 && next !== accountId) {
setAccountId(next) setAccountId(next)
// // account meta
loadConfigs() loadCurrentAccountMeta().then(() => {
loadAccountTradingStatus() loadConfigs()
loadCurrentAccountMeta() loadAccountTradingStatus()
})
} }
} }
window.addEventListener('ats:account:changed', onChanged) window.addEventListener('ats:account:changed', onChanged)
@ -505,10 +503,11 @@ const ConfigPanel = ({ currentUser }) => {
if (cur !== accountId) { if (cur !== accountId) {
setAccountId(cur) setAccountId(cur)
// // account meta
loadConfigs() loadCurrentAccountMeta().then(() => {
loadAccountTradingStatus() loadConfigs()
loadCurrentAccountMeta() loadAccountTradingStatus()
})
} }
}, 1000) }, 1000)
return () => clearInterval(timer) return () => clearInterval(timer)
@ -855,7 +854,7 @@ const ConfigPanel = ({ currentUser }) => {
<div className="account-switch"> <div className="account-switch">
<span className="account-hint">当前账号#{accountId}在顶部导航切换</span> <span className="account-hint">当前账号#{accountId}在顶部导航切换</span>
</div> </div>
{isAdmin && currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? ( {isAdmin && currentAccountMeta && currentAccountMeta.status === 'active' ? (
<div <div
className="system-hint" className="system-hint"
style={{ style={{
@ -897,16 +896,7 @@ const ConfigPanel = ({ currentUser }) => {
) : null} ) : null}
</div> </div>
<div className="header-actions"> <div className="header-actions">
<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> </div>
<div className="config-info"> <div className="config-info">
@ -914,7 +904,7 @@ const ConfigPanel = ({ currentUser }) => {
</div> </div>
{/* 我的交易进程按账号owner/admin 可启停) */} {/* 我的交易进程按账号owner/admin 可启停) */}
{currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? ( {currentAccountMeta && currentAccountMeta.status === 'active' ? (
<div className="system-section"> <div className="system-section">
<div className="system-header"> <div className="system-header">
<h3>我的交易进程当前账号 #{accountId}</h3> <h3>我的交易进程当前账号 #{accountId}</h3>
@ -985,7 +975,7 @@ const ConfigPanel = ({ currentUser }) => {
) : null} ) : null}
{/* 账号密钥当前账号owner/admin 可改) */} {/* 账号密钥当前账号owner/admin 可改) */}
{currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? ( {currentAccountMeta && currentAccountMeta.status === 'active' ? (
<div className="system-section"> <div className="system-section">
<div className="system-header"> <div className="system-header">
<h3>账号密钥当前账号</h3> <h3>账号密钥当前账号</h3>
@ -1056,89 +1046,6 @@ const ConfigPanel = ({ currentUser }) => {
</div> </div>
) : null} ) : null}
{/* 系统控制:清缓存 / 启停 / 重启supervisor */}
{isAdmin ? (
<div className="system-section">
<div className="system-header">
<h3>系统控制</h3>
<div className="system-status">
<span className={`system-status-badge ${systemStatus?.running ? 'running' : 'stopped'}`}>
{systemStatus?.running ? '运行中' : '未运行'}
</span>
{systemStatus?.pid ? <span className="system-status-meta">PID: {systemStatus.pid}</span> : null}
{systemStatus?.program ? <span className="system-status-meta">程序: {systemStatus.program}</span> : null}
{systemStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {systemStatus.meta.requested_at}</span> : null}
</div>
</div>
<div className="system-actions">
<button
type="button"
className="system-btn"
onClick={handleClearCache}
disabled={systemBusy}
title="清理Redis配置缓存并从数据库回灌。切换API Key后建议先点这里再重启交易系统。"
>
清除缓存
</button>
<button
type="button"
className="system-btn"
onClick={handleStopTrading}
disabled={systemBusy || systemStatus?.running === false}
title="通过 supervisorctl 停止交易系统"
>
停止
</button>
<button
type="button"
className="system-btn"
onClick={handleStartTrading}
disabled={systemBusy || systemStatus?.running === true}
title="通过 supervisorctl 启动交易系统"
>
启动
</button>
<button
type="button"
className="system-btn primary"
onClick={handleRestartTrading}
disabled={systemBusy}
title="通过 supervisorctl 重启交易系统建议切换API Key后使用"
>
重启交易系统
</button>
<button
type="button"
className="system-btn primary"
onClick={handleRestartAllTrading}
disabled={systemBusy}
title="批量重启所有账号交易进程auto_sys_acc*),用于代码升级后统一生效"
>
重启所有账号交易
</button>
<button
type="button"
className="system-btn primary"
onClick={handleRestartBackend}
disabled={systemBusy}
title="通过 backend/restart.sh 重启后端uvicorn。重启期间接口会短暂不可用。"
>
重启后端服务
</button>
</div>
<div className="system-status" style={{ marginTop: '10px' }}>
<span className={`system-status-badge ${backendStatus?.running ? 'running' : 'stopped'}`}>
后端 {backendStatus?.running ? '运行中' : '未知'}
</span>
{backendStatus?.pid ? <span className="system-status-meta">PID: {backendStatus.pid}</span> : null}
{backendStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {backendStatus.meta.requested_at}</span> : null}
</div>
<div className="system-hint">
建议流程先更新配置里的 Key 点击清除缓存 点击重启交易系统确保不再使用旧账号下单
</div>
</div>
) : null}
{/* 账号管理(超管) */} {/* 账号管理(超管) */}
{isAdmin ? ( {isAdmin ? (
<div className="accounts-admin-section"> <div className="accounts-admin-section">
@ -1406,106 +1313,8 @@ const ConfigPanel = ({ currentUser }) => {
</div> </div>
) : null} ) : null}
{/* 预设方案快速切换(仅管理员 + 全局策略账号:策略核心统一管理) */} {/* 用户提示 */}
{isAdmin && isGlobalStrategyAccount && currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? ( {!isAdmin && (
<div className="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[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>
{(() => {
const presetUiMeta = {
swing: { group: 'limit', tag: '纯限价' },
strict: { group: 'limit', tag: '纯限价' },
fill: { group: 'smart', tag: '智能入场' },
steady: { group: 'smart', tag: '智能入场' },
conservative: { group: 'legacy', tag: '传统' },
balanced: { group: 'legacy', tag: '传统' },
aggressive: { group: 'legacy', tag: '高频实验' },
}
const groups = [
{
key: 'limit',
title: 'A. 纯限价SMART_ENTRY_ENABLED=false',
desc: '只下 1 次限价单,未在确认时间内成交就撤单跳过。更控频、更接近“波段”,但更容易出现 NEW→撤单。',
presetKeys: ['swing', 'strict'],
},
{
key: 'smart',
title: 'B. 智能入场SMART_ENTRY_ENABLED=true',
desc: '限价回调 + 受限追价 +(趋势强时)可控市价兜底。更少漏单,但必须限制追价步数与偏离上限,避免回到高频追价。',
presetKeys: ['fill', 'steady'],
},
{
key: 'legacy',
title: 'C. 传统 / 实验(不建议长期)',
desc: '这组更多用于对比或临时实验(频率更高/更容易过度交易),建议在稳定盈利前谨慎使用。',
presetKeys: ['conservative', 'balanced', 'aggressive'],
},
]
return (
<div className="preset-groups">
{groups.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[k])
.map((k) => {
const preset = presets[k]
const meta = presetUiMeta[k] || { group: g.key, tag: '' }
return (
<button
key={k}
className={`preset-btn ${currentPreset === k ? 'active' : ''}`}
onClick={() => 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>
)
})}
</div>
</div>
))}
</div>
)
})()}
</div>
) : (
<div className="system-hint" style={{ marginTop: '12px' }}> <div className="system-hint" style={{ marginTop: '12px' }}>
平台已开启傻瓜化模式策略核心由管理员统一管理你只需要配置密钥充值余额并调整少量风控参数如最小/最大仓位每日开仓次数等 平台已开启傻瓜化模式策略核心由管理员统一管理你只需要配置密钥充值余额并调整少量风控参数如最小/最大仓位每日开仓次数等
</div> </div>
@ -1513,7 +1322,7 @@ const ConfigPanel = ({ currentUser }) => {
</div> </div>
{/* 配置可行性检查提示 */} {/* 配置可行性检查提示 */}
{currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' && feasibilityCheck && ( {currentAccountMeta && currentAccountMeta.status === 'active' && feasibilityCheck && (
<div className={`feasibility-check ${feasibilityCheck.feasible ? 'feasible' : 'infeasible'}`}> <div className={`feasibility-check ${feasibilityCheck.feasible ? 'feasible' : 'infeasible'}`}>
<div className="feasibility-header"> <div className="feasibility-header">
<h4> <h4>
@ -1639,7 +1448,7 @@ const ConfigPanel = ({ currentUser }) => {
)} )}
{/* 配置项禁用account时不显示 */} {/* 配置项禁用account时不显示 */}
{currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? ( {currentAccountMeta && currentAccountMeta.status === 'active' ? (
Object.entries(configCategories).map(([category, label]) => ( Object.entries(configCategories).map(([category, label]) => (
<section key={category} className="config-section"> <section key={category} className="config-section">
<h3>{label}</h3> <h3>{label}</h3>
@ -1663,56 +1472,13 @@ const ConfigPanel = ({ currentUser }) => {
</div> </div>
</section> </section>
)) ))
) : currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'disabled' ? ( ) : currentAccountMeta && currentAccountMeta.status === 'disabled' ? (
<div className="system-hint" style={{ padding: '20px', textAlign: 'center', color: '#666' }}> <div className="system-hint" style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
当前账号已禁用无法查看和修改配置 当前账号已禁用无法查看和修改配置
</div> </div>
) : null} ) : null}
{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> </div>
) )
} }

View File

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { api } from '../services/api' import { Link } from 'react-router-dom'
import { api, getCurrentAccountId } from '../services/api'
import './GlobalConfig.css' import './GlobalConfig.css'
import './ConfigPanel.css' // ConfigPanel
const GlobalConfig = ({ currentUser }) => { const GlobalConfig = ({ currentUser }) => {
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
@ -12,10 +14,191 @@ const GlobalConfig = ({ currentUser }) => {
const [showUserForm, setShowUserForm] = useState(false) const [showUserForm, setShowUserForm] = useState(false)
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user', status: 'active' }) const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user', status: 'active' })
const [editingUserId, setEditingUserId] = useState(null) const [editingUserId, setEditingUserId] = useState(null)
//
const [systemStatus, setSystemStatus] = useState(null)
const [backendStatus, setBackendStatus] = useState(null)
const [systemBusy, setSystemBusy] = useState(false)
//
const [configs, setConfigs] = useState({})
const [saving, setSaving] = useState(false)
const [currentAccountId, setCurrentAccountId] = useState(getCurrentAccountId())
const [configMeta, setConfigMeta] = useState(null)
//
const [showSnapshot, setShowSnapshot] = useState(false)
const [snapshotText, setSnapshotText] = useState('')
const [snapshotIncludeSecrets, setSnapshotIncludeSecrets] = useState(false)
const [snapshotBusy, setSnapshotBusy] = useState(false)
const PCT_LIKE_KEYS = new Set([
'LIMIT_ORDER_OFFSET_PCT',
'ENTRY_MAX_DRIFT_PCT_TRENDING',
'ENTRY_MAX_DRIFT_PCT_RANGING',
])
const isAdmin = (currentUser?.role || '') === 'admin'
const globalStrategyAccountId = parseInt(String(configMeta?.global_strategy_account_id || '1'), 10) || 1
const isGlobalStrategyAccount = isAdmin && currentAccountId === globalStrategyAccountId
//
const presets = {
swing: {
name: '波段回归(推荐)',
desc: '根治高频与追价:关闭智能入场,回归"纯限价 + 30分钟扫描 + 更高信号门槛"的低频波段。建议先跑20-30单再评估。',
configs: {
SCAN_INTERVAL: 1800,
TOP_N_SYMBOLS: 8,
MAX_POSITION_PERCENT: 2.0,
MAX_TOTAL_POSITION_PERCENT: 20.0,
MIN_POSITION_PERCENT: 0.0,
MIN_SIGNAL_STRENGTH: 8,
USE_TRAILING_STOP: false,
ATR_TAKE_PROFIT_MULTIPLIER: 4.5,
TAKE_PROFIT_PERCENT: 25.0,
MIN_HOLD_TIME_SEC: 1800,
SMART_ENTRY_ENABLED: false,
},
},
fill: {
name: '成交优先(更少漏单)',
desc: '优先解决"挂单NEW→超时撤单→没成交"的问题:解锁自动交易过滤 + 保守智能入场(限制追价步数与追价上限),在趋势强时允许可控的市价兜底。',
configs: {
SCAN_INTERVAL: 1800,
TOP_N_SYMBOLS: 6,
MIN_SIGNAL_STRENGTH: 7,
AUTO_TRADE_ONLY_TRENDING: false,
AUTO_TRADE_ALLOW_4H_NEUTRAL: true,
SMART_ENTRY_ENABLED: true,
LIMIT_ORDER_OFFSET_PCT: 0.1,
ENTRY_CONFIRM_TIMEOUT_SEC: 120,
ENTRY_CHASE_MAX_STEPS: 2,
ENTRY_STEP_WAIT_SEC: 20,
ENTRY_MARKET_FALLBACK_AFTER_SEC: 60,
ENTRY_MAX_DRIFT_PCT_TRENDING: 0.3,
ENTRY_MAX_DRIFT_PCT_RANGING: 0.15,
USE_TRAILING_STOP: false,
ATR_TAKE_PROFIT_MULTIPLIER: 4.5,
TAKE_PROFIT_PERCENT: 25.0,
MIN_HOLD_TIME_SEC: 1800,
},
},
strict: {
name: '精选低频(高胜率倾向)',
desc: '更偏"少单、质量优先":仅趋势行情自动交易 + 4H中性不自动下单 + 更高信号门槛。仍保持较贴近的限价偏移,减少"完全成交不了"。',
configs: {
SCAN_INTERVAL: 1800,
TOP_N_SYMBOLS: 6,
MIN_SIGNAL_STRENGTH: 8,
AUTO_TRADE_ONLY_TRENDING: true,
AUTO_TRADE_ALLOW_4H_NEUTRAL: false,
SMART_ENTRY_ENABLED: false,
LIMIT_ORDER_OFFSET_PCT: 0.1,
ENTRY_CONFIRM_TIMEOUT_SEC: 180,
USE_TRAILING_STOP: false,
ATR_TAKE_PROFIT_MULTIPLIER: 4.5,
TAKE_PROFIT_PERCENT: 25.0,
MIN_HOLD_TIME_SEC: 1800,
},
},
steady: {
name: '稳定出单(均衡收益/频率)',
desc: '在"会下单"的基础上略提高出单频率:更短扫描间隔 + 更宽松门槛 + 保守智能入场(追价受限),适合想要稳定有单但不想回到高频。',
configs: {
SCAN_INTERVAL: 900,
TOP_N_SYMBOLS: 8,
MIN_SIGNAL_STRENGTH: 6,
AUTO_TRADE_ONLY_TRENDING: false,
AUTO_TRADE_ALLOW_4H_NEUTRAL: true,
SMART_ENTRY_ENABLED: true,
LIMIT_ORDER_OFFSET_PCT: 0.12,
ENTRY_CONFIRM_TIMEOUT_SEC: 120,
ENTRY_CHASE_MAX_STEPS: 3,
ENTRY_STEP_WAIT_SEC: 15,
ENTRY_MARKET_FALLBACK_AFTER_SEC: 45,
ENTRY_MAX_DRIFT_PCT_TRENDING: 0.4,
ENTRY_MAX_DRIFT_PCT_RANGING: 0.2,
USE_TRAILING_STOP: false,
ATR_TAKE_PROFIT_MULTIPLIER: 4.5,
TAKE_PROFIT_PERCENT: 25.0,
MIN_HOLD_TIME_SEC: 1800,
},
},
conservative: {
name: '保守配置',
desc: '适合新手,风险较低,止损止盈较宽松,避免被正常波动触发',
configs: {
SCAN_INTERVAL: 900,
MIN_CHANGE_PERCENT: 2.0,
MIN_SIGNAL_STRENGTH: 5,
TOP_N_SYMBOLS: 10,
MAX_SCAN_SYMBOLS: 150,
MIN_VOLATILITY: 0.02,
STOP_LOSS_PERCENT: 10.0,
TAKE_PROFIT_PERCENT: 25.0,
MIN_STOP_LOSS_PRICE_PCT: 2.0,
MIN_TAKE_PROFIT_PRICE_PCT: 3.0,
USE_TRAILING_STOP: false,
ATR_TAKE_PROFIT_MULTIPLIER: 4.5,
MIN_HOLD_TIME_SEC: 1800
}
},
balanced: {
name: '平衡配置',
desc: '推荐使用平衡频率和质量止损止盈适中盈亏比2.5:1',
configs: {
SCAN_INTERVAL: 600,
MIN_CHANGE_PERCENT: 1.5,
MIN_SIGNAL_STRENGTH: 4,
TOP_N_SYMBOLS: 12,
MAX_SCAN_SYMBOLS: 250,
MIN_VOLATILITY: 0.018,
STOP_LOSS_PERCENT: 8.0,
TAKE_PROFIT_PERCENT: 25.0,
MIN_STOP_LOSS_PRICE_PCT: 2.0,
MIN_TAKE_PROFIT_PRICE_PCT: 3.0,
USE_TRAILING_STOP: false,
ATR_TAKE_PROFIT_MULTIPLIER: 4.5,
MIN_HOLD_TIME_SEC: 1800
}
},
aggressive: {
name: '激进高频',
desc: '晚间波动大时使用交易频率高止损较紧但止盈合理盈亏比3:1',
configs: {
SCAN_INTERVAL: 300,
MIN_CHANGE_PERCENT: 1.0,
MIN_SIGNAL_STRENGTH: 3,
TOP_N_SYMBOLS: 18,
MAX_SCAN_SYMBOLS: 350,
MIN_VOLATILITY: 0.015,
STOP_LOSS_PERCENT: 5.0,
TAKE_PROFIT_PERCENT: 25.0,
MIN_STOP_LOSS_PRICE_PCT: 1.5,
MIN_TAKE_PROFIT_PRICE_PCT: 2.0,
USE_TRAILING_STOP: false,
ATR_TAKE_PROFIT_MULTIPLIER: 4.5,
MIN_HOLD_TIME_SEC: 1800
}
}
}
useEffect(() => { useEffect(() => {
loadUsers() loadUsers()
loadAccounts() loadAccounts()
if (isAdmin) {
loadConfigMeta()
loadConfigs()
loadSystemStatus()
loadBackendStatus()
const timer = setInterval(() => {
loadSystemStatus()
loadBackendStatus()
}, 3000)
return () => clearInterval(timer)
}
}, []) }, [])
const loadUsers = async () => { const loadUsers = async () => {
@ -38,6 +221,364 @@ const GlobalConfig = ({ currentUser }) => {
} }
} }
const loadConfigMeta = async () => {
try {
const m = await api.getConfigMeta()
setConfigMeta(m || null)
} catch (e) {
setConfigMeta(null)
}
}
const loadConfigs = async () => {
try {
const data = await api.getConfigs()
setConfigs(data)
} catch (error) {
console.error('Failed to load configs:', error)
}
}
const loadSystemStatus = async () => {
try {
const res = await api.getTradingSystemStatus()
setSystemStatus(res)
} catch (error) {
//
}
}
const loadBackendStatus = async () => {
try {
const res = await api.getBackendStatus()
setBackendStatus(res)
} catch (error) {
//
}
}
//
const handleClearCache = async () => {
setSystemBusy(true)
setMessage('')
try {
const res = await api.clearSystemCache()
setMessage(res.message || '缓存已清理')
await loadConfigs()
await loadSystemStatus()
} catch (error) {
setMessage('清理缓存失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
const handleStartTrading = async () => {
setSystemBusy(true)
setMessage('')
try {
const res = await api.startTradingSystem()
setMessage(res.message || '交易系统已启动')
await loadSystemStatus()
} catch (error) {
setMessage('启动失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
const handleStopTrading = async () => {
setSystemBusy(true)
setMessage('')
try {
const res = await api.stopTradingSystem()
setMessage(res.message || '交易系统已停止')
await loadSystemStatus()
} catch (error) {
setMessage('停止失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
const handleRestartTrading = async () => {
setSystemBusy(true)
setMessage('')
try {
const res = await api.restartTradingSystem()
setMessage(res.message || '交易系统已重启')
await loadSystemStatus()
} catch (error) {
setMessage('重启失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
const handleRestartBackend = async () => {
if (!window.confirm('确定要重启后端服务吗?重启期间页面接口会短暂不可用(约 3-10 秒)。')) return
setSystemBusy(true)
setMessage('')
try {
const res = await api.restartBackend()
setMessage(res.message || '已发起后端重启')
setTimeout(() => {
loadBackendStatus()
}, 4000)
} catch (error) {
setMessage('重启后端失败: ' + (error.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
const handleRestartAllTrading = async () => {
if (!window.confirm('确定要重启【所有账号】的交易进程吗?这会让所有用户的交易服务短暂中断(约 3-10 秒),用于升级代码后统一生效。')) return
setSystemBusy(true)
setMessage('')
try {
const res = await api.restartAllTradingSystems({ prefix: 'auto_sys_acc', do_update: true })
setMessage(`已发起批量重启:共 ${res.count} 个,成功 ${res.ok},失败 ${res.failed}`)
} catch (e) {
setMessage('批量重启失败: ' + (e?.message || '未知错误'))
} finally {
setSystemBusy(false)
}
}
//
const detectCurrentPreset = () => {
if (!configs || Object.keys(configs).length === 0) return null
for (const [presetKey, preset] of Object.entries(presets)) {
let match = true
for (const [key, expectedValue] of Object.entries(preset.configs)) {
const currentConfig = configs[key]
if (!currentConfig) {
match = false
break
}
let currentValue = currentConfig.value
if (key.includes('PERCENT') || key.includes('PCT')) {
if (PCT_LIKE_KEYS.has(key)) {
currentValue = currentValue <= 0.05 ? currentValue * 100 : currentValue
} else {
currentValue = currentValue * 100
}
}
if (typeof expectedValue === 'number' && typeof currentValue === 'number') {
if (Math.abs(currentValue - expectedValue) > 0.01) {
match = false
break
}
} else if (currentValue !== expectedValue) {
match = false
break
}
}
if (match) {
return presetKey
}
}
return null
}
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)
const response = await api.updateConfigsBatch(configItems)
setMessage(response.message || `已应用${preset.name}`)
if (response.note) {
setTimeout(() => {
setMessage(response.note)
}, 2000)
}
await loadConfigs()
} catch (error) {
setMessage('应用预设失败: ' + error.message)
} finally {
setSaving(false)
}
}
//
const isSecretKey = (key) => {
return key === 'BINANCE_API_KEY' || key === 'BINANCE_API_SECRET'
}
const maskSecret = (val) => {
const s = val === null || val === undefined ? '' : String(val)
if (!s) return ''
if (s.length <= 8) return '****'
return `${s.slice(0, 4)}...${s.slice(-4)}`
}
const toDisplayValueForSnapshot = (key, value) => {
if (value === null || value === undefined) return value
if (typeof value === 'number' && (key.includes('PERCENT') || key.includes('PCT'))) {
if (PCT_LIKE_KEYS.has(key)) {
return value <= 0.05 ? value * 100 : value
}
return value < 1 ? value * 100 : value
}
return value
}
const buildConfigSnapshot = async (includeSecrets) => {
const 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 snapshot = {
fetched_at: now.toISOString(),
note: 'display_value 对 PERCENT/PCT 做了百分比换算;敏感字段可选择脱敏/明文。',
preset_detected: detectCurrentPreset(),
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 currentPreset = detectCurrentPreset()
const handleCreateUser = async () => { const handleCreateUser = async () => {
if (!newUser.username || !newUser.password) { if (!newUser.username || !newUser.password) {
setMessage('用户名和密码不能为空') setMessage('用户名和密码不能为空')
@ -112,11 +653,58 @@ const GlobalConfig = ({ currentUser }) => {
return <div className="global-config">加载中...</div> return <div className="global-config">加载中...</div>
} }
const presetUiMeta = {
swing: { group: 'limit', tag: '纯限价' },
strict: { group: 'limit', tag: '纯限价' },
fill: { group: 'smart', tag: '智能入场' },
steady: { group: 'smart', tag: '智能入场' },
conservative: { group: 'legacy', tag: '传统' },
balanced: { group: 'legacy', tag: '传统' },
aggressive: { group: 'legacy', tag: '高频实验' },
}
const presetGroups = [
{
key: 'limit',
title: 'A. 纯限价SMART_ENTRY_ENABLED=false',
desc: '只下 1 次限价单,未在确认时间内成交就撤单跳过。更控频、更接近"波段",但更容易出现 NEW→撤单。',
presetKeys: ['swing', 'strict'],
},
{
key: 'smart',
title: 'B. 智能入场SMART_ENTRY_ENABLED=true',
desc: '限价回调 + 受限追价 +(趋势强时)可控市价兜底。更少漏单,但必须限制追价步数与偏离上限,避免回到高频追价。',
presetKeys: ['fill', 'steady'],
},
{
key: 'legacy',
title: 'C. 传统 / 实验(不建议长期)',
desc: '这组更多用于对比或临时实验(频率更高/更容易过度交易),建议在稳定盈利前谨慎使用。',
presetKeys: ['conservative', 'balanced', 'aggressive'],
},
]
return ( return (
<div className="global-config"> <div className="global-config">
<div className="global-config-header"> <div className="global-config-header">
<h2>全局配置</h2> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p>管理用户账号和全局策略配置</p> <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> </div>
{message && ( {message && (
@ -125,6 +713,155 @@ const GlobalConfig = ({ currentUser }) => {
</div> </div>
)} )}
{/* 系统控制 */}
{isAdmin && (
<section className="global-section system-section">
<div className="system-header">
<h3>系统控制</h3>
<div className="system-status">
<span className={`system-status-badge ${systemStatus?.running ? 'running' : 'stopped'}`}>
{systemStatus?.running ? '运行中' : '未运行'}
</span>
{systemStatus?.pid ? <span className="system-status-meta">PID: {systemStatus.pid}</span> : null}
{systemStatus?.program ? <span className="system-status-meta">程序: {systemStatus.program}</span> : null}
{systemStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {systemStatus.meta.requested_at}</span> : null}
</div>
</div>
<div className="system-actions">
<button
type="button"
className="system-btn"
onClick={handleClearCache}
disabled={systemBusy}
title="清理Redis配置缓存并从数据库回灌。切换API Key后建议先点这里再重启交易系统。"
>
清除缓存
</button>
<button
type="button"
className="system-btn"
onClick={handleStopTrading}
disabled={systemBusy || systemStatus?.running === false}
title="通过 supervisorctl 停止交易系统"
>
停止
</button>
<button
type="button"
className="system-btn"
onClick={handleStartTrading}
disabled={systemBusy || systemStatus?.running === true}
title="通过 supervisorctl 启动交易系统"
>
启动
</button>
<button
type="button"
className="system-btn primary"
onClick={handleRestartTrading}
disabled={systemBusy}
title="通过 supervisorctl 重启交易系统建议切换API Key后使用"
>
重启交易系统
</button>
<button
type="button"
className="system-btn primary"
onClick={handleRestartAllTrading}
disabled={systemBusy}
title="批量重启所有账号交易进程auto_sys_acc*),用于代码升级后统一生效"
>
重启所有账号交易
</button>
<button
type="button"
className="system-btn primary"
onClick={handleRestartBackend}
disabled={systemBusy}
title="通过 backend/restart.sh 重启后端uvicorn。重启期间接口会短暂不可用。"
>
重启后端服务
</button>
</div>
<div className="system-status" style={{ marginTop: '10px' }}>
<span className={`system-status-badge ${backendStatus?.running ? 'running' : 'stopped'}`}>
后端 {backendStatus?.running ? '运行中' : '未知'}
</span>
{backendStatus?.pid ? <span className="system-status-meta">PID: {backendStatus.pid}</span> : null}
{backendStatus?.meta?.requested_at ? <span className="system-status-meta">上次重启: {backendStatus.meta.requested_at}</span> : null}
</div>
<div className="system-hint">
建议流程先更新配置里的 Key 点击"清除缓存" 点击"重启交易系统"确保不再使用旧账号下单
</div>
</section>
)}
{/* 预设方案快速切换(仅管理员 + 全局策略账号) */}
{isAdmin && isGlobalStrategyAccount && (
<section className="global-section preset-section">
<div className="preset-header">
<h3>快速切换方案</h3>
<div className="current-preset-status">
<span className="status-label">当前方案</span>
<span className={`status-badge ${currentPreset ? 'preset' : 'custom'}`}>
{currentPreset ? presets[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[k])
.map((k) => {
const preset = presets[k]
const meta = presetUiMeta[k] || { group: g.key, tag: '' }
return (
<button
key={k}
className={`preset-btn ${currentPreset === k ? 'active' : ''}`}
onClick={() => 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>
)
})}
</div>
</div>
))}
</div>
</section>
)}
{/* 用户管理 */} {/* 用户管理 */}
<section className="global-section"> <section className="global-section">
<div className="section-header"> <div className="section-header">
@ -321,6 +1058,51 @@ const GlobalConfig = ({ currentUser }) => {
</table> </table>
</div> </div>
</section> </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> </div>
) )
} }