a
This commit is contained in:
parent
28bce8f02b
commit
3bac042273
|
|
@ -10,23 +10,54 @@ import AccountSelector from './components/AccountSelector'
|
|||
import GlobalConfig from './components/GlobalConfig'
|
||||
import Login from './components/Login'
|
||||
import { api, clearAuthToken, clearCurrentAccountId, setCurrentAccountId, getCurrentAccountId } from './services/api'
|
||||
|
||||
const VIEWING_USER_ID_KEY = 'ats_viewing_user_id'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [me, setMe] = useState(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [users, setUsers] = useState([])
|
||||
const [viewingUserId, setViewingUserId] = useState(() => {
|
||||
// 管理员:从localStorage读取当前查看的用户ID,默认是当前登录用户
|
||||
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 () => {
|
||||
try {
|
||||
const u = await api.me()
|
||||
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)
|
||||
try {
|
||||
const list = await api.getAccounts()
|
||||
const accounts = Array.isArray(list) ? list : []
|
||||
const active = accounts.filter((a) => String(a?.status || 'active') === 'active')
|
||||
const isAdmin = (u?.role || '') === 'admin'
|
||||
|
||||
let target = null
|
||||
if (isAdmin) {
|
||||
|
|
@ -53,6 +84,34 @@ function App() {
|
|||
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)
|
||||
// 切换用户后,触发account变化事件,让AccountSelector重新加载该用户的账号列表
|
||||
window.dispatchEvent(new CustomEvent('ats:user:switched', { detail: { userId: nextUserId } }))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshMe()
|
||||
|
|
@ -82,7 +141,7 @@ function App() {
|
|||
<div className="nav-container">
|
||||
<div className="nav-left">
|
||||
<h1 className="nav-title">自动交易系统</h1>
|
||||
<AccountSelector currentUser={me} />
|
||||
<AccountSelector currentUser={me} viewingUserId={effectiveUserId} />
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
<Link to="/">仪表板</Link>
|
||||
|
|
@ -97,15 +156,88 @@ function App() {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="nav-user">
|
||||
<span className="nav-user-name">
|
||||
{me?.username ? me.username : 'user'}{isAdmin ? '(管理员)' : ''}
|
||||
</span>
|
||||
{isAdmin ? (
|
||||
<div className="nav-user-switcher" ref={userPopoverRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<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
|
||||
type="button"
|
||||
className="nav-logout"
|
||||
onClick={() => {
|
||||
clearAuthToken()
|
||||
clearCurrentAccountId()
|
||||
localStorage.removeItem(VIEWING_USER_ID_KEY)
|
||||
setMe(null)
|
||||
setChecking(false)
|
||||
}}
|
||||
|
|
@ -120,7 +252,7 @@ function App() {
|
|||
<Routes>
|
||||
<Route path="/" element={<StatsDashboard />} />
|
||||
<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="/trades" element={<TradeList />} />
|
||||
<Route path="/global-config" element={isAdmin ? <GlobalConfig currentUser={me} /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
||||
|
|
|
|||
|
|
@ -3,42 +3,45 @@ import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api'
|
|||
|
||||
const VIEWING_USER_ID_KEY = 'ats_viewing_user_id'
|
||||
|
||||
const AccountSelector = ({ onChanged, currentUser }) => {
|
||||
const AccountSelector = ({ onChanged, currentUser, viewingUserId: propViewingUserId }) => {
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [accountId, setAccountId] = useState(getCurrentAccountId())
|
||||
const [users, setUsers] = useState([])
|
||||
const [viewingUserId, setViewingUserId] = useState(() => {
|
||||
// 管理员:从localStorage读取当前查看的用户ID,默认是当前登录用户
|
||||
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 effectiveUserId = isAdmin ? viewingUserId : parseInt(String(currentUser?.id || ''), 10)
|
||||
// 当前实际查看的用户(管理员通过prop传入,普通用户使用自己的user_id)
|
||||
const effectiveUserId = propViewingUserId || parseInt(String(currentUser?.id || ''), 10)
|
||||
|
||||
// 管理员:加载用户列表
|
||||
// 监听用户切换事件(管理员切换用户时触发)
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
api.getUsers()
|
||||
.then((list) => {
|
||||
setUsers(Array.isArray(list) ? list : [])
|
||||
// 如果viewingUserId不在列表中,重置为当前登录用户
|
||||
const currentUserId = parseInt(String(currentUser?.id || ''), 10)
|
||||
if (viewingUserId && !list.some((u) => parseInt(String(u?.id || ''), 10) === viewingUserId)) {
|
||||
setViewingUserId(currentUserId)
|
||||
localStorage.setItem(VIEWING_USER_ID_KEY, String(currentUserId))
|
||||
}
|
||||
})
|
||||
.catch(() => setUsers([]))
|
||||
const handleUserSwitched = (e) => {
|
||||
const userId = e?.detail?.userId
|
||||
if (userId && isAdmin) {
|
||||
// 重新加载该用户的账号列表
|
||||
api.getUserAccounts(userId)
|
||||
.then((list) => {
|
||||
const accountsList = (Array.isArray(list) ? list : []).map((item) => ({
|
||||
id: item.account_id || item.id,
|
||||
name: item.account_name || item.name || '',
|
||||
status: item.account_status || item.status || 'active',
|
||||
role: item.role || 'viewer',
|
||||
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加载账号列表(管理员查看指定用户,普通用户查看自己)
|
||||
useEffect(() => {
|
||||
|
|
@ -82,19 +85,6 @@ const AccountSelector = ({ onChanged, currentUser }) => {
|
|||
}
|
||||
}
|
||||
}, [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(() => {
|
||||
setCurrentAccountId(accountId)
|
||||
|
|
@ -125,56 +115,8 @@ const AccountSelector = ({ onChanged, currentUser }) => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 (
|
||||
<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>
|
||||
<select
|
||||
className="nav-account-select"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
|
|||
import { api, getCurrentAccountId, setCurrentAccountId } from '../services/api'
|
||||
import './ConfigPanel.css'
|
||||
|
||||
const ConfigPanel = ({ currentUser }) => {
|
||||
const ConfigPanel = ({ currentUser, viewingUserId }) => {
|
||||
const [configs, setConfigs] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
|
@ -255,16 +255,24 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
|
||||
const loadCurrentAccountMeta = async () => {
|
||||
try {
|
||||
// 统一使用getAccounts获取所有账号(管理员会返回所有账号,普通用户返回自己的)
|
||||
// 这样可以确保获取到完整的status等信息
|
||||
const list = await api.getAccounts()
|
||||
const accounts = Array.isArray(list) ? list : []
|
||||
const meta = accounts.find((a) => parseInt(String(a?.id || '0'), 10) === parseInt(String(accountId || '0'), 10))
|
||||
// 确保status字段正确设置(如果meta存在但status为空,默认为'active')
|
||||
if (meta) {
|
||||
meta.status = meta.status || 'active'
|
||||
}
|
||||
setCurrentAccountMeta(meta || null)
|
||||
// 同步 testnet 开关到表单(仅在未输入时同步,避免打断正在输入)
|
||||
if (meta && !credForm.api_key && !credForm.api_secret) {
|
||||
setCredForm((prev) => ({ ...prev, use_testnet: !!meta.use_testnet }))
|
||||
}
|
||||
return meta
|
||||
} catch (e) {
|
||||
setCurrentAccountMeta(null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -429,18 +437,10 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
loadConfigMeta()
|
||||
loadConfigs()
|
||||
checkFeasibility()
|
||||
if (isAdmin) {
|
||||
loadSystemStatus()
|
||||
loadBackendStatus()
|
||||
}
|
||||
loadAccountTradingStatus()
|
||||
loadCurrentAccountMeta()
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (isAdmin) {
|
||||
loadSystemStatus()
|
||||
loadBackendStatus()
|
||||
}
|
||||
loadAccountTradingStatus()
|
||||
loadCurrentAccountMeta()
|
||||
}, 3000)
|
||||
|
|
@ -467,19 +467,16 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 切换账号时,刷新页面数据
|
||||
// 切换账号时,刷新页面数据(先加载account meta,再加载其他)
|
||||
useEffect(() => {
|
||||
setCurrentAccountId(accountId)
|
||||
setMessage('')
|
||||
setLoading(true)
|
||||
loadConfigs()
|
||||
checkFeasibility()
|
||||
if (isAdmin) {
|
||||
loadSystemStatus()
|
||||
loadBackendStatus()
|
||||
}
|
||||
loadAccountTradingStatus()
|
||||
loadCurrentAccountMeta()
|
||||
loadCurrentAccountMeta().then(() => {
|
||||
loadConfigs()
|
||||
checkFeasibility()
|
||||
loadAccountTradingStatus()
|
||||
})
|
||||
}, [accountId])
|
||||
|
||||
// 顶部导航切换账号时:即时同步(比 setInterval 更及时)
|
||||
|
|
@ -488,10 +485,11 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
const next = parseInt(String(e?.detail?.accountId || ''), 10)
|
||||
if (Number.isFinite(next) && next > 0 && next !== accountId) {
|
||||
setAccountId(next)
|
||||
// 立即重新加载相关数据
|
||||
loadConfigs()
|
||||
loadAccountTradingStatus()
|
||||
loadCurrentAccountMeta()
|
||||
// 立即重新加载相关数据(先加载account meta,再加载其他)
|
||||
loadCurrentAccountMeta().then(() => {
|
||||
loadConfigs()
|
||||
loadAccountTradingStatus()
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('ats:account:changed', onChanged)
|
||||
|
|
@ -505,10 +503,11 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
|
||||
if (cur !== accountId) {
|
||||
setAccountId(cur)
|
||||
// 同步时也重新加载数据
|
||||
loadConfigs()
|
||||
loadAccountTradingStatus()
|
||||
loadCurrentAccountMeta()
|
||||
// 同步时也重新加载数据(先加载account meta)
|
||||
loadCurrentAccountMeta().then(() => {
|
||||
loadConfigs()
|
||||
loadAccountTradingStatus()
|
||||
})
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
|
|
@ -855,7 +854,7 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
<div className="account-switch">
|
||||
<span className="account-hint">当前账号:#{accountId}(在顶部导航切换)</span>
|
||||
</div>
|
||||
{isAdmin && currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? (
|
||||
{isAdmin && currentAccountMeta && currentAccountMeta.status === 'active' ? (
|
||||
<div
|
||||
className="system-hint"
|
||||
style={{
|
||||
|
|
@ -897,16 +896,7 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
) : null}
|
||||
</div>
|
||||
<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 className="config-info">
|
||||
|
|
@ -914,7 +904,7 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
</div>
|
||||
|
||||
{/* 我的交易进程(按账号;owner/admin 可启停) */}
|
||||
{currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? (
|
||||
{currentAccountMeta && currentAccountMeta.status === 'active' ? (
|
||||
<div className="system-section">
|
||||
<div className="system-header">
|
||||
<h3>我的交易进程(当前账号 #{accountId})</h3>
|
||||
|
|
@ -985,7 +975,7 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
) : null}
|
||||
|
||||
{/* 账号密钥(当前账号;owner/admin 可改) */}
|
||||
{currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? (
|
||||
{currentAccountMeta && currentAccountMeta.status === 'active' ? (
|
||||
<div className="system-section">
|
||||
<div className="system-header">
|
||||
<h3>账号密钥(当前账号)</h3>
|
||||
|
|
@ -1056,89 +1046,6 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
</div>
|
||||
) : 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 ? (
|
||||
<div className="accounts-admin-section">
|
||||
|
|
@ -1406,106 +1313,8 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 预设方案快速切换(仅管理员 + 全局策略账号:策略核心统一管理) */}
|
||||
{isAdmin && isGlobalStrategyAccount && currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? (
|
||||
<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>
|
||||
) : (
|
||||
{/* 用户提示 */}
|
||||
{!isAdmin && (
|
||||
<div className="system-hint" style={{ marginTop: '12px' }}>
|
||||
平台已开启“傻瓜化模式”:策略核心由管理员统一管理。你只需要配置密钥、充值余额,并调整少量风控参数(如最小/最大仓位、每日开仓次数等)。
|
||||
</div>
|
||||
|
|
@ -1513,7 +1322,7 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
</div>
|
||||
|
||||
{/* 配置可行性检查提示 */}
|
||||
{currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' && feasibilityCheck && (
|
||||
{currentAccountMeta && currentAccountMeta.status === 'active' && feasibilityCheck && (
|
||||
<div className={`feasibility-check ${feasibilityCheck.feasible ? 'feasible' : 'infeasible'}`}>
|
||||
<div className="feasibility-header">
|
||||
<h4>
|
||||
|
|
@ -1639,7 +1448,7 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
)}
|
||||
|
||||
{/* 配置项(禁用account时不显示) */}
|
||||
{currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'active' ? (
|
||||
{currentAccountMeta && currentAccountMeta.status === 'active' ? (
|
||||
Object.entries(configCategories).map(([category, label]) => (
|
||||
<section key={category} className="config-section">
|
||||
<h3>{label}</h3>
|
||||
|
|
@ -1663,56 +1472,13 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
</div>
|
||||
</section>
|
||||
))
|
||||
) : currentAccountMeta && String(currentAccountMeta?.status || 'active') === 'disabled' ? (
|
||||
) : currentAccountMeta && currentAccountMeta.status === 'disabled' ? (
|
||||
<div className="system-hint" style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||
当前账号已禁用,无法查看和修改配置
|
||||
</div>
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { api } from '../services/api'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api, getCurrentAccountId } from '../services/api'
|
||||
import './GlobalConfig.css'
|
||||
import './ConfigPanel.css' // 复用 ConfigPanel 的样式
|
||||
|
||||
const GlobalConfig = ({ currentUser }) => {
|
||||
const [users, setUsers] = useState([])
|
||||
|
|
@ -12,10 +14,191 @@ const GlobalConfig = ({ currentUser }) => {
|
|||
const [showUserForm, setShowUserForm] = useState(false)
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user', status: 'active' })
|
||||
const [editingUserId, setEditingUserId] = useState(null)
|
||||
|
||||
// 系统控制相关
|
||||
const [systemStatus, setSystemStatus] = useState(null)
|
||||
const [backendStatus, setBackendStatus] = useState(null)
|
||||
const [systemBusy, setSystemBusy] = useState(false)
|
||||
|
||||
// 预设方案相关
|
||||
const [configs, setConfigs] = useState({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [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(() => {
|
||||
loadUsers()
|
||||
loadAccounts()
|
||||
if (isAdmin) {
|
||||
loadConfigMeta()
|
||||
loadConfigs()
|
||||
loadSystemStatus()
|
||||
loadBackendStatus()
|
||||
|
||||
const timer = setInterval(() => {
|
||||
loadSystemStatus()
|
||||
loadBackendStatus()
|
||||
}, 3000)
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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 () => {
|
||||
if (!newUser.username || !newUser.password) {
|
||||
setMessage('用户名和密码不能为空')
|
||||
|
|
@ -112,11 +653,58 @@ const GlobalConfig = ({ currentUser }) => {
|
|||
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 (
|
||||
<div className="global-config">
|
||||
<div className="global-config-header">
|
||||
<h2>全局配置</h2>
|
||||
<p>管理用户、账号和全局策略配置</p>
|
||||
<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 && (
|
||||
|
|
@ -125,6 +713,155 @@ const GlobalConfig = ({ currentUser }) => {
|
|||
</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">
|
||||
<div className="section-header">
|
||||
|
|
@ -321,6 +1058,51 @@ const GlobalConfig = ({ currentUser }) => {
|
|||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 配置快照 Modal */}
|
||||
{showSnapshot && (
|
||||
<div className="snapshot-modal-overlay" onClick={() => setShowSnapshot(false)} role="presentation">
|
||||
<div className="snapshot-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="snapshot-modal-header">
|
||||
<div>
|
||||
<h3>当前整体配置快照</h3>
|
||||
<div className="snapshot-hint">
|
||||
默认脱敏 BINANCE_API_KEY/SECRET。你可以选择明文后重新生成再复制/下载。
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="snapshot-close" onClick={() => setShowSnapshot(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="snapshot-toolbar">
|
||||
<label className="snapshot-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={snapshotIncludeSecrets}
|
||||
onChange={async (e) => {
|
||||
const checked = e.target.checked
|
||||
setSnapshotIncludeSecrets(checked)
|
||||
await openSnapshot(checked)
|
||||
}}
|
||||
/>
|
||||
显示敏感信息(明文)
|
||||
</label>
|
||||
|
||||
<div className="snapshot-actions">
|
||||
<button type="button" className="system-btn" onClick={copySnapshot}>
|
||||
复制
|
||||
</button>
|
||||
<button type="button" className="system-btn primary" onClick={downloadSnapshot}>
|
||||
下载 JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className="snapshot-pre">{snapshotText}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user