Refactor code structure for improved readability and maintainability

This commit is contained in:
dienakdz
2026-04-07 09:12:02 +07:00
parent 0818028b4c
commit aa09e85199
8 changed files with 1954 additions and 345 deletions
+14
View File
@@ -17,6 +17,7 @@ const api = {
trades: '/api/strategies/trades',
positions: '/api/strategies/positions',
equityCurve: '/api/strategies/equityCurve',
logs: '/api/strategies/logs',
notifications: '/api/strategies/notifications'
}
@@ -221,6 +222,19 @@ export function getStrategyEquityCurve (id) {
})
}
/**
* 获取策略运行日志
* @param {number} id - 策略ID
* @param {number} limit - 最大日志条数
*/
export function getStrategyLogs (id, limit = 200) {
return request({
url: api.logs,
method: 'get',
params: { id, limit }
})
}
/**
* Strategy signal notifications (browser channel persistence).
* @param {Object} params
+130 -1
View File
@@ -123,7 +123,7 @@
</div>
<!-- 运行策略 -->
<div class="kpi-card kpi-strategies clickable" @click="$router.push('/trading-assistant')">
<div class="kpi-card kpi-strategies clickable" @click="goToStrategyManagement">
<div class="kpi-content">
<div class="kpi-header">
<span class="kpi-icon">
@@ -146,6 +146,24 @@
</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">
<!-- 收益日历 -->
@@ -501,6 +519,12 @@ import { mapState } from 'vuex'
export default {
name: 'Dashboard',
props: {
hideSetupGuide: {
type: Boolean,
default: false
}
},
data () {
return {
summary: {
@@ -549,6 +573,12 @@ export default {
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 || []
},
@@ -738,6 +768,16 @@ export default {
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()
@@ -1446,9 +1486,80 @@ export default {
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;
@@ -1563,6 +1674,24 @@ export default {
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;
@@ -369,6 +369,13 @@
@click.stop="handlePublishIndicator(indicator)"
/>
</a-tooltip>
<a-tooltip :title="tt('trading-assistant.createStrategy', 'Create Strategy')">
<a-icon
type="rocket"
class="action-icon create-strategy-icon"
@click.stop="handleCreateStrategyFromIndicator(indicator)"
/>
</a-tooltip>
</div>
</div>
<span class="card-desc">{{ indicator.description || '' }}</span>
@@ -380,6 +387,69 @@
<span>{{ $t('dashboard.indicator.empty') }}</span>
</div>
</div>
<div class="indicator-section" :class="{ 'section-empty': purchasedIndicators.length === 0 }">
<div class="section-label">
<div class="section-label-left" @click="purchasedSectionCollapsed = !purchasedSectionCollapsed">
<a-icon :type="purchasedSectionCollapsed ? 'right' : 'down'" class="collapse-icon" />
<span>{{ $t('dashboard.indicator.section.purchased') }} ({{ purchasedIndicators.length }})</span>
</div>
<a-button
type="link"
size="small"
icon="shop"
class="buy-indicator-btn"
@click.stop="goToIndicatorMarket"
>
{{ $t('menu.dashboard.community') }}
</a-button>
</div>
<div v-show="!purchasedSectionCollapsed" class="section-content custom-scrollbar">
<div
v-for="indicator in purchasedIndicators"
:key="'purchased-' + indicator.id"
:class="['indicator-card', 'purchased-indicator', { 'indicator-active': isIndicatorActive('purchased-' + indicator.id) }]"
@click="toggleIndicator(indicator, 'purchased')"
>
<div class="card-content">
<div class="card-header">
<span class="card-name">
<a-icon type="shopping" class="purchased-icon" />
{{ indicator.name }}
</span>
<div class="card-actions">
<a-tooltip :title="isIndicatorActive('purchased-' + indicator.id) ? $t('dashboard.indicator.action.stop') : $t('dashboard.indicator.action.start')">
<a-icon
:type="isIndicatorActive('purchased-' + indicator.id) ? 'pause-circle' : 'play-circle'"
:class="['action-icon', 'toggle-icon', { active: isIndicatorActive('purchased-' + indicator.id) }]"
@click.stop="toggleIndicator(indicator, 'purchased')"
/>
</a-tooltip>
<a-tooltip :title="$t('dashboard.indicator.backtest.title')">
<a-icon
type="experiment"
class="action-icon backtest-icon"
@click.stop="handleOpenBacktest(indicator)"
/>
</a-tooltip>
<a-tooltip :title="$t('dashboard.indicator.backtest.historyTitle')">
<a-icon
type="clock-circle"
class="action-icon backtest-history-icon"
@click.stop="handleOpenBacktestHistory(indicator)"
/>
</a-tooltip>
</div>
</div>
<span class="card-desc">{{ indicator.description || '' }}</span>
</div>
</div>
<div v-if="purchasedIndicators.length === 0" class="empty-indicators">
<a-icon type="shopping" />
<span>{{ $t('dashboard.indicator.emptyPurchased') }}</span>
</div>
</div>
</div>
</div>
</template>
@@ -3001,23 +3071,23 @@ getMarketColor,
}
.mobile-tab-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden; /* 不在这里滚动,让section-content滚动 */
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
height: 100%;
width: 100%;
-webkit-overflow-scrolling: touch;
}
.section-content {
flex: 1;
overflow-y: auto !important; /* 只有这里滚动 */
flex: none;
overflow: visible !important;
overflow-x: hidden;
padding: 12px;
min-height: 0; /* 使用flex: 1来占据剩余空间 */
height: 100%; /* 使用100%高度,让flex生效 */
-webkit-overflow-scrolling: touch;
min-height: auto;
height: auto;
position: relative;
}
@@ -0,0 +1,485 @@
<template>
<div class="performance-analysis" :class="{ 'theme-dark': isDark }">
<a-spin :spinning="loading">
<div v-if="hasData" class="performance-shell">
<div class="metrics-grid">
<div class="metric-card" :class="getMetricClass(metrics.totalReturn)">
<div class="metric-label">{{ tt('trading-assistant.performance.totalReturn', 'Total Return') }}</div>
<div class="metric-value">{{ formatPercent(metrics.totalReturn) }}</div>
</div>
<div class="metric-card" :class="getMetricClass(metrics.annualReturn)">
<div class="metric-label">{{ tt('trading-assistant.performance.annualReturn', 'Annual Return') }}</div>
<div class="metric-value">{{ formatPercent(metrics.annualReturn) }}</div>
</div>
<div class="metric-card negative">
<div class="metric-label">{{ tt('trading-assistant.performance.maxDrawdown', 'Max Drawdown') }}</div>
<div class="metric-value">{{ formatPercent(metrics.maxDrawdown) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ tt('trading-assistant.performance.sharpe', 'Sharpe') }}</div>
<div class="metric-value">{{ formatNumber(metrics.sharpe) }}</div>
</div>
<div class="metric-card" :class="getMetricClass(metrics.winRate - 0.5)">
<div class="metric-label">{{ tt('trading-assistant.performance.winRate', 'Win Rate') }}</div>
<div class="metric-value">{{ formatPercent(metrics.winRate) }}</div>
</div>
<div class="metric-card" :class="getMetricClass((metrics.profitFactor || 0) - 1)">
<div class="metric-label">{{ tt('trading-assistant.performance.profitFactor', 'Profit Factor') }}</div>
<div class="metric-value">{{ formatNumber(metrics.profitFactor) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ tt('trading-assistant.performance.totalTrades', 'Trades') }}</div>
<div class="metric-value">{{ metrics.totalTrades || 0 }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ tt('trading-assistant.performance.runningDays', 'Running Days') }}</div>
<div class="metric-value">{{ metrics.runningDays || 0 }}</div>
</div>
</div>
<div class="chart-section">
<div class="chart-title">{{ tt('trading-assistant.performance.equityCurve', 'Equity Curve') }}</div>
<div ref="equityChart" class="chart-container"></div>
</div>
<div class="chart-section">
<div class="chart-title">{{ tt('trading-assistant.performance.dailyReturns', 'Daily Returns') }}</div>
<div ref="dailyChart" class="chart-container chart-container-sm"></div>
</div>
</div>
<a-empty
v-else-if="!loading"
class="performance-empty"
:description="tt('trading-assistant.performance.noData', 'No performance data yet')"
/>
</a-spin>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { getStrategyEquityCurve } from '@/api/strategy'
export default {
name: 'PerformanceAnalysis',
props: {
strategyId: {
type: [Number, String],
default: null
},
isDark: {
type: Boolean,
default: false
}
},
data () {
return {
loading: false,
metrics: {},
equityData: [],
dailyReturns: [],
equityChartInstance: null,
dailyChartInstance: null
}
},
computed: {
hasData () {
return this.equityData.length > 0
}
},
watch: {
strategyId: {
immediate: true,
handler (value) {
if (value) {
this.loadData()
} else {
this.resetState()
}
}
},
isDark () {
this.$nextTick(() => {
this.renderCharts()
})
}
},
mounted () {
window.addEventListener('resize', this.handleResize)
},
beforeDestroy () {
window.removeEventListener('resize', this.handleResize)
this.disposeCharts()
},
methods: {
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
},
resetState () {
this.metrics = {}
this.equityData = []
this.dailyReturns = []
this.disposeCharts()
},
async loadData () {
if (!this.strategyId) {
this.resetState()
return
}
this.loading = true
try {
const res = await getStrategyEquityCurve(this.strategyId)
const rawData = res && res.data
const curve = Array.isArray(rawData)
? rawData
: (rawData && Array.isArray(rawData.equity_curve) ? rawData.equity_curve : [])
this.equityData = curve
.map(item => ({
time: item.time || item.timestamp || item.created_at,
equity: Number(item.equity ?? item.value ?? item.y ?? 0),
trade_count: Number(item.trade_count || 0),
win_count: Number(item.win_count || 0),
gross_profit: Number(item.gross_profit || 0),
gross_loss: Number(item.gross_loss || 0)
}))
.filter(item => Number.isFinite(item.equity))
this.computeMetrics()
this.$nextTick(() => {
this.renderCharts()
})
} catch (error) {
this.resetState()
} finally {
this.loading = false
}
},
computeMetrics () {
if (!this.equityData.length) {
this.metrics = {}
this.dailyReturns = []
return
}
const values = this.equityData.map(item => item.equity)
const first = values[0] || 1
const last = values[values.length - 1] || first
const totalReturn = first > 0 ? (last - first) / first : 0
let peak = first || 1
let maxDrawdown = 0
const returns = []
for (let i = 1; i < values.length; i++) {
const prev = values[i - 1]
const current = values[i]
if (current > peak) {
peak = current
}
if (peak > 0) {
maxDrawdown = Math.min(maxDrawdown, (current - peak) / peak)
}
if (prev > 0) {
returns.push((current - prev) / prev)
}
}
this.dailyReturns = returns
const periods = Math.max(values.length - 1, 1)
const annualFactor = 365 / periods
const annualReturn = first > 0 && last > 0 ? Math.pow(last / first, annualFactor) - 1 : 0
const mean = returns.length ? returns.reduce((sum, value) => sum + value, 0) / returns.length : 0
const variance = returns.length > 1
? returns.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / (returns.length - 1)
: 0
const std = variance > 0 ? Math.sqrt(variance) : 0
const sharpe = std > 0 ? (mean / std) * Math.sqrt(365) : 0
const winningReturns = returns.filter(value => value > 0)
const losingReturns = returns.filter(value => value < 0)
const grossProfit = losingReturns.length || winningReturns.length
? winningReturns.reduce((sum, value) => sum + value, 0)
: this.equityData.reduce((sum, item) => sum + Math.max(item.gross_profit || 0, 0), 0)
const grossLoss = losingReturns.length
? Math.abs(losingReturns.reduce((sum, value) => sum + value, 0))
: Math.abs(this.equityData.reduce((sum, item) => sum + Math.min(item.gross_loss || 0, 0), 0))
this.metrics = {
totalReturn,
annualReturn: Number.isFinite(annualReturn) ? annualReturn : 0,
maxDrawdown: Math.abs(maxDrawdown),
sharpe: Number.isFinite(sharpe) ? sharpe : 0,
winRate: returns.length ? winningReturns.length / returns.length : 0,
profitFactor: grossLoss > 0 ? grossProfit / grossLoss : (grossProfit > 0 ? grossProfit : 0),
totalTrades: Math.max(values.length - 1, 0),
runningDays: values.length
}
},
renderCharts () {
if (!this.hasData) {
this.disposeCharts()
return
}
this.renderEquityChart()
this.renderDailyChart()
},
renderEquityChart () {
if (!this.$refs.equityChart) return
if (!this.equityChartInstance) {
this.equityChartInstance = echarts.init(this.$refs.equityChart)
}
const textColor = this.isDark ? '#d1d4dc' : '#1f2937'
const axisColor = this.isDark ? '#4b5563' : '#dbe2ea'
const areaTop = this.isDark ? 'rgba(34, 197, 94, 0.35)' : 'rgba(34, 197, 94, 0.22)'
const areaBottom = this.isDark ? 'rgba(59, 130, 246, 0.04)' : 'rgba(59, 130, 246, 0.02)'
this.equityChartInstance.setOption({
backgroundColor: 'transparent',
grid: { left: 16, right: 16, top: 24, bottom: 30, containLabel: true },
tooltip: {
trigger: 'axis',
backgroundColor: this.isDark ? '#111827' : '#ffffff',
borderColor: axisColor,
textStyle: { color: textColor },
formatter: (params) => {
const point = params && params[0]
if (!point) return ''
return `${point.axisValueLabel}<br/>${this.formatCurrency(point.data)}`
}
},
xAxis: {
type: 'category',
boundaryGap: false,
axisLine: { lineStyle: { color: axisColor } },
axisLabel: { color: textColor, margin: 12 },
data: this.equityData.map(item => this.formatAxisTime(item.time))
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: axisColor, opacity: 0.55 } },
axisLabel: {
color: textColor,
formatter: value => this.formatCurrency(value)
}
},
series: [{
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: { width: 3, color: '#22c55e' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: areaTop },
{ offset: 1, color: areaBottom }
])
},
data: this.equityData.map(item => item.equity)
}]
})
},
renderDailyChart () {
if (!this.$refs.dailyChart) return
if (!this.dailyChartInstance) {
this.dailyChartInstance = echarts.init(this.$refs.dailyChart)
}
const textColor = this.isDark ? '#d1d4dc' : '#1f2937'
const axisColor = this.isDark ? '#4b5563' : '#dbe2ea'
this.dailyChartInstance.setOption({
backgroundColor: 'transparent',
grid: { left: 16, right: 16, top: 20, bottom: 30, containLabel: true },
tooltip: {
trigger: 'axis',
backgroundColor: this.isDark ? '#111827' : '#ffffff',
borderColor: axisColor,
textStyle: { color: textColor },
formatter: (params) => {
const point = params && params[0]
if (!point) return ''
return `${point.axisValueLabel}<br/>${this.formatPercent(point.data)}`
}
},
xAxis: {
type: 'category',
axisLine: { lineStyle: { color: axisColor } },
axisLabel: { color: textColor },
data: this.dailyReturns.map((_, index) => `#${index + 1}`)
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: axisColor, opacity: 0.55 } },
axisLabel: {
color: textColor,
formatter: value => this.formatPercent(value)
}
},
series: [{
type: 'bar',
barWidth: '62%',
data: this.dailyReturns.map(value => ({
value,
itemStyle: {
color: value >= 0 ? '#22c55e' : '#ef4444',
borderRadius: value >= 0 ? [6, 6, 0, 0] : [0, 0, 6, 6]
}
}))
}]
})
},
handleResize () {
if (this.equityChartInstance) {
this.equityChartInstance.resize()
}
if (this.dailyChartInstance) {
this.dailyChartInstance.resize()
}
},
disposeCharts () {
if (this.equityChartInstance) {
this.equityChartInstance.dispose()
this.equityChartInstance = null
}
if (this.dailyChartInstance) {
this.dailyChartInstance.dispose()
this.dailyChartInstance = null
}
},
formatAxisTime (time) {
if (!time) return '--'
const raw = Number(time)
const date = Number.isFinite(raw)
? new Date(raw < 1e12 ? raw * 1000 : raw)
: new Date(time)
if (Number.isNaN(date.getTime())) return '--'
return date.toLocaleDateString(this.$i18n.locale === 'zh-CN' ? 'zh-CN' : 'en-US', {
month: 'short',
day: 'numeric'
})
},
formatPercent (value) {
const number = Number(value || 0) * 100
return `${number >= 0 ? '+' : ''}${number.toFixed(2)}%`
},
formatCurrency (value) {
const number = Number(value || 0)
return `$${number.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
},
formatNumber (value) {
return Number(value || 0).toFixed(2)
},
getMetricClass (value) {
return {
positive: Number(value) > 0,
negative: Number(value) < 0
}
}
}
}
</script>
<style lang="less" scoped>
.performance-analysis {
width: 100%;
.performance-shell {
display: flex;
flex-direction: column;
gap: 18px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.metric-card {
padding: 14px 16px;
border: 1px solid #e5edf5;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
&.positive {
border-color: rgba(34, 197, 94, 0.32);
}
&.negative {
border-color: rgba(239, 68, 68, 0.28);
}
}
.metric-label {
color: #64748b;
font-size: 12px;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.metric-value {
margin-top: 8px;
color: #0f172a;
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.chart-section {
border: 1px solid #e5edf5;
border-radius: 18px;
padding: 16px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
}
.chart-title {
margin-bottom: 14px;
color: #0f172a;
font-size: 15px;
font-weight: 600;
}
.chart-container {
height: 280px;
}
.chart-container-sm {
height: 240px;
}
.performance-empty {
padding: 54px 0;
}
&.theme-dark {
.metric-card,
.chart-section {
background: linear-gradient(180deg, #151d2f 0%, #101827 100%);
border-color: #25324a;
box-shadow: none;
}
.metric-label {
color: #94a3b8;
}
.metric-value,
.chart-title {
color: #e2e8f0;
}
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="position-records">
<div class="position-records" :class="{ 'theme-dark': isDark }">
<div v-if="positions.length === 0 && !loading" class="empty-state">
<a-empty :description="$t('trading-assistant.table.noPositions')" />
</div>
@@ -68,6 +68,10 @@ export default {
loading: {
type: Boolean,
default: false
},
isDark: {
type: Boolean,
default: false
}
},
data () {
@@ -0,0 +1,290 @@
<template>
<div class="strategy-logs" :class="{ 'theme-dark': isDark }">
<div class="logs-toolbar">
<div class="toolbar-left">
<a-radio-group v-model="filterLevel" size="small" button-style="solid">
<a-radio-button value="all">
{{ tt('common.all', 'All') }} ({{ logs.length }})
</a-radio-button>
<a-radio-button value="trade">
{{ tt('trading-assistant.logs.level.trade', 'Trade') }} ({{ countByLevel('trade') }})
</a-radio-button>
<a-radio-button value="signal">
{{ tt('trading-assistant.logs.level.signal', 'Signal') }} ({{ countByLevel('signal') }})
</a-radio-button>
<a-radio-button value="error">
{{ tt('trading-assistant.logs.level.error', 'Error') }} ({{ countByLevel('error') }})
</a-radio-button>
</a-radio-group>
</div>
<div class="toolbar-right">
<a-switch size="small" :checked="autoRefresh" @change="toggleAutoRefresh" />
<span class="auto-refresh-label">{{ tt('trading-assistant.logs.autoRefresh', 'Auto refresh') }}</span>
</div>
</div>
<a-spin :spinning="loading">
<div ref="logsContainer" class="logs-container custom-scrollbar">
<div v-if="filteredLogs.length === 0" class="logs-empty">
<a-icon type="file-text" />
<p>{{ tt('trading-assistant.logs.noLogs', 'No logs yet') }}</p>
</div>
<div
v-for="item in filteredLogs"
:key="item.id || `${item.timestamp}-${item.message}`"
class="log-entry"
:class="`level-${item.level || 'info'}`"
>
<span class="log-time">{{ formatTime(item.timestamp) }}</span>
<a-tag class="log-level" size="small" :color="getLevelColor(item.level)">
{{ getLevelText(item.level) }}
</a-tag>
<span class="log-message">{{ item.message }}</span>
</div>
</div>
</a-spin>
</div>
</template>
<script>
import { getStrategyLogs } from '@/api/strategy'
export default {
name: 'StrategyLogs',
props: {
strategyId: {
type: [Number, String],
default: null
},
isDark: {
type: Boolean,
default: false
}
},
data () {
return {
logs: [],
filterLevel: 'all',
autoRefresh: false,
refreshTimer: null,
loading: false
}
},
computed: {
filteredLogs () {
if (this.filterLevel === 'all') {
return this.logs
}
return this.logs.filter(item => item.level === this.filterLevel)
}
},
watch: {
strategyId: {
immediate: true,
handler (value) {
if (value) {
this.loadLogs()
} else {
this.logs = []
}
}
}
},
beforeDestroy () {
this.stopAutoRefresh()
},
methods: {
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
},
async loadLogs () {
if (!this.strategyId) return
this.loading = true
try {
const res = await getStrategyLogs(this.strategyId, 200)
if (res && res.code === 1 && Array.isArray(res.data)) {
this.logs = res.data
this.$nextTick(() => {
this.scrollToBottom()
})
}
} catch (error) {
} finally {
this.loading = false
}
},
toggleAutoRefresh (checked) {
this.autoRefresh = checked
if (checked) {
this.stopAutoRefresh()
this.refreshTimer = setInterval(() => {
this.loadLogs()
}, 5000)
} else {
this.stopAutoRefresh()
}
},
stopAutoRefresh () {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
this.refreshTimer = null
}
},
scrollToBottom () {
const element = this.$refs.logsContainer
if (element) {
element.scrollTop = element.scrollHeight
}
},
countByLevel (level) {
return this.logs.filter(item => item.level === level).length
},
formatTime (value) {
if (!value) return '--'
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} catch (error) {
return value
}
},
getLevelColor (level) {
return {
info: 'blue',
warn: 'orange',
error: 'red',
trade: 'green',
signal: 'purple'
}[level] || 'default'
},
getLevelText (level) {
const key = `trading-assistant.logs.level.${level}`
const fallback = {
info: 'Info',
warn: 'Warn',
error: 'Error',
trade: 'Trade',
signal: 'Signal'
}[level] || String(level || 'info')
return this.tt(key, fallback)
}
}
}
</script>
<style lang="less" scoped>
.strategy-logs {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 360px;
.logs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 12px;
}
.logs-container {
max-height: 420px;
overflow-y: auto;
padding: 14px;
border: 1px solid #e5edf5;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.logs-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
color: #94a3b8;
.anticon {
margin-bottom: 12px;
font-size: 32px;
}
}
.log-entry {
display: grid;
grid-template-columns: 88px auto 1fr;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border-radius: 12px;
transition: background-color 0.2s ease;
&:not(:last-child) {
margin-bottom: 6px;
}
&.level-trade {
background: rgba(34, 197, 94, 0.08);
}
&.level-signal {
background: rgba(168, 85, 247, 0.08);
}
&.level-error {
background: rgba(239, 68, 68, 0.08);
}
}
.log-time {
color: #64748b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
font-size: 12px;
line-height: 22px;
}
.log-message {
color: #0f172a;
line-height: 1.55;
word-break: break-word;
}
&.theme-dark {
.toolbar-right {
color: #94a3b8;
}
.logs-container {
background: linear-gradient(180deg, #151d2f 0%, #101827 100%);
border-color: #25324a;
}
.logs-empty,
.log-time {
color: #94a3b8;
}
.log-message {
color: #e2e8f0;
}
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="trading-records">
<div class="trading-records" :class="{ 'theme-dark': isDark }">
<div v-if="records.length === 0 && !loading" class="empty-state">
<a-empty :description="$t('trading-assistant.table.noPositions')" />
</div>
@@ -55,6 +55,10 @@ export default {
loading: {
type: Boolean,
default: false
},
isDark: {
type: Boolean,
default: false
}
},
computed: {
File diff suppressed because it is too large Load Diff