From 007827464af4ab75e8713c3323ccdba5e9e7d8bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E8=96=87=E5=AE=89?= Date: Sun, 1 Mar 2026 11:48:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E5=B1=80=E4=BB=AA=E8=A1=A8=E6=9D=BF?= =?UTF-8?q?=E8=B0=83=E6=95=B4=EF=BC=8C=E5=A2=9E=E5=8A=A0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E7=AE=A1=E7=90=86=E7=94=A8=E6=88=B7=E5=8F=8A?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/routes/stats.py | 98 ++- frontend/src/App.css | 45 + frontend/src/App.jsx | 12 + .../src/components/AdminAccountManagement.jsx | 42 + frontend/src/components/AdminDashboard.css | 72 ++ frontend/src/components/AdminDashboard.jsx | 767 +++--------------- frontend/src/components/AdminShared.jsx | 335 ++++++++ .../src/components/AdminUserManagement.jsx | 105 +++ frontend/src/services/api.js | 10 + 9 files changed, 830 insertions(+), 656 deletions(-) create mode 100644 frontend/src/components/AdminAccountManagement.jsx create mode 100644 frontend/src/components/AdminShared.jsx create mode 100644 frontend/src/components/AdminUserManagement.jsx diff --git a/backend/api/routes/stats.py b/backend/api/routes/stats.py index 9f653fe..f516447 100644 --- a/backend/api/routes/stats.py +++ b/backend/api/routes/stats.py @@ -22,56 +22,108 @@ router = APIRouter() @router.get("/admin/dashboard") async def get_admin_dashboard_stats(user: Dict[str, Any] = Depends(get_admin_user)): - """获取管理员仪表板数据(所有用户统计)""" + """获取管理员仪表板数据:总资产来自各账号快照汇总(不调币安),总盈亏为最近7天聚合已实现盈亏。""" try: accounts = Account.list_all() stats = [] - - total_assets = 0 - total_pnl = 0 + total_assets = 0.0 active_accounts = 0 - for acc in accounts: - aid = acc['id'] - # 获取最新快照 + aid = acc["id"] snapshots = AccountSnapshot.get_recent(1, account_id=aid) acc_stat = { "id": aid, - "name": acc['name'], - "status": acc['status'], + "name": acc["name"], + "status": acc["status"], "total_balance": 0, "total_pnl": 0, - "open_positions": 0 + "open_positions": 0, } - if snapshots: snap = snapshots[0] - acc_stat["total_balance"] = snap.get('total_balance', 0) - acc_stat["total_pnl"] = snap.get('total_pnl', 0) - acc_stat["open_positions"] = snap.get('open_positions', 0) - + acc_stat["total_balance"] = snap.get("total_balance", 0) + acc_stat["total_pnl"] = snap.get("total_pnl", 0) + acc_stat["open_positions"] = snap.get("open_positions", 0) 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 - 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 { "summary": { "total_accounts": len(accounts), "active_accounts": active_accounts, - "total_assets_usdt": total_assets, - "total_pnl_usdt": total_pnl + "total_assets_usdt": round(total_assets, 2), + "total_pnl_usdt": round(total_pnl_7d, 2), }, - "accounts": stats + "accounts": stats, } except Exception as e: logger.error(f"获取管理员仪表板数据失败: {e}", exc_info=True) 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: """将 daily(按 date+symbol)聚合成按 symbol 的汇总。""" from collections import defaultdict diff --git a/frontend/src/App.css b/frontend/src/App.css index 94124a9..3bbe634 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -195,6 +195,51 @@ 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 { display: flex; align-items: center; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 33fc8d9..2203c31 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,8 @@ import ConfigGuide from './components/ConfigGuide' import TradeList from './components/TradeList' import StatsDashboard from './components/StatsDashboard' import AdminDashboard from './components/AdminDashboard.jsx' +import AdminUserManagement from './components/AdminUserManagement.jsx' +import AdminAccountManagement from './components/AdminAccountManagement.jsx' import Recommendations from './components/Recommendations' import LogMonitor from './components/LogMonitor' import DataManagement from './components/DataManagement' @@ -82,6 +84,14 @@ function App() { )} {isAdmin && ( <> + 仪表板 +
+ 管理中心 +
+ 用户管理 + 系统账号管理 +
+
全局配置 数据管理 日志监控 @@ -112,6 +122,8 @@ function App() {
: } /> + :
无权限
} /> + :
无权限
} /> } /> } /> } /> diff --git a/frontend/src/components/AdminAccountManagement.jsx b/frontend/src/components/AdminAccountManagement.jsx new file mode 100644 index 0000000..b047f9b --- /dev/null +++ b/frontend/src/components/AdminAccountManagement.jsx @@ -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
加载中...
+ if (error) return
加载失败: {error}
+ + return ( +
+
+

系统账号管理

+ +
+ +
+ ) +} diff --git a/frontend/src/components/AdminDashboard.css b/frontend/src/components/AdminDashboard.css index 4eb34bf..a39de5f 100644 --- a/frontend/src/components/AdminDashboard.css +++ b/frontend/src/components/AdminDashboard.css @@ -57,6 +57,78 @@ 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 { background: white; padding: 24px; diff --git a/frontend/src/components/AdminDashboard.jsx b/frontend/src/components/AdminDashboard.jsx index c9bc3e9..d92ba3b 100644 --- a/frontend/src/components/AdminDashboard.jsx +++ b/frontend/src/components/AdminDashboard.jsx @@ -2,631 +2,40 @@ import React, { useEffect, useState } from 'react' import { api } from '../services/api' import './AdminDashboard.css' -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 ( -
-
setExpanded(!expanded)}> -
- - {user.username} - {user.role} - ({user.accounts.length} 账号) -
- {user.role != 'admin' && ( -
e.stopPropagation()}> - - -
- )} -
- - {expanded && user.role != 'admin' && ( -
- {user.accounts.length === 0 ? ( -
暂无关联账号
- ) : ( - - - - - - - - - - - - - - - - {user.accounts.map(acc => ( - - - - - - - - - - - - ))} - -
ID名称权限账户状态服务状态总资产总盈亏持仓数操作
{acc.id}{acc.name} - {acc.role || 'viewer'} - - - {acc.status === 'active' ? '启用' : '禁用'} - - - {acc.serviceStatus ? ( - - {acc.serviceStatus.running ? '运行中' : '停止'} - - ) : ( - 未启动 - )} - {acc.total_balance?.toFixed(2) || '-'}= 0 ? 'profit' : 'loss'}> - {acc.total_pnl?.toFixed(2) || '-'} - {acc.open_positions || 0} - - -
- )} - -
-

新增关联

-
- - - -
-
-
- )} -
- ) -} - -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 ( -
-

新建用户

- {message && ( -
{message}
- )} -
- - setForm({ ...form, username: e.target.value })} - placeholder="登录用,例如:trader1" - /> -
-
- - setForm({ ...form, password: e.target.value })} - placeholder="设置登录密码" - /> -
-
- - -
-
- - -
- -
- ) -} - -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 ( -
-

系统账号池管理

- {message &&
{message}
} - -
- {/* Create Account Card */} -
-

新增账号

-
- - setNewAccount({ ...newAccount, name: e.target.value })} - placeholder="例如:user_a" - /> -
-
- - setNewAccount({ ...newAccount, api_key: e.target.value })} - /> -
-
- - setNewAccount({ ...newAccount, api_secret: e.target.value })} - /> -
-
- -
-
- - -
- -
- - {/* Account List Card */} -
-

账号列表 ({accounts?.length || 0})

-
- - - - - - - - - - - - - {(accounts || []).map(a => ( - - - - - - - - - ))} - -
ID名称状态测试网API配置操作
#{a.id}{a.name} - - {a.status === 'active' ? '启用' : '禁用'} - - {a.use_testnet ? '是' : '否'} - {a.has_api_key ? '✅' : '❌'} / {a.has_api_secret ? '✅' : '❌'} - -
- - -
-
-
-
-
- - {/* Credential Edit Modal/Overlay */} - {credEditId && ( -
-
-

更新密钥 (账号 #{credEditId})

-
- - setCredForm({ ...credForm, api_key: e.target.value })} - /> -
-
- - setCredForm({ ...credForm, api_secret: e.target.value })} - /> -
-
- -
-
- - -
-
-
- )} -
- ) -} - -const AdminDashboard = () => { - const [data, setData] = useState(null) +export default function AdminDashboard() { + const [dashboardData, setDashboardData] = useState(null) + const [orderStats, setOrderStats] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const loadData = async () => { try { - // Don't set loading on background refresh (if data exists) - if (!data) setLoading(true) - - const [usersRes, dashboardRes, servicesRes, accountsRes] = await Promise.all([ - api.get('/admin/users/detailed').catch(() => ({ data: [] })), + if (!dashboardData) setLoading(true) + const [dashboardRes, statsRes] = await Promise.all([ api.getAdminDashboard(), - api.get('/system/trading/services').catch(() => ({ data: { services: [] } })), - api.get('/accounts').catch(() => ({ data: [] })) + api.getAdminOverallTradeStats(7).catch(() => null) ]) - - const users = usersRes.data || [] - 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 - }) + setDashboardData(dashboardRes) + setOrderStats(statsRes) setError(null) } catch (err) { - setError(err.message) - console.error(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}`) - // Short delay then refresh - setTimeout(loadData, 1000) - } catch (e) { - alert(`操作失败: ${e.message || '未知错误'}`) - } - } - useEffect(() => { loadData() - const timer = setInterval(loadData, 30000) // 30秒刷新一次 + const timer = setInterval(loadData, 30000) return () => clearInterval(timer) }, []) - useEffect(() => { - const onUpdated = () => loadData() - window.addEventListener('ats:accounts:updated', onUpdated) - return () => window.removeEventListener('ats:accounts:updated', onUpdated) - }, []) - if (loading && !data) return
加载中...
+ if (loading && !dashboardData) return
加载中...
if (error) return
加载失败: {error}
- if (!data) return null + if (!dashboardData) return null - const { summary, users } = data + const summary = dashboardData.summary || {} return (
@@ -638,47 +47,139 @@ const AdminDashboard = () => {
总资产 (USDT)
-
{summary.total_assets_usdt.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+ {(summary.total_assets_usdt ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
各账号快照汇总,不调币安接口
总盈亏 (USDT)
-
= 0 ? 'profit' : 'loss'}`}> - {summary.total_pnl_usdt > 0 ? '+' : ''}{summary.total_pnl_usdt.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
= 0 ? 'profit' : 'loss'}`}> + {(summary.total_pnl_usdt ?? 0) >= 0 ? '+' : ''} + {(summary.total_pnl_usdt ?? 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
最近 7 天聚合已实现盈亏
活跃账户数
-
{summary.active_accounts} / {summary.total_accounts}
+
{summary.active_accounts ?? 0} / {summary.total_accounts ?? 0}
-
-

用户管理 ({users.length})

-
-
-

➕ 添加用户

-

创建新用户后,可在下方为其关联交易账号。

- -
-
- {users.map(user => ( - - ))} -
+
+
+

整体订单统计(最近 7 天)

+ {orderStats ? ( + <> + {orderStats.summary && ( +
+

汇总

+

+ 笔数 {orderStats.summary.trade_count ?? 0} + ,盈 {orderStats.summary.win_count ?? 0} / 亏 {orderStats.summary.loss_count ?? 0} + ,净盈亏 = 0 ? 'positive' : 'negative'}> + {Number(orderStats.summary.net_pnl) >= 0 ? '+' : ''}{Number(orderStats.summary.net_pnl).toFixed(2)} USDT + +

+
+ )} +
+

按交易对:净盈亏、胜率

+ {orderStats.by_symbol?.length > 0 ? ( +
+ + + + + + + + + + + {orderStats.by_symbol.map((row) => ( + + + + + + + ))} + +
交易对笔数胜率%净盈亏(USDT)
{row.symbol}{row.trade_count}{row.win_rate_pct}= 0 ? 'positive' : 'negative'}> + {Number(row.net_pnl) >= 0 ? '+' : ''}{Number(row.net_pnl).toFixed(2)} +
+
+ ) : ( +

暂无按交易对统计(需先运行 scripts/aggregate_trade_stats.py 或依赖定时任务)

+ )} +
+
+

按小时:净盈亏(0–23 时,北京时间)

+ {orderStats.hourly_agg?.length > 0 ? ( +
+ + + + + + + + + + {orderStats.hourly_agg.map((row) => ( + + + + + + ))} + +
小时笔数净盈亏(USDT)
{row.hour}:00{row.trade_count}= 0 ? 'positive' : 'negative'}> + {Number(row.net_pnl) >= 0 ? '+' : ''}{Number(row.net_pnl).toFixed(2)} +
+
+ ) : ( +

暂无按小时统计

+ )} +
+ {orderStats.suggestions && (orderStats.suggestions.blacklist?.length > 0 || orderStats.suggestions.whitelist?.length > 0) && ( +
+

策略建议(仅供参考)

+ {orderStats.suggestions.blacklist?.length > 0 && ( +
+ 建议降权/观察: +
    + {orderStats.suggestions.blacklist.map((item) => ( +
  • + {item.symbol} + 笔数 {item.trade_count},净盈亏 {item.net_pnl} USDT,胜率 {item.win_rate_pct}% +
  • + ))} +
+
+ )} + {orderStats.suggestions.whitelist?.length > 0 && ( +
+ 可优先考虑: +
    + {orderStats.suggestions.whitelist.map((item) => ( +
  • + {item.symbol} + 笔数 {item.trade_count},净盈亏 +{item.net_pnl} USDT,胜率 {item.win_rate_pct}% +
  • + ))} +
+
+ )} +
+ )} + + ) : ( +

加载统计中…(若长期无数据,请先运行 scripts/aggregate_trade_stats.py 或依赖定时任务更新)

+ )}
- -
) } - -export default AdminDashboard diff --git a/frontend/src/components/AdminShared.jsx b/frontend/src/components/AdminShared.jsx new file mode 100644 index 0000000..a638edf --- /dev/null +++ b/frontend/src/components/AdminShared.jsx @@ -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 ( +
+
setExpanded(!expanded)}> +
+ + {user.username} + {user.role} + ({user.accounts.length} 账号) +
+ {user.role !== 'admin' && ( +
e.stopPropagation()}> + + +
+ )} +
+ {expanded && user.role !== 'admin' && ( +
+ {user.accounts.length === 0 ? ( +
暂无关联账号
+ ) : ( + + + + + + + + {user.accounts.map(acc => ( + + + + + + + + + + + + ))} + +
ID名称权限账户状态服务状态总资产总盈亏持仓数操作
{acc.id}{acc.name}{acc.role || 'viewer'}{acc.status === 'active' ? '启用' : '禁用'} + {acc.serviceStatus ? ( + {acc.serviceStatus.running ? '运行中' : '停止'} + ) : ( + 未启动 + )} + {acc.total_balance?.toFixed(2) || '-'}= 0 ? 'profit' : 'loss'}>{acc.total_pnl?.toFixed(2) || '-'}{acc.open_positions || 0} + + +
+ )} +
+

新增关联

+
+ + + +
+
+
+ )} +
+ ) +} + +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 ( +
+

新建用户

+ {message &&
{message}
} +
+ + setForm({ ...form, username: e.target.value })} placeholder="登录用,例如:trader1" /> +
+
+ + setForm({ ...form, password: e.target.value })} placeholder="设置登录密码" /> +
+
+ + +
+
+ + +
+ +
+ ) +} + +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 ( +
+

系统账号池管理

+ {message &&
{message}
} +
+
+

新增账号

+
+ + setNewAccount({ ...newAccount, name: e.target.value })} placeholder="例如:user_a" /> +
+
+ + setNewAccount({ ...newAccount, api_key: e.target.value })} /> +
+
+ + setNewAccount({ ...newAccount, api_secret: e.target.value })} /> +
+
+ +
+
+ + +
+ +
+
+

账号列表 ({accounts?.length || 0})

+
+ + + + + + {(accounts || []).map(a => ( + + + + + + + + + ))} + +
ID名称状态测试网API配置操作
#{a.id}{a.name}{a.status === 'active' ? '启用' : '禁用'}{a.use_testnet ? '是' : '否'}{a.has_api_key ? '✅' : '❌'} / {a.has_api_secret ? '✅' : '❌'} +
+ + +
+
+
+
+
+ {credEditId && ( +
+
+

更新密钥 (账号 #{credEditId})

+
+ + setCredForm({ ...credForm, api_key: e.target.value })} /> +
+
+ + setCredForm({ ...credForm, api_secret: e.target.value })} /> +
+
+ +
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/AdminUserManagement.jsx b/frontend/src/components/AdminUserManagement.jsx new file mode 100644 index 0000000..8b539c3 --- /dev/null +++ b/frontend/src/components/AdminUserManagement.jsx @@ -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
加载中...
+ if (error) return
加载失败: {error}
+ if (!data) return null + + const { users, allAccounts } = data + return ( +
+
+

用户管理

+ +
+
+

用户管理 ({users.length})

+
+
+

➕ 添加用户

+

创建新用户后,可在下方为其关联交易账号。

+ +
+
+ {users.map(user => ( + + ))} +
+
+
+
+ ) +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index a925569..8f8ad2c 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -475,6 +475,16 @@ export const api = { } 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) => {