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. Provides endpoints for user CRUD operations, role management, etc.
Only accessible by admin users. Only accessible by admin users.
""" """
import json
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from app.services.user_service import get_user_service from app.services.user_service import get_user_service
from app.utils.auth import login_required, admin_required from app.utils.auth import login_required, admin_required
from app.utils.db import get_db_connection
from app.utils.logger import get_logger from app.utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -749,3 +751,265 @@ def change_password():
except Exception as e: except Exception as e:
logger.error(f"change_password failed: {e}") logger.error(f"change_password failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500 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 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.selectDate': 'Please select a date',
'userManage.remark': 'Remark', 'userManage.remark': 'Remark',
'userManage.remarkPlaceholder': 'Optional 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 - Billing
'settings.group.billing': 'Billing & Credits', 'settings.group.billing': 'Billing & Credits',
+30
View File
@@ -2370,6 +2370,36 @@ const locale = {
'userManage.selectDate': '请选择日期', 'userManage.selectDate': '请选择日期',
'userManage.remark': '备注', 'userManage.remark': '备注',
'userManage.remarkPlaceholder': '可选备注', '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 - Billing
'settings.group.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> <p class="page-desc">{{ $t('userManage.description') || 'Manage system users, roles and permissions' }}</p>
</div> </div>
<!-- Toolbar --> <!-- Tabs -->
<div class="toolbar"> <a-tabs v-model="activeTab" @change="handleTabChange" class="manage-tabs">
<div class="toolbar-left"> <!-- Tab 1: User Management -->
<a-button type="primary" @click="showCreateModal"> <a-tab-pane key="users" :tab="$t('userManage.tabUsers') || 'User Management'">
<a-icon type="user-add" /> <!-- Toolbar -->
{{ $t('userManage.createUser') || 'Create User' }} <div class="toolbar">
</a-button> <div class="toolbar-left">
<a-button @click="loadUsers"> <a-button type="primary" @click="showCreateModal">
<a-icon type="reload" /> <a-icon type="user-add" />
{{ $t('common.refresh') || 'Refresh' }} {{ $t('userManage.createUser') || 'Create User' }}
</a-button> </a-button>
</div> <a-button @click="loadUsers">
<div class="toolbar-right"> <a-icon type="reload" />
<a-input-search {{ $t('common.refresh') || 'Refresh' }}
v-model="searchKeyword" </a-button>
:placeholder="$t('userManage.searchPlaceholder') || 'Search by username/email'" </div>
style="width: 280px" <div class="toolbar-right">
allowClear <a-input-search
@search="handleSearch" v-model="searchKeyword"
@pressEnter="handleSearch" :placeholder="$t('userManage.searchPlaceholder') || 'Search by username/email'"
/> style="width: 280px"
</div> allowClear
</div> @search="handleSearch"
@pressEnter="handleSearch"
/>
</div>
</div>
<!-- User Table --> <!-- User Table -->
<a-card :bordered="false" class="user-table-card"> <a-card :bordered="false" class="user-table-card">
<a-table <a-table
:columns="columns" :columns="columns"
:dataSource="users" :dataSource="users"
:loading="loading" :loading="loading"
:pagination="pagination" :pagination="pagination"
:rowKey="record => record.id" :rowKey="record => record.id"
@change="handleTableChange" @change="handleTableChange"
> >
<!-- Status Column --> <!-- Status Column -->
<template slot="status" slot-scope="text"> <template slot="status" slot-scope="text">
<a-tag :color="text === 'active' ? 'green' : 'red'"> <a-tag :color="text === 'active' ? 'green' : 'red'">
{{ text === 'active' ? ($t('userManage.active') || 'Active') : ($t('userManage.disabled') || 'Disabled') }} {{ text === 'active' ? ($t('userManage.active') || 'Active') : ($t('userManage.disabled') || 'Disabled') }}
</a-tag> </a-tag>
</template> </template>
<!-- Role Column --> <!-- Role Column -->
<template slot="role" slot-scope="text"> <template slot="role" slot-scope="text">
<a-tag :color="getRoleColor(text)"> <a-tag :color="getRoleColor(text)">
{{ getRoleLabel(text) }} {{ getRoleLabel(text) }}
</a-tag> </a-tag>
</template> </template>
<!-- Last Login Column --> <!-- Last Login Column -->
<template slot="last_login_at" slot-scope="text"> <template slot="last_login_at" slot-scope="text">
<span v-if="text">{{ formatTime(text) }}</span> <span v-if="text">{{ formatTime(text) }}</span>
<span v-else class="text-muted">{{ $t('userManage.neverLogin') || 'Never' }}</span> <span v-else class="text-muted">{{ $t('userManage.neverLogin') || 'Never' }}</span>
</template> </template>
<!-- Credits Column --> <!-- Credits Column -->
<template slot="credits" slot-scope="text"> <template slot="credits" slot-scope="text">
<span class="credits-value">{{ formatCredits(text) }}</span> <span class="credits-value">{{ formatCredits(text) }}</span>
</template> </template>
<!-- VIP Column --> <!-- VIP Column -->
<template slot="vip_expires_at" slot-scope="text"> <template slot="vip_expires_at" slot-scope="text">
<template v-if="text && isVipActive(text)"> <template v-if="text && isVipActive(text)">
<a-tag color="gold"> <a-tag color="gold">
<a-icon type="crown" /> <a-icon type="crown" />
{{ formatDate(text) }} {{ formatDate(text) }}
</a-tag> </a-tag>
</template> </template>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</template> </template>
<!-- Actions Column --> <!-- Actions Column -->
<template slot="action" slot-scope="text, record"> <template slot="action" slot-scope="text, record">
<a-space> <a-space>
<a-tooltip :title="$t('common.edit') || 'Edit'"> <a-tooltip :title="$t('common.edit') || 'Edit'">
<a-button type="link" size="small" @click="showEditModal(record)"> <a-button type="link" size="small" @click="showEditModal(record)">
<a-icon type="edit" /> <a-icon type="edit" />
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<a-tooltip :title="$t('userManage.adjustCredits') || 'Adjust Credits'"> <a-tooltip :title="$t('userManage.adjustCredits') || 'Adjust Credits'">
<a-button type="link" size="small" @click="showCreditsModal(record)"> <a-button type="link" size="small" @click="showCreditsModal(record)">
<a-icon type="wallet" style="color: #722ed1" /> <a-icon type="wallet" style="color: #722ed1" />
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<a-tooltip :title="$t('userManage.setVip') || 'Set VIP'"> <a-tooltip :title="$t('userManage.setVip') || 'Set VIP'">
<a-button type="link" size="small" @click="showVipModal(record)"> <a-button type="link" size="small" @click="showVipModal(record)">
<a-icon type="crown" style="color: #faad14" /> <a-icon type="crown" style="color: #faad14" />
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<a-tooltip :title="$t('userManage.resetPassword') || 'Reset Password'"> <a-tooltip :title="$t('userManage.resetPassword') || 'Reset Password'">
<a-button type="link" size="small" @click="showResetPasswordModal(record)"> <a-button type="link" size="small" @click="showResetPasswordModal(record)">
<a-icon type="key" /> <a-icon type="key" />
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<a-tooltip :title="$t('common.delete') || 'Delete'"> <a-tooltip :title="$t('common.delete') || 'Delete'">
<a-popconfirm <a-popconfirm
:title="$t('userManage.confirmDelete') || 'Are you sure to delete this user?'" :title="$t('userManage.confirmDelete') || 'Are you sure to delete this user?'"
@confirm="handleDelete(record.id)" @confirm="handleDelete(record.id)"
> >
<a-button type="link" size="small" :disabled="record.id === currentUserId"> <a-button type="link" size="small" :disabled="record.id === currentUserId">
<a-icon type="delete" style="color: #ff4d4f" /> <a-icon type="delete" style="color: #ff4d4f" />
</a-button> </a-button>
</a-popconfirm> </a-popconfirm>
</a-tooltip> </a-tooltip>
</a-space> </a-space>
</template> </template>
</a-table> </a-table>
</a-card> </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 --> <!-- Create/Edit User Modal -->
<a-modal <a-modal
@@ -301,7 +477,7 @@
</template> </template>
<script> <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 { baseMixin } from '@/store/app-mixin'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
@@ -310,6 +486,7 @@ export default {
mixins: [baseMixin], mixins: [baseMixin],
data () { data () {
return { return {
activeTab: 'users',
loading: false, loading: false,
users: [], users: [],
roles: [], roles: [],
@@ -340,7 +517,19 @@ export default {
vipEditingUser: null, vipEditingUser: null,
vipDays: 30, vipDays: 30,
vipCustomDate: null, 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: { computed: {
@@ -410,6 +599,99 @@ export default {
scopedSlots: { customRender: 'action' } 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 () { beforeCreate () {
@@ -421,6 +703,72 @@ export default {
this.loadRoles() this.loadRoles()
}, },
methods: { 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 () { async loadUsers () {
this.loading = true this.loading = true
try { 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 { .toolbar {
margin-bottom: 16px; margin-bottom: 16px;
display: flex; 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 // Dark theme
&.theme-dark { &.theme-dark {
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%); 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 { .user-table-card {
background: #1e222d; background: #1e222d;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); 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> </style>