Files
DinQuant/frontend_vue/src/views/dashboard/index.vue
T

2513 lines
76 KiB
Vue

<template>
<div class="dashboard-pro" :class="{ 'theme-dark': isDarkTheme }">
<!-- 主要KPI指标卡片 -->
<div class="kpi-grid">
<!-- 总权益 -->
<div class="kpi-card kpi-primary">
<div class="kpi-glow"></div>
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="wallet" />
</span>
<span class="kpi-label">{{ $t('dashboard.totalEquity') }}</span>
</div>
<div class="kpi-value">
<span class="currency">$</span>
<span class="amount">{{ formatNumber(summary.total_equity) }}</span>
</div>
<div class="kpi-sub">
<span :class="summary.total_pnl >= 0 ? 'positive' : 'negative'">
{{ summary.total_pnl >= 0 ? '+' : '' }}{{ formatNumber(summary.total_pnl) }}
</span>
<span class="label">{{ $t('dashboard.label.totalPnl') }}</span>
</div>
</div>
</div>
<!-- 胜率 -->
<div class="kpi-card kpi-win-rate">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="trophy" />
</span>
<span class="kpi-label">{{ $t('dashboard.winRate') || '胜率' }}</span>
</div>
<div class="kpi-value">
<span class="amount">{{ formatNumber(performance.win_rate, 1) }}</span>
<span class="unit">%</span>
</div>
<div class="kpi-sub">
<span class="positive">{{ performance.winning_trades }}</span>
<span class="label">{{ $t('dashboard.label.win') }}</span>
<span class="divider">/</span>
<span class="negative">{{ performance.losing_trades }}</span>
<span class="label">{{ $t('dashboard.label.lose') }}</span>
</div>
</div>
<div class="kpi-ring">
<svg viewBox="0 0 36 36">
<path
class="ring-bg"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
class="ring-progress"
:stroke-dasharray="`${performance.win_rate || 0}, 100`"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
</div>
</div>
<!-- 盈亏比 -->
<div class="kpi-card kpi-profit-factor">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="rise" />
</span>
<span class="kpi-label">{{ $t('dashboard.profitFactor') || '盈亏比' }}</span>
</div>
<div class="kpi-value">
<span class="amount">{{ formatNumber(performance.profit_factor, 2) }}</span>
<span class="unit">:1</span>
</div>
<div class="kpi-sub">
<span>{{ $t('dashboard.label.avgProfit') }} </span>
<span class="positive">${{ formatNumber(performance.avg_win) }}</span>
</div>
</div>
</div>
<!-- 最大回撤 -->
<div class="kpi-card kpi-drawdown">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="fall" />
</span>
<span class="kpi-label">{{ $t('dashboard.maxDrawdown') || '最大回撤' }}</span>
</div>
<div class="kpi-value">
<span class="amount negative">{{ formatNumber(performance.max_drawdown_pct, 1) }}</span>
<span class="unit">%</span>
</div>
<div class="kpi-sub">
<span>${{ formatNumber(performance.max_drawdown) }}</span>
</div>
</div>
</div>
<!-- 总交易数 -->
<div class="kpi-card kpi-trades">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="swap" />
</span>
<span class="kpi-label">{{ $t('dashboard.totalTrades') || '总交易' }}</span>
</div>
<div class="kpi-value">
<span class="amount">{{ performance.total_trades }}</span>
<span class="unit">{{ $t('dashboard.unit.trades') }}</span>
</div>
<div class="kpi-sub">
<span>{{ $t('dashboard.label.avgDaily') }} </span>
<span>{{ avgTradesPerDay }}</span>
<span class="label"> {{ $t('dashboard.unit.trades') }}</span>
</div>
</div>
</div>
<!-- 运行策略 -->
<div class="kpi-card kpi-strategies clickable" @click="goToStrategyManagement">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
<a-icon type="thunderbolt" theme="filled" />
</span>
<span class="kpi-label">{{ $t('dashboard.runningStrategies') || '运行中策略' }}</span>
</div>
<div class="kpi-value">
<span class="amount">{{ summary.indicator_strategy_count }}</span>
<span class="unit">{{ $t('dashboard.unit.strategies') }}</span>
</div>
<div class="kpi-sub">
<span class="highlight">{{ summary.indicator_strategy_count }}</span>
<span class="label"> {{ $t('dashboard.label.indicator') }}</span>
</div>
</div>
<div class="card-arrow">
<a-icon type="right" />
</div>
</div>
</div>
<div v-if="showSetupGuide && !hideSetupGuide" class="setup-guide-card">
<div class="setup-guide-copy">
<div class="setup-guide-title">{{ tt('dashboard.setupGuide.title', 'Bring your first live strategy online') }}</div>
<div class="setup-guide-desc">{{ tt('dashboard.setupGuide.desc', 'The latest frontend adds a clearer handoff from overview into strategy management. Use this entry point to create, launch, and monitor a strategy flow faster.') }}</div>
<div class="setup-guide-path">{{ tt('dashboard.setupGuide.path', 'Overview -> Strategy Manager -> Create Strategy') }}</div>
</div>
<div class="setup-guide-actions">
<a-button @click="goToStrategyManagement">
<a-icon type="appstore" />
{{ tt('dashboard.setupGuide.secondary', 'Open Strategy Manager') }}
</a-button>
<a-button type="primary" @click="goToStrategyCreate">
<a-icon type="plus" />
{{ tt('dashboard.setupGuide.primary', 'Create Strategy') }}
</a-button>
</div>
</div>
<!-- 图表区域 - 第一行 -->
<div class="chart-row">
<!-- 收益日历 -->
<div class="chart-panel chart-main">
<div class="panel-header">
<div class="panel-title">
<a-icon type="calendar" />
<span>{{ $t('dashboard.profitCalendar') || '收益日曆' }}</span>
</div>
<div class="calendar-nav">
<a-button type="link" size="small" @click="prevMonth" :disabled="currentCalendarIndex >= calendarMonths.length - 1">
<a-icon type="left" />
</a-button>
<span class="current-month">{{ currentMonthLabel }}</span>
<a-button type="link" size="small" @click="nextMonth" :disabled="currentCalendarIndex <= 0">
<a-icon type="right" />
</a-button>
</div>
</div>
<div class="profit-calendar">
<div v-if="!currentCalendarMonth" class="calendar-empty">
<a-icon type="inbox" />
<span>{{ $t('dashboard.noData') }}</span>
</div>
<template v-else>
<!-- Month summary -->
<div class="month-summary">
<div class="summary-item">
<span class="summary-label">{{ $t('dashboard.ranking.totalProfit') }}</span>
<span class="summary-value" :class="currentCalendarMonth.total >= 0 ? 'positive' : 'negative'">
{{ currentCalendarMonth.total >= 0 ? '+' : '' }}${{ formatNumber(currentCalendarMonth.total) }}
</span>
</div>
<div class="summary-item">
<span class="summary-label">{{ $t('dashboard.label.win') }}</span>
<span class="summary-value positive">{{ currentCalendarMonth.win_days }}</span>
</div>
<div class="summary-item">
<span class="summary-label">{{ $t('dashboard.label.lose') }}</span>
<span class="summary-value negative">{{ currentCalendarMonth.lose_days }}</span>
</div>
</div>
<!-- Weekday header -->
<div class="calendar-weekdays">
<div class="weekday" v-for="w in weekdays" :key="w">{{ w }}</div>
</div>
<!-- Calendar grid -->
<div class="calendar-grid">
<!-- Empty cells for offset -->
<div
v-for="n in calendarFirstDayOffset"
:key="'empty-' + n"
class="calendar-cell empty"
></div>
<!-- Day cells -->
<div
v-for="day in currentCalendarMonth.days_in_month"
:key="day"
class="calendar-cell"
:class="getDayClass(day)"
>
<span class="day-number">{{ day }}</span>
<span class="day-profit" v-if="getDayProfit(day) !== null" :class="getDayProfit(day) >= 0 ? 'positive' : 'negative'">
{{ getDayProfit(day) >= 0 ? '+' : '' }}{{ formatCompactNumber(getDayProfit(day)) }}
</span>
</div>
</div>
</template>
</div>
</div>
<!-- 策略表现饼图 -->
<div class="chart-panel chart-side">
<div class="panel-header">
<div class="panel-title">
<a-icon type="pie-chart" />
<span>{{ $t('dashboard.strategyAllocation') || '策略分布' }}</span>
</div>
</div>
<div ref="pieChart" class="chart-body"></div>
</div>
</div>
<!-- 图表区域 - 第二行 -->
<div class="chart-row">
<!-- 回撤曲线 -->
<div class="chart-panel chart-half">
<div class="panel-header">
<div class="panel-title">
<a-icon type="area-chart" />
<span>{{ $t('dashboard.drawdownCurve') || '回撤曲线' }}</span>
</div>
</div>
<div ref="drawdownChart" class="chart-body chart-sm"></div>
</div>
<!-- 交易时段分布 -->
<div class="chart-panel chart-half">
<div class="panel-header">
<div class="panel-title">
<a-icon type="clock-circle" />
<span>{{ $t('dashboard.hourlyDistribution') || '交易时段' }}</span>
</div>
</div>
<div ref="hourlyChart" class="chart-body chart-sm"></div>
</div>
</div>
<!-- 策略排行榜 -->
<div class="chart-panel">
<div class="panel-header">
<div class="panel-title">
<a-icon type="ordered-list" />
<span>{{ $t('dashboard.strategyRanking') || '策略排行榜' }}</span>
</div>
</div>
<div class="strategy-ranking">
<div v-if="strategyStats.length === 0" class="empty-state">
<a-icon type="inbox" />
<span>{{ $t('dashboard.noStrategyData') }}</span>
</div>
<div v-else class="ranking-grid">
<div
v-for="(s, idx) in strategyStats.slice(0, 6)"
:key="s.strategy_id"
class="ranking-card"
:class="{ 'rank-top': idx < 3 }"
>
<div class="rank-badge" :class="`rank-${idx + 1}`">{{ idx + 1 }}</div>
<div class="rank-info">
<div class="rank-name">{{ s.strategy_name }}</div>
<div class="rank-stats">
<span class="stat">
<label>{{ $t('dashboard.ranking.totalProfit') }}</label>
<span :class="s.total_pnl >= 0 ? 'positive' : 'negative'">
{{ s.total_pnl >= 0 ? '+' : '' }}${{ formatNumber(s.total_pnl) }}
</span>
</span>
<span class="stat">
<label>{{ $t('dashboard.winRate') }}</label>
<span>{{ formatNumber(s.win_rate, 1) }}%</span>
</span>
<span class="stat">
<label>{{ $t('dashboard.profitFactor') }}</label>
<span>{{ formatNumber(s.profit_factor, 2) }}</span>
</span>
<span class="stat">
<label>{{ $t('dashboard.ranking.trades') }}</label>
<span>{{ s.total_trades }}</span>
</span>
</div>
</div>
<div class="rank-pnl-bar">
<div
class="bar-fill"
:class="s.total_pnl >= 0 ? 'positive' : 'negative'"
:style="{ width: `${Math.min(100, Math.abs(s.total_pnl) / maxStrategyPnl * 100)}%` }"
></div>
</div>
</div>
</div>
</div>
</div>
<!-- 数据表格区域 -->
<div class="table-row">
<!-- 当前持仓 -->
<div class="table-panel">
<div class="panel-header">
<div class="panel-title">
<a-icon type="stock" />
<span>{{ $t('dashboard.currentPositions') }}</span>
</div>
<div class="panel-badge">{{ (summary.current_positions || []).length }}</div>
</div>
<a-table
:columns="positionColumns"
:data-source="summary.current_positions"
rowKey="id"
:pagination="false"
size="small"
:scroll="{ x: 'max-content' }"
class="pro-table"
>
<template slot="symbol" slot-scope="text, record">
<div class="symbol-cell">
<span class="symbol-name">{{ text }}</span>
<span class="symbol-strategy">{{ record.strategy_name }}</span>
</div>
</template>
<template slot="side" slot-scope="text">
<span class="side-tag" :class="text === 'long' ? 'long' : 'short'">
{{ text === 'long' ? 'LONG' : 'SHORT' }}
</span>
</template>
<template slot="unrealized_pnl" slot-scope="text, record">
<div class="pnl-cell">
<span :class="text >= 0 ? 'positive' : 'negative'">
{{ text >= 0 ? '+' : '' }}${{ formatNumber(text) }}
</span>
<span class="pnl-percent" :class="record.pnl_percent >= 0 ? 'positive' : 'negative'">
{{ record.pnl_percent >= 0 ? '+' : '' }}{{ formatNumber(record.pnl_percent) }}%
</span>
</div>
</template>
</a-table>
</div>
<!-- 最近交易 -->
<div class="table-panel">
<div class="panel-header">
<div class="panel-title">
<a-icon type="history" />
<span>{{ $t('dashboard.recentTrades') }}</span>
</div>
</div>
<a-table
:columns="columns"
:data-source="summary.recent_trades"
rowKey="id"
:pagination="{ pageSize: 8, size: 'small' }"
size="small"
:scroll="{ x: 'max-content' }"
class="pro-table"
>
<template slot="type" slot-scope="text">
<span class="type-tag" :class="getTypeClass(text)">
{{ getSignalTypeText(text) }}
</span>
</template>
<template slot="profit" slot-scope="text, record">
<span :class="formatProfitValue(text, record) !== '--' ? (text >= 0 ? 'positive' : 'negative') : ''">
{{ formatProfitValue(text, record) }}
</span>
</template>
<template slot="time" slot-scope="text">
<span class="time-cell">{{ formatTime(text) }}</span>
</template>
</a-table>
</div>
</div>
<!-- 订单执行记录 -->
<div class="chart-panel orders-panel">
<div class="panel-header">
<div class="panel-title">
<a-icon type="unordered-list" />
<span>{{ $t('dashboard.pendingOrders') }}</span>
<a-tooltip :title="soundEnabled ? $t('dashboard.clickToMute') : $t('dashboard.clickToUnmute')">
<a-icon
:type="soundEnabled ? 'sound' : 'audio-muted'"
class="sound-toggle"
:class="{ 'sound-off': !soundEnabled }"
@click="toggleSound"
/>
</a-tooltip>
</div>
<div class="panel-badge">{{ ordersPagination.total }}</div>
</div>
<a-table
:columns="orderColumns"
:data-source="pendingOrders"
rowKey="id"
:pagination="{
current: ordersPagination.current,
pageSize: ordersPagination.pageSize,
total: ordersPagination.total,
showSizeChanger: true,
size: 'small',
showTotal: (total) => $t('dashboard.totalOrders', { total })
}"
size="small"
:loading="ordersLoading"
:scroll="{ x: 1200 }"
class="pro-table"
@change="handleOrdersTableChange"
>
<template slot="strategy_name" slot-scope="text, record">
<div class="symbol-cell">
<span class="symbol-name">{{ text || '-' }}</span>
<span class="symbol-strategy">ID: {{ record.strategy_id }}</span>
</div>
</template>
<template slot="symbol" slot-scope="text">
<span class="symbol-tag">{{ text }}</span>
</template>
<template slot="signal_type" slot-scope="text">
<span class="type-tag" :class="getTypeClass(text)">
{{ getSignalTypeText(text) }}
</span>
</template>
<template slot="exchange" slot-scope="text, record">
<span
v-if="(record && (record.exchange_display || record.exchange_id || text))"
class="exchange-tag"
:class="(record.exchange_display || record.exchange_id || text).toLowerCase()"
>
{{ String(record.exchange_display || record.exchange_id || text).toUpperCase() }}
</span>
<span v-else class="text-muted">-</span>
<div v-if="record && record.market_type" class="market-type">
{{ String(record.market_type).toUpperCase() }}
</div>
</template>
<template slot="notify" slot-scope="text, record">
<div class="notify-icons">
<a-tooltip
v-for="ch in (record && record.notify_channels ? record.notify_channels : [])"
:key="`${record.id}-${ch}`"
:title="String(ch)"
>
<a-icon :type="getNotifyIconType(ch)" class="notify-icon" />
</a-tooltip>
<span v-if="!record || !record.notify_channels || record.notify_channels.length === 0" class="text-muted">-</span>
</div>
</template>
<template slot="status" slot-scope="text, record">
<span class="status-tag" :class="text">
{{ getStatusText(text) }}
</span>
<div v-if="text === 'failed' && record.error_message" class="error-hint">
<a-tooltip :title="record.error_message">
<a-icon type="exclamation-circle" />
<span>{{ $t('dashboard.viewError') }}</span>
</a-tooltip>
</div>
</template>
<template slot="amount" slot-scope="text, record">
<div>{{ formatNumber(text, 8) }}</div>
<div v-if="record.filled_amount" class="sub-text">
{{ $t('dashboard.filled') }}: {{ formatNumber(record.filled_amount, 8) }}
</div>
</template>
<template slot="price" slot-scope="text, record">
<div v-if="record.filled_price">{{ formatNumber(record.filled_price) }}</div>
<div v-else class="text-muted">-</div>
</template>
<template slot="time_info" slot-scope="text, record">
<div class="time-cell">{{ formatTime(record.created_at) }}</div>
<div v-if="record.executed_at" class="sub-text">
{{ formatTime(record.executed_at) }}
</div>
</template>
</a-table>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { getDashboardSummary, getPendingOrders } from '@/api/dashboard'
import { mapState } from 'vuex'
export default {
name: 'Dashboard',
props: {
hideSetupGuide: {
type: Boolean,
default: false
}
},
data () {
return {
summary: {
ai_strategy_count: 0,
indicator_strategy_count: 0,
total_equity: 0,
total_pnl: 0,
total_realized_pnl: 0,
total_unrealized_pnl: 0,
performance: {},
strategy_stats: [],
daily_pnl_chart: [],
strategy_pnl_chart: [],
monthly_returns: [],
hourly_distribution: [],
calendar_months: [],
recent_trades: [],
current_positions: []
},
currentCalendarIndex: 0,
pieChart: null,
drawdownChart: null,
hourlyChart: null,
pendingOrders: [],
ordersLoading: false,
ordersPagination: {
current: 1,
pageSize: 20,
total: 0
},
// 声音提醒相关
orderPollTimer: null,
lastOrderId: 0,
orderPollIntervalMs: 5000,
soundEnabled: true,
beepCtx: null
}
},
computed: {
...mapState({
navTheme: state => state.app.theme
}),
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
performance () {
return this.summary.performance || {}
},
showSetupGuide () {
const strategyCount = Number(this.summary.indicator_strategy_count || 0)
const hasPositions = Array.isArray(this.summary.current_positions) && this.summary.current_positions.length > 0
const hasRecentTrades = Array.isArray(this.summary.recent_trades) && this.summary.recent_trades.length > 0
return strategyCount === 0 || (!hasPositions && !hasRecentTrades)
},
strategyStats () {
return this.summary.strategy_stats || []
},
maxStrategyPnl () {
const stats = this.strategyStats
if (!stats.length) return 1
return Math.max(...stats.map(s => Math.abs(s.total_pnl || 0)), 1)
},
avgTradesPerDay () {
const chart = this.summary.daily_pnl_chart || []
const days = chart.length || 1
const total = this.performance.total_trades || 0
return (total / days).toFixed(1)
},
calendarMonths () {
return this.summary.calendar_months || []
},
currentCalendarMonth () {
const months = this.calendarMonths
if (!months.length) return null
return months[this.currentCalendarIndex] || null
},
currentMonthLabel () {
const m = this.currentCalendarMonth
if (!m) return '-'
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
return `${monthNames[m.month - 1]} ${m.year}`
},
weekdays () {
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
calendarFirstDayOffset () {
const m = this.currentCalendarMonth
if (!m) return 0
// first_weekday: 0=Monday, 6=Sunday
return m.first_weekday
},
orderStrategyFilters () {
const list = Array.isArray(this.pendingOrders) ? this.pendingOrders : []
const map = new Map()
for (const item of list) {
const id = item && item.strategy_id
if (id === undefined || id === null || map.has(String(id))) continue
const name = (item && item.strategy_name) ? String(item.strategy_name) : ''
const text = name ? `${name} (ID: ${id})` : `ID: ${id}`
map.set(String(id), { text, value: String(id) })
}
return Array.from(map.values()).sort((a, b) => String(a.text).localeCompare(String(b.text)))
},
columns () {
return [
{
title: this.$t('dashboard.table.time'),
dataIndex: 'created_at',
scopedSlots: { customRender: 'time' },
width: 150
},
{
title: this.$t('dashboard.table.symbol'),
dataIndex: 'symbol',
width: 100
},
{
title: this.$t('dashboard.table.type'),
dataIndex: 'type',
scopedSlots: { customRender: 'type' },
width: 90
},
{
title: this.$t('dashboard.table.price'),
dataIndex: 'price',
customRender: (text) => this.formatNumber(text),
width: 100
},
{
title: this.$t('dashboard.table.profit'),
dataIndex: 'profit',
scopedSlots: { customRender: 'profit' },
align: 'right',
width: 100
}
]
},
positionColumns () {
return [
{
title: this.$t('dashboard.table.symbol'),
dataIndex: 'symbol',
scopedSlots: { customRender: 'symbol' }
},
{
title: this.$t('dashboard.table.side'),
dataIndex: 'side',
scopedSlots: { customRender: 'side' }
},
{
title: this.$t('dashboard.table.size'),
dataIndex: 'size',
customRender: (text) => this.formatNumber(text, 4)
},
{
title: this.$t('dashboard.table.entryPrice'),
dataIndex: 'entry_price',
customRender: (text) => this.formatNumber(text)
},
{
title: this.$t('dashboard.table.pnl'),
dataIndex: 'unrealized_pnl',
scopedSlots: { customRender: 'unrealized_pnl' },
align: 'right'
}
]
},
orderColumns () {
return [
{
title: this.$t('dashboard.orderTable.strategy'),
dataIndex: 'strategy_name',
scopedSlots: { customRender: 'strategy_name' },
filters: this.orderStrategyFilters,
filterMultiple: true,
onFilter: (value, record) => String(record && record.strategy_id) === String(value),
width: 150
},
{
title: this.$t('dashboard.orderTable.exchange'),
dataIndex: 'exchange_id',
scopedSlots: { customRender: 'exchange' },
width: 120
},
{
title: this.$t('dashboard.orderTable.notify'),
dataIndex: 'notify_channels',
scopedSlots: { customRender: 'notify' },
width: 100
},
{
title: this.$t('dashboard.orderTable.symbol'),
dataIndex: 'symbol',
scopedSlots: { customRender: 'symbol' },
width: 110
},
{
title: this.$t('dashboard.orderTable.signalType'),
dataIndex: 'signal_type',
scopedSlots: { customRender: 'signal_type' },
width: 100
},
{
title: this.$t('dashboard.orderTable.amount'),
dataIndex: 'amount',
scopedSlots: { customRender: 'amount' },
width: 130
},
{
title: this.$t('dashboard.orderTable.price'),
dataIndex: 'filled_price',
scopedSlots: { customRender: 'price' },
width: 100
},
{
title: this.$t('dashboard.orderTable.status'),
dataIndex: 'status',
scopedSlots: { customRender: 'status' },
width: 130
},
{
title: this.$t('dashboard.orderTable.timeInfo'),
dataIndex: 'created_at',
scopedSlots: { customRender: 'time_info' },
width: 160
}
]
}
},
mounted () {
this.fetchData()
this.fetchPendingOrders()
this.startOrderPolling()
window.addEventListener('resize', this.handleResize)
},
beforeDestroy () {
this.stopOrderPolling()
window.removeEventListener('resize', this.handleResize)
if (this.pieChart) this.pieChart.dispose()
if (this.drawdownChart) this.drawdownChart.dispose()
if (this.hourlyChart) this.hourlyChart.dispose()
},
methods: {
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
},
goToStrategyManagement () {
this.$router.push('/trading-assistant?tab=strategy')
},
goToStrategyCreate () {
this.$router.push('/trading-assistant?tab=strategy&mode=create')
},
async fetchData () {
try {
const res = await getDashboardSummary()
if (res.code === 1) {
this.summary = { ...this.summary, ...res.data }
this.$nextTick(() => {
this.initCharts()
})
}
} catch (e) {
console.error('Failed to fetch dashboard data:', e)
}
},
async fetchPendingOrders (page, pageSize) {
this.ordersLoading = true
try {
const current = page || this.ordersPagination.current || 1
const size = pageSize || this.ordersPagination.pageSize || 20
const res = await getPendingOrders({ page: current, pageSize: size })
if (res.code === 1) {
const data = res.data || {}
this.pendingOrders = data.list || []
this.ordersPagination.current = Number(data.page || current || 1)
this.ordersPagination.pageSize = Number(data.pageSize || size || 20)
this.ordersPagination.total = Number(data.total || 0)
}
} catch (e) {
console.error('获取订单列表失败:', e)
} finally {
this.ordersLoading = false
}
},
// ========== 订单声音提醒 ==========
playOrderBeep () {
if (!this.soundEnabled) return
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext
if (!AudioCtx) return
if (!this.beepCtx) this.beepCtx = new AudioCtx()
const ctx = this.beepCtx
// 部分浏览器需要用户交互后才能播放声音
if (ctx.state === 'suspended' && typeof ctx.resume === 'function') {
ctx.resume().catch(() => {})
}
// 播放两声短促的提示音
const playTone = (startTime, freq) => {
const o = ctx.createOscillator()
const g = ctx.createGain()
o.type = 'sine'
o.frequency.value = freq
g.gain.value = 0.08
o.connect(g)
g.connect(ctx.destination)
o.start(startTime)
o.stop(startTime + 0.12)
}
const now = ctx.currentTime
playTone(now, 880) // 第一声
playTone(now + 0.18, 1100) // 第二声更高
} catch (e) {
console.error('播放提示音失败:', e)
}
},
startOrderPolling () {
this.stopOrderPolling()
// 初始化 lastOrderId
this.initLastOrderId()
this.orderPollTimer = setInterval(() => {
this.pollNewOrders()
}, this.orderPollIntervalMs)
},
stopOrderPolling () {
if (this.orderPollTimer) {
clearInterval(this.orderPollTimer)
this.orderPollTimer = null
}
},
async initLastOrderId () {
try {
const res = await getPendingOrders({ page: 1, pageSize: 1 })
if (res.code === 1 && res.data && res.data.list && res.data.list.length > 0) {
// 获取最新的订单ID
this.lastOrderId = res.data.list[0].id || 0
}
} catch (e) {
console.error('初始化订单ID失败:', e)
}
},
async pollNewOrders () {
try {
const res = await getPendingOrders({ page: 1, pageSize: 10 })
if (res.code !== 1 || !res.data || !res.data.list) return
const orders = res.data.list || []
if (orders.length === 0) return
// 检查是否有新订单
let hasNew = false
let maxId = this.lastOrderId
for (const order of orders) {
const orderId = order.id || 0
if (orderId > this.lastOrderId) {
hasNew = true
if (orderId > maxId) maxId = orderId
}
}
if (hasNew) {
this.lastOrderId = maxId
this.playOrderBeep()
// 刷新订单列表
this.fetchPendingOrders()
// 显示通知
this.$notification.info({
message: this.$t('dashboard.newOrderNotify'),
description: this.$t('dashboard.newOrderDesc'),
duration: 4
})
}
} catch (e) {
console.error('轮询订单失败:', e)
}
},
toggleSound () {
this.soundEnabled = !this.soundEnabled
if (this.soundEnabled) {
this.$message.success(this.$t('dashboard.soundEnabled'))
} else {
this.$message.info(this.$t('dashboard.soundDisabled'))
}
},
handleOrdersTableChange (pagination) {
const current = (pagination && pagination.current) ? pagination.current : 1
const pageSize = (pagination && pagination.pageSize) ? pagination.pageSize : (this.ordersPagination.pageSize || 20)
this.ordersPagination.current = current
this.ordersPagination.pageSize = pageSize
this.fetchPendingOrders(current, pageSize)
},
getTypeClass (type) {
if (!type) return ''
const t = type.toLowerCase()
if (t.includes('open_long') || t.includes('add_long')) return 'long'
if (t.includes('open_short') || t.includes('add_short')) return 'short'
if (t.includes('close_long')) return 'close-long'
if (t.includes('close_short')) return 'close-short'
return ''
},
getSignalTypeColor (type) {
if (!type) return 'default'
type = type.toLowerCase()
if (type.includes('open_long') || type.includes('add_long')) return 'green'
if (type.includes('open_short') || type.includes('add_short')) return 'red'
if (type.includes('close_long')) return 'orange'
if (type.includes('close_short')) return 'purple'
return 'blue'
},
getSignalTypeText (type) {
if (!type) return '-'
const typeMap = {
'open_long': this.$t('dashboard.signalType.openLong'),
'open_short': this.$t('dashboard.signalType.openShort'),
'close_long': this.$t('dashboard.signalType.closeLong'),
'close_short': this.$t('dashboard.signalType.closeShort'),
'add_long': this.$t('dashboard.signalType.addLong'),
'add_short': this.$t('dashboard.signalType.addShort')
}
return typeMap[type.toLowerCase()] || type.toUpperCase()
},
getStatusColor (status) {
const colorMap = {
'pending': 'orange',
'processing': 'blue',
'completed': 'green',
'failed': 'red',
'cancelled': 'default'
}
return colorMap[status] || 'default'
},
getStatusText (status) {
if (!status) return '-'
const statusMap = {
'pending': this.$t('dashboard.status.pending'),
'processing': this.$t('dashboard.status.processing'),
'completed': this.$t('dashboard.status.completed'),
'failed': this.$t('dashboard.status.failed'),
'cancelled': this.$t('dashboard.status.cancelled')
}
return statusMap[status.toLowerCase()] || status.toUpperCase()
},
getNotifyIconType (channel) {
const c = String(channel || '').trim().toLowerCase()
const map = {
browser: 'bell',
webhook: 'link',
discord: 'comment',
telegram: 'message',
tg: 'message',
tele: 'message',
email: 'mail',
phone: 'phone'
}
return map[c] || 'notification'
},
getExchangeTagColor (exchange) {
const ex = String(exchange || '').trim().toLowerCase()
const map = {
binance: 'gold',
okx: 'purple',
bitget: 'cyan',
signal: 'geekblue'
}
return map[ex] || 'blue'
},
formatNumber (num, digits = 2) {
if (num === undefined || num === null) return '0.00'
return Number(num).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })
},
// 格式化盈亏值(处理信号模式下没有实盘的情况)
formatProfitValue (value, record) {
if (value === null || value === undefined) return '--'
const numValue = parseFloat(value)
// 如果值为0且是开仓信号(open_long/open_short),显示--
const openTypes = ['open_long', 'open_short', 'add_long', 'add_short']
if (numValue === 0 && record && openTypes.includes(record.type)) {
return '--'
}
// 如果值极小(科学计数法如0E-8),视为0
if (Math.abs(numValue) < 0.000001) {
if (record && openTypes.includes(record.type)) {
return '--'
}
return '$0.00'
}
// 正常显示
const sign = numValue >= 0 ? '+' : ''
return `${sign}$${this.formatNumber(numValue)}`
},
formatCompactNumber (num) {
if (num === undefined || num === null) return '0'
const abs = Math.abs(num)
if (abs >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (abs >= 1000) return (num / 1000).toFixed(1) + 'k'
if (abs >= 100) return Math.round(num)
return num.toFixed(0)
},
prevMonth () {
if (this.currentCalendarIndex < this.calendarMonths.length - 1) {
this.currentCalendarIndex++
}
},
nextMonth () {
if (this.currentCalendarIndex > 0) {
this.currentCalendarIndex--
}
},
getDayProfit (day) {
const m = this.currentCalendarMonth
if (!m || !m.days) return null
const dayStr = String(day).padStart(2, '0')
return m.days[dayStr] !== undefined ? m.days[dayStr] : null
},
getDayClass (day) {
const profit = this.getDayProfit(day)
if (profit === null) return 'no-data'
if (profit > 0) return 'profit'
if (profit < 0) return 'loss'
return 'zero'
},
formatTime (timestamp) {
if (!timestamp) return '-'
try {
let date
// Handle ISO 8601 date strings (e.g., "2024-01-17T01:58:10.000Z")
if (typeof timestamp === 'string' && timestamp.includes('-') && timestamp.includes(':')) {
date = new Date(timestamp)
} else if (typeof timestamp === 'number' || (typeof timestamp === 'string' && /^\d+$/.test(timestamp))) {
// Handle numeric timestamps (seconds or milliseconds)
const numTimestamp = typeof timestamp === 'string' ? parseInt(timestamp, 10) : timestamp
const ms = numTimestamp < 1e12 ? numTimestamp * 1000 : numTimestamp
date = new Date(ms)
} else {
return '-'
}
if (isNaN(date.getTime())) return '-'
return date.toLocaleString()
} catch (e) {
return '-'
}
},
initCharts () {
this.initPieChart()
this.initDrawdownChart()
this.initHourlyChart()
},
initPieChart () {
const chartDom = this.$refs.pieChart
if (!chartDom) return
this.pieChart = echarts.init(chartDom)
// Use strategy_stats for pie chart data (shows all strategies, not just those with positions)
const stats = Array.isArray(this.summary.strategy_stats) ? this.summary.strategy_stats : []
const raw = Array.isArray(this.summary.strategy_pnl_chart) ? this.summary.strategy_pnl_chart : []
// Prefer strategy_stats if available, fallback to strategy_pnl_chart
let data = []
if (stats.length > 0) {
data = stats.map(it => {
const name = (it && it.strategy_name) ? String(it.strategy_name) : '-'
const val = Number(it && it.total_pnl ? it.total_pnl : 0)
const trades = Number(it && it.total_trades ? it.total_trades : 0)
// Use trades count as value if no PnL, so at least we show the distribution
const displayVal = val !== 0 ? Math.abs(val) : trades
return { name, value: displayVal, signedValue: val, trades }
}).filter(it => it.value > 0)
} else {
data = raw
.map(it => {
const name = (it && it.name) ? String(it.name) : '-'
const val = Number(it && it.value ? it.value : 0)
return { name, value: Math.abs(val), signedValue: val }
})
.filter(it => it.value > 0)
}
const isDark = this.isDarkTheme
const textColor = isDark ? '#9ca3af' : '#6b7280'
// Modern gradient colors
const colors = [
new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: '#3b82f6' },
{ offset: 1, color: '#1d4ed8' }
]),
new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: '#8b5cf6' },
{ offset: 1, color: '#6d28d9' }
]),
new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: '#10b981' },
{ offset: 1, color: '#059669' }
]),
new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: '#f59e0b' },
{ offset: 1, color: '#d97706' }
]),
new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: '#ec4899' },
{ offset: 1, color: '#be185d' }
]),
new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: '#06b6d4' },
{ offset: 1, color: '#0891b2' }
])
]
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: isDark ? 'rgba(17, 24, 39, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderColor: isDark ? '#374151' : '#e5e7eb',
textStyle: { color: isDark ? '#f3f4f6' : '#1f2937' },
formatter: (p) => {
const sv = (p && p.data && typeof p.data.signedValue === 'number') ? p.data.signedValue : 0
const svStr = (sv >= 0 ? '+' : '') + this.formatNumber(sv, 2)
const svColor = sv >= 0 ? '#10b981' : '#ef4444'
return `
<div style="padding: 4px 0;">
<div style="font-weight:600;margin-bottom:6px;">${p.name}</div>
<div style="color:${textColor}">占比 <span style="font-weight:600;color:${isDark ? '#f3f4f6' : '#1f2937'}">${p.percent}%</span></div>
<div style="color:${textColor}">PNL <span style="font-weight:600;color:${svColor}">$${svStr}</span></div>
</div>
`
}
},
legend: {
bottom: 10,
left: 'center',
itemWidth: 12,
itemHeight: 12,
itemGap: 16,
textStyle: {
color: textColor,
fontSize: 11
}
},
color: colors,
series: [
{
name: '策略分布',
type: 'pie',
radius: ['50%', '75%'],
center: ['50%', '45%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: isDark ? '#1f2937' : '#ffffff',
borderWidth: 3
},
label: { show: false },
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
color: isDark ? '#f3f4f6' : '#1f2937'
},
scaleSize: 8
},
labelLine: { show: false },
data: data.length > 0 ? data : [{ value: 1, name: this.$t('dashboard.noData'), signedValue: 0 }]
}
]
}
this.pieChart.setOption(option)
},
initDrawdownChart () {
const chartDom = this.$refs.drawdownChart
if (!chartDom) return
this.drawdownChart = echarts.init(chartDom)
const dates = (this.summary.daily_pnl_chart || []).map(item => item.date)
const values = (this.summary.daily_pnl_chart || []).map(item => Number(item.profit || 0))
// cumulative and drawdown
const cum = []
let acc = 0
for (const v of values) {
acc += Number(v || 0)
cum.push(acc)
}
let peak = -Infinity
let maxDdValue = 0
let maxDdIndex = 0
const dd = cum.map((v, i) => {
peak = Math.max(peak, v)
const drawdown = Number((v - peak).toFixed(2))
if (drawdown < maxDdValue) {
maxDdValue = drawdown
maxDdIndex = i
}
return drawdown
})
const isDark = this.isDarkTheme
const textColor = isDark ? '#a1a1aa' : '#52525b'
const gridColor = isDark ? 'rgba(63, 63, 70, 0.5)' : 'rgba(228, 228, 231, 0.8)'
const option = {
backgroundColor: 'transparent',
animation: true,
animationDuration: 800,
animationEasing: 'cubicOut',
tooltip: {
trigger: 'axis',
backgroundColor: isDark ? 'rgba(24, 24, 27, 0.96)' : 'rgba(255, 255, 255, 0.96)',
borderColor: isDark ? 'rgba(63, 63, 70, 0.8)' : 'rgba(228, 228, 231, 0.8)',
borderWidth: 1,
padding: [12, 16],
textStyle: { color: isDark ? '#fafafa' : '#18181b', fontSize: 13 },
extraCssText: 'box-shadow: 0 8px 32px rgba(0,0,0,0.12); border-radius: 12px;',
axisPointer: {
type: 'line',
lineStyle: { color: 'rgba(239, 68, 68, 0.4)', type: 'dashed' }
},
formatter: (params) => {
const p = Array.isArray(params) ? params[0] : null
const date = p ? p.axisValue : ''
const v = p ? Number(p.data || 0) : 0
const vStr = this.formatNumber(Math.abs(v), 2)
const pctOfMax = maxDdValue !== 0 ? Math.abs((v / maxDdValue) * 100).toFixed(0) : 0
return `
<div style="min-width: 140px;">
<div style="font-weight:600;margin-bottom:10px;font-size:14px;color:${isDark ? '#fafafa' : '#18181b'}">${date}</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<span style="display:flex;align-items:center;gap:8px;">
<span style="width:10px;height:10px;border-radius:2px;background:linear-gradient(180deg,#f87171,#dc2626);"></span>
<span style="color:${textColor}">${this.$t('dashboard.drawdown') || 'Drawdown'}</span>
</span>
<span style="font-weight:700;color:#ef4444;font-family:monospace;">-$${vStr}</span>
</div>
<div style="background:${isDark ? 'rgba(63,63,70,0.5)' : 'rgba(228,228,231,0.5)'};height:6px;border-radius:3px;overflow:hidden;">
<div style="width:${pctOfMax}%;height:100%;background:linear-gradient(90deg,#f87171,#ef4444);border-radius:3px;"></div>
</div>
</div>
`
}
},
grid: { left: 55, right: 20, bottom: 35, top: 20, containLabel: false },
xAxis: {
type: 'category',
data: dates,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: textColor,
fontSize: 10,
formatter: (v) => v.slice(5) // Show MM-DD only
}
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: textColor,
fontSize: 10,
formatter: (v) => {
if (Math.abs(v) >= 1000) return '-$' + (Math.abs(v) / 1000).toFixed(1) + 'k'
return v === 0 ? '0' : '-$' + Math.abs(v)
}
},
splitLine: { lineStyle: { color: gridColor, type: [4, 4] } },
max: 0
},
series: [
{
name: this.$t('dashboard.drawdown') || 'Drawdown',
type: 'line',
data: dd,
smooth: 0.3,
showSymbol: false,
lineStyle: {
width: 2.5,
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#ef4444' },
{ offset: 0.5, color: '#f87171' },
{ offset: 1, color: '#fca5a5' }
]),
shadowColor: 'rgba(239, 68, 68, 0.4)',
shadowBlur: 8,
shadowOffsetY: 4
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isDark ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.25)' },
{ offset: 0.5, color: isDark ? 'rgba(248, 113, 113, 0.15)' : 'rgba(248, 113, 113, 0.1)' },
{ offset: 1, color: 'transparent' }
])
},
markPoint: maxDdValue < 0 ? {
symbol: 'pin',
symbolSize: 45,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#f87171' },
{ offset: 1, color: '#dc2626' }
]),
shadowColor: 'rgba(239, 68, 68, 0.5)',
shadowBlur: 10
},
label: {
show: true,
color: '#fff',
fontSize: 10,
fontWeight: 'bold',
formatter: () => this.$t('dashboard.label.maxDrawdownPoint') || 'MAX'
},
data: [{
name: this.$t('dashboard.maxDrawdown') || 'Max Drawdown',
coord: [maxDdIndex, maxDdValue]
}]
} : undefined,
markLine: {
silent: true,
symbol: 'none',
lineStyle: { color: isDark ? '#52525b' : '#a1a1aa', type: 'dashed', width: 1 },
data: [{ yAxis: 0 }],
label: { show: false }
}
}
]
}
this.drawdownChart.setOption(option)
},
initHourlyChart () {
const chartDom = this.$refs.hourlyChart
if (!chartDom) return
this.hourlyChart = echarts.init(chartDom)
const hourlyData = this.summary.hourly_distribution || []
const hours = hourlyData.map(h => `${String(h.hour).padStart(2, '0')}:00`)
const counts = hourlyData.map(h => h.count || 0)
const profits = hourlyData.map(h => h.profit || 0)
const isDark = this.isDarkTheme
const textColor = isDark ? '#9ca3af' : '#6b7280'
const gridColor = isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)'
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: isDark ? 'rgba(17, 24, 39, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderColor: isDark ? '#374151' : '#e5e7eb',
textStyle: { color: isDark ? '#f3f4f6' : '#1f2937' },
formatter: (params) => {
const arr = Array.isArray(params) ? params : []
const hour = (arr[0] && arr[0].axisValue) ? arr[0].axisValue : ''
let count = 0
let profit = 0
const tradeCountLabel = this.$t('dashboard.tradeCount') || 'Trade Count'
const profitLabel = this.$t('dashboard.profit') || 'Profit'
const unitLabel = this.$t('dashboard.unit.trades') || ''
for (const p of arr) {
if (p.seriesName === tradeCountLabel) count = p.data || 0
if (p.seriesName === profitLabel) profit = p.data || 0
}
const profitColor = profit >= 0 ? '#10b981' : '#ef4444'
const profitStr = (profit >= 0 ? '+' : '') + this.formatNumber(profit, 2)
return `
<div style="padding: 4px 0;">
<div style="font-weight:600;margin-bottom:6px;">${hour}</div>
<div style="color:${textColor}">${tradeCountLabel} <span style="font-weight:600;color:${isDark ? '#f3f4f6' : '#1f2937'}">${count} ${unitLabel}</span></div>
<div style="color:${textColor}">${profitLabel} <span style="font-weight:600;color:${profitColor}">$${profitStr}</span></div>
</div>
`
}
},
grid: { left: 50, right: 20, bottom: 30, top: 10, containLabel: false },
xAxis: {
type: 'category',
data: hours,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: textColor,
fontSize: 10,
interval: 3
}
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: textColor, fontSize: 10 },
splitLine: { lineStyle: { color: gridColor, type: 'dashed' } }
},
series: [
{
name: this.$t('dashboard.tradeCount') || 'Trade Count',
type: 'bar',
data: counts,
barMaxWidth: 16,
itemStyle: {
borderRadius: [3, 3, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#60a5fa' },
{ offset: 1, color: '#3b82f6' }
])
}
},
{
name: this.$t('dashboard.profit') || 'Profit',
type: 'line',
data: profits,
smooth: true,
showSymbol: false,
lineStyle: { width: 2, color: '#a855f7' }
}
]
}
this.hourlyChart.setOption(option)
},
handleResize () {
if (this.pieChart) this.pieChart.resize()
if (this.drawdownChart) this.drawdownChart.resize()
if (this.hourlyChart) this.hourlyChart.resize()
}
}
}
</script>
<style lang="less" scoped>
// Design tokens
@bg-dark: #0f172a;
@bg-card-dark: #1e293b;
@bg-card-hover-dark: #334155;
@border-dark: #334155;
@text-primary-dark: #f1f5f9;
@text-secondary-dark: #94a3b8;
@bg-light: #f8fafc;
@bg-card-light: #ffffff;
@border-light: #e2e8f0;
@text-primary-light: #1e293b;
@text-secondary-light: #64748b;
// Colors
@green: #10b981;
@green-light: #34d399;
@red: #ef4444;
@red-light: #f87171;
@blue: #3b82f6;
@purple: #8b5cf6;
@amber: #f59e0b;
@cyan: #06b6d4;
.dashboard-pro {
min-height: 100vh;
padding: 20px;
background: @bg-light;
transition: background 0.3s;
.setup-guide-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
padding: 20px 22px;
border-radius: 24px;
border: 1px solid #dce7f3;
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.14), transparent 36%),
radial-gradient(circle at bottom right, rgba(16, 185, 129, 0.1), transparent 34%),
linear-gradient(135deg, #ffffff 0%, #f8fbff 55%, #eef7ff 100%);
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.08);
}
.setup-guide-copy {
min-width: 0;
}
.setup-guide-title {
color: #0f172a;
font-size: 24px;
font-weight: 700;
line-height: 1.2;
}
.setup-guide-desc {
margin-top: 8px;
color: #475569;
font-size: 14px;
line-height: 1.7;
}
.setup-guide-path {
margin-top: 10px;
color: #2563eb;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.setup-guide-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
&.theme-dark {
background: @bg-dark;
.setup-guide-card {
border-color: rgba(59, 130, 246, 0.16);
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.24), transparent 36%),
radial-gradient(circle at bottom right, rgba(16, 185, 129, 0.12), transparent 34%),
linear-gradient(135deg, #161b22 0%, #111827 58%, #0f172a 100%);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.26);
}
.setup-guide-title {
color: @text-primary-dark;
}
.setup-guide-desc {
color: @text-secondary-dark;
}
.setup-guide-path {
color: #60a5fa;
}
.kpi-card {
background: @bg-card-dark;
border-color: @border-dark;
.kpi-label { color: @text-secondary-dark; }
.kpi-value .amount { color: @text-primary-dark; }
.kpi-sub { color: @text-secondary-dark; }
}
.chart-panel, .table-panel {
background: @bg-card-dark;
border-color: @border-dark;
.panel-header {
border-color: @border-dark;
.panel-title { color: @text-primary-dark; }
}
}
.ranking-card {
background: rgba(51, 65, 85, 0.5);
border-color: @border-dark;
.rank-name { color: @text-primary-dark; }
.rank-stats label { color: @text-secondary-dark; }
}
.calendar-nav {
.current-month { color: @text-primary-dark; }
.ant-btn-link { color: @text-secondary-dark; }
}
.profit-calendar {
.calendar-empty { color: @text-secondary-dark; }
.month-summary {
background: rgba(51, 65, 85, 0.5);
.summary-label { color: @text-secondary-dark; }
}
.calendar-weekdays .weekday { color: @text-secondary-dark; }
.calendar-grid .calendar-cell {
background: rgba(51, 65, 85, 0.3);
&.no-data { background: rgba(51, 65, 85, 0.2); }
&.profit {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.15) 0%, rgba(34, 197, 94, 0.25) 100%);
}
&.loss {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15) 0%, rgba(239, 68, 68, 0.25) 100%);
}
&.zero {
background: rgba(100, 116, 139, 0.2);
}
.day-number { color: @text-primary-dark; }
}
}
.pro-table {
::v-deep .ant-table {
background: transparent;
color: @text-primary-dark;
}
::v-deep .ant-table-thead > tr > th {
background: rgba(51, 65, 85, 0.5);
color: @text-secondary-dark;
border-color: @border-dark;
}
::v-deep .ant-table-tbody > tr > td {
border-color: @border-dark;
color: @text-primary-dark;
}
::v-deep .ant-table-tbody > tr:hover > td {
background: rgba(51, 65, 85, 0.3);
}
::v-deep .ant-table-placeholder {
background: transparent;
.ant-empty-description { color: @text-secondary-dark; }
}
::v-deep .ant-pagination {
.ant-pagination-item {
background: @bg-card-dark;
border-color: @border-dark;
a { color: @text-primary-dark; }
&.ant-pagination-item-active {
background: @blue;
border-color: @blue;
}
}
.ant-pagination-prev, .ant-pagination-next {
.ant-pagination-item-link {
background: @bg-card-dark;
border-color: @border-dark;
color: @text-primary-dark;
}
}
}
}
}
// KPI Grid
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
@media (max-width: 768px) {
.setup-guide-card {
flex-direction: column;
align-items: flex-start;
padding: 18px;
border-radius: 20px;
}
.setup-guide-title {
font-size: 20px;
}
.setup-guide-actions {
width: 100%;
justify-content: flex-start;
}
}
.kpi-card {
position: relative;
background: @bg-card-light;
border: 1px solid @border-light;
border-radius: 16px;
padding: 20px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-2px);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}
&.clickable {
cursor: pointer;
.card-arrow {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
opacity: 0.4;
transition: all 0.3s;
}
&:hover .card-arrow {
opacity: 1;
right: 12px;
}
}
.kpi-glow {
position: absolute;
top: -50%;
right: -50%;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.15) 0%, transparent 70%);
pointer-events: none;
}
.kpi-content {
position: relative;
z-index: 1;
}
.kpi-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.kpi-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(59, 130, 246, 0.1);
color: @blue;
font-size: 16px;
}
.kpi-label {
font-size: 12px;
font-weight: 600;
color: @text-secondary-light;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kpi-value {
display: flex;
align-items: baseline;
gap: 2px;
.currency {
font-size: 18px;
font-weight: 500;
color: @text-secondary-light;
}
.amount {
font-size: 28px;
font-weight: 700;
color: @text-primary-light;
font-feature-settings: 'tnum';
}
.unit {
font-size: 14px;
font-weight: 500;
color: @text-secondary-light;
margin-left: 4px;
}
}
.kpi-sub {
margin-top: 8px;
font-size: 12px;
color: @text-secondary-light;
.label { margin: 0 2px; }
.divider { margin: 0 6px; opacity: 0.5; }
.highlight { font-weight: 600; color: @blue; }
}
// Primary card with gradient
&.kpi-primary {
background: linear-gradient(135deg, #1e40af 0%, #3b82f6 50%, #60a5fa 100%);
border: none;
.kpi-icon {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.kpi-label { color: rgba(255, 255, 255, 0.8); }
.kpi-value {
.currency, .amount, .unit { color: #fff; }
}
.kpi-sub { color: rgba(255, 255, 255, 0.7); }
}
// Win rate ring
&.kpi-win-rate {
.kpi-ring {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 60px;
height: 60px;
svg {
transform: rotate(-90deg);
.ring-bg {
fill: none;
stroke: rgba(16, 185, 129, 0.15);
stroke-width: 3;
}
.ring-progress {
fill: none;
stroke: @green;
stroke-width: 3;
stroke-linecap: round;
transition: stroke-dasharray 0.5s ease;
}
}
}
.kpi-icon { background: rgba(16, 185, 129, 0.1); color: @green; }
}
&.kpi-profit-factor {
.kpi-icon { background: rgba(139, 92, 246, 0.1); color: @purple; }
}
&.kpi-drawdown {
.kpi-icon { background: rgba(239, 68, 68, 0.1); color: @red; }
.kpi-value .amount { color: @red; }
}
&.kpi-trades {
.kpi-icon { background: rgba(6, 182, 212, 0.1); color: @cyan; }
}
&.kpi-strategies {
.kpi-icon { background: rgba(245, 158, 11, 0.1); color: @amber; }
}
}
// Chart panels
.chart-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
@media (max-width: 1024px) {
flex-direction: column;
}
}
.chart-panel {
background: @bg-card-light;
border: 1px solid @border-light;
border-radius: 16px;
overflow: hidden;
&.chart-main { flex: 2; }
&.chart-side { flex: 1; min-width: 300px; }
&.chart-half { flex: 1; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid @border-light;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: @text-primary-light;
.anticon { color: @blue; }
}
.panel-legend {
display: flex;
gap: 16px;
font-size: 12px;
color: @text-secondary-light;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
.dot {
width: 10px;
height: 10px;
border-radius: 2px;
&.bar { background: linear-gradient(180deg, @green-light, @green); }
&.line { background: linear-gradient(90deg, @blue, @purple); }
&.loss { background: @red; }
&.profit { background: @green; }
}
}
&.calendar-legend {
align-items: center;
gap: 10px;
.legend-gradient {
width: 80px;
height: 10px;
border-radius: 3px;
background: linear-gradient(90deg, #ef4444, #fca5a5, #f4f4f5, #86efac, #22c55e);
}
}
}
.panel-badge {
background: @blue;
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
}
.chart-body {
height: 320px;
padding: 16px;
&.chart-sm { height: 220px; }
&.calendar-chart { height: 280px; }
}
.calendar-nav {
display: flex;
align-items: center;
gap: 8px;
.current-month {
font-size: 14px;
font-weight: 600;
color: @text-primary-light;
min-width: 80px;
text-align: center;
}
.ant-btn-link {
padding: 0;
height: auto;
color: @text-secondary-light;
&:hover:not(:disabled) {
color: @blue;
}
&:disabled {
opacity: 0.3;
}
}
}
.profit-calendar {
padding: 12px 16px;
display: flex;
flex-direction: column;
overflow: hidden;
.calendar-empty {
height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: @text-secondary-light;
.anticon {
font-size: 40px;
margin-bottom: 10px;
opacity: 0.3;
}
}
.month-summary {
display: flex;
gap: 20px;
margin-bottom: 10px;
padding: 8px 12px;
background: rgba(241, 245, 249, 0.5);
border-radius: 8px;
.summary-item {
display: flex;
flex-direction: column;
gap: 2px;
.summary-label {
font-size: 10px;
color: @text-secondary-light;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.summary-value {
font-size: 15px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
&.positive { color: @green; }
&.negative { color: @red; }
}
}
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 3px;
margin-bottom: 4px;
.weekday {
text-align: center;
font-size: 10px;
font-weight: 600;
color: @text-secondary-light;
padding: 4px 0;
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 3px;
.calendar-cell {
height: 36px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 6px;
background: rgba(241, 245, 249, 0.5);
border: 1px solid transparent;
transition: all 0.2s ease;
position: relative;
&.empty {
background: transparent;
border: none;
}
&.no-data {
background: rgba(241, 245, 249, 0.3);
}
&.profit {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(34, 197, 94, 0.2) 100%);
border-color: rgba(34, 197, 94, 0.3);
&:hover {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.3) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.2);
}
}
&.loss {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(239, 68, 68, 0.2) 100%);
border-color: rgba(239, 68, 68, 0.3);
&:hover {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.3) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
}
}
&.zero {
background: rgba(161, 161, 170, 0.1);
border-color: rgba(161, 161, 170, 0.2);
}
.day-number {
font-size: 11px;
font-weight: 600;
color: @text-primary-light;
line-height: 1;
}
.day-profit {
font-size: 9px;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
margin-top: 1px;
line-height: 1;
&.positive { color: @green; }
&.negative { color: @red; }
}
}
}
}
}
// Strategy ranking
.strategy-ranking {
padding: 16px;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: @text-secondary-light;
.anticon { font-size: 40px; margin-bottom: 12px; opacity: 0.3; }
}
.ranking-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.ranking-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: rgba(241, 245, 249, 0.5);
border: 1px solid @border-light;
border-radius: 12px;
position: relative;
overflow: hidden;
&.rank-top {
border-color: transparent;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%);
}
.rank-badge {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
background: @text-secondary-light;
color: #fff;
flex-shrink: 0;
&.rank-1 { background: linear-gradient(135deg, #fbbf24, #f59e0b); }
&.rank-2 { background: linear-gradient(135deg, #9ca3af, #6b7280); }
&.rank-3 { background: linear-gradient(135deg, #cd7f32, #b87333); }
}
.rank-info {
flex: 1;
min-width: 0;
.rank-name {
font-size: 13px;
font-weight: 600;
color: @text-primary-light;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.rank-stats {
display: flex;
gap: 12px;
flex-wrap: wrap;
.stat {
font-size: 11px;
label {
color: @text-secondary-light;
margin-right: 4px;
}
}
}
}
.rank-pnl-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.05);
.bar-fill {
height: 100%;
border-radius: 0 3px 3px 0;
transition: width 0.5s ease;
&.positive { background: linear-gradient(90deg, @green, @green-light); }
&.negative { background: linear-gradient(90deg, @red, @red-light); }
}
}
}
}
// Table panels
.table-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
@media (max-width: 1024px) {
flex-direction: column;
}
}
.table-panel {
flex: 1;
background: @bg-card-light;
border: 1px solid @border-light;
border-radius: 16px;
overflow: hidden;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid @border-light;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: @text-primary-light;
.anticon { color: @blue; }
.sound-toggle {
margin-left: 8px;
font-size: 16px;
cursor: pointer;
color: @green;
transition: all 0.2s;
&:hover {
transform: scale(1.1);
}
&.sound-off {
color: @text-secondary-light;
}
}
}
.panel-badge {
background: @blue;
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
}
}
.orders-panel {
margin-bottom: 20px;
}
// Pro table styles
.pro-table {
::v-deep .ant-table {
font-size: 13px;
}
::v-deep .ant-table-thead > tr > th {
background: rgba(241, 245, 249, 0.8);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: @text-secondary-light;
border-bottom: 1px solid @border-light;
padding: 12px 16px;
}
::v-deep .ant-table-tbody > tr > td {
padding: 12px 16px;
border-bottom: 1px solid @border-light;
}
::v-deep .ant-table-tbody > tr:hover > td {
background: rgba(59, 130, 246, 0.04);
}
::v-deep .ant-pagination {
padding: 12px 16px;
margin: 0;
}
}
// Cell styles
.symbol-cell {
.symbol-name {
font-weight: 600;
display: block;
}
.symbol-strategy {
font-size: 11px;
color: @text-secondary-light;
}
}
.side-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
&.long {
background: rgba(16, 185, 129, 0.1);
color: @green;
}
&.short {
background: rgba(239, 68, 68, 0.1);
color: @red;
}
}
.type-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
&.long {
background: rgba(16, 185, 129, 0.1);
color: @green;
}
&.short {
background: rgba(239, 68, 68, 0.1);
color: @red;
}
&.close-long {
background: rgba(245, 158, 11, 0.1);
color: @amber;
}
&.close-short {
background: rgba(139, 92, 246, 0.1);
color: @purple;
}
}
.symbol-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
background: rgba(59, 130, 246, 0.1);
color: @blue;
}
.exchange-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
&.binance { background: rgba(240, 185, 11, 0.1); color: #f0b90b; }
&.okx { background: rgba(139, 92, 246, 0.1); color: @purple; }
&.bitget { background: rgba(6, 182, 212, 0.1); color: @cyan; }
&.signal { background: rgba(59, 130, 246, 0.1); color: @blue; }
}
.market-type {
font-size: 10px;
color: @text-secondary-light;
margin-top: 2px;
}
.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
&.pending { background: rgba(245, 158, 11, 0.1); color: @amber; }
&.processing { background: rgba(59, 130, 246, 0.1); color: @blue; }
&.completed { background: rgba(16, 185, 129, 0.1); color: @green; }
&.failed { background: rgba(239, 68, 68, 0.1); color: @red; }
&.cancelled { background: rgba(100, 116, 139, 0.1); color: @text-secondary-light; }
}
.error-hint {
font-size: 11px;
color: @red;
margin-top: 4px;
cursor: pointer;
.anticon { margin-right: 4px; }
}
.notify-icons {
display: flex;
gap: 8px;
.notify-icon {
color: @text-secondary-light;
font-size: 14px;
}
}
.pnl-cell {
text-align: right;
.pnl-percent {
display: block;
font-size: 11px;
}
}
.time-cell {
font-size: 12px;
color: @text-secondary-light;
}
.sub-text {
font-size: 11px;
color: @text-secondary-light;
}
.text-muted {
color: @text-secondary-light;
}
.positive { color: @green; }
.negative { color: @red; }
// Responsive
@media (max-width: 768px) {
padding: 12px;
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.kpi-card {
padding: 14px;
.kpi-value .amount {
font-size: 22px;
}
&.kpi-win-rate .kpi-ring {
width: 48px;
height: 48px;
right: 8px;
}
}
.chart-panel .chart-body {
height: 260px;
padding: 12px;
&.chart-sm { height: 180px; }
}
.ranking-grid {
grid-template-columns: 1fr;
}
}
}
</style>