This commit is contained in:
薇薇安 2026-02-14 14:36:23 +08:00
parent ca959c1f8a
commit ca0bbeddbf
8 changed files with 244 additions and 16 deletions

View File

@ -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) 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") @router.get("/positions")
async def get_realtime_positions(account_id: int = Depends(get_account_id)): async def get_realtime_positions(account_id: int = Depends(get_account_id)):
"""获取实时持仓数据""" """获取实时持仓数据"""

View File

@ -149,7 +149,13 @@ async def grant_user_account(user_id: int, account_id: int, payload: GrantReq, _
a = Account.get(int(account_id)) a = Account.get(int(account_id))
if not a: if not a:
raise HTTPException(status_code=404, detail="账号不存在") 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} return {"success": True}

View File

@ -171,11 +171,27 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)):
formatted_trade = { formatted_trade = {
**trade, **trade,
'entry_value_usdt': entry_value_usdt, 'entry_value_usdt': entry_value_usdt,
'mark_price': trade.get('entry_price', 0), # 数据库中没有标记价,使用入场价 'mark_price': trade.get('entry_price', 0), # 默认入场价,下面用实时数据覆盖
'pnl': pnl, 'pnl': pnl,
'pnl_percent': pnl_percent # 使用重新计算的收益率 'pnl_percent': pnl_percent
} }
open_trades.append(formatted_trade) 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)} 个持仓") logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓")
except Exception as db_error: except Exception as db_error:
logger.error(f"从数据库获取持仓记录失败: {db_error}") logger.error(f"从数据库获取持仓记录失败: {db_error}")

View File

@ -136,6 +136,50 @@
margin-bottom: 40px; 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 { .user-group-card {
background: white; background: white;
border-radius: 8px; border-radius: 8px;

View File

@ -32,9 +32,14 @@ const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => {
const handleGrant = async () => { const handleGrant = async () => {
if (!linkAccountId) return if (!linkAccountId) return
const accountId = Number(linkAccountId)
if (!Number.isInteger(accountId) || accountId < 1) {
alert('请选择有效的账号')
return
}
setAssociating(true) setAssociating(true)
try { try {
await api.grantUserAccount(user.id, linkAccountId, linkRole) await api.grantUserAccount(user.id, accountId, linkRole)
setLinkAccountId('') setLinkAccountId('')
if (onServiceAction) onServiceAction(null, 'refresh') if (onServiceAction) onServiceAction(null, 'refresh')
} catch (e) { } catch (e) {
@ -141,7 +146,7 @@ const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => {
<td> <td>
<button <button
className="btn-icon" className="btn-icon"
onClick={() => handleServiceAction(acc.id, acc.serviceStatus?.running ? 'stop' : 'start')} onClick={() => onServiceAction && onServiceAction(acc.id, acc.serviceStatus?.running ? 'stop' : 'start')}
title={acc.serviceStatus?.running ? '停止服务' : '启动服务'} title={acc.serviceStatus?.running ? '停止服务' : '启动服务'}
> >
{acc.serviceStatus?.running ? '⏹' : '▶'} {acc.serviceStatus?.running ? '⏹' : '▶'}
@ -199,6 +204,94 @@ const UserAccountGroup = ({ user, allAccounts, onServiceAction }) => {
) )
} }
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 AccountManager = ({ accounts, onRefresh }) => {
const [newAccount, setNewAccount] = useState({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' }) const [newAccount, setNewAccount] = useState({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' })
const [credEditId, setCredEditId] = useState(null) const [credEditId, setCredEditId] = useState(null)
@ -561,15 +654,22 @@ const AdminDashboard = () => {
<div className="users-section"> <div className="users-section">
<h3>用户管理 ({users.length})</h3> <h3>用户管理 ({users.length})</h3>
<div className="users-list"> <div className="users-section-grid">
{users.map(user => ( <div className="create-user-block">
<UserAccountGroup <h4 className="create-user-title"> 添加用户</h4>
key={user.id} <p className="create-user-desc">创建新用户后可在下方为其关联交易账号</p>
user={user} <CreateUserForm onSuccess={loadData} />
allAccounts={data.allAccounts} </div>
onServiceAction={handleServiceAction} <div className="users-list">
/> {users.map(user => (
))} <UserAccountGroup
key={user.id}
user={user}
allAccounts={data.allAccounts}
onServiceAction={handleServiceAction}
/>
))}
</div>
</div> </div>
</div> </div>

View File

@ -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 { .trade-symbol {
font-weight: bold; font-weight: bold;
color: #2c3e50; color: #2c3e50;

View File

@ -677,6 +677,7 @@ const StatsDashboard = () => {
return ( return (
<div key={trade.id || trade.symbol || index} className="trade-item"> <div key={trade.id || trade.symbol || index} className="trade-item">
<div className="trade-index" title="序号">{index + 1}</div>
<div className="trade-symbol">{trade.symbol}</div> <div className="trade-symbol">{trade.symbol}</div>
<div className={`trade-side ${trade.side === 'BUY' ? 'buy' : 'sell'}`}> <div className={`trade-side ${trade.side === 'BUY' ? 'buy' : 'sell'}`}>
{trade.side} {trade.side}

View File

@ -814,10 +814,12 @@ export const api = {
return response.json(); return response.json();
}, },
grantUserAccount: async (userId, accountId, role = 'viewer') => { 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', method: 'PUT',
headers: withAuthHeaders({ 'Content-Type': 'application/json' }), headers: withAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ role }), body: JSON.stringify({ role: role === 'owner' ? 'owner' : 'viewer' }),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '授权失败' })); const error = await response.json().catch(() => ({ detail: '授权失败' }));