全局仪表板调整,增加管理中心管理用户及账号
This commit is contained in:
parent
0127edbc97
commit
007827464a
|
|
@ -22,56 +22,108 @@ router = APIRouter()
|
||||||
|
|
||||||
@router.get("/admin/dashboard")
|
@router.get("/admin/dashboard")
|
||||||
async def get_admin_dashboard_stats(user: Dict[str, Any] = Depends(get_admin_user)):
|
async def get_admin_dashboard_stats(user: Dict[str, Any] = Depends(get_admin_user)):
|
||||||
"""获取管理员仪表板数据(所有用户统计)"""
|
"""获取管理员仪表板数据:总资产来自各账号快照汇总(不调币安),总盈亏为最近7天聚合已实现盈亏。"""
|
||||||
try:
|
try:
|
||||||
accounts = Account.list_all()
|
accounts = Account.list_all()
|
||||||
stats = []
|
stats = []
|
||||||
|
total_assets = 0.0
|
||||||
total_assets = 0
|
|
||||||
total_pnl = 0
|
|
||||||
active_accounts = 0
|
active_accounts = 0
|
||||||
|
|
||||||
for acc in accounts:
|
for acc in accounts:
|
||||||
aid = acc['id']
|
aid = acc["id"]
|
||||||
# 获取最新快照
|
|
||||||
snapshots = AccountSnapshot.get_recent(1, account_id=aid)
|
snapshots = AccountSnapshot.get_recent(1, account_id=aid)
|
||||||
acc_stat = {
|
acc_stat = {
|
||||||
"id": aid,
|
"id": aid,
|
||||||
"name": acc['name'],
|
"name": acc["name"],
|
||||||
"status": acc['status'],
|
"status": acc["status"],
|
||||||
"total_balance": 0,
|
"total_balance": 0,
|
||||||
"total_pnl": 0,
|
"total_pnl": 0,
|
||||||
"open_positions": 0
|
"open_positions": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if snapshots:
|
if snapshots:
|
||||||
snap = snapshots[0]
|
snap = snapshots[0]
|
||||||
acc_stat["total_balance"] = snap.get('total_balance', 0)
|
acc_stat["total_balance"] = snap.get("total_balance", 0)
|
||||||
acc_stat["total_pnl"] = snap.get('total_pnl', 0)
|
acc_stat["total_pnl"] = snap.get("total_pnl", 0)
|
||||||
acc_stat["open_positions"] = snap.get('open_positions', 0)
|
acc_stat["open_positions"] = snap.get("open_positions", 0)
|
||||||
|
|
||||||
total_assets += float(acc_stat["total_balance"])
|
total_assets += float(acc_stat["total_balance"])
|
||||||
total_pnl += float(acc_stat["total_pnl"])
|
if acc["status"] == "active":
|
||||||
|
|
||||||
if acc['status'] == 'active':
|
|
||||||
active_accounts += 1
|
active_accounts += 1
|
||||||
|
|
||||||
stats.append(acc_stat)
|
stats.append(acc_stat)
|
||||||
|
total_pnl_7d = 0.0
|
||||||
|
try:
|
||||||
|
global_symbols = TradeStats.get_global_symbol_stats(days=7)
|
||||||
|
for row in global_symbols:
|
||||||
|
total_pnl_7d += float(row.get("net_pnl") or 0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"获取全局7天净盈亏失败: {e}")
|
||||||
return {
|
return {
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_accounts": len(accounts),
|
"total_accounts": len(accounts),
|
||||||
"active_accounts": active_accounts,
|
"active_accounts": active_accounts,
|
||||||
"total_assets_usdt": total_assets,
|
"total_assets_usdt": round(total_assets, 2),
|
||||||
"total_pnl_usdt": total_pnl
|
"total_pnl_usdt": round(total_pnl_7d, 2),
|
||||||
},
|
},
|
||||||
"accounts": stats
|
"accounts": stats,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取管理员仪表板数据失败: {e}", exc_info=True)
|
logger.error(f"获取管理员仪表板数据失败: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/overall-trade-stats")
|
||||||
|
async def get_admin_overall_trade_stats(
|
||||||
|
days: int = Query(7, ge=1, le=90),
|
||||||
|
user: Dict[str, Any] = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
"""管理员:全账号最近 N 天整体订单统计。"""
|
||||||
|
try:
|
||||||
|
by_symbol_raw = TradeStats.get_global_symbol_stats(days=days)
|
||||||
|
by_hour_raw = TradeStats.get_global_hourly_stats(days=days)
|
||||||
|
by_symbol = []
|
||||||
|
for row in by_symbol_raw:
|
||||||
|
tc = int(row.get("trade_count") or 0)
|
||||||
|
win_count = int(row.get("win_count") or 0)
|
||||||
|
loss_count = int(row.get("loss_count") or 0)
|
||||||
|
net_pnl = float(row.get("net_pnl") or 0)
|
||||||
|
win_rate = (100.0 * win_count / tc) if tc > 0 else 0.0
|
||||||
|
by_symbol.append({
|
||||||
|
"symbol": (row.get("symbol") or "").strip(),
|
||||||
|
"trade_count": tc,
|
||||||
|
"win_count": win_count,
|
||||||
|
"loss_count": loss_count,
|
||||||
|
"net_pnl": round(net_pnl, 4),
|
||||||
|
"win_rate_pct": round(win_rate, 1),
|
||||||
|
})
|
||||||
|
by_symbol = [x for x in by_symbol if x["symbol"]]
|
||||||
|
by_symbol.sort(key=lambda x: (-x["net_pnl"], -x["trade_count"]))
|
||||||
|
hourly_agg = [{"hour": h, "trade_count": 0, "net_pnl": 0.0} for h in range(24)]
|
||||||
|
for row in by_hour_raw:
|
||||||
|
h = row.get("hour")
|
||||||
|
if h is not None and 0 <= int(h) <= 23:
|
||||||
|
hi = int(h)
|
||||||
|
hourly_agg[hi]["trade_count"] = int(row.get("trade_count") or 0)
|
||||||
|
hourly_agg[hi]["net_pnl"] = round(float(row.get("net_pnl") or 0), 4)
|
||||||
|
total_trade_count = sum(x["trade_count"] for x in by_symbol)
|
||||||
|
total_win = sum(x["win_count"] for x in by_symbol)
|
||||||
|
total_loss = sum(x["loss_count"] for x in by_symbol)
|
||||||
|
total_net_pnl = sum(x["net_pnl"] for x in by_symbol)
|
||||||
|
suggestions = _build_suggestions(by_symbol)
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"summary": {
|
||||||
|
"trade_count": total_trade_count,
|
||||||
|
"win_count": total_win,
|
||||||
|
"loss_count": total_loss,
|
||||||
|
"net_pnl": round(total_net_pnl, 4),
|
||||||
|
},
|
||||||
|
"by_symbol": by_symbol,
|
||||||
|
"hourly_agg": hourly_agg,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("get_admin_overall_trade_stats 失败")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
def _aggregate_daily_by_symbol(daily: list) -> list:
|
def _aggregate_daily_by_symbol(daily: list) -> list:
|
||||||
"""将 daily(按 date+symbol)聚合成按 symbol 的汇总。"""
|
"""将 daily(按 date+symbol)聚合成按 symbol 的汇总。"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,51 @@
|
||||||
background-color: #34495e;
|
background-color: #34495e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-dropdown-trigger {
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: default;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.nav-dropdown-trigger { font-size: 1rem; }
|
||||||
|
}
|
||||||
|
.nav-dropdown:hover .nav-dropdown-trigger {
|
||||||
|
background-color: #34495e;
|
||||||
|
}
|
||||||
|
.nav-dropdown-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
min-width: 140px;
|
||||||
|
background: #2c3e50;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.nav-dropdown:hover .nav-dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.nav-dropdown-menu a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-dropdown-menu a:hover {
|
||||||
|
background-color: #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-user {
|
.nav-user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import ConfigGuide from './components/ConfigGuide'
|
||||||
import TradeList from './components/TradeList'
|
import TradeList from './components/TradeList'
|
||||||
import StatsDashboard from './components/StatsDashboard'
|
import StatsDashboard from './components/StatsDashboard'
|
||||||
import AdminDashboard from './components/AdminDashboard.jsx'
|
import AdminDashboard from './components/AdminDashboard.jsx'
|
||||||
|
import AdminUserManagement from './components/AdminUserManagement.jsx'
|
||||||
|
import AdminAccountManagement from './components/AdminAccountManagement.jsx'
|
||||||
import Recommendations from './components/Recommendations'
|
import Recommendations from './components/Recommendations'
|
||||||
import LogMonitor from './components/LogMonitor'
|
import LogMonitor from './components/LogMonitor'
|
||||||
import DataManagement from './components/DataManagement'
|
import DataManagement from './components/DataManagement'
|
||||||
|
|
@ -82,6 +84,14 @@ function App() {
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
|
<Link to="/">仪表板</Link>
|
||||||
|
<div className="nav-dropdown">
|
||||||
|
<span className="nav-dropdown-trigger">管理中心</span>
|
||||||
|
<div className="nav-dropdown-menu">
|
||||||
|
<Link to="/admin/users">用户管理</Link>
|
||||||
|
<Link to="/admin/accounts">系统账号管理</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Link to="/global-config">全局配置</Link>
|
<Link to="/global-config">全局配置</Link>
|
||||||
<Link to="/data-management">数据管理</Link>
|
<Link to="/data-management">数据管理</Link>
|
||||||
<Link to="/logs">日志监控</Link>
|
<Link to="/logs">日志监控</Link>
|
||||||
|
|
@ -112,6 +122,8 @@ function App() {
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={isAdmin ? <AdminDashboard /> : <StatsDashboard />} />
|
<Route path="/" element={isAdmin ? <AdminDashboard /> : <StatsDashboard />} />
|
||||||
|
<Route path="/admin/users" element={isAdmin ? <AdminUserManagement /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
||||||
|
<Route path="/admin/accounts" element={isAdmin ? <AdminAccountManagement /> : <div style={{ padding: '24px' }}>无权限</div>} />
|
||||||
<Route path="/recommendations" element={<Recommendations />} />
|
<Route path="/recommendations" element={<Recommendations />} />
|
||||||
<Route path="/config" element={<ConfigPanel />} />
|
<Route path="/config" element={<ConfigPanel />} />
|
||||||
<Route path="/config/guide" element={<ConfigGuide />} />
|
<Route path="/config/guide" element={<ConfigGuide />} />
|
||||||
|
|
|
||||||
42
frontend/src/components/AdminAccountManagement.jsx
Normal file
42
frontend/src/components/AdminAccountManagement.jsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../services/api'
|
||||||
|
import { AccountManager } from './AdminShared'
|
||||||
|
import './AdminDashboard.css'
|
||||||
|
|
||||||
|
export default function AdminAccountManagement() {
|
||||||
|
const [accounts, setAccounts] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/accounts').catch(() => ({ data: [] }))
|
||||||
|
setAccounts(res.data || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
const onUpdated = () => loadData()
|
||||||
|
window.addEventListener('ats:accounts:updated', onUpdated)
|
||||||
|
return () => window.removeEventListener('ats:accounts:updated', onUpdated)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading && accounts.length === 0) return <div className="loading">加载中...</div>
|
||||||
|
if (error) return <div className="error">加载失败: {error}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-dashboard">
|
||||||
|
<div className="dashboard-header">
|
||||||
|
<h2>系统账号管理</h2>
|
||||||
|
<button className="refresh-btn" onClick={loadData}>刷新</button>
|
||||||
|
</div>
|
||||||
|
<AccountManager accounts={accounts} onRefresh={loadData} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,78 @@
|
||||||
color: #c62828;
|
color: #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
margin-top: 24px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table th,
|
||||||
|
.stats-table td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table th {
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table .positive { color: #2e7d32; }
|
||||||
|
.stats-table .negative { color: #c62828; }
|
||||||
|
|
||||||
|
.stats-empty {
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-block { margin-bottom: 12px; }
|
||||||
|
.suggestion-block ul { margin: 4px 0 0 0; padding-left: 20px; }
|
||||||
|
.suggestion-block.blacklist .suggestion-symbol { color: #c62828; }
|
||||||
|
.suggestion-block.whitelist .suggestion-symbol { color: #2e7d32; }
|
||||||
|
|
||||||
|
.positive { color: #2e7d32; }
|
||||||
|
.negative { color: #c62828; }
|
||||||
|
|
||||||
.accounts-section {
|
.accounts-section {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
|
||||||
|
|
@ -2,631 +2,40 @@ import React, { useEffect, useState } from 'react'
|
||||||
import { api } from '../services/api'
|
import { api } from '../services/api'
|
||||||
import './AdminDashboard.css'
|
import './AdminDashboard.css'
|
||||||
|
|
||||||
const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => {
|
export default function AdminDashboard() {
|
||||||
const [expanded, setExpanded] = useState(true)
|
const [dashboardData, setDashboardData] = useState(null)
|
||||||
const [processing, setProcessing] = useState(false)
|
const [orderStats, setOrderStats] = useState(null)
|
||||||
|
|
||||||
// 关联管理状态
|
|
||||||
const [linkAccountId, setLinkAccountId] = useState('')
|
|
||||||
const [linkRole, setLinkRole] = useState('viewer')
|
|
||||||
const [associating, setAssociating] = useState(false)
|
|
||||||
|
|
||||||
const handleUserAction = async (action) => {
|
|
||||||
if (!window.confirm(`确定要${action === 'start' ? '启动' : '停止'}用户 ${user.username} 下所有账号的交易服务吗?`)) return
|
|
||||||
|
|
||||||
setProcessing(true)
|
|
||||||
try {
|
|
||||||
// 并行执行
|
|
||||||
const promises = user.accounts.map(acc =>
|
|
||||||
api.post(`/accounts/${acc.id}/service/${action}`)
|
|
||||||
.catch(e => console.error(`Failed to ${action} account ${acc.id}:`, e))
|
|
||||||
)
|
|
||||||
await Promise.all(promises)
|
|
||||||
if (onServiceAction) onServiceAction(null, 'refresh')
|
|
||||||
} catch (e) {
|
|
||||||
alert(`操作失败: ${e.message}`)
|
|
||||||
} finally {
|
|
||||||
setProcessing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleGrant = async () => {
|
|
||||||
if (!linkAccountId) return
|
|
||||||
const accountId = Number(linkAccountId)
|
|
||||||
if (!Number.isInteger(accountId) || accountId < 1) {
|
|
||||||
alert('请选择有效的账号')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAssociating(true)
|
|
||||||
try {
|
|
||||||
await api.grantUserAccount(user.id, accountId, linkRole)
|
|
||||||
setLinkAccountId('')
|
|
||||||
if (onServiceAction) onServiceAction(null, 'refresh')
|
|
||||||
} catch (e) {
|
|
||||||
alert(`关联失败: ${e.message}`)
|
|
||||||
} finally {
|
|
||||||
setAssociating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRevoke = async (accountId) => {
|
|
||||||
if (!window.confirm('确定要取消该账号的关联吗?')) return
|
|
||||||
try {
|
|
||||||
await api.revokeUserAccount(user.id, accountId)
|
|
||||||
if (onServiceAction) onServiceAction(null, 'refresh')
|
|
||||||
} catch (e) {
|
|
||||||
alert(`取消关联失败: ${e.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算该用户下所有账号的汇总状态
|
|
||||||
const allRunning = user.accounts.every(a => a.serviceStatus?.running)
|
|
||||||
const allStopped = user.accounts.every(a => !a.serviceStatus?.running)
|
|
||||||
|
|
||||||
// 可供关联的账号(排除已关联的)
|
|
||||||
const availableAccounts = (allAccounts || []).filter(a =>
|
|
||||||
!user.accounts.some(ua => ua.id === a.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="user-group-card">
|
|
||||||
<div className="user-group-header" onClick={() => setExpanded(!expanded)}>
|
|
||||||
<div className="user-info">
|
|
||||||
<span className={`expand-icon ${expanded ? 'expanded' : ''}`}>▶</span>
|
|
||||||
<span className="user-name">{user.username}</span>
|
|
||||||
<span className={`user-role-badge ${user.role}`}>{user.role}</span>
|
|
||||||
<span className="account-count">({user.accounts.length} 账号)</span>
|
|
||||||
</div>
|
|
||||||
{user.role != 'admin' && (
|
|
||||||
<div className="user-actions" onClick={e => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
className="btn-text-action start"
|
|
||||||
onClick={() => handleUserAction('start')}
|
|
||||||
disabled={processing || allRunning || user.accounts.length === 0}
|
|
||||||
>
|
|
||||||
全部启动
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn-text-action stop"
|
|
||||||
onClick={() => handleUserAction('stop')}
|
|
||||||
disabled={processing || allStopped || user.accounts.length === 0}
|
|
||||||
>
|
|
||||||
全部停止
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && user.role != 'admin' && (
|
|
||||||
<div className="user-accounts-table">
|
|
||||||
{user.accounts.length === 0 ? (
|
|
||||||
<div className="no-accounts">暂无关联账号</div>
|
|
||||||
) : (
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>名称</th>
|
|
||||||
<th>权限</th>
|
|
||||||
<th>账户状态</th>
|
|
||||||
<th>服务状态</th>
|
|
||||||
<th>总资产</th>
|
|
||||||
<th>总盈亏</th>
|
|
||||||
<th>持仓数</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{user.accounts.map(acc => (
|
|
||||||
<tr key={acc.id}>
|
|
||||||
<td>{acc.id}</td>
|
|
||||||
<td>{acc.name}</td>
|
|
||||||
<td>
|
|
||||||
<span className="role-tag">{acc.role || 'viewer'}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status-badge ${acc.status}`}>
|
|
||||||
{acc.status === 'active' ? '启用' : '禁用'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{acc.serviceStatus ? (
|
|
||||||
<span className={`status-badge ${acc.serviceStatus.running ? 'running' : 'stopped'}`}>
|
|
||||||
{acc.serviceStatus.running ? '运行中' : '停止'}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="status-badge stopped">未启动</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{acc.total_balance?.toFixed(2) || '-'}</td>
|
|
||||||
<td className={acc.total_pnl >= 0 ? 'profit' : 'loss'}>
|
|
||||||
{acc.total_pnl?.toFixed(2) || '-'}
|
|
||||||
</td>
|
|
||||||
<td>{acc.open_positions || 0}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
onClick={() => onServiceAction && onServiceAction(acc.id, acc.serviceStatus?.running ? 'stop' : 'start')}
|
|
||||||
title={acc.serviceStatus?.running ? '停止服务' : '启动服务'}
|
|
||||||
>
|
|
||||||
{acc.serviceStatus?.running ? '⏹' : '▶'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn-icon danger"
|
|
||||||
onClick={() => handleRevoke(acc.id)}
|
|
||||||
title="取消关联"
|
|
||||||
style={{ marginLeft: '8px' }}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="add-account-section">
|
|
||||||
<h4>新增关联</h4>
|
|
||||||
<div className="add-account-form">
|
|
||||||
<select
|
|
||||||
value={linkAccountId}
|
|
||||||
onChange={e => setLinkAccountId(e.target.value)}
|
|
||||||
disabled={associating}
|
|
||||||
>
|
|
||||||
<option value="">选择账号...</option>
|
|
||||||
{availableAccounts.map(a => (
|
|
||||||
<option key={a.id} value={a.id}>
|
|
||||||
#{a.id} {a.name} ({a.status === 'active' ? '启用' : '禁用'})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={linkRole}
|
|
||||||
onChange={e => setLinkRole(e.target.value)}
|
|
||||||
disabled={associating}
|
|
||||||
>
|
|
||||||
<option value="viewer">观察者 (Viewer)</option>
|
|
||||||
<option value="owner">拥有者 (Owner,每账号仅一个)</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={handleGrant}
|
|
||||||
disabled={!linkAccountId || associating}
|
|
||||||
className="btn-primary"
|
|
||||||
>
|
|
||||||
{associating ? '关联中...' : '关联'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreateUserForm = ({ onSuccess }) => {
|
|
||||||
const [form, setForm] = useState({ username: '', password: '', role: 'user', status: 'active' })
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!form.username.trim()) {
|
|
||||||
setMessage('请输入用户名')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!form.password.trim()) {
|
|
||||||
setMessage('请输入密码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusy(true)
|
|
||||||
setMessage('')
|
|
||||||
try {
|
|
||||||
await api.createUser({
|
|
||||||
username: form.username.trim(),
|
|
||||||
password: form.password,
|
|
||||||
role: form.role,
|
|
||||||
status: form.status,
|
|
||||||
})
|
|
||||||
setMessage('用户已创建')
|
|
||||||
setForm({ username: '', password: '', role: 'user', status: 'active' })
|
|
||||||
if (onSuccess) onSuccess()
|
|
||||||
} catch (e) {
|
|
||||||
setMessage('创建用户失败: ' + (e?.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card create-user-card">
|
|
||||||
<h4 className="create-user-card-heading">新建用户</h4>
|
|
||||||
{message && (
|
|
||||||
<div className={`message ${message.includes('失败') ? 'error' : 'success'}`}>{message}</div>
|
|
||||||
)}
|
|
||||||
<div className="form-group">
|
|
||||||
<label>用户名</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.username}
|
|
||||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
|
||||||
placeholder="登录用,例如:trader1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>密码</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
||||||
placeholder="设置登录密码"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>角色</label>
|
|
||||||
<select
|
|
||||||
value={form.role}
|
|
||||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="user">普通用户 (user)</option>
|
|
||||||
<option value="admin">管理员 (admin)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>状态</label>
|
|
||||||
<select
|
|
||||||
value={form.status}
|
|
||||||
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="active">启用</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn-primary full-width"
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={busy || !form.username.trim() || !form.password}
|
|
||||||
>
|
|
||||||
{busy ? '创建中...' : '创建用户'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const AccountManager = ({ accounts, onRefresh }) => {
|
|
||||||
const [newAccount, setNewAccount] = useState({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' })
|
|
||||||
const [credEditId, setCredEditId] = useState(null)
|
|
||||||
const [credForm, setCredForm] = useState({ api_key: '', api_secret: '', use_testnet: false })
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
|
|
||||||
const notifyAccountsUpdated = () => {
|
|
||||||
try {
|
|
||||||
window.dispatchEvent(new Event('ats:accounts:updated'))
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!newAccount.name.trim()) return
|
|
||||||
setBusy(true)
|
|
||||||
setMessage('')
|
|
||||||
try {
|
|
||||||
await api.createAccount(newAccount)
|
|
||||||
setMessage('账号已创建')
|
|
||||||
setNewAccount({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' })
|
|
||||||
if (onRefresh) onRefresh()
|
|
||||||
notifyAccountsUpdated()
|
|
||||||
} catch (e) {
|
|
||||||
setMessage('创建账号失败: ' + (e?.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateStatus = async (account) => {
|
|
||||||
setBusy(true)
|
|
||||||
setMessage('')
|
|
||||||
try {
|
|
||||||
const next = account.status === 'active' ? 'disabled' : 'active'
|
|
||||||
await api.updateAccount(account.id, { status: next })
|
|
||||||
setMessage(`账号 #${account.id} 已${next === 'active' ? '启用' : '禁用'}`)
|
|
||||||
if (onRefresh) onRefresh()
|
|
||||||
notifyAccountsUpdated()
|
|
||||||
} catch (e) {
|
|
||||||
setMessage('更新账号失败: ' + (e?.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateCreds = async () => {
|
|
||||||
if (!credEditId) return
|
|
||||||
setBusy(true)
|
|
||||||
setMessage('')
|
|
||||||
try {
|
|
||||||
const payload = {}
|
|
||||||
if (credForm.api_key) payload.api_key = credForm.api_key
|
|
||||||
if (credForm.api_secret) payload.api_secret = credForm.api_secret
|
|
||||||
payload.use_testnet = !!credForm.use_testnet
|
|
||||||
await api.updateAccountCredentials(credEditId, payload)
|
|
||||||
setMessage(`账号 #${credEditId} 密钥已更新`)
|
|
||||||
setCredEditId(null)
|
|
||||||
if (onRefresh) onRefresh()
|
|
||||||
notifyAccountsUpdated()
|
|
||||||
} catch (e) {
|
|
||||||
setMessage('更新密钥失败: ' + (e?.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="accounts-manager-section">
|
|
||||||
<h3>系统账号池管理</h3>
|
|
||||||
{message && <div className={`message ${message.includes('失败') ? 'error' : 'success'}`}>{message}</div>}
|
|
||||||
|
|
||||||
<div className="accounts-manager-grid">
|
|
||||||
{/* Create Account Card */}
|
|
||||||
<div className="card create-account-card">
|
|
||||||
<h4>新增账号</h4>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>名称</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newAccount.name}
|
|
||||||
onChange={(e) => setNewAccount({ ...newAccount, name: e.target.value })}
|
|
||||||
placeholder="例如:user_a"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>API KEY (可选)</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newAccount.api_key}
|
|
||||||
onChange={(e) => setNewAccount({ ...newAccount, api_key: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>API SECRET (可选)</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newAccount.api_secret}
|
|
||||||
onChange={(e) => setNewAccount({ ...newAccount, api_secret: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group checkbox">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!newAccount.use_testnet}
|
|
||||||
onChange={(e) => setNewAccount({ ...newAccount, use_testnet: e.target.checked })}
|
|
||||||
/>
|
|
||||||
测试网
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>初始状态</label>
|
|
||||||
<select
|
|
||||||
value={newAccount.status}
|
|
||||||
onChange={(e) => setNewAccount({ ...newAccount, status: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="active">启用</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn-primary full-width"
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={busy || !newAccount.name.trim()}
|
|
||||||
>
|
|
||||||
创建账号
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account List Card */}
|
|
||||||
<div className="card account-list-card">
|
|
||||||
<h4>账号列表 ({accounts?.length || 0})</h4>
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>名称</th>
|
|
||||||
<th>状态</th>
|
|
||||||
<th>测试网</th>
|
|
||||||
<th>API配置</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(accounts || []).map(a => (
|
|
||||||
<tr key={a.id}>
|
|
||||||
<td>#{a.id}</td>
|
|
||||||
<td>{a.name}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status-badge ${a.status}`}>
|
|
||||||
{a.status === 'active' ? '启用' : '禁用'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{a.use_testnet ? '是' : '否'}</td>
|
|
||||||
<td>
|
|
||||||
{a.has_api_key ? '✅' : '❌'} / {a.has_api_secret ? '✅' : '❌'}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button
|
|
||||||
className="btn-sm"
|
|
||||||
disabled={busy || a.id === 1}
|
|
||||||
onClick={() => handleUpdateStatus(a)}
|
|
||||||
title={a.id === 1 ? '默认账号不可禁用' : '切换状态'}
|
|
||||||
>
|
|
||||||
{a.status === 'active' ? '禁用' : '启用'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn-sm"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => {
|
|
||||||
setCredEditId(a.id)
|
|
||||||
setCredForm({ api_key: '', api_secret: '', use_testnet: !!a.use_testnet })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
密钥
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Credential Edit Modal/Overlay */}
|
|
||||||
{credEditId && (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal-content">
|
|
||||||
<h4>更新密钥 (账号 #{credEditId})</h4>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>API KEY (留空=不改)</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={credForm.api_key}
|
|
||||||
onChange={(e) => setCredForm({ ...credForm, api_key: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>API SECRET (留空=不改)</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={credForm.api_secret}
|
|
||||||
onChange={(e) => setCredForm({ ...credForm, api_secret: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group checkbox">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!credForm.use_testnet}
|
|
||||||
onChange={(e) => setCredForm({ ...credForm, use_testnet: e.target.checked })}
|
|
||||||
/>
|
|
||||||
测试网
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button className="btn-secondary" onClick={() => setCredEditId(null)}>取消</button>
|
|
||||||
<button className="btn-primary" onClick={handleUpdateCreds} disabled={busy}>保存</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdminDashboard = () => {
|
|
||||||
const [data, setData] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
// Don't set loading on background refresh (if data exists)
|
if (!dashboardData) setLoading(true)
|
||||||
if (!data) setLoading(true)
|
const [dashboardRes, statsRes] = await Promise.all([
|
||||||
|
|
||||||
const [usersRes, dashboardRes, servicesRes, accountsRes] = await Promise.all([
|
|
||||||
api.get('/admin/users/detailed').catch(() => ({ data: [] })),
|
|
||||||
api.getAdminDashboard(),
|
api.getAdminDashboard(),
|
||||||
api.get('/system/trading/services').catch(() => ({ data: { services: [] } })),
|
api.getAdminOverallTradeStats(7).catch(() => null)
|
||||||
api.get('/accounts').catch(() => ({ data: [] }))
|
|
||||||
])
|
])
|
||||||
|
setDashboardData(dashboardRes)
|
||||||
const users = usersRes.data || []
|
setOrderStats(statsRes)
|
||||||
const globalStats = dashboardRes // summary, accounts (list of stats)
|
|
||||||
const services = servicesRes.data.services || []
|
|
||||||
const allAccounts = accountsRes.data || []
|
|
||||||
|
|
||||||
// Index stats and services by account ID
|
|
||||||
const statsMap = {}
|
|
||||||
globalStats.accounts.forEach(a => { statsMap[a.id] = a })
|
|
||||||
|
|
||||||
const serviceMap = {}
|
|
||||||
services.forEach(s => {
|
|
||||||
// program name format: auto_sys_acc{id}
|
|
||||||
const match = s.program.match(/auto_sys_acc(\d+)/)
|
|
||||||
if (match) {
|
|
||||||
serviceMap[match[1]] = s
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Merge data into users structure
|
|
||||||
const enrichedUsers = users.map(u => {
|
|
||||||
const enrichedAccounts = (u.accounts || []).map(acc => {
|
|
||||||
const st = statsMap[acc.id] || {}
|
|
||||||
const sv = serviceMap[acc.id]
|
|
||||||
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
total_balance: st.total_balance || 0,
|
|
||||||
total_pnl: st.total_pnl || 0,
|
|
||||||
open_positions: st.open_positions || 0,
|
|
||||||
serviceStatus: sv
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
...u,
|
|
||||||
accounts: enrichedAccounts
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setData({
|
|
||||||
summary: globalStats.summary,
|
|
||||||
users: enrichedUsers,
|
|
||||||
allAccounts
|
|
||||||
})
|
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err?.message)
|
||||||
console.error(err)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleServiceAction = async (accountId, action) => {
|
|
||||||
if (action === 'refresh') {
|
|
||||||
loadData()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.confirm(`确定要${action === 'start' ? '启动' : '停止'}账号 #${accountId} 的交易服务吗?`)) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.post(`/accounts/${accountId}/service/${action}`)
|
|
||||||
// Short delay then refresh
|
|
||||||
setTimeout(loadData, 1000)
|
|
||||||
} catch (e) {
|
|
||||||
alert(`操作失败: ${e.message || '未知错误'}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
const timer = setInterval(loadData, 30000) // 30秒刷新一次
|
const timer = setInterval(loadData, 30000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
useEffect(() => {
|
|
||||||
const onUpdated = () => loadData()
|
|
||||||
window.addEventListener('ats:accounts:updated', onUpdated)
|
|
||||||
return () => window.removeEventListener('ats:accounts:updated', onUpdated)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (loading && !data) return <div className="loading">加载中...</div>
|
if (loading && !dashboardData) return <div className="loading">加载中...</div>
|
||||||
if (error) return <div className="error">加载失败: {error}</div>
|
if (error) return <div className="error">加载失败: {error}</div>
|
||||||
if (!data) return null
|
if (!dashboardData) return null
|
||||||
|
|
||||||
const { summary, users } = data
|
const summary = dashboardData.summary || {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-dashboard">
|
<div className="admin-dashboard">
|
||||||
|
|
@ -638,47 +47,139 @@ const AdminDashboard = () => {
|
||||||
<div className="summary-cards">
|
<div className="summary-cards">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-title">总资产 (USDT)</div>
|
<div className="card-title">总资产 (USDT)</div>
|
||||||
<div className="card-value">{summary.total_assets_usdt.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
<div className="card-value">
|
||||||
|
{(summary.total_assets_usdt ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
<div className="card-desc">各账号快照汇总,不调币安接口</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-title">总盈亏 (USDT)</div>
|
<div className="card-title">总盈亏 (USDT)</div>
|
||||||
<div className={`card-value ${summary.total_pnl_usdt >= 0 ? 'profit' : 'loss'}`}>
|
<div className={`card-value ${(summary.total_pnl_usdt ?? 0) >= 0 ? 'profit' : 'loss'}`}>
|
||||||
{summary.total_pnl_usdt > 0 ? '+' : ''}{summary.total_pnl_usdt.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
{(summary.total_pnl_usdt ?? 0) >= 0 ? '+' : ''}
|
||||||
|
{(summary.total_pnl_usdt ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card-desc">最近 7 天聚合已实现盈亏</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-title">活跃账户数</div>
|
<div className="card-title">活跃账户数</div>
|
||||||
<div className="card-value">{summary.active_accounts} / {summary.total_accounts}</div>
|
<div className="card-value">{summary.active_accounts ?? 0} / {summary.total_accounts ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="users-section">
|
<div className="dashboard-card stats-card">
|
||||||
<h3>用户管理 ({users.length})</h3>
|
<div className="stats-card-header">
|
||||||
<div className="users-section-grid">
|
<h3>整体订单统计(最近 7 天)</h3>
|
||||||
<div className="create-user-block">
|
|
||||||
<h4 className="create-user-title">➕ 添加用户</h4>
|
|
||||||
<p className="create-user-desc">创建新用户后,可在下方为其关联交易账号。</p>
|
|
||||||
<CreateUserForm onSuccess={loadData} />
|
|
||||||
</div>
|
|
||||||
<div className="users-list">
|
|
||||||
{users.map(user => (
|
|
||||||
<UserAccountGroup
|
|
||||||
key={user.id}
|
|
||||||
user={user}
|
|
||||||
allAccounts={data.allAccounts}
|
|
||||||
onServiceAction={handleServiceAction}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{orderStats ? (
|
||||||
|
<>
|
||||||
|
{orderStats.summary && (
|
||||||
|
<div className="stats-section">
|
||||||
|
<h4>汇总</h4>
|
||||||
|
<p>
|
||||||
|
笔数 <strong>{orderStats.summary.trade_count ?? 0}</strong>
|
||||||
|
,盈 <strong>{orderStats.summary.win_count ?? 0}</strong> / 亏 <strong>{orderStats.summary.loss_count ?? 0}</strong>
|
||||||
|
,净盈亏 <span className={Number(orderStats.summary.net_pnl) >= 0 ? 'positive' : 'negative'}>
|
||||||
|
{Number(orderStats.summary.net_pnl) >= 0 ? '+' : ''}{Number(orderStats.summary.net_pnl).toFixed(2)} USDT
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="stats-section">
|
||||||
|
<h4>按交易对:净盈亏、胜率</h4>
|
||||||
|
{orderStats.by_symbol?.length > 0 ? (
|
||||||
|
<div className="stats-table-wrap">
|
||||||
|
<table className="stats-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>交易对</th>
|
||||||
|
<th>笔数</th>
|
||||||
|
<th>胜率%</th>
|
||||||
|
<th>净盈亏(USDT)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orderStats.by_symbol.map((row) => (
|
||||||
|
<tr key={row.symbol}>
|
||||||
|
<td className="stats-symbol">{row.symbol}</td>
|
||||||
|
<td>{row.trade_count}</td>
|
||||||
|
<td>{row.win_rate_pct}</td>
|
||||||
|
<td className={Number(row.net_pnl) >= 0 ? 'positive' : 'negative'}>
|
||||||
|
{Number(row.net_pnl) >= 0 ? '+' : ''}{Number(row.net_pnl).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="stats-empty">暂无按交易对统计(需先运行 scripts/aggregate_trade_stats.py 或依赖定时任务)</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="stats-section">
|
||||||
|
<h4>按小时:净盈亏(0–23 时,北京时间)</h4>
|
||||||
|
{orderStats.hourly_agg?.length > 0 ? (
|
||||||
|
<div className="stats-table-wrap hourly-table">
|
||||||
|
<table className="stats-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>小时</th>
|
||||||
|
<th>笔数</th>
|
||||||
|
<th>净盈亏(USDT)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orderStats.hourly_agg.map((row) => (
|
||||||
|
<tr key={row.hour}>
|
||||||
|
<td>{row.hour}:00</td>
|
||||||
|
<td>{row.trade_count}</td>
|
||||||
|
<td className={Number(row.net_pnl) >= 0 ? 'positive' : 'negative'}>
|
||||||
|
{Number(row.net_pnl) >= 0 ? '+' : ''}{Number(row.net_pnl).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="stats-empty">暂无按小时统计</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{orderStats.suggestions && (orderStats.suggestions.blacklist?.length > 0 || orderStats.suggestions.whitelist?.length > 0) && (
|
||||||
|
<div className="stats-section suggestions-section">
|
||||||
|
<h4>策略建议(仅供参考)</h4>
|
||||||
|
{orderStats.suggestions.blacklist?.length > 0 && (
|
||||||
|
<div className="suggestion-block blacklist">
|
||||||
|
<strong>建议降权/观察:</strong>
|
||||||
|
<ul>
|
||||||
|
{orderStats.suggestions.blacklist.map((item) => (
|
||||||
|
<li key={item.symbol}>
|
||||||
|
<span className="suggestion-symbol">{item.symbol}</span>
|
||||||
|
笔数 {item.trade_count},净盈亏 {item.net_pnl} USDT,胜率 {item.win_rate_pct}%
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{orderStats.suggestions.whitelist?.length > 0 && (
|
||||||
|
<div className="suggestion-block whitelist">
|
||||||
|
<strong>可优先考虑:</strong>
|
||||||
|
<ul>
|
||||||
|
{orderStats.suggestions.whitelist.map((item) => (
|
||||||
|
<li key={item.symbol}>
|
||||||
|
<span className="suggestion-symbol">{item.symbol}</span>
|
||||||
|
笔数 {item.trade_count},净盈亏 +{item.net_pnl} USDT,胜率 {item.win_rate_pct}%
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="stats-empty">加载统计中…(若长期无数据,请先运行 scripts/aggregate_trade_stats.py 或依赖定时任务更新)</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccountManager
|
|
||||||
accounts={data.allAccounts}
|
|
||||||
onRefresh={loadData}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AdminDashboard
|
|
||||||
|
|
|
||||||
335
frontend/src/components/AdminShared.jsx
Normal file
335
frontend/src/components/AdminShared.jsx
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
/**
|
||||||
|
* 管理员共用组件:用户管理、系统账号管理
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { api } from '../services/api'
|
||||||
|
import './AdminDashboard.css'
|
||||||
|
|
||||||
|
export const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => {
|
||||||
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
const [linkAccountId, setLinkAccountId] = useState('')
|
||||||
|
const [linkRole, setLinkRole] = useState('viewer')
|
||||||
|
const [associating, setAssociating] = useState(false)
|
||||||
|
|
||||||
|
const handleUserAction = async (action) => {
|
||||||
|
if (!window.confirm(`确定要${action === 'start' ? '启动' : '停止'}用户 ${user.username} 下所有账号的交易服务吗?`)) return
|
||||||
|
setProcessing(true)
|
||||||
|
try {
|
||||||
|
const promises = user.accounts.map(acc =>
|
||||||
|
api.post(`/accounts/${acc.id}/service/${action}`).catch(e => console.error(`Failed to ${action} account ${acc.id}:`, e))
|
||||||
|
)
|
||||||
|
await Promise.all(promises)
|
||||||
|
if (onServiceAction) onServiceAction(null, 'refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert(`操作失败: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGrant = async () => {
|
||||||
|
if (!linkAccountId) return
|
||||||
|
const accountId = Number(linkAccountId)
|
||||||
|
if (!Number.isInteger(accountId) || accountId < 1) {
|
||||||
|
alert('请选择有效的账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAssociating(true)
|
||||||
|
try {
|
||||||
|
await api.grantUserAccount(user.id, accountId, linkRole)
|
||||||
|
setLinkAccountId('')
|
||||||
|
if (onServiceAction) onServiceAction(null, 'refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert(`关联失败: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
setAssociating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevoke = async (accountId) => {
|
||||||
|
if (!window.confirm('确定要取消该账号的关联吗?')) return
|
||||||
|
try {
|
||||||
|
await api.revokeUserAccount(user.id, accountId)
|
||||||
|
if (onServiceAction) onServiceAction(null, 'refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert(`取消关联失败: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRunning = user.accounts.every(a => a.serviceStatus?.running)
|
||||||
|
const allStopped = user.accounts.every(a => !a.serviceStatus?.running)
|
||||||
|
const availableAccounts = (allAccounts || []).filter(a => !user.accounts.some(ua => ua.id === a.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-group-card">
|
||||||
|
<div className="user-group-header" onClick={() => setExpanded(!expanded)}>
|
||||||
|
<div className="user-info">
|
||||||
|
<span className={`expand-icon ${expanded ? 'expanded' : ''}`}>▶</span>
|
||||||
|
<span className="user-name">{user.username}</span>
|
||||||
|
<span className={`user-role-badge ${user.role}`}>{user.role}</span>
|
||||||
|
<span className="account-count">({user.accounts.length} 账号)</span>
|
||||||
|
</div>
|
||||||
|
{user.role !== 'admin' && (
|
||||||
|
<div className="user-actions" onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="btn-text-action start" onClick={() => handleUserAction('start')} disabled={processing || allRunning || user.accounts.length === 0}>全部启动</button>
|
||||||
|
<button className="btn-text-action stop" onClick={() => handleUserAction('stop')} disabled={processing || allStopped || user.accounts.length === 0}>全部停止</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expanded && user.role !== 'admin' && (
|
||||||
|
<div className="user-accounts-table">
|
||||||
|
{user.accounts.length === 0 ? (
|
||||||
|
<div className="no-accounts">暂无关联账号</div>
|
||||||
|
) : (
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th><th>名称</th><th>权限</th><th>账户状态</th><th>服务状态</th><th>总资产</th><th>总盈亏</th><th>持仓数</th><th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{user.accounts.map(acc => (
|
||||||
|
<tr key={acc.id}>
|
||||||
|
<td>{acc.id}</td>
|
||||||
|
<td>{acc.name}</td>
|
||||||
|
<td><span className="role-tag">{acc.role || 'viewer'}</span></td>
|
||||||
|
<td><span className={`status-badge ${acc.status}`}>{acc.status === 'active' ? '启用' : '禁用'}</span></td>
|
||||||
|
<td>
|
||||||
|
{acc.serviceStatus ? (
|
||||||
|
<span className={`status-badge ${acc.serviceStatus.running ? 'running' : 'stopped'}`}>{acc.serviceStatus.running ? '运行中' : '停止'}</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-badge stopped">未启动</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{acc.total_balance?.toFixed(2) || '-'}</td>
|
||||||
|
<td className={acc.total_pnl >= 0 ? 'profit' : 'loss'}>{acc.total_pnl?.toFixed(2) || '-'}</td>
|
||||||
|
<td>{acc.open_positions || 0}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn-icon" onClick={() => onServiceAction && onServiceAction(acc.id, acc.serviceStatus?.running ? 'stop' : 'start')} title={acc.serviceStatus?.running ? '停止服务' : '启动服务'}>
|
||||||
|
{acc.serviceStatus?.running ? '⏹' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-icon danger" onClick={() => handleRevoke(acc.id)} title="取消关联" style={{ marginLeft: '8px' }}>✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<div className="add-account-section">
|
||||||
|
<h4>新增关联</h4>
|
||||||
|
<div className="add-account-form">
|
||||||
|
<select value={linkAccountId} onChange={e => setLinkAccountId(e.target.value)} disabled={associating}>
|
||||||
|
<option value="">选择账号...</option>
|
||||||
|
{availableAccounts.map(a => (
|
||||||
|
<option key={a.id} value={a.id}>#{a.id} {a.name} ({a.status === 'active' ? '启用' : '禁用'})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={linkRole} onChange={e => setLinkRole(e.target.value)} disabled={associating}>
|
||||||
|
<option value="viewer">观察者 (Viewer)</option>
|
||||||
|
<option value="owner">拥有者 (Owner)</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={handleGrant} disabled={!linkAccountId || associating} className="btn-primary">{associating ? '关联中...' : '关联'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateUserForm = ({ onSuccess }) => {
|
||||||
|
const [form, setForm] = useState({ username: '', password: '', role: 'user', status: 'active' })
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!form.username.trim()) { setMessage('请输入用户名'); return }
|
||||||
|
if (!form.password.trim()) { setMessage('请输入密码'); return }
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await api.createUser({ username: form.username.trim(), password: form.password, role: form.role, status: form.status })
|
||||||
|
setMessage('用户已创建')
|
||||||
|
setForm({ username: '', password: '', role: 'user', status: 'active' })
|
||||||
|
if (onSuccess) onSuccess()
|
||||||
|
} catch (e) {
|
||||||
|
setMessage('创建用户失败: ' + (e?.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="card create-user-card">
|
||||||
|
<h4 className="create-user-card-heading">新建用户</h4>
|
||||||
|
{message && <div className={`message ${message.includes('失败') ? 'error' : 'success'}`}>{message}</div>}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} placeholder="登录用,例如:trader1" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} placeholder="设置登录密码" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>角色</label>
|
||||||
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||||
|
<option value="user">普通用户 (user)</option>
|
||||||
|
<option value="admin">管理员 (admin)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>状态</label>
|
||||||
|
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
|
||||||
|
<option value="active">启用</option>
|
||||||
|
<option value="disabled">禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary full-width" onClick={handleCreate} disabled={busy || !form.username.trim() || !form.password}>{busy ? '创建中...' : '创建用户'}</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccountManager = ({ accounts, onRefresh }) => {
|
||||||
|
const [newAccount, setNewAccount] = useState({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' })
|
||||||
|
const [credEditId, setCredEditId] = useState(null)
|
||||||
|
const [credForm, setCredForm] = useState({ api_key: '', api_secret: '', use_testnet: false })
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const notifyAccountsUpdated = () => {
|
||||||
|
try { window.dispatchEvent(new Event('ats:accounts:updated')) } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newAccount.name.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await api.createAccount(newAccount)
|
||||||
|
setMessage('账号已创建')
|
||||||
|
setNewAccount({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' })
|
||||||
|
if (onRefresh) onRefresh()
|
||||||
|
notifyAccountsUpdated()
|
||||||
|
} catch (e) {
|
||||||
|
setMessage('创建账号失败: ' + (e?.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleUpdateStatus = async (account) => {
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
const next = account.status === 'active' ? 'disabled' : 'active'
|
||||||
|
await api.updateAccount(account.id, { status: next })
|
||||||
|
setMessage(`账号 #${account.id} 已${next === 'active' ? '启用' : '禁用'}`)
|
||||||
|
if (onRefresh) onRefresh()
|
||||||
|
notifyAccountsUpdated()
|
||||||
|
} catch (e) {
|
||||||
|
setMessage('更新账号失败: ' + (e?.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleUpdateCreds = async () => {
|
||||||
|
if (!credEditId) return
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
const payload = {}
|
||||||
|
if (credForm.api_key) payload.api_key = credForm.api_key
|
||||||
|
if (credForm.api_secret) payload.api_secret = credForm.api_secret
|
||||||
|
payload.use_testnet = !!credForm.use_testnet
|
||||||
|
await api.updateAccountCredentials(credEditId, payload)
|
||||||
|
setMessage(`账号 #${credEditId} 密钥已更新`)
|
||||||
|
setCredEditId(null)
|
||||||
|
if (onRefresh) onRefresh()
|
||||||
|
notifyAccountsUpdated()
|
||||||
|
} catch (e) {
|
||||||
|
setMessage('更新密钥失败: ' + (e?.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="accounts-manager-section">
|
||||||
|
<h3>系统账号池管理</h3>
|
||||||
|
{message && <div className={`message ${message.includes('失败') ? 'error' : 'success'}`}>{message}</div>}
|
||||||
|
<div className="accounts-manager-grid">
|
||||||
|
<div className="card create-account-card">
|
||||||
|
<h4>新增账号</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>名称</label>
|
||||||
|
<input type="text" value={newAccount.name} onChange={(e) => setNewAccount({ ...newAccount, name: e.target.value })} placeholder="例如:user_a" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>API KEY (可选)</label>
|
||||||
|
<input type="password" value={newAccount.api_key} onChange={(e) => setNewAccount({ ...newAccount, api_key: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>API SECRET (可选)</label>
|
||||||
|
<input type="password" value={newAccount.api_secret} onChange={(e) => setNewAccount({ ...newAccount, api_secret: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group checkbox">
|
||||||
|
<label><input type="checkbox" checked={!!newAccount.use_testnet} onChange={(e) => setNewAccount({ ...newAccount, use_testnet: e.target.checked })} />测试网</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>初始状态</label>
|
||||||
|
<select value={newAccount.status} onChange={(e) => setNewAccount({ ...newAccount, status: e.target.value })}>
|
||||||
|
<option value="active">启用</option>
|
||||||
|
<option value="disabled">禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary full-width" onClick={handleCreate} disabled={busy || !newAccount.name.trim()}>创建账号</button>
|
||||||
|
</div>
|
||||||
|
<div className="card account-list-card">
|
||||||
|
<h4>账号列表 ({accounts?.length || 0})</h4>
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>名称</th><th>状态</th><th>测试网</th><th>API配置</th><th>操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(accounts || []).map(a => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>#{a.id}</td>
|
||||||
|
<td>{a.name}</td>
|
||||||
|
<td><span className={`status-badge ${a.status}`}>{a.status === 'active' ? '启用' : '禁用'}</span></td>
|
||||||
|
<td>{a.use_testnet ? '是' : '否'}</td>
|
||||||
|
<td>{a.has_api_key ? '✅' : '❌'} / {a.has_api_secret ? '✅' : '❌'}</td>
|
||||||
|
<td>
|
||||||
|
<div className="action-buttons">
|
||||||
|
<button className="btn-sm" disabled={busy || a.id === 1} onClick={() => handleUpdateStatus(a)} title={a.id === 1 ? '默认账号不可禁用' : '切换状态'}>{a.status === 'active' ? '禁用' : '启用'}</button>
|
||||||
|
<button className="btn-sm" disabled={busy} onClick={() => { setCredEditId(a.id); setCredForm({ api_key: '', api_secret: '', use_testnet: !!a.use_testnet }) }}>密钥</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{credEditId && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal-content">
|
||||||
|
<h4>更新密钥 (账号 #{credEditId})</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>API KEY (留空=不改)</label>
|
||||||
|
<input type="password" value={credForm.api_key} onChange={(e) => setCredForm({ ...credForm, api_key: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>API SECRET (留空=不改)</label>
|
||||||
|
<input type="password" value={credForm.api_secret} onChange={(e) => setCredForm({ ...credForm, api_secret: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group checkbox">
|
||||||
|
<label><input type="checkbox" checked={!!credForm.use_testnet} onChange={(e) => setCredForm({ ...credForm, use_testnet: e.target.checked })} />测试网</label>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn-secondary" onClick={() => setCredEditId(null)}>取消</button>
|
||||||
|
<button className="btn-primary" onClick={handleUpdateCreds} disabled={busy}>保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
frontend/src/components/AdminUserManagement.jsx
Normal file
105
frontend/src/components/AdminUserManagement.jsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../services/api'
|
||||||
|
import { UserAccountGroup, CreateUserForm } from './AdminShared'
|
||||||
|
import './AdminDashboard.css'
|
||||||
|
|
||||||
|
export default function AdminUserManagement() {
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
if (!data) setLoading(true)
|
||||||
|
const [usersRes, dashboardRes, servicesRes, accountsRes] = await Promise.all([
|
||||||
|
api.get('/admin/users/detailed').catch(() => ({ data: [] })),
|
||||||
|
api.getAdminDashboard(),
|
||||||
|
api.get('/system/trading/services').catch(() => ({ data: { services: [] } })),
|
||||||
|
api.get('/accounts').catch(() => ({ data: [] }))
|
||||||
|
])
|
||||||
|
const users = usersRes.data || []
|
||||||
|
const globalStats = dashboardRes
|
||||||
|
const services = servicesRes.data?.services || []
|
||||||
|
const allAccounts = accountsRes.data || []
|
||||||
|
const statsMap = {}
|
||||||
|
globalStats.accounts?.forEach(a => { statsMap[a.id] = a })
|
||||||
|
const serviceMap = {}
|
||||||
|
services.forEach(s => {
|
||||||
|
const match = s.program?.match(/auto_sys_acc(\d+)/)
|
||||||
|
if (match) serviceMap[match[1]] = s
|
||||||
|
})
|
||||||
|
const enrichedUsers = users.map(u => ({
|
||||||
|
...u,
|
||||||
|
accounts: (u.accounts || []).map(acc => ({
|
||||||
|
...acc,
|
||||||
|
total_balance: statsMap[acc.id]?.total_balance ?? 0,
|
||||||
|
total_pnl: statsMap[acc.id]?.total_pnl ?? 0,
|
||||||
|
open_positions: statsMap[acc.id]?.open_positions ?? 0,
|
||||||
|
serviceStatus: serviceMap[acc.id]
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
setData({ users: enrichedUsers, allAccounts })
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleServiceAction = async (accountId, action) => {
|
||||||
|
if (action === 'refresh') { loadData(); return }
|
||||||
|
if (!window.confirm(`确定要${action === 'start' ? '启动' : '停止'}账号 #${accountId} 的交易服务吗?`)) return
|
||||||
|
try {
|
||||||
|
await api.post(`/accounts/${accountId}/service/${action}`)
|
||||||
|
setTimeout(loadData, 1000)
|
||||||
|
} catch (e) {
|
||||||
|
alert(`操作失败: ${e?.message || '未知错误'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
const t = setInterval(loadData, 30000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
const onUpdated = () => loadData()
|
||||||
|
window.addEventListener('ats:accounts:updated', onUpdated)
|
||||||
|
return () => window.removeEventListener('ats:accounts:updated', onUpdated)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading && !data) return <div className="loading">加载中...</div>
|
||||||
|
if (error) return <div className="error">加载失败: {error}</div>
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
const { users, allAccounts } = data
|
||||||
|
return (
|
||||||
|
<div className="admin-dashboard">
|
||||||
|
<div className="dashboard-header">
|
||||||
|
<h2>用户管理</h2>
|
||||||
|
<button className="refresh-btn" onClick={loadData}>刷新</button>
|
||||||
|
</div>
|
||||||
|
<div className="users-section">
|
||||||
|
<h3>用户管理 ({users.length})</h3>
|
||||||
|
<div className="users-section-grid">
|
||||||
|
<div className="create-user-block">
|
||||||
|
<h4 className="create-user-title">➕ 添加用户</h4>
|
||||||
|
<p className="create-user-desc">创建新用户后,可在下方为其关联交易账号。</p>
|
||||||
|
<CreateUserForm onSuccess={loadData} />
|
||||||
|
</div>
|
||||||
|
<div className="users-list">
|
||||||
|
{users.map(user => (
|
||||||
|
<UserAccountGroup
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
allAccounts={allAccounts}
|
||||||
|
onServiceAction={handleServiceAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -476,6 +476,16 @@ export const api = {
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getAdminOverallTradeStats: async (days = 7) => {
|
||||||
|
const url = buildUrl(`/api/stats/admin/overall-trade-stats?days=${days}`);
|
||||||
|
const response = await fetch(url, { headers: withAuthHeaders() });
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: '获取整体订单统计失败' }));
|
||||||
|
throw new Error(error.detail || '获取整体订单统计失败');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
// 平仓操作
|
// 平仓操作
|
||||||
closePosition: async (symbol) => {
|
closePosition: async (symbol) => {
|
||||||
const response = await fetch(buildUrl(`/api/account/positions/${symbol}/close`), {
|
const response = await fetch(buildUrl(`/api/account/positions/${symbol}/close`), {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user