1
This commit is contained in:
parent
ca959c1f8a
commit
ca0bbeddbf
|
|
@ -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)):
|
||||
"""获取实时持仓数据"""
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
<td>
|
||||
<button
|
||||
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 ? '停止服务' : '启动服务'}
|
||||
>
|
||||
{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 [newAccount, setNewAccount] = useState({ name: '', api_key: '', api_secret: '', use_testnet: false, status: 'active' })
|
||||
const [credEditId, setCredEditId] = useState(null)
|
||||
|
|
@ -561,15 +654,22 @@ const AdminDashboard = () => {
|
|||
|
||||
<div className="users-section">
|
||||
<h3>用户管理 ({users.length})</h3>
|
||||
<div className="users-list">
|
||||
{users.map(user => (
|
||||
<UserAccountGroup
|
||||
key={user.id}
|
||||
user={user}
|
||||
allAccounts={data.allAccounts}
|
||||
onServiceAction={handleServiceAction}
|
||||
/>
|
||||
))}
|
||||
<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={data.allAccounts}
|
||||
onServiceAction={handleServiceAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -677,6 +677,7 @@ const StatsDashboard = () => {
|
|||
|
||||
return (
|
||||
<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-side ${trade.side === 'BUY' ? 'buy' : 'sell'}`}>
|
||||
{trade.side}
|
||||
|
|
|
|||
|
|
@ -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: '授权失败' }));
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user