diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index ccde550..b90ae38 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -599,6 +599,55 @@ async def get_realtime_account(account_id: int = Depends(get_account_id)): return await get_realtime_account_data(account_id=account_id) +async def fetch_live_positions_pnl(account_id: int): + """ + 获取指定账号的实时持仓盈亏(仅 mark_price / pnl / pnl_percent),供仪表板合并用。 + 失败时返回空列表,不抛异常。 + """ + client = None + try: + api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) + if not api_key or not api_secret: + return [] + try: + from binance_client import BinanceClient + except ImportError: + trading_system_path = project_root / 'trading_system' + sys.path.insert(0, str(trading_system_path)) + from binance_client import BinanceClient + client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) + await client.connect() + positions = await client.get_open_positions() + result = [] + for pos in positions: + amt = float(pos.get('positionAmt', 0)) + if amt == 0: + continue + entry_price = float(pos.get('entryPrice', 0)) + mark_price = float(pos.get('markPrice', 0)) or entry_price + unrealized_pnl = float(pos.get('unRealizedProfit', 0)) + leverage = max(1, float(pos.get('leverage', 1))) + notional = abs(amt) * mark_price + margin = notional / leverage + pnl_percent = (unrealized_pnl / margin * 100) if margin > 0 else 0 + result.append({ + "symbol": pos.get("symbol"), + "mark_price": mark_price, + "pnl": unrealized_pnl, + "pnl_percent": pnl_percent, + }) + return result + except Exception as e: + logger.debug(f"fetch_live_positions_pnl(account_id={account_id}) 失败: {e}") + return [] + finally: + try: + if client is not None: + await client.disconnect() + except Exception: + pass + + @router.get("/positions") async def get_realtime_positions(account_id: int = Depends(get_account_id)): """获取实时持仓数据""" diff --git a/backend/api/routes/admin.py b/backend/api/routes/admin.py index 3bf8ac7..e0afcae 100644 --- a/backend/api/routes/admin.py +++ b/backend/api/routes/admin.py @@ -149,7 +149,13 @@ async def grant_user_account(user_id: int, account_id: int, payload: GrantReq, _ a = Account.get(int(account_id)) if not a: raise HTTPException(status_code=404, detail="账号不存在") - UserAccountMembership.add(int(user_id), int(account_id), role=payload.role) + try: + UserAccountMembership.add(int(user_id), int(account_id), role=payload.role) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"关联账号失败: {str(e)}", + ) return {"success": True} diff --git a/backend/api/routes/stats.py b/backend/api/routes/stats.py index b71b787..9753534 100644 --- a/backend/api/routes/stats.py +++ b/backend/api/routes/stats.py @@ -171,11 +171,27 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)): formatted_trade = { **trade, 'entry_value_usdt': entry_value_usdt, - 'mark_price': trade.get('entry_price', 0), # 数据库中没有标记价,使用入场价 + 'mark_price': trade.get('entry_price', 0), # 默认入场价,下面用实时数据覆盖 'pnl': pnl, - 'pnl_percent': pnl_percent # 使用重新计算的收益率 + 'pnl_percent': pnl_percent } open_trades.append(formatted_trade) + # 合并实时持仓盈亏(mark_price / pnl / pnl_percent),仪表板可显示浮盈浮亏 + try: + from api.routes.account import fetch_live_positions_pnl + live_list = await fetch_live_positions_pnl(account_id) + by_symbol = {p["symbol"]: p for p in live_list} + for t in open_trades: + sym = t.get("symbol") + if sym and sym in by_symbol: + lp = by_symbol[sym] + t["mark_price"] = lp.get("mark_price", t.get("entry_price")) + t["pnl"] = lp.get("pnl", 0) + t["pnl_percent"] = lp.get("pnl_percent", 0) + if by_symbol: + logger.info(f"已合并 {len(by_symbol)} 个实时持仓盈亏到仪表板") + except Exception as merge_err: + logger.debug(f"合并实时持仓盈亏失败(仪表板仍显示持仓,盈亏为 0): {merge_err}") logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓") except Exception as db_error: logger.error(f"从数据库获取持仓记录失败: {db_error}") diff --git a/frontend/src/components/AdminDashboard.css b/frontend/src/components/AdminDashboard.css index 74463b3..4eb34bf 100644 --- a/frontend/src/components/AdminDashboard.css +++ b/frontend/src/components/AdminDashboard.css @@ -136,6 +136,50 @@ margin-bottom: 40px; } +.users-section-grid { + display: grid; + grid-template-columns: 320px 1fr; + gap: 24px; + align-items: start; +} + +@media (max-width: 900px) { + .users-section-grid { + grid-template-columns: 1fr; + } +} + +.create-user-block { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border: 1px solid #e0e0e0; +} + +.create-user-title { + margin: 0 0 6px 0; + font-size: 1.1rem; + color: #333; +} + +.create-user-desc { + margin: 0 0 16px 0; + font-size: 13px; + color: #666; +} + +.create-user-card { + height: auto; + box-shadow: none; + padding: 0; + border: none; +} + +.create-user-card .create-user-card-heading { + display: none; +} + .user-group-card { background: white; border-radius: 8px; diff --git a/frontend/src/components/AdminDashboard.jsx b/frontend/src/components/AdminDashboard.jsx index fc8ba03..126c182 100644 --- a/frontend/src/components/AdminDashboard.jsx +++ b/frontend/src/components/AdminDashboard.jsx @@ -32,9 +32,14 @@ const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => { 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, linkAccountId, linkRole) + await api.grantUserAccount(user.id, accountId, linkRole) setLinkAccountId('') if (onServiceAction) onServiceAction(null, 'refresh') } catch (e) { @@ -141,7 +146,7 @@ const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => { + + ) +} + const AccountManager = ({ accounts, onRefresh }) => { const [newAccount, setNewAccount] = useState({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' }) const [credEditId, setCredEditId] = useState(null) @@ -561,15 +654,22 @@ const AdminDashboard = () => {

用户管理 ({users.length})

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

➕ 添加用户

+

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

+ +
+
+ {users.map(user => ( + + ))} +
diff --git a/frontend/src/components/StatsDashboard.css b/frontend/src/components/StatsDashboard.css index 4e721ec..51e743c 100644 --- a/frontend/src/components/StatsDashboard.css +++ b/frontend/src/components/StatsDashboard.css @@ -276,6 +276,16 @@ } } +.trade-index { + flex-shrink: 0; + width: 2rem; + min-width: 2rem; + font-size: 0.9rem; + color: #6c757d; + font-weight: 600; + text-align: center; +} + .trade-symbol { font-weight: bold; color: #2c3e50; diff --git a/frontend/src/components/StatsDashboard.jsx b/frontend/src/components/StatsDashboard.jsx index 1f1c205..34aa442 100644 --- a/frontend/src/components/StatsDashboard.jsx +++ b/frontend/src/components/StatsDashboard.jsx @@ -677,6 +677,7 @@ const StatsDashboard = () => { return (
+
{index + 1}
{trade.symbol}
{trade.side} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 29667b6..a3185e4 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -814,10 +814,12 @@ export const api = { return response.json(); }, grantUserAccount: async (userId, accountId, role = 'viewer') => { - const response = await fetch(buildUrl(`/api/admin/users/${userId}/accounts/${accountId}`), { + const uid = Number(userId); + const aid = Number(accountId); + const response = await fetch(buildUrl(`/api/admin/users/${uid}/accounts/${aid}`), { method: 'PUT', headers: withAuthHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ role }), + body: JSON.stringify({ role: role === 'owner' ? 'owner' : 'viewer' }), }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: '授权失败' }));