1309 lines
56 KiB
HTML
1309 lines
56 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>XClaw AI</title>
|
||
|
||
<!-- Global Styles -->
|
||
<link rel="stylesheet" href="/static/css/style.css">
|
||
|
||
<!-- Fonts -->
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
|
||
<!-- Page-Specific Styles -->
|
||
<style>
|
||
.paper-badge {
|
||
display: inline-block;
|
||
background: linear-gradient(135deg, #8fd7ff, #63e6be);
|
||
color: #071018;
|
||
padding: 5px 12px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 1px;
|
||
margin-left: 12px;
|
||
box-shadow: 0 10px 24px rgba(49, 132, 189, 0.22);
|
||
}
|
||
|
||
.metrics-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||
gap: var(--space-md);
|
||
margin-bottom: var(--space-xl);
|
||
}
|
||
|
||
.stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: var(--space-md);
|
||
margin-bottom: var(--space-lg);
|
||
}
|
||
|
||
.metric-card, .stat-card {
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--space-lg);
|
||
transition: all 0.2s;
|
||
box-shadow: var(--shadow-soft);
|
||
backdrop-filter: blur(16px);
|
||
}
|
||
|
||
.metric-card:hover, .stat-card:hover {
|
||
border-color: var(--primary);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
|
||
.metric-label, .stat-label {
|
||
font-size: var(--font-sm);
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: var(--space-sm);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.metric-value, .stat-value {
|
||
font-size: var(--font-2xl);
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
margin-bottom: var(--space-xs);
|
||
font-family: "IBM Plex Mono", monospace;
|
||
}
|
||
|
||
.metric-sub {
|
||
font-size: var(--font-sm);
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
.metric-value.positive, .stat-value.positive {
|
||
color: var(--success);
|
||
}
|
||
|
||
.metric-value.negative, .stat-value.negative {
|
||
color: var(--error);
|
||
}
|
||
|
||
.grade-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
gap: var(--space-lg);
|
||
margin-top: var(--space-xl);
|
||
}
|
||
|
||
.grade-card {
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--space-lg);
|
||
}
|
||
|
||
.grade-card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--space-md);
|
||
padding-bottom: var(--space-md);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.grade-card-title {
|
||
font-size: var(--font-md);
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.grade-card-stats {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-sm);
|
||
}
|
||
|
||
.grade-stat-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: var(--space-sm) 0;
|
||
}
|
||
|
||
.grade-stat-label {
|
||
font-size: var(--font-sm);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.grade-stat-value {
|
||
font-size: var(--font-base);
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.grade-stat-value.positive {
|
||
color: var(--success);
|
||
}
|
||
|
||
.grade-stat-value.negative {
|
||
color: var(--error);
|
||
}
|
||
|
||
/* Real-time Prices */
|
||
.price-section {
|
||
margin-bottom: 24px;
|
||
padding: 16px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
box-shadow: var(--shadow-soft);
|
||
}
|
||
|
||
.price-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.price-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px;
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.price-item .symbol {
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.price-item .price {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
}
|
||
|
||
/* Core Metrics Grid */
|
||
.core-metrics-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.core-metric-card {
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
padding: 24px;
|
||
text-align: center;
|
||
box-shadow: var(--shadow-soft);
|
||
}
|
||
|
||
.core-metric-label {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.core-metric-value {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.core-metric-value.positive {
|
||
color: var(--success);
|
||
}
|
||
|
||
.core-metric-value.negative {
|
||
color: var(--error);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.metrics-grid, .stat-grid, .grade-stats, .core-metrics-grid, .price-list {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* Admin Dropdown */
|
||
.admin-dropdown {
|
||
position: relative;
|
||
}
|
||
|
||
.admin-menu {
|
||
position: absolute;
|
||
top: 100%;
|
||
right: 0;
|
||
margin-top: 8px;
|
||
background: var(--panel-strong);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
box-shadow: var(--shadow-lg);
|
||
min-width: 200px;
|
||
z-index: 1000;
|
||
backdrop-filter: blur(18px);
|
||
}
|
||
|
||
.admin-menu-item {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
background: none;
|
||
border: none;
|
||
text-align: left;
|
||
font-size: 14px;
|
||
color: var(--text-primary);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.admin-menu-item:hover {
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.admin-menu-item:first-child {
|
||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||
}
|
||
|
||
.admin-menu-item:last-child {
|
||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||
}
|
||
|
||
.pending-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pending-main {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.pending-sub {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.pending-sub.text-success,
|
||
.pending-sub.text-error {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.pending-chip-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.pending-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 8px;
|
||
border-radius: 999px;
|
||
background: rgba(126, 200, 255, 0.08);
|
||
border: 1px solid var(--border);
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.4;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pending-signal {
|
||
min-width: 140px;
|
||
}
|
||
|
||
.platform-halts {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.platform-halt-card {
|
||
background: var(--bg-primary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
padding: 14px 16px;
|
||
box-shadow: var(--shadow-soft);
|
||
}
|
||
|
||
.platform-halt-card.halted {
|
||
border-color: var(--error);
|
||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--error) 20%, transparent);
|
||
}
|
||
|
||
.platform-halt-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.platform-halt-text {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.5;
|
||
margin-bottom: 8px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<div class="container">
|
||
<!-- Header -->
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title" @click="handleTitleClick" style="cursor: pointer;">
|
||
XClaw
|
||
<span class="paper-badge">AI</span>
|
||
</div>
|
||
<div class="page-subtitle">XClaw AI Auto Trading</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="btn btn-secondary" @click="refreshData">刷新</button>
|
||
<!-- 管理员菜单 -->
|
||
<div class="admin-dropdown" v-if="adminMode">
|
||
<button class="btn btn-secondary" @click="toggleAdminMenu">管理 ▾</button>
|
||
<div class="admin-menu" v-if="showAdminMenu">
|
||
<button class="admin-menu-item" @click="toggleAdminMode">
|
||
🔓 关闭管理员模式
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Account Info -->
|
||
<div class="metrics-grid">
|
||
<div class="metric-card">
|
||
<div class="metric-label">账户余额</div>
|
||
<div class="metric-value">${{ account.current_balance ? account.current_balance.toLocaleString() : '0' }}</div>
|
||
<div class="metric-sub">可用: ${{ account.available_margin ? account.available_margin.toLocaleString() : '0' }}</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">已实现盈亏</div>
|
||
<div class="metric-value" :class="stats.total_pnl >= 0 ? 'positive' : 'negative'">
|
||
${{ stats.total_pnl ? stats.total_pnl.toLocaleString() : '0' }}
|
||
</div>
|
||
<div class="metric-sub">{{ stats.total_trades || 0 }} 笔交易</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">收益率</div>
|
||
<div class="metric-value" :class="stats.total_pnl_percent >= 0 ? 'positive' : 'negative'">
|
||
{{ stats.total_pnl_percent >= 0 ? '+' : '' }}{{ stats.total_pnl_percent ? stats.total_pnl_percent.toFixed(2) : '0.00' }}%
|
||
</div>
|
||
<div class="metric-sub">初始资金: $10,000</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stats Grid -->
|
||
<div class="stat-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-label">胜率</div>
|
||
<div class="stat-value positive">{{ stats.win_rate ? stats.win_rate.toFixed(1) : '0.0' }}%</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">盈亏比</div>
|
||
<div class="stat-value">{{ stats.profit_factor === Infinity ? '∞' : (stats.profit_factor ? stats.profit_factor.toFixed(2) : '0.00') }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">最大回撤</div>
|
||
<div class="stat-value" :class="stats.max_drawdown <= 0 ? 'negative' : 'positive'">
|
||
{{ stats.max_drawdown ? stats.max_drawdown.toFixed(2) : '0.00' }}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Real-time Prices -->
|
||
<div v-if="Object.keys(latestPrices).length > 0" class="price-section">
|
||
<div class="stat-label" style="margin-bottom: 8px;">实时价格</div>
|
||
<div class="price-list">
|
||
<div class="price-item" v-for="(price, symbol) in latestPrices" :key="symbol">
|
||
<span class="symbol">{{ symbol }}</span>
|
||
<span class="price">${{ price.toLocaleString() }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="tabs">
|
||
<button class="tab" :class="{ active: currentTab === 'positions' }" @click="switchTab('positions')">
|
||
当前持仓 ({{ openPositions.length }})
|
||
</button>
|
||
<button class="tab" :class="{ active: currentTab === 'pending' }" @click="switchTab('pending')">
|
||
挂单中 ({{ pendingOrders.length }})
|
||
</button>
|
||
<button class="tab" :class="{ active: currentTab === 'history' }" @click="switchTab('history')">
|
||
历史订单
|
||
</button>
|
||
<button class="tab" :class="{ active: currentTab === 'stats' }" @click="switchTab('stats')">
|
||
详细统计
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div v-if="loading" class="loading-state">
|
||
<div class="spinner"></div>
|
||
<p style="margin-top: 16px; font-size: 14px;">加载中...</p>
|
||
</div>
|
||
|
||
<div v-else-if="currentTab === 'pending' && pendingOrders.length === 0" class="empty-state">
|
||
<p>暂无挂单</p>
|
||
</div>
|
||
|
||
<div v-else-if="currentTab === 'history' && orderHistory.length === 0" class="empty-state">
|
||
<p>暂无历史订单</p>
|
||
</div>
|
||
|
||
<!-- Open Positions Table -->
|
||
<div v-if="currentTab === 'positions'" class="table-container">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>交易对</th>
|
||
<th>方向</th>
|
||
<th>数量</th>
|
||
<th>入场价</th>
|
||
<th>当前价</th>
|
||
<th>止损</th>
|
||
<th>止盈</th>
|
||
<th>杠杆</th>
|
||
<th>保证金</th>
|
||
<th>未实现盈亏</th>
|
||
<th>盈亏比例</th>
|
||
<th v-if="adminMode">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="order in openPositions" :key="order.order_id">
|
||
<td><strong>{{ order.symbol }}</strong></td>
|
||
<td>
|
||
<span class="badge" :class="order.side === 'long' ? 'badge-success' : 'badge-error'">
|
||
{{ order.side === 'long' ? '做多' : '做空' }}
|
||
</span>
|
||
</td>
|
||
<td>{{ formatNumber(order.quantity, 4) }}</td>
|
||
<td>{{ formatCurrency(order.display_entry_price) }}</td>
|
||
<td>{{ formatCurrency(order.current_price) }}</td>
|
||
<td>{{ formatCurrency(order.stop_loss) }}</td>
|
||
<td>{{ formatCurrency(order.take_profit) }}</td>
|
||
<td>{{ order.leverage || 0 }}x</td>
|
||
<td>{{ formatCurrency(order.margin) }}</td>
|
||
<td :class="order.unrealized_pnl >= 0 ? 'text-success' : 'text-error'">
|
||
{{ formatSignedCurrency(order.unrealized_pnl) }}
|
||
</td>
|
||
<td :class="order.pnl_percent >= 0 ? 'text-success' : 'text-error'">
|
||
{{ formatSignedPercent(order.pnl_percent) }}
|
||
</td>
|
||
<td v-if="adminMode">
|
||
<button class="btn btn-danger btn-small" @click="closeOrder(order)">平仓</button>
|
||
<button v-if="adminMode" class="btn btn-secondary btn-small" @click="deleteOrder(order)" style="margin-left: 4px;">删除</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Pending Orders Table -->
|
||
<div v-if="currentTab === 'pending'" class="table-container">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>交易对</th>
|
||
<th>方向</th>
|
||
<th>规模</th>
|
||
<th>挂单信息</th>
|
||
<th>风控目标</th>
|
||
<th>仓位占用</th>
|
||
<th>信号</th>
|
||
<th>时间</th>
|
||
<th v-if="adminMode">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="order in pendingOrders" :key="order.order_id">
|
||
<td><strong>{{ order.symbol }}</strong></td>
|
||
<td>
|
||
<span class="badge" :class="order.side === 'long' ? 'badge-success' : 'badge-error'">
|
||
{{ order.side === 'long' ? '做多' : '做空' }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div class="pending-stack">
|
||
<div class="pending-main">{{ formatNumber(order.quantity, 4) }}</div>
|
||
<div class="pending-sub">杠杆 {{ order.leverage || 0 }}x</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="pending-stack">
|
||
<div class="pending-main">{{ formatCurrency(order.display_entry_price) }}</div>
|
||
<div class="pending-sub">现价 {{ formatCurrency(order.current_price) }}</div>
|
||
<div class="pending-sub" :class="order.distance_class">{{ order.distance_text }}</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="pending-stack">
|
||
<div class="pending-main">{{ formatCurrency(order.stop_loss) }}</div>
|
||
<div class="pending-sub text-error">止损 {{ formatOptionalSignedPercent(order.stop_loss_percent) }}</div>
|
||
<div class="pending-main">{{ formatCurrency(order.take_profit) }}</div>
|
||
<div class="pending-sub text-success">止盈 {{ formatOptionalSignedPercent(order.take_profit_percent) }}</div>
|
||
<div class="pending-sub">盈亏比 {{ order.risk_reward_text }}</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="pending-stack">
|
||
<div class="pending-main">{{ formatCurrency(order.margin) }}</div>
|
||
<div class="pending-sub">保证金</div>
|
||
<div class="pending-main">{{ formatCurrency(order.expected_position_value) }}</div>
|
||
<div class="pending-sub">名义仓位</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="pending-stack pending-signal">
|
||
<div class="pending-main">{{ order.signal_grade_text }}</div>
|
||
<div class="pending-chip-row">
|
||
<span v-if="order.signal_type_text" class="pending-chip">{{ order.signal_type_text }}</span>
|
||
<span v-if="order.confidence_text" class="pending-chip">{{ order.confidence_text }}</span>
|
||
<span v-if="order.entry_type_text" class="pending-chip">{{ order.entry_type_text }}</span>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>{{ formatTime(order.created_at) }}</td>
|
||
<td v-if="adminMode">
|
||
<button class="btn btn-danger btn-small" @click="cancelOrder(order)">撤单</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Order History Table -->
|
||
<div v-if="currentTab === 'history'" class="table-container">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>交易对</th>
|
||
<th>方向</th>
|
||
<th>数量</th>
|
||
<th>入场价</th>
|
||
<th>出场价</th>
|
||
<th>实际收益</th>
|
||
<th>收益率</th>
|
||
<th>状态</th>
|
||
<th>时间</th>
|
||
<th v-if="adminMode">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="order in orderHistory" :key="order.order_id">
|
||
<td><strong>{{ order.symbol }}</strong></td>
|
||
<td>
|
||
<span class="badge" :class="order.side === 'long' ? 'badge-success' : 'badge-error'">
|
||
{{ order.side === 'long' ? '做多' : '做空' }}
|
||
</span>
|
||
</td>
|
||
<td>{{ order.quantity ? order.quantity.toFixed(4) : '0.0000' }}</td>
|
||
<td>{{ order.entry_price ? '$' + order.entry_price.toFixed(2) : '$0.00' }}</td>
|
||
<td>{{ order.exit_price ? '$' + order.exit_price.toFixed(2) : '$0.00' }}</td>
|
||
<td :class="order.pnl_amount >= 0 ? 'text-success' : 'text-error'">
|
||
{{ order.pnl_amount >= 0 ? '+' : '' }}${{ order.pnl_amount ? order.pnl_amount.toFixed(2) : '0.00' }}
|
||
</td>
|
||
<td :class="order.pnl_percent >= 0 ? 'text-success' : 'text-error'">
|
||
{{ order.pnl_percent >= 0 ? '+' : '' }}{{ order.pnl_percent ? order.pnl_percent.toFixed(2) : '0.00' }}%
|
||
</td>
|
||
<td>
|
||
<span class="badge" :class="getStatusBadgeClass(order.status)">
|
||
{{ getStatusText(order.status) }}
|
||
</span>
|
||
</td>
|
||
<td>{{ formatTime(order.closed_at) }}</td>
|
||
<td v-if="adminMode">
|
||
<button class="btn btn-danger btn-small" @click="deleteOrder(order)">删除</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Detailed Statistics Tab -->
|
||
<div v-if="currentTab === 'stats'" class="table-container">
|
||
<!-- Core Metrics -->
|
||
<div class="core-metrics-grid">
|
||
<div class="core-metric-card">
|
||
<div class="core-metric-label">累计收益率</div>
|
||
<div class="core-metric-value" :class="stats.total_pnl_percent >= 0 ? 'positive' : 'negative'">
|
||
{{ stats.total_pnl_percent >= 0 ? '+' : '' }}{{ stats.total_pnl_percent ? stats.total_pnl_percent.toFixed(2) : '0.00' }}%
|
||
</div>
|
||
</div>
|
||
<div class="core-metric-card">
|
||
<div class="core-metric-label">总盈亏</div>
|
||
<div class="core-metric-value" :class="stats.total_pnl >= 0 ? 'positive' : 'negative'">
|
||
{{ stats.total_pnl >= 0 ? '+' : '' }}${{ stats.total_pnl ? stats.total_pnl.toFixed(2) : '0.00' }}
|
||
</div>
|
||
</div>
|
||
<div class="core-metric-card">
|
||
<div class="core-metric-label">胜率</div>
|
||
<div class="core-metric-value positive">
|
||
{{ stats.win_rate ? stats.win_rate.toFixed(1) : '0.0' }}%
|
||
</div>
|
||
</div>
|
||
<div class="core-metric-card">
|
||
<div class="core-metric-label">盈亏比</div>
|
||
<div class="core-metric-value">
|
||
{{ stats.profit_factor === Infinity ? '∞' : (stats.profit_factor ? stats.profit_factor.toFixed(2) : '0.00') }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Grade Stats -->
|
||
<div class="grade-stats" style="margin-top: 24px;">
|
||
<div class="grade-card">
|
||
<div class="grade-card-header">
|
||
<span class="grade-card-title">交易详情</span>
|
||
</div>
|
||
<div class="grade-card-stats">
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">总交易数</span>
|
||
<span class="grade-stat-value">{{ stats.total_trades || 0 }}</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">盈利交易</span>
|
||
<span class="grade-stat-value positive">{{ stats.winning_trades || 0 }}</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">亏损交易</span>
|
||
<span class="grade-stat-value negative">{{ stats.losing_trades || 0 }}</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">最佳交易</span>
|
||
<span class="grade-stat-value positive">{{ stats.best_trade ? stats.best_trade.toFixed(2) : '0.00' }}%</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">最差交易</span>
|
||
<span class="grade-stat-value negative">{{ stats.worst_trade ? stats.worst_trade.toFixed(2) : '0.00' }}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grade-card">
|
||
<div class="grade-card-header">
|
||
<span class="grade-card-title">收益分析</span>
|
||
</div>
|
||
<div class="grade-card-stats">
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">平均盈利</span>
|
||
<span class="grade-stat-value positive">${{ stats.average_win ? stats.average_win.toFixed(2) : '0.00' }}</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">平均亏损</span>
|
||
<span class="grade-stat-value negative">${{ stats.average_loss ? stats.average_loss.toFixed(2) : '0.00' }}</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">最大回撤</span>
|
||
<span class="grade-stat-value negative">{{ stats.max_drawdown ? stats.max_drawdown.toFixed(2) : '0.00' }}%</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">收益率</span>
|
||
<span class="grade-stat-value" :class="stats.return_percent >= 0 ? 'positive' : 'negative'">
|
||
{{ stats.return_percent >= 0 ? '+' : '' }}{{ stats.return_percent ? stats.return_percent.toFixed(2) : '0.00' }}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||
<script>
|
||
const { createApp } = Vue;
|
||
|
||
createApp({
|
||
data() {
|
||
return {
|
||
currentTab: 'positions',
|
||
loading: false,
|
||
sendingReport: false,
|
||
stats: {
|
||
total_trades: 0,
|
||
winning_trades: 0,
|
||
losing_trades: 0,
|
||
win_rate: 0,
|
||
total_pnl: 0,
|
||
total_pnl_percent: 0,
|
||
average_win: 0,
|
||
average_loss: 0,
|
||
profit_factor: 0,
|
||
max_drawdown: 0,
|
||
best_trade: 0,
|
||
worst_trade: 0,
|
||
return_percent: 0
|
||
},
|
||
account: {
|
||
initial_balance: 10000,
|
||
current_balance: 10000,
|
||
used_margin: 0,
|
||
available_margin: 10000,
|
||
leverage: 10,
|
||
margin_per_order: 1000,
|
||
active_orders: 0,
|
||
max_orders: 10,
|
||
available_orders: 10,
|
||
total_position_value: 0,
|
||
margin_ratio: 0,
|
||
realized_pnl: 0,
|
||
current_total_leverage: 0,
|
||
max_total_leverage: 10
|
||
},
|
||
orders: [],
|
||
adminMode: false,
|
||
showAdminMenu: false,
|
||
adminPassword: '223388',
|
||
titleClickCount: 0,
|
||
titleClickTimer: null,
|
||
refreshInterval: null,
|
||
latestPrices: {},
|
||
platformHalts: {}
|
||
};
|
||
},
|
||
computed: {
|
||
openPositions() {
|
||
return this.orders
|
||
.filter(order => order.status === 'open')
|
||
.map(order => {
|
||
const displayEntryPrice = this.getDisplayEntryPrice(order);
|
||
const currentPrice = this.resolveOrderCurrentPrice(order);
|
||
const pnlPercent = this.calculateOpenOrderPnlPercent(order, currentPrice, displayEntryPrice);
|
||
const unrealizedPnl = this.calculateOpenOrderPnlAmount(order, pnlPercent);
|
||
|
||
return {
|
||
...order,
|
||
display_entry_price: displayEntryPrice,
|
||
current_price: currentPrice || null,
|
||
pnl_percent: pnlPercent,
|
||
unrealized_pnl: unrealizedPnl
|
||
};
|
||
});
|
||
},
|
||
pendingOrders() {
|
||
return this.orders
|
||
.filter(order => order.status === 'pending')
|
||
.map(order => {
|
||
const entryPrice = Number(order.entry_price || 0);
|
||
const currentPrice = this.resolveOrderCurrentPrice(order);
|
||
const stopLossPercent = this.calculateOrderTargetPercent(order.side, entryPrice, order.stop_loss);
|
||
const takeProfitPercent = this.calculateOrderTargetPercent(order.side, entryPrice, order.take_profit);
|
||
const margin = Number(order.margin || 0);
|
||
const leverage = Number(order.leverage || 0);
|
||
|
||
return {
|
||
...order,
|
||
display_entry_price: entryPrice,
|
||
current_price: currentPrice || null,
|
||
distance_percent: this.calculatePendingDistancePercent(currentPrice, entryPrice),
|
||
distance_text: this.getPendingDistanceText(currentPrice, entryPrice),
|
||
distance_class: this.getPendingDistanceClass(currentPrice, entryPrice),
|
||
stop_loss_percent: stopLossPercent,
|
||
take_profit_percent: takeProfitPercent,
|
||
risk_reward_ratio: this.calculateRiskRewardRatio(stopLossPercent, takeProfitPercent),
|
||
risk_reward_text: this.formatRiskRewardRatio(stopLossPercent, takeProfitPercent),
|
||
expected_position_value: margin > 0 && leverage > 0 ? margin * leverage : 0,
|
||
signal_grade_text: order.signal_grade || '-',
|
||
signal_type_text: this.getSignalTypeText(order.signal_type),
|
||
confidence_text: this.formatConfidence(order.confidence),
|
||
entry_type_text: this.getEntryTypeText(order.entry_type)
|
||
};
|
||
});
|
||
},
|
||
orderHistory() {
|
||
return this.orders.filter(order => {
|
||
const status = typeof order.status === 'string' ? order.status : '';
|
||
return status.startsWith('closed') || status === 'cancelled';
|
||
});
|
||
}
|
||
},
|
||
methods: {
|
||
async switchTab(tab) {
|
||
this.currentTab = tab;
|
||
},
|
||
|
||
async refreshData() {
|
||
this.loading = true;
|
||
try {
|
||
await Promise.all([
|
||
this.fetchAccountStatus(),
|
||
this.fetchStatistics(),
|
||
this.fetchOrders(),
|
||
this.fetchLatestPrices(),
|
||
this.fetchPlatformHalts()
|
||
]);
|
||
} catch (e) {
|
||
console.error('刷新数据失败:', e);
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
async silentRefresh() {
|
||
try {
|
||
await Promise.all([
|
||
this.fetchAccountStatus(),
|
||
this.fetchStatistics(),
|
||
this.fetchOrders(),
|
||
this.fetchLatestPrices(),
|
||
this.fetchPlatformHalts()
|
||
]);
|
||
} catch (e) {
|
||
console.error('静默刷新失败:', e);
|
||
}
|
||
},
|
||
|
||
async fetchLatestPrices() {
|
||
try {
|
||
const response = await axios.get('/api/trading/monitor/status');
|
||
if (response.data.success && response.data.latest_prices) {
|
||
this.latestPrices = response.data.latest_prices;
|
||
}
|
||
} catch (error) {
|
||
console.error('获取实时价格失败:', error);
|
||
}
|
||
},
|
||
|
||
async fetchAccountStatus() {
|
||
try {
|
||
const response = await axios.get('/api/trading/account');
|
||
if (response.data.success) {
|
||
this.account = response.data.account;
|
||
}
|
||
} catch (error) {
|
||
console.error('获取账户状态失败:', error);
|
||
}
|
||
},
|
||
|
||
async fetchStatistics() {
|
||
try {
|
||
const response = await axios.get('/api/trading/statistics');
|
||
if (response.data.success) {
|
||
this.stats = response.data.statistics;
|
||
}
|
||
} catch (error) {
|
||
console.error('获取统计数据失败:', error);
|
||
}
|
||
},
|
||
|
||
async fetchOrders() {
|
||
try {
|
||
const response = await axios.get('/api/trading/orders');
|
||
if (response.data.success) {
|
||
this.orders = response.data.orders || [];
|
||
}
|
||
} catch (error) {
|
||
console.error('获取订单失败:', error);
|
||
}
|
||
},
|
||
|
||
async fetchPlatformHalts() {
|
||
try {
|
||
const response = await axios.get('/api/trading/platform-halts');
|
||
if (response.data.success) {
|
||
this.platformHalts = response.data.platform_halts || {};
|
||
}
|
||
} catch (error) {
|
||
console.error('获取平台停机状态失败:', error);
|
||
}
|
||
},
|
||
|
||
async closeOrder(order) {
|
||
if (!confirm('确定要平仓吗?')) return;
|
||
|
||
try {
|
||
const exitPrice = this.resolveOrderCurrentPrice(order) || this.getDisplayEntryPrice(order);
|
||
const response = await axios.post(`/api/trading/orders/${order.order_id}/close`, {
|
||
exit_price: exitPrice
|
||
});
|
||
if (response.data.success) {
|
||
await this.refreshData();
|
||
alert('平仓成功');
|
||
} else {
|
||
alert('平仓失败: ' + (response.data.message || '未知错误'));
|
||
}
|
||
} catch (error) {
|
||
console.error('平仓失败:', error);
|
||
alert('平仓失败: ' + (error.response?.data?.detail || error.message));
|
||
}
|
||
},
|
||
|
||
async cancelOrder(order) {
|
||
if (!confirm('确定要撤单吗?')) return;
|
||
|
||
try {
|
||
const response = await axios.post(`/api/trading/orders/${order.order_id}/cancel`);
|
||
if (response.data.success) {
|
||
await this.refreshData();
|
||
alert('撤单成功');
|
||
} else {
|
||
alert('撤单失败: ' + (response.data.message || '未知错误'));
|
||
}
|
||
} catch (error) {
|
||
console.error('撤单失败:', error);
|
||
alert('撤单失败: ' + (error.response?.data?.detail || error.message));
|
||
}
|
||
},
|
||
|
||
async deleteOrder(order) {
|
||
if (!confirm('确定要删除该订单吗?此操作不可恢复!')) return;
|
||
|
||
try {
|
||
const response = await axios.delete(`/api/trading/orders/${order.order_id}`);
|
||
if (response.data.success) {
|
||
await this.refreshData();
|
||
alert('订单删除成功');
|
||
} else {
|
||
alert('删除失败: ' + (response.data.message || '未知错误'));
|
||
}
|
||
} catch (error) {
|
||
console.error('删除订单失败:', error);
|
||
alert('删除失败: ' + (error.response?.data?.detail || error.message));
|
||
}
|
||
},
|
||
|
||
async resumePlatform(platform) {
|
||
if (!confirm(`确定恢复 ${platform} 吗?恢复后会重新允许该平台执行。`)) return;
|
||
|
||
try {
|
||
const response = await axios.post('/api/trading/platform-halts/resume', {
|
||
platform
|
||
});
|
||
if (response.data.success) {
|
||
await this.refreshData();
|
||
alert(`${platform} 已恢复`);
|
||
} else {
|
||
alert(`恢复失败: ${response.data.message || '未知错误'}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('恢复平台失败:', error);
|
||
alert('恢复失败: ' + (error.response?.data?.detail || error.message));
|
||
}
|
||
},
|
||
|
||
async sendReport() {
|
||
this.sendingReport = true;
|
||
try {
|
||
const response = await axios.post('/api/trading/report?hours=4&send_telegram=true');
|
||
if (response.data.success) {
|
||
alert('报告已发送');
|
||
} else {
|
||
alert('发送失败: ' + (response.data.message || '未知错误'));
|
||
}
|
||
} catch (error) {
|
||
console.error('发送报告失败:', error);
|
||
alert('发送失败: ' + (error.response?.data?.detail || error.message));
|
||
} finally {
|
||
this.sendingReport = false;
|
||
}
|
||
},
|
||
|
||
formatTime(timeStr) {
|
||
if (!timeStr) return '-';
|
||
const date = new Date(timeStr);
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
},
|
||
|
||
formatCurrency(value) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number) || number <= 0) return '-';
|
||
return `$${number.toFixed(2)}`;
|
||
},
|
||
|
||
formatSignedCurrency(value) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return '$0.00';
|
||
return `${number >= 0 ? '+' : '-'}$${Math.abs(number).toFixed(2)}`;
|
||
},
|
||
|
||
formatSignedPercent(value) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return '0.00%';
|
||
return `${number >= 0 ? '+' : '-'}${Math.abs(number).toFixed(2)}%`;
|
||
},
|
||
|
||
formatOptionalSignedPercent(value) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return '-';
|
||
return this.formatSignedPercent(number);
|
||
},
|
||
|
||
formatNumber(value, digits = 2) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return (0).toFixed(digits);
|
||
return number.toFixed(digits);
|
||
},
|
||
|
||
formatConfidence(value) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number) || number <= 0) return '';
|
||
const percent = number <= 1 ? number * 100 : number;
|
||
return `置信度 ${percent.toFixed(1)}%`;
|
||
},
|
||
|
||
getDisplayEntryPrice(order) {
|
||
const price = Number(order.filled_price || order.entry_price || 0);
|
||
return Number.isFinite(price) ? price : 0;
|
||
},
|
||
|
||
resolveOrderCurrentPrice(order) {
|
||
const latest = Number(this.latestPrices?.[order.symbol]);
|
||
if (Number.isFinite(latest) && latest > 0) {
|
||
return latest;
|
||
}
|
||
|
||
const current = Number(order.current_price);
|
||
if (Number.isFinite(current) && current > 0) {
|
||
return current;
|
||
}
|
||
|
||
return 0;
|
||
},
|
||
|
||
calculateOpenOrderPnlPercent(order, currentPrice, entryPrice) {
|
||
if (!currentPrice || !entryPrice) {
|
||
return Number(order.pnl_percent || 0);
|
||
}
|
||
|
||
if (order.side === 'long') {
|
||
return ((currentPrice - entryPrice) / entryPrice) * 100;
|
||
}
|
||
|
||
if (order.side === 'short') {
|
||
return ((entryPrice - currentPrice) / entryPrice) * 100;
|
||
}
|
||
|
||
return Number(order.pnl_percent || 0);
|
||
},
|
||
|
||
calculateOpenOrderPnlAmount(order, pnlPercent) {
|
||
const positionValue = Number(order.quantity || 0);
|
||
if (!Number.isFinite(positionValue) || positionValue <= 0) {
|
||
return Number(order.unrealized_pnl || 0);
|
||
}
|
||
return positionValue * pnlPercent / 100;
|
||
},
|
||
|
||
calculatePendingDistancePercent(currentPrice, entryPrice) {
|
||
const current = Number(currentPrice);
|
||
const entry = Number(entryPrice);
|
||
if (!Number.isFinite(current) || current <= 0 || !Number.isFinite(entry) || entry <= 0) {
|
||
return null;
|
||
}
|
||
return ((entry - current) / current) * 100;
|
||
},
|
||
|
||
getPendingDistanceText(currentPrice, entryPrice) {
|
||
const distancePercent = this.calculatePendingDistancePercent(currentPrice, entryPrice);
|
||
if (!Number.isFinite(distancePercent)) {
|
||
return '等待触发';
|
||
}
|
||
|
||
if (Math.abs(distancePercent) < 0.05) {
|
||
return '接近触发';
|
||
}
|
||
|
||
const direction = distancePercent > 0 ? '需上涨触发' : '需回落触发';
|
||
return `${direction} ${this.formatPercent(Math.abs(distancePercent))}`;
|
||
},
|
||
|
||
getPendingDistanceClass(currentPrice, entryPrice) {
|
||
const distancePercent = this.calculatePendingDistancePercent(currentPrice, entryPrice);
|
||
if (!Number.isFinite(distancePercent)) {
|
||
return '';
|
||
}
|
||
|
||
if (Math.abs(distancePercent) < 0.35) {
|
||
return 'text-success';
|
||
}
|
||
|
||
if (Math.abs(distancePercent) < 1) {
|
||
return '';
|
||
}
|
||
|
||
return 'text-error';
|
||
},
|
||
|
||
calculateOrderTargetPercent(side, entryPrice, targetPrice) {
|
||
const entry = Number(entryPrice);
|
||
const target = Number(targetPrice);
|
||
if (!Number.isFinite(entry) || entry <= 0 || !Number.isFinite(target) || target <= 0) {
|
||
return null;
|
||
}
|
||
|
||
if (side === 'long') {
|
||
return ((target - entry) / entry) * 100;
|
||
}
|
||
|
||
if (side === 'short') {
|
||
return ((entry - target) / entry) * 100;
|
||
}
|
||
|
||
return null;
|
||
},
|
||
|
||
calculateRiskRewardRatio(stopLossPercent, takeProfitPercent) {
|
||
const stop = Number(stopLossPercent);
|
||
const take = Number(takeProfitPercent);
|
||
if (!Number.isFinite(stop) || !Number.isFinite(take) || stop >= 0 || take <= 0) {
|
||
return null;
|
||
}
|
||
return take / Math.abs(stop);
|
||
},
|
||
|
||
formatRiskRewardRatio(stopLossPercent, takeProfitPercent) {
|
||
const ratio = this.calculateRiskRewardRatio(stopLossPercent, takeProfitPercent);
|
||
if (!Number.isFinite(ratio) || ratio <= 0) {
|
||
return '-';
|
||
}
|
||
return `1:${ratio.toFixed(2)}`;
|
||
},
|
||
|
||
formatPercent(value, digits = 2) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return '-';
|
||
return `${number.toFixed(digits)}%`;
|
||
},
|
||
|
||
getSignalTypeText(signalType) {
|
||
const map = {
|
||
short_term: '日内',
|
||
medium_term: '中线',
|
||
long_term: '趋势',
|
||
swing: '波段'
|
||
};
|
||
return map[signalType] || signalType || '';
|
||
},
|
||
|
||
getEntryTypeText(entryType) {
|
||
const map = {
|
||
market: '市价',
|
||
limit: '限价',
|
||
pending: '挂单'
|
||
};
|
||
return map[entryType] || entryType || '';
|
||
},
|
||
|
||
getCloseReason(reason) {
|
||
const map = {
|
||
'manual': '手动',
|
||
'stop_loss': '止损',
|
||
'take_profit': '止盈',
|
||
'timeout': '超时',
|
||
'signal': '信号'
|
||
};
|
||
return map[reason] || reason || '-';
|
||
},
|
||
|
||
handleTitleClick() {
|
||
this.titleClickCount++;
|
||
if (this.titleClickTimer) {
|
||
clearTimeout(this.titleClickTimer);
|
||
}
|
||
this.titleClickTimer = setTimeout(() => {
|
||
this.titleClickCount = 0;
|
||
}, 2000);
|
||
|
||
if (this.titleClickCount === 3) {
|
||
this.titleClickCount = 0;
|
||
this.promptAdminMode();
|
||
}
|
||
},
|
||
|
||
promptAdminMode() {
|
||
if (this.adminMode) {
|
||
this.adminMode = false;
|
||
this.showAdminMenu = false;
|
||
alert('管理员模式已关闭');
|
||
} else {
|
||
const password = prompt('请输入管理员密码:');
|
||
if (password === this.adminPassword) {
|
||
this.adminMode = true;
|
||
alert('管理员模式已开启');
|
||
} else if (password !== null) {
|
||
alert('密码错误');
|
||
}
|
||
}
|
||
},
|
||
|
||
toggleAdminMenu() {
|
||
this.showAdminMenu = !this.showAdminMenu;
|
||
},
|
||
|
||
toggleAdminMode() {
|
||
this.promptAdminMode();
|
||
},
|
||
|
||
getStatusBadgeClass(status) {
|
||
const classMap = {
|
||
'closed_tp': 'badge-success',
|
||
'closed_sl': 'badge-error',
|
||
'closed_be': 'badge-warning',
|
||
'closed_ts': 'badge-success',
|
||
'closed_manual': 'badge-info',
|
||
'cancelled': 'badge-secondary'
|
||
};
|
||
return classMap[status] || 'badge-secondary';
|
||
},
|
||
|
||
getStatusText(status) {
|
||
const textMap = {
|
||
'pending': '挂单中',
|
||
'open': '持仓中',
|
||
'closed_tp': '止盈平仓',
|
||
'closed_sl': '止损平仓',
|
||
'closed_be': '保本平仓',
|
||
'closed_ts': '移动止盈',
|
||
'closed_manual': '手动平仓',
|
||
'cancelled': '已取消'
|
||
};
|
||
return textMap[status] || status;
|
||
}
|
||
},
|
||
mounted() {
|
||
this.refreshData();
|
||
|
||
// 每3秒自动刷新(静默刷新,不显示 loading)
|
||
this.refreshInterval = setInterval(() => {
|
||
this.silentRefresh();
|
||
}, 3000);
|
||
|
||
// 点击外部关闭管理菜单
|
||
document.addEventListener('click', (e) => {
|
||
if (this.showAdminMenu && !e.target.closest('.admin-dropdown')) {
|
||
this.showAdminMenu = false;
|
||
}
|
||
});
|
||
},
|
||
beforeUnmount() {
|
||
if (this.refreshInterval) {
|
||
clearInterval(this.refreshInterval);
|
||
}
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|