feat: Add System Overview tab to User Management page (admin only)

- Add backend API endpoint /api/users/system-strategies for system-wide strategy data
- Query all strategies across all users with positions, PnL, trade stats
- Add summary statistics (total strategies, running count, total capital, total PnL/ROI)
- Support filtering by status (running/stopped) and search by strategy/symbol/user
- Add System Overview tab with summary cards and detailed strategy table
- Display user, strategy name, status, symbol, capital, PnL/ROI, positions, trades, indicator, exchange, timeframe, leverage
- Add i18n translations for zh-CN and en-US
- Lazy-load strategy data when tab is first accessed
This commit is contained in:
TIANHE
2026-02-13 02:46:59 +08:00
parent f0a5af595d
commit 4c73439bd6
5 changed files with 953 additions and 103 deletions
+264
View File
@@ -4,9 +4,11 @@ User Management API Routes
Provides endpoints for user CRUD operations, role management, etc.
Only accessible by admin users.
"""
import json
from flask import Blueprint, request, jsonify, g
from app.services.user_service import get_user_service
from app.utils.auth import login_required, admin_required
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
@@ -749,3 +751,265 @@ def change_password():
except Exception as e:
logger.error(f"change_password failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# ==================== System Overview (Admin) ====================
def _safe_json_loads(s, default=None):
"""Safely parse JSON string."""
if not s:
return default
if isinstance(s, dict):
return s
try:
return json.loads(s)
except Exception:
return default
@user_bp.route('/system-strategies', methods=['GET'])
@login_required
@admin_required
def get_system_strategies():
"""
Get all strategies across the entire system (admin only).
Returns strategy details with user info, positions, PnL, indicators, etc.
Query params:
page: int (default 1)
page_size: int (default 20, max 100)
status: str (optional, filter by status: running/stopped/all)
search: str (optional, search by strategy name/symbol/username)
"""
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
status_filter = request.args.get('status', '', type=str).strip().lower()
search = request.args.get('search', '', type=str).strip()
page_size = min(100, max(1, page_size))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# Build WHERE clause
conditions = []
params = []
if status_filter and status_filter != 'all':
conditions.append("s.status = ?")
params.append(status_filter)
if search:
conditions.append(
"(s.strategy_name ILIKE ? OR s.symbol ILIKE ? OR u.username ILIKE ? OR u.nickname ILIKE ?)"
)
like_val = f"%{search}%"
params.extend([like_val, like_val, like_val, like_val])
where_clause = ""
if conditions:
where_clause = "WHERE " + " AND ".join(conditions)
# Get total count
count_sql = f"""
SELECT COUNT(*) as cnt
FROM qd_strategies_trading s
LEFT JOIN qd_users u ON u.id = s.user_id
{where_clause}
"""
cur.execute(count_sql, tuple(params))
total = cur.fetchone()['cnt']
# Get strategies with user info
query_sql = f"""
SELECT
s.id,
s.user_id,
s.strategy_name,
s.strategy_type,
s.market_category,
s.execution_mode,
s.status,
s.symbol,
s.timeframe,
s.initial_capital,
s.leverage,
s.market_type,
s.indicator_config,
s.trading_config,
s.exchange_config,
s.decide_interval,
s.created_at,
s.updated_at,
u.username,
u.nickname
FROM qd_strategies_trading s
LEFT JOIN qd_users u ON u.id = s.user_id
{where_clause}
ORDER BY s.status DESC, s.updated_at DESC
LIMIT ? OFFSET ?
"""
cur.execute(query_sql, tuple(params) + (page_size, offset))
strategies = cur.fetchall() or []
# Collect strategy IDs
strategy_ids = [s['id'] for s in strategies]
# Batch load positions for these strategies
positions_map = {}
if strategy_ids:
placeholders = ','.join(['?'] * len(strategy_ids))
cur.execute(
f"""
SELECT strategy_id, symbol, side, size, entry_price, current_price,
unrealized_pnl, pnl_percent, equity, updated_at
FROM qd_strategy_positions
WHERE strategy_id IN ({placeholders})
ORDER BY strategy_id, updated_at DESC
""",
tuple(strategy_ids)
)
for pos in (cur.fetchall() or []):
sid = pos['strategy_id']
if sid not in positions_map:
positions_map[sid] = []
positions_map[sid].append(dict(pos))
# Batch load recent trade stats (realized PnL per strategy)
trade_stats_map = {}
if strategy_ids:
placeholders = ','.join(['?'] * len(strategy_ids))
cur.execute(
f"""
SELECT strategy_id,
COUNT(*) as trade_count,
COALESCE(SUM(profit), 0) as total_realized_pnl
FROM qd_strategy_trades
WHERE strategy_id IN ({placeholders})
GROUP BY strategy_id
""",
tuple(strategy_ids)
)
for row in (cur.fetchall() or []):
trade_stats_map[row['strategy_id']] = {
'trade_count': row['trade_count'],
'total_realized_pnl': float(row['total_realized_pnl'] or 0)
}
cur.close()
# Build response
items = []
for s in strategies:
sid = s['id']
indicator_config = _safe_json_loads(s.get('indicator_config'), {})
trading_config = _safe_json_loads(s.get('trading_config'), {})
exchange_config = _safe_json_loads(s.get('exchange_config'), {})
# Extract indicator name
indicator_name = ''
if isinstance(indicator_config, dict):
indicator_name = indicator_config.get('indicator_name') or indicator_config.get('name') or ''
# Extract exchange name
exchange_name = ''
if isinstance(exchange_config, dict):
exchange_name = exchange_config.get('exchange_id') or exchange_config.get('exchange') or ''
# Positions data
positions = positions_map.get(sid, [])
total_unrealized_pnl = sum(float(p.get('unrealized_pnl') or 0) for p in positions)
total_equity = sum(float(p.get('equity') or 0) for p in positions)
position_count = len(positions)
# Trade stats
trade_stats = trade_stats_map.get(sid, {'trade_count': 0, 'total_realized_pnl': 0})
total_realized_pnl = trade_stats['total_realized_pnl']
trade_count = trade_stats['trade_count']
# Calculate total PnL and ROI
initial_capital = float(s.get('initial_capital') or 0)
total_pnl = total_unrealized_pnl + total_realized_pnl
roi = (total_pnl / initial_capital * 100) if initial_capital > 0 else 0
# Cross-sectional info
cs_type = ''
symbol_list = []
if isinstance(trading_config, dict):
cs_type = trading_config.get('cs_strategy_type') or 'single'
symbol_list = trading_config.get('symbol_list') or []
# Format timestamps
created_at = s.get('created_at')
updated_at = s.get('updated_at')
if hasattr(created_at, 'isoformat'):
created_at = created_at.isoformat()
if hasattr(updated_at, 'isoformat'):
updated_at = updated_at.isoformat()
# Format position timestamps
for p in positions:
if hasattr(p.get('updated_at'), 'isoformat'):
p['updated_at'] = p['updated_at'].isoformat()
items.append({
'id': sid,
'user_id': s['user_id'],
'username': s.get('username') or '',
'nickname': s.get('nickname') or '',
'strategy_name': s.get('strategy_name') or '',
'strategy_type': s.get('strategy_type') or '',
'cs_strategy_type': cs_type,
'market_category': s.get('market_category') or '',
'execution_mode': s.get('execution_mode') or '',
'status': s.get('status') or 'stopped',
'symbol': s.get('symbol') or '',
'symbol_list': symbol_list,
'timeframe': s.get('timeframe') or '',
'initial_capital': initial_capital,
'leverage': int(s.get('leverage') or 1),
'market_type': s.get('market_type') or '',
'indicator_name': indicator_name,
'exchange_name': exchange_name,
'decide_interval': s.get('decide_interval') or 300,
'position_count': position_count,
'total_unrealized_pnl': round(total_unrealized_pnl, 4),
'total_realized_pnl': round(total_realized_pnl, 4),
'total_pnl': round(total_pnl, 4),
'total_equity': round(total_equity, 4),
'roi': round(roi, 2),
'trade_count': trade_count,
'positions': positions,
'created_at': created_at,
'updated_at': updated_at
})
# Compute summary stats
all_running = [i for i in items if i['status'] == 'running']
total_capital = sum(i['initial_capital'] for i in items)
total_system_pnl = sum(i['total_pnl'] for i in items)
total_running = len(all_running)
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'summary': {
'total_strategies': total,
'running_strategies': total_running,
'total_capital': round(total_capital, 2),
'total_pnl': round(total_system_pnl, 4),
'total_roi': round((total_system_pnl / total_capital * 100) if total_capital > 0 else 0, 2)
}
}
})
except Exception as e:
logger.error(f"get_system_strategies failed: {e}")
import traceback
logger.error(traceback.format_exc())
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
+12
View File
@@ -208,3 +208,15 @@ export function getUserCreditsLog (params) {
params
})
}
/**
* Get system-wide strategy overview (admin only)
* @param {Object} params - { page, page_size, status, search }
*/
export function getSystemStrategies (params) {
return request({
url: '/api/users/system-strategies',
method: 'get',
params
})
}
+30
View File
@@ -2561,6 +2561,36 @@ const locale = {
'userManage.selectDate': 'Please select a date',
'userManage.remark': 'Remark',
'userManage.remarkPlaceholder': 'Optional remark',
'userManage.tabUsers': 'User Management',
// System Overview (Admin)
'systemOverview.tabTitle': 'System Overview',
'systemOverview.totalStrategies': 'Total Strategies',
'systemOverview.runningStrategies': 'Running',
'systemOverview.totalCapital': 'Total Capital',
'systemOverview.totalPnl': 'Total PnL',
'systemOverview.filterAll': 'All Status',
'systemOverview.filterRunning': 'Running',
'systemOverview.filterStopped': 'Stopped',
'systemOverview.searchPlaceholder': 'Search strategy/symbol/user',
'systemOverview.running': 'Running',
'systemOverview.stopped': 'Stopped',
'systemOverview.colUser': 'User',
'systemOverview.colStrategy': 'Strategy',
'systemOverview.colStatus': 'Status',
'systemOverview.colSymbol': 'Symbol',
'systemOverview.colCapital': 'Capital',
'systemOverview.colPnl': 'PnL / ROI',
'systemOverview.colPositions': 'Pos',
'systemOverview.colTrades': 'Trades',
'systemOverview.colIndicator': 'Indicator',
'systemOverview.colExchange': 'Exchange',
'systemOverview.colTimeframe': 'TF',
'systemOverview.colLeverage': 'Lev',
'systemOverview.colCreatedAt': 'Created',
'systemOverview.realized': 'Real',
'systemOverview.unrealized': 'Unreal',
'systemOverview.symbols': 'symbols',
// Settings - Billing
'settings.group.billing': 'Billing & Credits',
+30
View File
@@ -2370,6 +2370,36 @@ const locale = {
'userManage.selectDate': '请选择日期',
'userManage.remark': '备注',
'userManage.remarkPlaceholder': '可选备注',
'userManage.tabUsers': '用户管理',
// System Overview (Admin)
'systemOverview.tabTitle': '系统总览',
'systemOverview.totalStrategies': '策略总数',
'systemOverview.runningStrategies': '运行中',
'systemOverview.totalCapital': '总资金',
'systemOverview.totalPnl': '总盈亏',
'systemOverview.filterAll': '全部状态',
'systemOverview.filterRunning': '运行中',
'systemOverview.filterStopped': '已停止',
'systemOverview.searchPlaceholder': '搜索策略名/交易对/用户名',
'systemOverview.running': '运行中',
'systemOverview.stopped': '已停止',
'systemOverview.colUser': '用户',
'systemOverview.colStrategy': '策略名称',
'systemOverview.colStatus': '状态',
'systemOverview.colSymbol': '交易对',
'systemOverview.colCapital': '资金',
'systemOverview.colPnl': '盈亏 / ROI',
'systemOverview.colPositions': '持仓',
'systemOverview.colTrades': '交易次数',
'systemOverview.colIndicator': '指标',
'systemOverview.colExchange': '交易所',
'systemOverview.colTimeframe': '周期',
'systemOverview.colLeverage': '杠杆',
'systemOverview.colCreatedAt': '创建时间',
'systemOverview.realized': '已实现',
'systemOverview.unrealized': '未实现',
'systemOverview.symbols': '个交易对',
// Settings - Billing
'settings.group.billing': '计费配置',
+617 -103
View File
@@ -8,113 +8,289 @@
<p class="page-desc">{{ $t('userManage.description') || 'Manage system users, roles and permissions' }}</p>
</div>
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<a-button type="primary" @click="showCreateModal">
<a-icon type="user-add" />
{{ $t('userManage.createUser') || 'Create User' }}
</a-button>
<a-button @click="loadUsers">
<a-icon type="reload" />
{{ $t('common.refresh') || 'Refresh' }}
</a-button>
</div>
<div class="toolbar-right">
<a-input-search
v-model="searchKeyword"
:placeholder="$t('userManage.searchPlaceholder') || 'Search by username/email'"
style="width: 280px"
allowClear
@search="handleSearch"
@pressEnter="handleSearch"
/>
</div>
</div>
<!-- Tabs -->
<a-tabs v-model="activeTab" @change="handleTabChange" class="manage-tabs">
<!-- Tab 1: User Management -->
<a-tab-pane key="users" :tab="$t('userManage.tabUsers') || 'User Management'">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<a-button type="primary" @click="showCreateModal">
<a-icon type="user-add" />
{{ $t('userManage.createUser') || 'Create User' }}
</a-button>
<a-button @click="loadUsers">
<a-icon type="reload" />
{{ $t('common.refresh') || 'Refresh' }}
</a-button>
</div>
<div class="toolbar-right">
<a-input-search
v-model="searchKeyword"
:placeholder="$t('userManage.searchPlaceholder') || 'Search by username/email'"
style="width: 280px"
allowClear
@search="handleSearch"
@pressEnter="handleSearch"
/>
</div>
</div>
<!-- User Table -->
<a-card :bordered="false" class="user-table-card">
<a-table
:columns="columns"
:dataSource="users"
:loading="loading"
:pagination="pagination"
:rowKey="record => record.id"
@change="handleTableChange"
>
<!-- Status Column -->
<template slot="status" slot-scope="text">
<a-tag :color="text === 'active' ? 'green' : 'red'">
{{ text === 'active' ? ($t('userManage.active') || 'Active') : ($t('userManage.disabled') || 'Disabled') }}
</a-tag>
</template>
<!-- User Table -->
<a-card :bordered="false" class="user-table-card">
<a-table
:columns="columns"
:dataSource="users"
:loading="loading"
:pagination="pagination"
:rowKey="record => record.id"
@change="handleTableChange"
>
<!-- Status Column -->
<template slot="status" slot-scope="text">
<a-tag :color="text === 'active' ? 'green' : 'red'">
{{ text === 'active' ? ($t('userManage.active') || 'Active') : ($t('userManage.disabled') || 'Disabled') }}
</a-tag>
</template>
<!-- Role Column -->
<template slot="role" slot-scope="text">
<a-tag :color="getRoleColor(text)">
{{ getRoleLabel(text) }}
</a-tag>
</template>
<!-- Role Column -->
<template slot="role" slot-scope="text">
<a-tag :color="getRoleColor(text)">
{{ getRoleLabel(text) }}
</a-tag>
</template>
<!-- Last Login Column -->
<template slot="last_login_at" slot-scope="text">
<span v-if="text">{{ formatTime(text) }}</span>
<span v-else class="text-muted">{{ $t('userManage.neverLogin') || 'Never' }}</span>
</template>
<!-- Last Login Column -->
<template slot="last_login_at" slot-scope="text">
<span v-if="text">{{ formatTime(text) }}</span>
<span v-else class="text-muted">{{ $t('userManage.neverLogin') || 'Never' }}</span>
</template>
<!-- Credits Column -->
<template slot="credits" slot-scope="text">
<span class="credits-value">{{ formatCredits(text) }}</span>
</template>
<!-- Credits Column -->
<template slot="credits" slot-scope="text">
<span class="credits-value">{{ formatCredits(text) }}</span>
</template>
<!-- VIP Column -->
<template slot="vip_expires_at" slot-scope="text">
<template v-if="text && isVipActive(text)">
<a-tag color="gold">
<a-icon type="crown" />
{{ formatDate(text) }}
</a-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
<!-- VIP Column -->
<template slot="vip_expires_at" slot-scope="text">
<template v-if="text && isVipActive(text)">
<a-tag color="gold">
<a-icon type="crown" />
{{ formatDate(text) }}
</a-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
<!-- Actions Column -->
<template slot="action" slot-scope="text, record">
<a-space>
<a-tooltip :title="$t('common.edit') || 'Edit'">
<a-button type="link" size="small" @click="showEditModal(record)">
<a-icon type="edit" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('userManage.adjustCredits') || 'Adjust Credits'">
<a-button type="link" size="small" @click="showCreditsModal(record)">
<a-icon type="wallet" style="color: #722ed1" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('userManage.setVip') || 'Set VIP'">
<a-button type="link" size="small" @click="showVipModal(record)">
<a-icon type="crown" style="color: #faad14" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('userManage.resetPassword') || 'Reset Password'">
<a-button type="link" size="small" @click="showResetPasswordModal(record)">
<a-icon type="key" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('common.delete') || 'Delete'">
<a-popconfirm
:title="$t('userManage.confirmDelete') || 'Are you sure to delete this user?'"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" :disabled="record.id === currentUserId">
<a-icon type="delete" style="color: #ff4d4f" />
</a-button>
</a-popconfirm>
</a-tooltip>
</a-space>
</template>
</a-table>
</a-card>
<!-- Actions Column -->
<template slot="action" slot-scope="text, record">
<a-space>
<a-tooltip :title="$t('common.edit') || 'Edit'">
<a-button type="link" size="small" @click="showEditModal(record)">
<a-icon type="edit" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('userManage.adjustCredits') || 'Adjust Credits'">
<a-button type="link" size="small" @click="showCreditsModal(record)">
<a-icon type="wallet" style="color: #722ed1" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('userManage.setVip') || 'Set VIP'">
<a-button type="link" size="small" @click="showVipModal(record)">
<a-icon type="crown" style="color: #faad14" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('userManage.resetPassword') || 'Reset Password'">
<a-button type="link" size="small" @click="showResetPasswordModal(record)">
<a-icon type="key" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('common.delete') || 'Delete'">
<a-popconfirm
:title="$t('userManage.confirmDelete') || 'Are you sure to delete this user?'"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" :disabled="record.id === currentUserId">
<a-icon type="delete" style="color: #ff4d4f" />
</a-button>
</a-popconfirm>
</a-tooltip>
</a-space>
</template>
</a-table>
</a-card>
</a-tab-pane>
<!-- Tab 2: System Strategy Overview -->
<a-tab-pane key="strategies" :tab="$t('systemOverview.tabTitle') || 'System Overview'">
<!-- Summary Cards -->
<div class="summary-cards" v-if="strategySummary">
<div class="summary-card">
<div class="summary-icon" style="background: linear-gradient(135deg, #667eea, #764ba2)">
<a-icon type="fund" />
</div>
<div class="summary-info">
<div class="summary-value">{{ strategySummary.total_strategies || 0 }}</div>
<div class="summary-label">{{ $t('systemOverview.totalStrategies') || 'Total Strategies' }}</div>
</div>
</div>
<div class="summary-card">
<div class="summary-icon" style="background: linear-gradient(135deg, #11998e, #38ef7d)">
<a-icon type="play-circle" />
</div>
<div class="summary-info">
<div class="summary-value">{{ strategySummary.running_strategies || 0 }}</div>
<div class="summary-label">{{ $t('systemOverview.runningStrategies') || 'Running' }}</div>
</div>
</div>
<div class="summary-card">
<div class="summary-icon" style="background: linear-gradient(135deg, #f093fb, #f5576c)">
<a-icon type="dollar" />
</div>
<div class="summary-info">
<div class="summary-value">{{ formatNumber(strategySummary.total_capital) }}</div>
<div class="summary-label">{{ $t('systemOverview.totalCapital') || 'Total Capital' }}</div>
</div>
</div>
<div class="summary-card">
<div class="summary-icon" :style="{ background: (strategySummary.total_pnl || 0) >= 0 ? 'linear-gradient(135deg, #11998e, #38ef7d)' : 'linear-gradient(135deg, #ff416c, #ff4b2b)' }">
<a-icon type="rise" />
</div>
<div class="summary-info">
<div class="summary-value" :class="(strategySummary.total_pnl || 0) >= 0 ? 'text-profit' : 'text-loss'">
{{ formatPnl(strategySummary.total_pnl) }}
<span class="roi-badge">{{ strategySummary.total_roi || 0 }}%</span>
</div>
<div class="summary-label">{{ $t('systemOverview.totalPnl') || 'Total PnL' }}</div>
</div>
</div>
</div>
<!-- Strategy Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<a-button @click="loadSystemStrategies">
<a-icon type="reload" />
{{ $t('common.refresh') || 'Refresh' }}
</a-button>
<a-select v-model="strategyStatusFilter" style="width: 140px" @change="handleStrategyFilterChange">
<a-select-option value="all">{{ $t('systemOverview.filterAll') || 'All Status' }}</a-select-option>
<a-select-option value="running">{{ $t('systemOverview.filterRunning') || 'Running' }}</a-select-option>
<a-select-option value="stopped">{{ $t('systemOverview.filterStopped') || 'Stopped' }}</a-select-option>
</a-select>
</div>
<div class="toolbar-right">
<a-input-search
v-model="strategySearchKeyword"
:placeholder="$t('systemOverview.searchPlaceholder') || 'Search strategy/symbol/user'"
style="width: 280px"
allowClear
@search="handleStrategySearch"
@pressEnter="handleStrategySearch"
/>
</div>
</div>
<!-- Strategy Table -->
<a-card :bordered="false" class="user-table-card">
<a-table
:columns="strategyColumns"
:dataSource="systemStrategies"
:loading="strategyLoading"
:pagination="strategyPagination"
:rowKey="record => record.id"
:scroll="{ x: 1600 }"
@change="handleStrategyTableChange"
>
<!-- Strategy Status -->
<template slot="strategyStatus" slot-scope="text">
<a-badge :status="text === 'running' ? 'processing' : 'default'" />
<a-tag :color="text === 'running' ? 'green' : 'default'" size="small">
{{ text === 'running' ? ($t('systemOverview.running') || 'Running') : ($t('systemOverview.stopped') || 'Stopped') }}
</a-tag>
</template>
<!-- User Column -->
<template slot="userInfo" slot-scope="text, record">
<span class="user-cell">
<a-icon type="user" style="margin-right: 4px; color: #1890ff" />
{{ record.nickname || record.username || '-' }}
</span>
</template>
<!-- Symbol Column -->
<template slot="symbolInfo" slot-scope="text, record">
<div>
<span class="symbol-text">{{ record.symbol || '-' }}</span>
<a-tag v-if="record.cs_strategy_type === 'cross_sectional'" color="purple" size="small" style="margin-left: 4px">CS</a-tag>
</div>
<div v-if="record.cs_strategy_type === 'cross_sectional' && record.symbol_list && record.symbol_list.length" class="symbol-count text-muted">
{{ record.symbol_list.length }} {{ $t('systemOverview.symbols') || 'symbols' }}
</div>
</template>
<!-- Capital Column -->
<template slot="capitalInfo" slot-scope="text">
<span>{{ formatNumber(text) }}</span>
</template>
<!-- PnL Column -->
<template slot="pnlInfo" slot-scope="text, record">
<div :class="record.total_pnl >= 0 ? 'text-profit' : 'text-loss'">
<span class="pnl-value">{{ formatPnl(record.total_pnl) }}</span>
<span class="roi-text">({{ record.roi >= 0 ? '+' : '' }}{{ record.roi }}%)</span>
</div>
<div class="pnl-detail text-muted">
<span>{{ $t('systemOverview.realized') || 'Real' }}: {{ formatPnl(record.total_realized_pnl) }}</span>
<span style="margin-left: 8px">{{ $t('systemOverview.unrealized') || 'Unreal' }}: {{ formatPnl(record.total_unrealized_pnl) }}</span>
</div>
</template>
<!-- Positions Column -->
<template slot="positionInfo" slot-scope="text, record">
<a-badge :count="record.position_count" :numberStyle="{ backgroundColor: record.position_count > 0 ? '#52c41a' : '#d9d9d9' }" />
</template>
<!-- Trades Column -->
<template slot="tradeInfo" slot-scope="text">
<span>{{ text || 0 }}</span>
</template>
<!-- Indicator Column -->
<template slot="indicatorInfo" slot-scope="text">
<a-tooltip v-if="text" :title="text">
<span class="indicator-name">{{ truncate(text, 16) }}</span>
</a-tooltip>
<span v-else class="text-muted">-</span>
</template>
<!-- Exchange Column -->
<template slot="exchangeInfo" slot-scope="text">
<span v-if="text" class="exchange-name">{{ text }}</span>
<span v-else class="text-muted">-</span>
</template>
<!-- Timeframe Column -->
<template slot="timeframeInfo" slot-scope="text">
<a-tag v-if="text" size="small">{{ text }}</a-tag>
<span v-else class="text-muted">-</span>
</template>
<!-- Leverage Column -->
<template slot="leverageInfo" slot-scope="text">
<span v-if="text > 1" style="color: #fa8c16; font-weight: 600">{{ text }}x</span>
<span v-else>{{ text || 1 }}x</span>
</template>
<!-- Created At Column -->
<template slot="createdAtInfo" slot-scope="text">
<span v-if="text">{{ formatTime(text) }}</span>
<span v-else class="text-muted">-</span>
</template>
</a-table>
</a-card>
</a-tab-pane>
</a-tabs>
<!-- Create/Edit User Modal -->
<a-modal
@@ -301,7 +477,7 @@
</template>
<script>
import { getUserList, createUser, updateUser, deleteUser, resetUserPassword, getRoles, setUserCredits, setUserVip } from '@/api/user'
import { getUserList, createUser, updateUser, deleteUser, resetUserPassword, getRoles, setUserCredits, setUserVip, getSystemStrategies } from '@/api/user'
import { baseMixin } from '@/store/app-mixin'
import { mapGetters } from 'vuex'
@@ -310,6 +486,7 @@ export default {
mixins: [baseMixin],
data () {
return {
activeTab: 'users',
loading: false,
users: [],
roles: [],
@@ -340,7 +517,19 @@ export default {
vipEditingUser: null,
vipDays: 30,
vipCustomDate: null,
vipRemark: ''
vipRemark: '',
// System Strategy Overview
strategyLoading: false,
systemStrategies: [],
strategySummary: null,
strategyStatusFilter: 'all',
strategySearchKeyword: '',
strategyPagination: {
current: 1,
pageSize: 20,
total: 0
},
strategiesLoaded: false
}
},
computed: {
@@ -410,6 +599,99 @@ export default {
scopedSlots: { customRender: 'action' }
}
]
},
strategyColumns () {
return [
{
title: 'ID',
dataIndex: 'id',
width: 60,
fixed: 'left'
},
{
title: this.$t('systemOverview.colUser') || 'User',
dataIndex: 'username',
width: 110,
fixed: 'left',
scopedSlots: { customRender: 'userInfo' }
},
{
title: this.$t('systemOverview.colStrategy') || 'Strategy',
dataIndex: 'strategy_name',
width: 160,
ellipsis: true
},
{
title: this.$t('systemOverview.colStatus') || 'Status',
dataIndex: 'status',
width: 100,
scopedSlots: { customRender: 'strategyStatus' }
},
{
title: this.$t('systemOverview.colSymbol') || 'Symbol',
dataIndex: 'symbol',
width: 140,
scopedSlots: { customRender: 'symbolInfo' }
},
{
title: this.$t('systemOverview.colCapital') || 'Capital',
dataIndex: 'initial_capital',
width: 110,
scopedSlots: { customRender: 'capitalInfo' }
},
{
title: this.$t('systemOverview.colPnl') || 'PnL / ROI',
dataIndex: 'total_pnl',
width: 200,
scopedSlots: { customRender: 'pnlInfo' }
},
{
title: this.$t('systemOverview.colPositions') || 'Pos',
dataIndex: 'position_count',
width: 70,
align: 'center',
scopedSlots: { customRender: 'positionInfo' }
},
{
title: this.$t('systemOverview.colTrades') || 'Trades',
dataIndex: 'trade_count',
width: 80,
align: 'center',
scopedSlots: { customRender: 'tradeInfo' }
},
{
title: this.$t('systemOverview.colIndicator') || 'Indicator',
dataIndex: 'indicator_name',
width: 130,
scopedSlots: { customRender: 'indicatorInfo' }
},
{
title: this.$t('systemOverview.colExchange') || 'Exchange',
dataIndex: 'exchange_name',
width: 100,
scopedSlots: { customRender: 'exchangeInfo' }
},
{
title: this.$t('systemOverview.colTimeframe') || 'TF',
dataIndex: 'timeframe',
width: 70,
align: 'center',
scopedSlots: { customRender: 'timeframeInfo' }
},
{
title: this.$t('systemOverview.colLeverage') || 'Lev',
dataIndex: 'leverage',
width: 70,
align: 'center',
scopedSlots: { customRender: 'leverageInfo' }
},
{
title: this.$t('systemOverview.colCreatedAt') || 'Created',
dataIndex: 'created_at',
width: 150,
scopedSlots: { customRender: 'createdAtInfo' }
}
]
}
},
beforeCreate () {
@@ -421,6 +703,72 @@ export default {
this.loadRoles()
},
methods: {
handleTabChange (key) {
if (key === 'strategies' && !this.strategiesLoaded) {
this.loadSystemStrategies()
}
},
// ==================== System Strategy Overview ====================
async loadSystemStrategies () {
this.strategyLoading = true
try {
const res = await getSystemStrategies({
page: this.strategyPagination.current,
page_size: this.strategyPagination.pageSize,
status: this.strategyStatusFilter === 'all' ? '' : this.strategyStatusFilter,
search: this.strategySearchKeyword || ''
})
if (res.code === 1) {
this.systemStrategies = res.data.items || []
this.strategyPagination.total = res.data.total || 0
this.strategySummary = res.data.summary || {}
this.strategiesLoaded = true
} else {
this.$message.error(res.msg || 'Failed to load strategies')
}
} catch (error) {
console.error('Failed to load system strategies:', error)
this.$message.error('Failed to load system strategies')
} finally {
this.strategyLoading = false
}
},
handleStrategySearch () {
this.strategyPagination.current = 1
this.loadSystemStrategies()
},
handleStrategyFilterChange () {
this.strategyPagination.current = 1
this.loadSystemStrategies()
},
handleStrategyTableChange (pagination) {
this.strategyPagination.current = pagination.current
this.strategyPagination.pageSize = pagination.pageSize
this.loadSystemStrategies()
},
formatNumber (num) {
if (!num && num !== 0) return '0'
return Number(num).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
},
formatPnl (pnl) {
if (!pnl && pnl !== 0) return '0'
const val = Number(pnl)
const prefix = val >= 0 ? '+' : ''
return prefix + val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 })
},
truncate (str, maxLen) {
if (!str) return ''
return str.length > maxLen ? str.substring(0, maxLen) + '...' : str
},
// ==================== User Management ====================
async loadUsers () {
this.loading = true
try {
@@ -729,6 +1077,78 @@ export default {
}
}
.manage-tabs {
/deep/ .ant-tabs-bar {
margin-bottom: 20px;
}
}
// Summary Cards
.summary-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
.summary-card {
background: #fff;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.summary-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.anticon {
font-size: 22px;
color: #fff;
}
}
.summary-info {
flex: 1;
min-width: 0;
.summary-value {
font-size: 22px;
font-weight: 700;
color: #1e293b;
line-height: 1.3;
.roi-badge {
font-size: 13px;
font-weight: 600;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.04);
}
}
.summary-label {
font-size: 13px;
color: #94a3b8;
margin-top: 2px;
}
}
}
}
.toolbar {
margin-bottom: 16px;
display: flex;
@@ -755,6 +1175,54 @@ export default {
}
}
// PnL colors
.text-profit {
color: #52c41a;
font-weight: 600;
}
.text-loss {
color: #ff4d4f;
font-weight: 600;
}
.pnl-value {
font-size: 14px;
}
.roi-text {
font-size: 12px;
margin-left: 4px;
}
.pnl-detail {
font-size: 11px;
margin-top: 2px;
}
.symbol-text {
font-weight: 500;
}
.symbol-count {
font-size: 11px;
margin-top: 2px;
}
.user-cell {
font-size: 13px;
}
.indicator-name {
color: #722ed1;
font-size: 12px;
}
.exchange-name {
font-size: 12px;
text-transform: capitalize;
}
// Dark theme
&.theme-dark {
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
@@ -768,6 +1236,39 @@ export default {
}
}
.manage-tabs {
/deep/ .ant-tabs-bar {
border-bottom-color: #30363d;
}
/deep/ .ant-tabs-tab {
color: #8b949e;
&:hover {
color: #c9d1d9;
}
}
/deep/ .ant-tabs-tab-active {
color: @primary-color;
}
}
.summary-cards .summary-card {
background: #1e222d;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
.summary-info {
.summary-value {
color: #e0e6ed;
.roi-badge {
background: rgba(255, 255, 255, 0.08);
}
}
.summary-label {
color: #6e7681;
}
}
}
.user-table-card {
background: #1e222d;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
@@ -837,4 +1338,17 @@ export default {
}
}
}
// Responsive
@media (max-width: 1200px) {
.summary-cards {
grid-template-columns: repeat(2, 1fr) !important;
}
}
@media (max-width: 768px) {
.summary-cards {
grid-template-columns: 1fr !important;
}
}
</style>