@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
404 page
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: '404'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<a-result status="403" title="403" sub-title="Sorry, you don't have access to this page.">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="toHome">
|
||||
Back Home
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Exception403',
|
||||
methods: {
|
||||
toHome () {
|
||||
this.$router.push({ path: '/' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<a-result status="404" title="404" sub-title="Sorry, the page you visited does not exist.">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="toHome">
|
||||
Back Home
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Exception404',
|
||||
methods: {
|
||||
toHome () {
|
||||
this.$router.push({ path: '/' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<a-result status="500" title="500" sub-title="Sorry, the server is reporting an error.">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="toHome">
|
||||
Back Home
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Exception500',
|
||||
methods: {
|
||||
toHome () {
|
||||
this.$router.push({ path: '/' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
:title="$t('dashboard.indicator.backtest.historyTitle')"
|
||||
:visible="visible"
|
||||
:width="isMobile ? '100%' : 980"
|
||||
:maskClosable="true"
|
||||
@close="$emit('cancel')"
|
||||
>
|
||||
<div style="display:flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
|
||||
<a-switch v-model="useCurrentFilters" />
|
||||
<span style="color:#8c8c8c;">
|
||||
{{ $t('dashboard.indicator.backtest.historyUseCurrent') }}
|
||||
</span>
|
||||
<a-button type="primary" :loading="loading" @click="loadRuns">
|
||||
{{ $t('dashboard.indicator.backtest.historyRefresh') }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="selectedRowKeys.length === 0"
|
||||
:loading="analyzing"
|
||||
@click="handleAIAnalyze"
|
||||
>
|
||||
{{ $t('dashboard.indicator.backtest.historyAIAnalyze') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!useCurrentFilters" style="display:flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
|
||||
<a-input v-model="filterSymbol" style="width: 220px" :placeholder="$t('dashboard.indicator.backtest.historyFilterSymbol')" />
|
||||
<a-select v-model="filterTimeframe" style="width: 140px" :placeholder="$t('dashboard.indicator.backtest.historyFilterTimeframe')" allowClear>
|
||||
<a-select-option v-for="tf in timeframes" :key="tf" :value="tf">{{ tf }}</a-select-option>
|
||||
</a-select>
|
||||
<a-button :loading="loading" @click="loadRuns">{{ $t('dashboard.indicator.backtest.historyApply') }}</a-button>
|
||||
<span style="color:#8c8c8c;">{{ filterLabel }}</span>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="runs"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
:pagination="{ pageSize: 10, size: 'small' }"
|
||||
rowKey="id"
|
||||
:scroll="{ x: 900 }"
|
||||
:rowSelection="{ selectedRowKeys: selectedRowKeys, onChange: onRowSelectionChange }"
|
||||
>
|
||||
<template slot="range" slot-scope="text, record">
|
||||
<span>{{ (record.start_date || '') }} ~ {{ (record.end_date || '') }}</span>
|
||||
</template>
|
||||
<template slot="status" slot-scope="text">
|
||||
<a-tag :color="text === 'success' ? 'green' : text === 'failed' ? 'red' : 'blue'">
|
||||
{{ text === 'success' ? $t('dashboard.indicator.backtest.historyStatusSuccess') : text === 'failed' ? $t('dashboard.indicator.backtest.historyStatusFailed') : text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template slot="actions" slot-scope="text, record">
|
||||
<a-button type="link" size="small" :loading="detailLoadingId === record.id" @click="viewRun(record)">
|
||||
{{ $t('dashboard.indicator.backtest.historyView') }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-empty v-if="!loading && runs.length === 0" :description="$t('dashboard.indicator.backtest.historyNoData')" />
|
||||
|
||||
<a-modal
|
||||
:title="$t('dashboard.indicator.backtest.historyAIAnalyzeTitle')"
|
||||
:visible="showAIResult"
|
||||
:footer="null"
|
||||
:width="isMobile ? '100%' : 900"
|
||||
@cancel="showAIResult = false"
|
||||
>
|
||||
<div v-if="analyzing" style="padding: 12px 0;">
|
||||
<a-spin />
|
||||
</div>
|
||||
<div v-else style="white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;">
|
||||
{{ aiResult || $t('dashboard.indicator.backtest.historyNoAIResult') }}
|
||||
</div>
|
||||
</a-modal>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import request from '@/utils/request'
|
||||
|
||||
export default {
|
||||
name: 'BacktestHistoryDrawer',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
userId: { type: [Number, String], default: 1 },
|
||||
indicatorId: { type: [Number, String], default: null },
|
||||
symbol: { type: String, default: '' },
|
||||
market: { type: String, default: '' },
|
||||
timeframe: { type: String, default: '' },
|
||||
isMobile: { type: Boolean, default: false }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
detailLoadingId: null,
|
||||
analyzing: false,
|
||||
showAIResult: false,
|
||||
aiResult: '',
|
||||
useCurrentFilters: true,
|
||||
filterSymbol: '',
|
||||
filterTimeframe: '',
|
||||
timeframes: ['1m', '5m', '15m', '30m', '1H', '4H', '1D', '1W'],
|
||||
runs: [],
|
||||
columns: [],
|
||||
selectedRowKeys: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filterLabel () {
|
||||
const parts = []
|
||||
if (this.indicatorId) parts.push(`indicatorId=${this.indicatorId}`)
|
||||
const m = this.useCurrentFilters ? this.market : (this.market || '')
|
||||
const s = this.useCurrentFilters ? this.symbol : (this.filterSymbol || '')
|
||||
const tf = this.useCurrentFilters ? this.timeframe : (this.filterTimeframe || '')
|
||||
if (m) parts.push(`market=${m}`)
|
||||
if (s) parts.push(`symbol=${s}`)
|
||||
if (tf) parts.push(`timeframe=${tf}`)
|
||||
return parts.length ? parts.join(' | ') : ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible (val) {
|
||||
if (val) {
|
||||
this.initColumns()
|
||||
this.useCurrentFilters = true
|
||||
this.filterSymbol = this.symbol || ''
|
||||
this.filterTimeframe = this.timeframe || ''
|
||||
this.selectedRowKeys = []
|
||||
this.aiResult = ''
|
||||
this.showAIResult = false
|
||||
this.loadRuns()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onRowSelectionChange (keys) {
|
||||
this.selectedRowKeys = keys || []
|
||||
},
|
||||
initColumns () {
|
||||
if (this.columns.length) return
|
||||
this.columns = [
|
||||
{ title: this.$t('dashboard.indicator.backtest.historyRunId'), dataIndex: 'id', key: 'id', width: 90 },
|
||||
{ title: this.$t('dashboard.indicator.backtest.historyCreatedAt'), dataIndex: 'created_at', key: 'created_at', width: 140 },
|
||||
{ title: this.$t('dashboard.indicator.backtest.tradeDirection'), dataIndex: 'trade_direction', key: 'trade_direction', width: 90 },
|
||||
{ title: this.$t('dashboard.indicator.backtest.leverage'), dataIndex: 'leverage', key: 'leverage', width: 90 },
|
||||
{ title: this.$t('dashboard.indicator.backtest.historyRange'), key: 'range', width: 220, scopedSlots: { customRender: 'range' } },
|
||||
{ title: this.$t('dashboard.indicator.backtest.historyStatus'), dataIndex: 'status', key: 'status', width: 90, scopedSlots: { customRender: 'status' } },
|
||||
{ title: this.$t('dashboard.indicator.backtest.historyActions'), key: 'actions', width: 90, scopedSlots: { customRender: 'actions' } }
|
||||
]
|
||||
},
|
||||
async loadRuns () {
|
||||
if (!this.userId) return
|
||||
this.loading = true
|
||||
try {
|
||||
const symbol = this.useCurrentFilters ? this.symbol : (this.filterSymbol || '')
|
||||
const timeframe = this.useCurrentFilters ? this.timeframe : (this.filterTimeframe || '')
|
||||
const market = this.market || ''
|
||||
const res = await request({
|
||||
url: '/api/indicator/backtest/history',
|
||||
method: 'post',
|
||||
data: {
|
||||
userid: this.userId,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
indicatorId: this.indicatorId,
|
||||
symbol,
|
||||
market,
|
||||
timeframe
|
||||
}
|
||||
})
|
||||
if (res && res.code === 1 && Array.isArray(res.data)) {
|
||||
this.runs = res.data
|
||||
} else {
|
||||
this.runs = []
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async viewRun (record) {
|
||||
if (!record || !record.id) return
|
||||
this.detailLoadingId = record.id
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/api/indicator/backtest/get',
|
||||
method: 'post',
|
||||
data: { userid: this.userId, runId: record.id }
|
||||
})
|
||||
if (res && res.code === 1 && res.data) {
|
||||
this.$emit('view', res.data)
|
||||
}
|
||||
} finally {
|
||||
this.detailLoadingId = null
|
||||
}
|
||||
},
|
||||
async handleAIAnalyze () {
|
||||
if (!this.userId || !this.selectedRowKeys.length) return
|
||||
this.analyzing = true
|
||||
this.showAIResult = true
|
||||
this.aiResult = ''
|
||||
try {
|
||||
const lang = (this.$i18n && this.$i18n.locale) ? this.$i18n.locale : 'zh-CN'
|
||||
const res = await request({
|
||||
url: '/api/indicator/backtest/aiAnalyze',
|
||||
method: 'post',
|
||||
data: { userid: this.userId, runIds: this.selectedRowKeys, lang }
|
||||
})
|
||||
if (res && res.code === 1 && res.data && res.data.analysis) {
|
||||
this.aiResult = res.data.analysis
|
||||
} else {
|
||||
this.aiResult = res.msg || this.$t('dashboard.indicator.backtest.historyNoAIResult')
|
||||
}
|
||||
} finally {
|
||||
this.analyzing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:title="modalTitle"
|
||||
:visible="visible"
|
||||
:width="1100"
|
||||
:maskClosable="false"
|
||||
@cancel="$emit('cancel')"
|
||||
class="backtest-run-viewer"
|
||||
>
|
||||
<div v-if="!run || !run.result" style="padding: 12px 0;">
|
||||
<a-empty :description="$t('dashboard.indicator.backtest.historyNoData')" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<a-alert
|
||||
v-if="run.id"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 12px;"
|
||||
:message="$t('dashboard.indicator.backtest.savedRunId', { id: run.id })"
|
||||
/>
|
||||
|
||||
<!-- Metrics -->
|
||||
<div class="metrics-cards">
|
||||
<div class="metric-card" :class="{ positive: result.totalReturn > 0, negative: result.totalReturn < 0 }">
|
||||
<div class="metric-label">{{ $t('dashboard.indicator.backtest.totalReturn') }}</div>
|
||||
<div class="metric-value">{{ formatPercent(result.totalReturn) }}</div>
|
||||
<div class="metric-amount">{{ formatMoney(result.totalProfit) }}</div>
|
||||
</div>
|
||||
<div class="metric-card" :class="{ positive: result.annualReturn > 0, negative: result.annualReturn < 0 }">
|
||||
<div class="metric-label">{{ $t('dashboard.indicator.backtest.annualReturn') }}</div>
|
||||
<div class="metric-value">{{ formatPercent(result.annualReturn) }}</div>
|
||||
</div>
|
||||
<div class="metric-card negative">
|
||||
<div class="metric-label">{{ $t('dashboard.indicator.backtest.maxDrawdown') }}</div>
|
||||
<div class="metric-value">{{ formatPercent(result.maxDrawdown) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ $t('dashboard.indicator.backtest.sharpeRatio') }}</div>
|
||||
<div class="metric-value">{{ (result.sharpeRatio ?? 0).toFixed(2) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ $t('dashboard.indicator.backtest.winRate') }}</div>
|
||||
<div class="metric-value">{{ formatPercent(result.winRate) }}</div>
|
||||
</div>
|
||||
<div class="metric-card" :class="{ positive: result.profitFactor >= 1.5, negative: result.profitFactor < 1 }">
|
||||
<div class="metric-label">{{ $t('dashboard.indicator.backtest.profitFactor') }}</div>
|
||||
<div class="metric-value">{{ (result.profitFactor ?? 0).toFixed(2) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ $t('dashboard.indicator.backtest.totalTrades') }}</div>
|
||||
<div class="metric-value">{{ result.totalTrades ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card negative">
|
||||
<div class="metric-label">{{ $t('dashboard.indicator.backtest.totalCommission') }}</div>
|
||||
<div class="metric-value">-${{ result.totalCommission ? result.totalCommission.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '0.00' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equity curve -->
|
||||
<div class="chart-section">
|
||||
<div class="chart-title">{{ $t('dashboard.indicator.backtest.equityCurve') }}</div>
|
||||
<div ref="equityChartRef" class="equity-chart"></div>
|
||||
</div>
|
||||
|
||||
<!-- Trades -->
|
||||
<div class="trades-section">
|
||||
<div class="chart-title">{{ $t('dashboard.indicator.backtest.tradeHistory') }}</div>
|
||||
<a-table
|
||||
:columns="tradeColumns"
|
||||
:data-source="result.trades || []"
|
||||
:pagination="{ pageSize: 10, size: 'small' }"
|
||||
size="small"
|
||||
:scroll="{ x: 800 }"
|
||||
:rowKey="rowKey"
|
||||
>
|
||||
<template slot="type" slot-scope="text">
|
||||
<a-tag :color="getTradeTypeColor(text)">
|
||||
{{ getTradeTypeText(text) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template slot="balance" slot-scope="text">
|
||||
<span style="color: #1890ff; font-weight: 500;">
|
||||
${{ text ? text.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '--' }}
|
||||
</span>
|
||||
</template>
|
||||
<template slot="profit" slot-scope="text">
|
||||
<span :style="{ color: text > 0 ? '#52c41a' : text < 0 ? '#f5222d' : '#666' }">
|
||||
{{ formatMoney(text) }}
|
||||
</span>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template slot="footer">
|
||||
<a-button @click="$emit('cancel')">{{ $t('dashboard.indicator.backtest.close') }}</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
name: 'BacktestRunViewer',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
run: { type: Object, default: null }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
equityChart: null,
|
||||
tradeColumns: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
result () {
|
||||
return (this.run && this.run.result) ? this.run.result : {}
|
||||
},
|
||||
modalTitle () {
|
||||
const id = this.run && this.run.id ? `#${this.run.id}` : ''
|
||||
return `${this.$t('dashboard.indicator.backtest.historyTitle')} ${id}`.trim()
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible (val) {
|
||||
if (val) {
|
||||
this.$nextTick(() => {
|
||||
this.initColumns()
|
||||
this.renderEquityChart()
|
||||
})
|
||||
} else {
|
||||
if (this.equityChart) {
|
||||
this.equityChart.dispose()
|
||||
this.equityChart = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
rowKey (record, index) {
|
||||
return index
|
||||
},
|
||||
initColumns () {
|
||||
if (this.tradeColumns.length) return
|
||||
this.tradeColumns = [
|
||||
{ title: this.$t('dashboard.indicator.backtest.tradeTime'), dataIndex: 'time', key: 'time', width: 160 },
|
||||
{ title: this.$t('dashboard.indicator.backtest.tradeType'), dataIndex: 'type', key: 'type', width: 150, scopedSlots: { customRender: 'type' } },
|
||||
{ title: this.$t('dashboard.indicator.backtest.price'), dataIndex: 'price', key: 'price', width: 110 },
|
||||
{ title: this.$t('dashboard.indicator.backtest.amount'), dataIndex: 'amount', key: 'amount', width: 100 },
|
||||
{ title: this.$t('dashboard.indicator.backtest.profit'), dataIndex: 'profit', key: 'profit', width: 110, scopedSlots: { customRender: 'profit' } },
|
||||
{ title: this.$t('dashboard.indicator.backtest.balance'), dataIndex: 'balance', key: 'balance', width: 120, scopedSlots: { customRender: 'balance' } }
|
||||
]
|
||||
},
|
||||
getTradeTypeColor (type) {
|
||||
const colorMap = {
|
||||
buy: 'green',
|
||||
sell: 'red',
|
||||
liquidation: 'orange',
|
||||
open_long: 'green',
|
||||
add_long: 'cyan',
|
||||
close_long: 'orange',
|
||||
close_long_stop: 'red',
|
||||
close_long_profit: 'lime',
|
||||
close_long_trailing: 'gold',
|
||||
reduce_long: 'volcano',
|
||||
open_short: 'red',
|
||||
add_short: 'magenta',
|
||||
close_short: 'blue',
|
||||
close_short_stop: 'red',
|
||||
close_short_profit: 'cyan',
|
||||
close_short_trailing: 'gold',
|
||||
reduce_short: 'volcano'
|
||||
}
|
||||
return colorMap[type] || 'default'
|
||||
},
|
||||
getTradeTypeText (type) {
|
||||
const textMap = {
|
||||
// New format - long
|
||||
open_long: this.$t('dashboard.indicator.backtest.openLong'),
|
||||
add_long: this.$t('dashboard.indicator.backtest.addLong'),
|
||||
close_long: this.$t('dashboard.indicator.backtest.closeLong'),
|
||||
close_long_stop: this.$t('dashboard.indicator.backtest.closeLongStop'),
|
||||
close_long_profit: this.$t('dashboard.indicator.backtest.closeLongProfit'),
|
||||
close_long_trailing: this.$t('dashboard.indicator.backtest.closeLongTrailing'),
|
||||
reduce_long: this.$t('dashboard.indicator.backtest.reduceLong'),
|
||||
// New format - short
|
||||
open_short: this.$t('dashboard.indicator.backtest.openShort'),
|
||||
add_short: this.$t('dashboard.indicator.backtest.addShort'),
|
||||
close_short: this.$t('dashboard.indicator.backtest.closeShort'),
|
||||
close_short_stop: this.$t('dashboard.indicator.backtest.closeShortStop'),
|
||||
close_short_profit: this.$t('dashboard.indicator.backtest.closeShortProfit'),
|
||||
close_short_trailing: this.$t('dashboard.indicator.backtest.closeShortTrailing'),
|
||||
reduce_short: this.$t('dashboard.indicator.backtest.reduceShort'),
|
||||
liquidation: this.$t('dashboard.indicator.backtest.liquidation')
|
||||
}
|
||||
return textMap[type] || type
|
||||
},
|
||||
formatPercent (value) {
|
||||
if (value === null || value === undefined) return '--'
|
||||
const sign = value >= 0 ? '+' : ''
|
||||
return `${sign}${Number(value).toFixed(2)}%`
|
||||
},
|
||||
formatMoney (value) {
|
||||
if (value === null || value === undefined) return '--'
|
||||
const sign = value >= 0 ? '+' : '-'
|
||||
return `${sign}$${Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
},
|
||||
renderEquityChart () {
|
||||
if (!this.$refs.equityChartRef) return
|
||||
if (this.equityChart) this.equityChart.dispose()
|
||||
this.equityChart = echarts.init(this.$refs.equityChartRef)
|
||||
|
||||
const data = this.result.equityCurve || []
|
||||
const dates = data.map(item => item.time || item.date)
|
||||
const equity = data.map(item => item.value !== undefined ? item.value : item.equity)
|
||||
const initialValue = equity[0] || 100000
|
||||
const finalValue = equity[equity.length - 1] || initialValue
|
||||
const isPositive = finalValue >= initialValue
|
||||
const mainColor = isPositive ? '#52c41a' : '#f5222d'
|
||||
const gradientColor = isPositive
|
||||
? [{ offset: 0, color: 'rgba(82, 196, 26, 0.35)' }, { offset: 1, color: 'rgba(82, 196, 26, 0.02)' }]
|
||||
: [{ offset: 0, color: 'rgba(245, 34, 45, 0.35)' }, { offset: 1, color: 'rgba(245, 34, 45, 0.02)' }]
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
|
||||
xAxis: { type: 'category', data: dates, boundaryGap: false },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{
|
||||
name: this.$t('dashboard.indicator.backtest.strategy'),
|
||||
type: 'line',
|
||||
data: equity,
|
||||
smooth: 0.4,
|
||||
symbol: 'none',
|
||||
sampling: 'lttb',
|
||||
lineStyle: { width: 2.5, color: mainColor },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, gradientColor) }
|
||||
}
|
||||
]
|
||||
}
|
||||
this.equityChart.setOption(option)
|
||||
window.addEventListener('resize', () => this.equityChart && this.equityChart.resize())
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.backtest-run-viewer {
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 16px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.metric-card.positive { border-color: rgba(82, 196, 26, 0.35); }
|
||||
.metric-card.negative { border-color: rgba(245, 34, 45, 0.35); }
|
||||
.metric-label { color: #8c8c8c; font-size: 12px; }
|
||||
.metric-value { font-size: 18px; font-weight: 600; margin-top: 4px; }
|
||||
.metric-amount { color: #8c8c8c; font-size: 12px; margin-top: 4px; }
|
||||
|
||||
.chart-section { margin-top: 8px; }
|
||||
.chart-title { font-weight: 600; margin: 8px 0; color: #262626; }
|
||||
.equity-chart { width: 100%; height: 280px; }
|
||||
.trades-section { margin-top: 16px; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="indicator-community-iframe">
|
||||
<iframe
|
||||
class="community-iframe"
|
||||
:src="url"
|
||||
title="Indicator Community"
|
||||
frameborder="0"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
allow="clipboard-read; clipboard-write; fullscreen"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'IndicatorCommunity',
|
||||
data () {
|
||||
return {
|
||||
url: 'https://www.quantdinger.com'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.indicator-community-iframe {
|
||||
position: relative;
|
||||
left: -24px;
|
||||
top: -24px;
|
||||
width: calc(100% + 48px);
|
||||
height: calc(100vh - 120px + 48px);
|
||||
}
|
||||
|
||||
.community-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,539 @@
|
||||
<template>
|
||||
<div class="ai-decision-records">
|
||||
<a-spin :spinning="loading">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="decisions"
|
||||
:pagination="paginationConfig"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template slot="reasoning" slot-scope="text">
|
||||
<a-tooltip :title="text">
|
||||
<div class="reasoning-text">{{ text }}</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template slot="decisions" slot-scope="decisionList">
|
||||
<a-tag
|
||||
v-for="(d, index) in decisionList"
|
||||
:key="index"
|
||||
:color="getActionColor(d.action)"
|
||||
style="margin-right: 4px;"
|
||||
>
|
||||
{{ getActionText(d.action) }}: {{ d.symbol }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template slot="confidence" slot-scope="confidence">
|
||||
<a-progress
|
||||
:percent="Math.round(confidence * 100)"
|
||||
:status="confidence > 0.7 ? 'success' : confidence > 0.4 ? 'active' : 'exception'"
|
||||
:show-info="true"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getAIDecisions } from '@/api/ai-trading'
|
||||
|
||||
export default {
|
||||
name: 'AIDecisionRecords',
|
||||
props: {
|
||||
strategyId: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
decisions: [],
|
||||
pagination: {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
paginationConfig () {
|
||||
return {
|
||||
...this.pagination,
|
||||
showTotal: (total) => this.$t('ai-trading-assistant.table.totalRecords', { total }),
|
||||
onChange: this.handlePageChange,
|
||||
onShowSizeChange: this.handlePageSizeChange
|
||||
}
|
||||
},
|
||||
columns () {
|
||||
return [
|
||||
{
|
||||
title: this.$t('ai-trading-assistant.table.time'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
customRender: (text) => {
|
||||
return new Date(text * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: this.$t('ai-trading-assistant.table.reasoning'),
|
||||
dataIndex: 'decision.reasoning',
|
||||
key: 'reasoning',
|
||||
width: 300,
|
||||
scopedSlots: { customRender: 'reasoning' }
|
||||
},
|
||||
{
|
||||
title: this.$t('ai-trading-assistant.table.decisions'),
|
||||
dataIndex: 'decision.decisions',
|
||||
key: 'decisions',
|
||||
width: 200,
|
||||
scopedSlots: { customRender: 'decisions' }
|
||||
},
|
||||
{
|
||||
title: this.$t('ai-trading-assistant.table.riskAssessment'),
|
||||
dataIndex: 'decision.risk_assessment',
|
||||
key: 'risk_assessment',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: this.$t('ai-trading-assistant.table.confidence'),
|
||||
dataIndex: 'decision.confidence',
|
||||
key: 'confidence',
|
||||
width: 150,
|
||||
scopedSlots: { customRender: 'confidence' }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
strategyId: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
this.loadDecisions()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadDecisions () {
|
||||
if (!this.strategyId) return
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await getAIDecisions(this.strategyId, {
|
||||
page: this.pagination.current,
|
||||
limit: this.pagination.pageSize
|
||||
})
|
||||
if (res.code === 1) {
|
||||
// 处理返回的数据格式
|
||||
const data = res.data
|
||||
let decisions = []
|
||||
let total = 0
|
||||
|
||||
// 兼容新旧格式
|
||||
if (data && typeof data === 'object') {
|
||||
if (Array.isArray(data)) {
|
||||
// 旧格式:直接是数组
|
||||
decisions = data
|
||||
total = data.length
|
||||
} else if (data.decisions) {
|
||||
// 新格式:包含 decisions 和 total
|
||||
decisions = data.decisions || []
|
||||
total = data.total || 0
|
||||
}
|
||||
}
|
||||
|
||||
this.decisions = decisions.map(item => ({
|
||||
...item,
|
||||
decision: item.decision || {}
|
||||
}))
|
||||
this.pagination.total = total
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(this.$t('ai-trading-assistant.messages.loadDecisionsFailed'))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
getActionColor (action) {
|
||||
const colorMap = {
|
||||
buy: 'green',
|
||||
sell: 'red',
|
||||
short: 'orange',
|
||||
close_short: 'blue',
|
||||
hold: 'cyan'
|
||||
}
|
||||
return colorMap[action] || 'default'
|
||||
},
|
||||
getActionText (action) {
|
||||
const actionMap = {
|
||||
buy: this.$t('ai-trading-assistant.table.buy'),
|
||||
sell: this.$t('ai-trading-assistant.table.sell'),
|
||||
short: this.$t('ai-trading-assistant.table.short'),
|
||||
close_short: this.$t('ai-trading-assistant.table.closeShort'),
|
||||
hold: this.$t('ai-trading-assistant.table.hold')
|
||||
}
|
||||
return actionMap[action] || action
|
||||
},
|
||||
handlePageChange (page, pageSize) {
|
||||
this.pagination.current = page
|
||||
this.pagination.pageSize = pageSize
|
||||
this.loadDecisions()
|
||||
},
|
||||
handlePageSizeChange (current, size) {
|
||||
this.pagination.current = 1
|
||||
this.pagination.pageSize = size
|
||||
this.loadDecisions()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.ai-decision-records {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
color: var(--text-color, #1f1f1f);
|
||||
|
||||
.reasoning-text {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-color, #1f1f1f);
|
||||
}
|
||||
|
||||
// 确保 hold 标签(cyan)在所有主题下都有足够的对比度
|
||||
/deep/ .ant-tag {
|
||||
&.ant-tag-cyan {
|
||||
background-color: #13c2c2 !important;
|
||||
border-color: #13c2c2 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 表格容器横向滚动
|
||||
/deep/ .ant-table-wrapper {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
// 适配主题色
|
||||
/deep/ .ant-table {
|
||||
background: var(--table-row-bg, #fff);
|
||||
color: var(--text-color, #1f1f1f);
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: var(--table-header-bg, #fafafa);
|
||||
color: var(--text-color, #1f1f1f);
|
||||
border-bottom-color: var(--table-border-color, #e8e8e8);
|
||||
|
||||
.ant-table-column-title {
|
||||
color: var(--text-color, #1f1f1f);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background-color: var(--hover-bg-color, #fafafa);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
background: var(--table-row-bg, #fff);
|
||||
color: var(--text-color, #1f1f1f);
|
||||
border-bottom-color: var(--table-border-color, #e8e8e8);
|
||||
}
|
||||
|
||||
.ant-table-body {
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--table-row-bg, #fff);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #bfbfbf;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条使用主题色
|
||||
/deep/ .ant-progress {
|
||||
.ant-progress-bg {
|
||||
background-color: var(--primary-color, #1890ff);
|
||||
}
|
||||
|
||||
&.ant-progress-status-success .ant-progress-bg {
|
||||
background-color: #52c41a;
|
||||
}
|
||||
|
||||
&.ant-progress-status-exception .ant-progress-bg {
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #555;
|
||||
}
|
||||
}
|
||||
|
||||
// 分页器主题适配
|
||||
/deep/ .ant-pagination {
|
||||
color: var(--text-color, #1f1f1f);
|
||||
|
||||
.ant-pagination-total-text {
|
||||
color: var(--text-color-secondary, #8c8c8c);
|
||||
}
|
||||
|
||||
.ant-pagination-item {
|
||||
background: var(--table-row-bg, #fff);
|
||||
border-color: var(--table-border-color, #e8e8e8);
|
||||
|
||||
a {
|
||||
color: var(--text-color, #1f1f1f);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color, #1890ff);
|
||||
|
||||
a {
|
||||
color: var(--primary-color, #1890ff);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-pagination-item-active {
|
||||
background: var(--primary-color, #1890ff);
|
||||
border-color: var(--primary-color, #1890ff);
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-prev,
|
||||
.ant-pagination-next {
|
||||
.ant-pagination-item-link {
|
||||
background: var(--table-row-bg, #fff);
|
||||
border-color: var(--table-border-color, #e8e8e8);
|
||||
color: var(--text-color, #1f1f1f);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color, #1890ff);
|
||||
color: var(--primary-color, #1890ff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-options {
|
||||
.ant-select-selector {
|
||||
background: var(--table-row-bg, #fff);
|
||||
border-color: var(--table-border-color, #e8e8e8);
|
||||
color: var(--text-color, #1f1f1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
// 全局样式:暗色主题下的标签优化
|
||||
.trading-assistant.theme-dark {
|
||||
.ai-decision-records {
|
||||
/deep/ .ant-table {
|
||||
background: var(--table-row-bg, #1e222d);
|
||||
color: var(--text-color, #d1d4dc);
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: var(--table-header-bg, #252932) !important;
|
||||
color: var(--text-color, #d1d4dc) !important;
|
||||
border-bottom-color: var(--table-border-color, #2a2e39);
|
||||
|
||||
.ant-table-column-title {
|
||||
color: var(--text-color, #d1d4dc) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
background: var(--table-row-bg, #1e222d);
|
||||
color: var(--text-color, #d1d4dc);
|
||||
border-bottom-color: var(--table-border-color, #2a2e39);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background-color: var(--table-hover-bg, #252932);
|
||||
}
|
||||
|
||||
.ant-table-body {
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--table-row-bg, #1e222d);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
|
||||
&:hover {
|
||||
background: #777;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reasoning-text {
|
||||
color: var(--text-color, #d1d4dc);
|
||||
}
|
||||
|
||||
/deep/ .ant-tag {
|
||||
// hold 标签使用青色,确保在暗色背景下可见
|
||||
&.ant-tag-cyan {
|
||||
background-color: #13c2c2 !important;
|
||||
border-color: #13c2c2 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
// 其他标签在暗色主题下的优化
|
||||
&.ant-tag-green {
|
||||
background-color: #52c41a !important;
|
||||
border-color: #52c41a !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
&.ant-tag-red {
|
||||
background-color: #ff4d4f !important;
|
||||
border-color: #ff4d4f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
&.ant-tag-orange {
|
||||
background-color: #fa8c16 !important;
|
||||
border-color: #fa8c16 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
&.ant-tag-blue {
|
||||
background-color: var(--primary-color, #1890ff) !important;
|
||||
border-color: var(--primary-color, #1890ff) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
// default 标签在暗色主题下的优化
|
||||
&.ant-tag-default {
|
||||
background-color: #434343 !important;
|
||||
border-color: #434343 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条在暗色主题下使用主题色
|
||||
/deep/ .ant-progress {
|
||||
.ant-progress-bg {
|
||||
background-color: var(--primary-color, #1890ff);
|
||||
}
|
||||
|
||||
&.ant-progress-status-success .ant-progress-bg {
|
||||
background-color: #52c41a;
|
||||
}
|
||||
|
||||
&.ant-progress-status-exception .ant-progress-bg {
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题下的滚动条样式
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #2a2e39;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
|
||||
&:hover {
|
||||
background: #777;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题下的分页器
|
||||
/deep/ .ant-pagination {
|
||||
color: var(--text-color, #d1d4dc);
|
||||
|
||||
.ant-pagination-total-text {
|
||||
color: var(--text-color-secondary, #868993);
|
||||
}
|
||||
|
||||
.ant-pagination-item {
|
||||
background: var(--table-row-bg, #1e222d);
|
||||
border-color: var(--table-border-color, #2a2e39);
|
||||
|
||||
a {
|
||||
color: var(--text-color, #d1d4dc);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color, #1890ff);
|
||||
|
||||
a {
|
||||
color: var(--primary-color, #1890ff);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-pagination-item-active {
|
||||
background: var(--primary-color, #1890ff);
|
||||
border-color: var(--primary-color, #1890ff);
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-prev,
|
||||
.ant-pagination-next {
|
||||
.ant-pagination-item-link {
|
||||
background: var(--table-row-bg, #1e222d);
|
||||
border-color: var(--table-border-color, #2a2e39);
|
||||
color: var(--text-color, #d1d4dc);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color, #1890ff);
|
||||
color: var(--primary-color, #1890ff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-options {
|
||||
.ant-select-selector {
|
||||
background: var(--table-row-bg, #1e222d);
|
||||
border-color: var(--table-border-color, #2a2e39);
|
||||
color: var(--text-color, #d1d4dc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,733 @@
|
||||
<template>
|
||||
<div class="position-records">
|
||||
<div v-if="positions.length === 0 && !loading" class="empty-state">
|
||||
<a-empty :description="$t('trading-assistant.table.noPositions')" />
|
||||
</div>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="positions"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
rowKey="id"
|
||||
:scroll="{ x: 800 }"
|
||||
>
|
||||
<template slot="symbol" slot-scope="text, record">
|
||||
<strong>{{ record.symbol || text }}</strong>
|
||||
</template>
|
||||
<template slot="side" slot-scope="text, record">
|
||||
<a-tag :color="(record.side || text) === 'long' ? 'green' : 'red'">
|
||||
{{ (record.side || text) === 'long' ? $t('trading-assistant.table.long') : $t('trading-assistant.table.short') }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template slot="entryPrice" slot-scope="text, record">
|
||||
${{ parseFloat(record.entry_price || text || 0).toFixed(4) }}
|
||||
</template>
|
||||
<template slot="currentPrice" slot-scope="text, record">
|
||||
${{ parseFloat(record.current_price || text || 0).toFixed(4) }}
|
||||
</template>
|
||||
<template slot="size" slot-scope="text, record">
|
||||
{{ parseFloat(record.size || text || 0).toFixed(4) }}
|
||||
</template>
|
||||
<template slot="unrealizedPnl" slot-scope="text, record">
|
||||
<span :class="{ 'profit': parseFloat(record.unrealized_pnl || text || 0) > 0, 'loss': parseFloat(record.unrealized_pnl || text || 0) < 0 }">
|
||||
${{ parseFloat(record.unrealized_pnl || text || 0).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
<template slot="pnlPercent" slot-scope="text, record">
|
||||
<span :class="{ 'profit': parseFloat(record.pnl_percent || text || 0) > 0, 'loss': parseFloat(record.pnl_percent || text || 0) < 0 }">
|
||||
{{ parseFloat(record.pnl_percent || text || 0).toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getStrategyPositions } from '@/api/strategy'
|
||||
|
||||
export default {
|
||||
name: 'PositionRecords',
|
||||
props: {
|
||||
strategyId: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
marketType: {
|
||||
type: String,
|
||||
default: 'swap'
|
||||
},
|
||||
leverage: {
|
||||
type: [Number, String],
|
||||
default: 1
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
positions: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
columns () {
|
||||
return [
|
||||
{
|
||||
title: this.$t('trading-assistant.table.symbol'),
|
||||
dataIndex: 'symbol',
|
||||
key: 'symbol',
|
||||
width: 120,
|
||||
scopedSlots: { customRender: 'symbol' }
|
||||
},
|
||||
{
|
||||
title: this.$t('trading-assistant.table.side'),
|
||||
dataIndex: 'side',
|
||||
key: 'side',
|
||||
width: 80,
|
||||
scopedSlots: { customRender: 'side' }
|
||||
},
|
||||
{
|
||||
title: this.$t('trading-assistant.table.size'),
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 120,
|
||||
scopedSlots: { customRender: 'size' }
|
||||
},
|
||||
{
|
||||
title: this.$t('trading-assistant.table.entryPrice'),
|
||||
dataIndex: 'entry_price',
|
||||
key: 'entry_price',
|
||||
width: 120,
|
||||
scopedSlots: { customRender: 'entryPrice' }
|
||||
},
|
||||
{
|
||||
title: this.$t('trading-assistant.table.currentPrice'),
|
||||
dataIndex: 'current_price',
|
||||
key: 'current_price',
|
||||
width: 120,
|
||||
scopedSlots: { customRender: 'currentPrice' }
|
||||
},
|
||||
{
|
||||
title: this.$t('trading-assistant.table.unrealizedPnl'),
|
||||
dataIndex: 'unrealized_pnl',
|
||||
key: 'unrealized_pnl',
|
||||
width: 120,
|
||||
scopedSlots: { customRender: 'unrealizedPnl' }
|
||||
},
|
||||
{
|
||||
title: this.$t('trading-assistant.table.pnlPercent'),
|
||||
dataIndex: 'pnl_percent',
|
||||
key: 'pnl_percent',
|
||||
width: 100,
|
||||
scopedSlots: { customRender: 'pnlPercent' }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
strategyId: {
|
||||
handler (val) {
|
||||
if (val) {
|
||||
this.loadPositions()
|
||||
// 每5秒刷新一次持仓
|
||||
this.startPolling()
|
||||
} else {
|
||||
this.stopPolling()
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
beforeDestroy () {
|
||||
this.stopPolling()
|
||||
},
|
||||
methods: {
|
||||
async loadPositions () {
|
||||
if (!this.strategyId) return
|
||||
|
||||
try {
|
||||
const res = await getStrategyPositions(this.strategyId)
|
||||
if (res.code === 1) {
|
||||
// 确保数据格式正确,处理可能的字段名不一致
|
||||
const rawPositions = res.data.positions || []
|
||||
|
||||
this.positions = rawPositions.map((position, index) => {
|
||||
const mt = String(this.marketType || 'swap').toLowerCase()
|
||||
let lev = parseFloat(this.leverage)
|
||||
if (!isFinite(lev) || lev <= 0) lev = 1
|
||||
if (mt === 'spot') lev = 1
|
||||
|
||||
const entryPrice = parseFloat(position.entry_price || position.entryPrice || '0') || 0
|
||||
const size = parseFloat(position.size || '0') || 0
|
||||
const pnl = parseFloat(position.unrealized_pnl || position.unrealizedPnl || '0') || 0
|
||||
let pnlPercent = parseFloat(position.pnl_percent || position.pnlPercent || '0') || 0
|
||||
|
||||
// Prefer margin-based pnl% (pnl / (notional / leverage)).
|
||||
// If backend already returns pnl_percent, we still recompute from pnl/entry/size to keep it consistent.
|
||||
if (entryPrice > 0 && size > 0) {
|
||||
pnlPercent = (pnl / (entryPrice * size)) * 100 * lev
|
||||
} else if (mt !== 'spot') {
|
||||
pnlPercent = pnlPercent * lev
|
||||
}
|
||||
|
||||
const mapped = {
|
||||
id: position.id || index,
|
||||
symbol: position.symbol || '',
|
||||
side: position.side || 'long',
|
||||
size: position.size || '0',
|
||||
entry_price: position.entry_price || position.entryPrice || '0',
|
||||
current_price: position.current_price || position.currentPrice || '0',
|
||||
unrealized_pnl: position.unrealized_pnl || position.unrealizedPnl || '0',
|
||||
pnl_percent: pnlPercent,
|
||||
updated_at: position.updated_at || position.updatedAt || ''
|
||||
}
|
||||
return mapped
|
||||
})
|
||||
} else {
|
||||
// 不显示错误,可能策略还没有持仓
|
||||
this.positions = []
|
||||
}
|
||||
} catch (error) {
|
||||
this.positions = []
|
||||
}
|
||||
},
|
||||
startPolling () {
|
||||
this.stopPolling()
|
||||
this.pollingTimer = setInterval(() => {
|
||||
this.loadPositions()
|
||||
}, 5000)
|
||||
},
|
||||
stopPolling () {
|
||||
if (this.pollingTimer) {
|
||||
clearInterval(this.pollingTimer)
|
||||
this.pollingTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.position-records {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
padding: 0;
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
::v-deep .ant-table {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
// 自定义细滚动条
|
||||
::v-deep .ant-table-body {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin; // Firefox - 细滚动条
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; // Firefox - 滑块颜色和轨道颜色
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px; // 横向滚动条高度
|
||||
width: 6px; // 纵向滚动条宽度
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent; // 轨道背景透明
|
||||
border-radius: 3px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2); // 滑块颜色
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3); // 悬停时颜色
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表格容器的滚动条样式
|
||||
::v-deep .ant-table-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有可能的表格滚动容器的滚动条样式
|
||||
::v-deep .ant-table-content,
|
||||
::v-deep .ant-table-wrapper {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr > td {
|
||||
padding: 12px 16px;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
// 暗黑主题适配
|
||||
&.theme-dark,
|
||||
.theme-dark & {
|
||||
::v-deep .ant-table {
|
||||
background: #1e222d !important;
|
||||
color: #d1d4dc !important;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-thead > tr > th {
|
||||
background: #2a2e39 !important;
|
||||
color: #d1d4dc !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr > td {
|
||||
background: #1e222d !important;
|
||||
color: #d1d4dc !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr:hover > td {
|
||||
background: #2a2e39 !important;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr > td strong {
|
||||
color: #d1d4dc !important;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr:hover > td {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
::v-deep .ant-empty {
|
||||
margin: 40px 0;
|
||||
|
||||
.ant-empty-description {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
.profit {
|
||||
color: #52c41a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loss {
|
||||
color: #ff4d4f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
min-height: 200px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
// 移动端也使用细滚动条
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 150px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
::v-deep .ant-table {
|
||||
font-size: 12px;
|
||||
min-width: 700px; // 确保表格最小宽度,触发横向滚动
|
||||
}
|
||||
|
||||
// 移动端也使用细滚动条
|
||||
::v-deep .ant-table-body,
|
||||
::v-deep .ant-table-container,
|
||||
::v-deep .ant-table-wrapper {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .ant-table-thead > tr > th {
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr > td {
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::v-deep .ant-empty {
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
::v-deep .ant-table {
|
||||
font-size: 11px;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-thead > tr > th {
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
::v-deep .ant-table-tbody > tr > td {
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.profit,
|
||||
.loss {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
// 暗黑主题适配 - 使用更高优先级的选择器覆盖 scoped 样式
|
||||
// 必须使用完整的 scoped 选择器路径来覆盖
|
||||
.theme-dark .position-records .ant-table-tbody > tr > td,
|
||||
.theme-dark .position-records[data-v] .ant-table-tbody > tr > td,
|
||||
body.dark .position-records .ant-table-tbody > tr > td,
|
||||
body.realdark .position-records .ant-table-tbody > tr > td {
|
||||
color: #d1d4dc !important;
|
||||
background: #1e222d !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records .ant-table-thead > tr > th,
|
||||
.theme-dark .position-records[data-v] .ant-table-thead > tr > th,
|
||||
body.dark .position-records .ant-table-thead > tr > th,
|
||||
body.realdark .position-records .ant-table-thead > tr > th {
|
||||
background: #2a2e39 !important;
|
||||
color: #d1d4dc !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records .ant-table,
|
||||
.theme-dark .position-records[data-v] .ant-table,
|
||||
body.dark .position-records .ant-table,
|
||||
body.realdark .position-records .ant-table {
|
||||
background: #1e222d !important;
|
||||
color: #d1d4dc !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records .ant-table-tbody > tr > td *,
|
||||
.theme-dark .position-records[data-v] .ant-table-tbody > tr > td *,
|
||||
body.dark .position-records .ant-table-tbody > tr > td *,
|
||||
body.realdark .position-records .ant-table-tbody > tr > td * {
|
||||
color: #d1d4dc !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records .ant-table-tbody > tr:hover > td,
|
||||
.theme-dark .position-records[data-v] .ant-table-tbody > tr:hover > td,
|
||||
body.dark .position-records .ant-table-tbody > tr:hover > td,
|
||||
body.realdark .position-records .ant-table-tbody > tr:hover > td {
|
||||
background: #2a2e39 !important;
|
||||
}
|
||||
|
||||
// 确保表头文字可见
|
||||
.theme-dark .position-records .ant-table-thead > tr > th,
|
||||
.theme-dark .position-records[data-v] .ant-table-thead > tr > th,
|
||||
body.dark .position-records .ant-table-thead > tr > th,
|
||||
body.realdark .position-records .ant-table-thead > tr > th {
|
||||
.ant-table-column-title {
|
||||
color: #d1d4dc !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 通用选择器作为后备
|
||||
.theme-dark .position-records[data-v] .ant-table-tbody > tr > td,
|
||||
.theme-dark [class*="position-records"][data-v] .ant-table-tbody > tr > td {
|
||||
color: #d1d4dc !important;
|
||||
background: #1e222d !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records[data-v] .ant-table-thead > tr > th,
|
||||
.theme-dark [class*="position-records"][data-v] .ant-table-thead > tr > th {
|
||||
background: #2a2e39 !important;
|
||||
color: #d1d4dc !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
// 暗黑主题适配 - 使用最高优先级的选择器覆盖 scoped 样式
|
||||
// 关键:必须使用与 scoped 样式完全相同的选择器结构,加上 theme-dark 前缀
|
||||
// 使用属性选择器的精确匹配来覆盖 scoped 样式
|
||||
|
||||
// 方法1:精确匹配 data-v-6c1eb557
|
||||
.theme-dark .position-records[data-v-6c1eb557] .ant-table-tbody > tr > td,
|
||||
.theme-dark [data-v-6c1eb557].position-records .ant-table-tbody > tr > td,
|
||||
.theme-dark [data-v-6c1eb557] .position-records .ant-table-tbody > tr > td {
|
||||
color: #d1d4dc !important;
|
||||
background: #1e222d !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
// 方法2:使用属性选择器前缀匹配(更通用)
|
||||
.theme-dark [data-v-6c1eb557] .ant-table-tbody > tr > td {
|
||||
color: #d1d4dc !important;
|
||||
background: #1e222d !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records[data-v-6c1eb557] .ant-table-thead > tr > th,
|
||||
.theme-dark [data-v-6c1eb557].position-records .ant-table-thead > tr > th {
|
||||
background: #2a2e39 !important;
|
||||
color: #d1d4dc !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records[data-v-6c1eb557] .ant-table,
|
||||
.theme-dark [data-v-6c1eb557].position-records .ant-table {
|
||||
background: #1e222d !important;
|
||||
color: #d1d4dc !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records[data-v-6c1eb557] .ant-table-tbody > tr:hover > td,
|
||||
.theme-dark [data-v-6c1eb557].position-records .ant-table-tbody > tr:hover > td {
|
||||
background: #2a2e39 !important;
|
||||
}
|
||||
|
||||
// body.dark 和 body.realdark 支持
|
||||
body.dark .position-records[data-v-6c1eb557] .ant-table-tbody > tr > td,
|
||||
body.dark [data-v-6c1eb557].position-records .ant-table-tbody > tr > td,
|
||||
body.dark [data-v-6c1eb557] .position-records .ant-table-tbody > tr > td,
|
||||
body.realdark .position-records[data-v-6c1eb557] .ant-table-tbody > tr > td,
|
||||
body.realdark [data-v-6c1eb557].position-records .ant-table-tbody > tr > td,
|
||||
body.realdark [data-v-6c1eb557] .position-records .ant-table-tbody > tr > td {
|
||||
color: #d1d4dc !important;
|
||||
background: #1e222d !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
// 方法2:使用属性选择器前缀匹配(更通用)
|
||||
body.dark [data-v-6c1eb557] .ant-table-tbody > tr > td,
|
||||
body.realdark [data-v-6c1eb557] .ant-table-tbody > tr > td {
|
||||
color: #d1d4dc !important;
|
||||
background: #1e222d !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
body.dark .position-records[data-v-6c1eb557] .ant-table-thead > tr > th,
|
||||
body.dark [data-v-6c1eb557].position-records .ant-table-thead > tr > th,
|
||||
body.dark [data-v-6c1eb557] .position-records .ant-table-thead > tr > th,
|
||||
body.realdark .position-records[data-v-6c1eb557] .ant-table-thead > tr > th,
|
||||
body.realdark [data-v-6c1eb557].position-records .ant-table-thead > tr > th,
|
||||
body.realdark [data-v-6c1eb557] .position-records .ant-table-thead > tr > th {
|
||||
background: #2a2e39 !important;
|
||||
color: #d1d4dc !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
// 方法2:使用属性选择器前缀匹配(更通用)
|
||||
body.dark [data-v-6c1eb557] .ant-table-thead > tr > th,
|
||||
body.realdark [data-v-6c1eb557] .ant-table-thead > tr > th {
|
||||
background: #2a2e39 !important;
|
||||
color: #d1d4dc !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
// 通用后备选择器(如果 data-v 值变化)
|
||||
.theme-dark .position-records[data-v] .ant-table-tbody > tr > td,
|
||||
body.dark .position-records[data-v] .ant-table-tbody > tr > td,
|
||||
body.realdark .position-records[data-v] .ant-table-tbody > tr > td {
|
||||
color: #d1d4dc !important;
|
||||
background: #1e222d !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records[data-v] .ant-table-thead > tr > th,
|
||||
body.dark .position-records[data-v] .ant-table-thead > tr > th,
|
||||
body.realdark .position-records[data-v] .ant-table-thead > tr > th {
|
||||
background: #2a2e39 !important;
|
||||
color: #d1d4dc !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
// 其他样式
|
||||
.theme-dark .position-records[data-v-6c1eb557] .ant-empty .ant-empty-description,
|
||||
body.dark .position-records[data-v-6c1eb557] .ant-empty .ant-empty-description,
|
||||
body.realdark .position-records[data-v-6c1eb557] .ant-empty .ant-empty-description {
|
||||
color: #868993 !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records[data-v-6c1eb557] .profit,
|
||||
body.dark .position-records[data-v-6c1eb557] .profit,
|
||||
body.realdark .position-records[data-v-6c1eb557] .profit {
|
||||
color: #52c41a !important;
|
||||
}
|
||||
|
||||
.theme-dark .position-records[data-v-6c1eb557] .loss,
|
||||
body.dark .position-records[data-v-6c1eb557] .loss,
|
||||
body.realdark .position-records[data-v-6c1eb557] .loss {
|
||||
color: #ff4d4f !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
// 最终覆盖方案:使用更长的选择器链确保最高优先级
|
||||
// 直接匹配 scoped 样式生成的完整选择器路径
|
||||
.theme-dark .trading-assistant .position-records[data-v-6c1eb557] .ant-table-tbody > tr > td,
|
||||
.theme-dark .trading-assistant [data-v-6c1eb557].position-records .ant-table-tbody > tr > td,
|
||||
body.dark .trading-assistant .position-records[data-v-6c1eb557] .ant-table-tbody > tr > td,
|
||||
body.dark .trading-assistant [data-v-6c1eb557].position-records .ant-table-tbody > tr > td,
|
||||
body.realdark .trading-assistant .position-records[data-v-6c1eb557] .ant-table-tbody > tr > td,
|
||||
body.realdark .trading-assistant [data-v-6c1eb557].position-records .ant-table-tbody > tr > td {
|
||||
color: #d1d4dc !important;
|
||||
background: #1e222d !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
.theme-dark .trading-assistant .position-records[data-v-6c1eb557] .ant-table-thead > tr > th,
|
||||
.theme-dark .trading-assistant [data-v-6c1eb557].position-records .ant-table-thead > tr > th,
|
||||
body.dark .trading-assistant .position-records[data-v-6c1eb557] .ant-table-thead > tr > th,
|
||||
body.dark .trading-assistant [data-v-6c1eb557].position-records .ant-table-thead > tr > th,
|
||||
body.realdark .trading-assistant .position-records[data-v-6c1eb557] .ant-table-thead > tr > th,
|
||||
body.realdark .trading-assistant [data-v-6c1eb557].position-records .ant-table-thead > tr > th {
|
||||
background: #2a2e39 !important;
|
||||
color: #d1d4dc !important;
|
||||
border-bottom-color: #363c4e !important;
|
||||
}
|
||||
|
||||
// 暗黑主题滚动条样式
|
||||
.theme-dark .position-records[data-v-6c1eb557] .ant-table-body,
|
||||
.theme-dark .position-records[data-v-6c1eb557] .ant-table-container,
|
||||
.theme-dark .position-records[data-v-6c1eb557] .ant-table-content,
|
||||
.theme-dark .position-records[data-v-6c1eb557] .ant-table-wrapper,
|
||||
body.dark .position-records[data-v-6c1eb557] .ant-table-body,
|
||||
body.dark .position-records[data-v-6c1eb557] .ant-table-container,
|
||||
body.dark .position-records[data-v-6c1eb557] .ant-table-content,
|
||||
body.dark .position-records[data-v-6c1eb557] .ant-table-wrapper,
|
||||
body.realdark .position-records[data-v-6c1eb557] .ant-table-body,
|
||||
body.realdark .position-records[data-v-6c1eb557] .ant-table-container,
|
||||
body.realdark .position-records[data-v-6c1eb557] .ant-table-content,
|
||||
body.realdark .position-records[data-v-6c1eb557] .ant-table-wrapper {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(209, 212, 220, 0.3) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(209, 212, 220, 0.3);
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: rgba(209, 212, 220, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通用后备选择器
|
||||
.theme-dark .position-records[data-v] .ant-table-body,
|
||||
.theme-dark .position-records[data-v] .ant-table-container,
|
||||
.theme-dark .position-records[data-v] .ant-table-content,
|
||||
.theme-dark .position-records[data-v] .ant-table-wrapper,
|
||||
body.dark .position-records[data-v] .ant-table-body,
|
||||
body.dark .position-records[data-v] .ant-table-container,
|
||||
body.dark .position-records[data-v] .ant-table-content,
|
||||
body.dark .position-records[data-v] .ant-table-wrapper,
|
||||
body.realdark .position-records[data-v] .ant-table-body,
|
||||
body.realdark .position-records[data-v] .ant-table-container,
|
||||
body.realdark .position-records[data-v] .ant-table-content,
|
||||
body.realdark .position-records[data-v] .ant-table-wrapper {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(209, 212, 220, 0.3) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(209, 212, 220, 0.3);
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: rgba(209, 212, 220, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="main">
|
||||
<div class="auth-intro">
|
||||
<div class="desc">AI driven quantitative insights for global markets</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<a-form
|
||||
id="formLogin"
|
||||
class="user-layout-login"
|
||||
ref="formLogin"
|
||||
:form="form"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<a-alert v-if="isLoginError" type="error" showIcon style="margin-bottom: 24px;" :message="loginErrorMessage || 'Login failed'" />
|
||||
<a-alert type="info" showIcon style="margin-bottom: 16px;" message="Default account/password: quantdinger / 123456 (configurable via backend env)" />
|
||||
|
||||
<a-form-item>
|
||||
<a-input
|
||||
size="large"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
v-decorator="[
|
||||
'username',
|
||||
{rules: [{ required: true, message: 'Please enter username' }], validateTrigger: 'blur'}
|
||||
]"
|
||||
>
|
||||
<a-icon slot="prefix" type="user" :style="{ color: 'rgba(0,0,0,.25)' }"/>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-input-password
|
||||
size="large"
|
||||
placeholder="Password"
|
||||
v-decorator="[
|
||||
'password',
|
||||
{rules: [{ required: true, message: 'Please enter password' }], validateTrigger: 'blur'}
|
||||
]"
|
||||
>
|
||||
<a-icon slot="prefix" type="lock" :style="{ color: 'rgba(0,0,0,.25)' }"/>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item style="margin-top:24px">
|
||||
<a-button
|
||||
size="large"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
class="login-button"
|
||||
:loading="state.loginBtn"
|
||||
:disabled="state.loginBtn"
|
||||
block
|
||||
>Login</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="legal-wrap">
|
||||
<div class="legal-header">
|
||||
<div class="legal-title">{{ $t('user.login.legal.title') }}</div>
|
||||
<a class="legal-toggle" @click="showLegal = !showLegal">
|
||||
{{ showLegal ? $t('user.login.legal.collapse') : $t('user.login.legal.view') }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-show="showLegal" class="legal-content">
|
||||
{{ $t('user.login.legal.content') }}
|
||||
</div>
|
||||
<div class="legal-agree">
|
||||
<a-checkbox v-model="legalAgreed">
|
||||
{{ $t('user.login.legal.agree') }}
|
||||
</a-checkbox>
|
||||
<div v-if="legalError" class="legal-error">{{ $t('user.login.legal.required') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex'
|
||||
import { timeFix } from '@/utils/util'
|
||||
|
||||
export default {
|
||||
name: 'Login',
|
||||
data () {
|
||||
return {
|
||||
isLoginError: false,
|
||||
loginErrorMessage: '',
|
||||
showLegal: false,
|
||||
legalAgreed: true,
|
||||
legalError: false,
|
||||
form: this.$form.createForm(this),
|
||||
state: {
|
||||
time: 60,
|
||||
loginBtn: false
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['Login', 'Logout']),
|
||||
handleSubmit (e) {
|
||||
e.preventDefault()
|
||||
this.legalError = false
|
||||
if (!this.legalAgreed) {
|
||||
this.legalError = true
|
||||
return
|
||||
}
|
||||
const {
|
||||
form: { validateFields },
|
||||
state,
|
||||
Login
|
||||
} = this
|
||||
|
||||
state.loginBtn = true
|
||||
|
||||
validateFields(['username', 'password'], { force: true }, (err, values) => {
|
||||
if (!err) {
|
||||
const loginParams = { ...values }
|
||||
|
||||
Login(loginParams)
|
||||
.then((res) => {
|
||||
this.loginSuccess(res)
|
||||
})
|
||||
.catch(err => {
|
||||
this.requestFailed(err)
|
||||
})
|
||||
.finally(() => {
|
||||
state.loginBtn = false
|
||||
})
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
state.loginBtn = false
|
||||
}, 600)
|
||||
}
|
||||
})
|
||||
},
|
||||
loginSuccess (res) {
|
||||
this.$router.push({ path: '/' })
|
||||
this.isLoginError = false
|
||||
this.$notification.success({
|
||||
message: 'Welcome',
|
||||
description: `${timeFix()}, welcome back.`
|
||||
})
|
||||
},
|
||||
requestFailed (err) {
|
||||
this.isLoginError = true
|
||||
this.loginErrorMessage = ((err.response || {}).data || {}).msg || err.message || 'Incorrect username or password'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
padding: 40px 0;
|
||||
|
||||
.auth-intro {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
|
||||
.brand-badge {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-top: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
min-width: 360px;
|
||||
width: 380px;
|
||||
background: #fff;
|
||||
padding: 32px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.user-layout-login {
|
||||
button.login-button {
|
||||
padding: 0 15px;
|
||||
font-size: 16px;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.legal-wrap {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
|
||||
.legal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
line-height: 20px;
|
||||
}
|
||||
.legal-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
.legal-toggle {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
}
|
||||
.legal-content {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.legal-agree {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.legal-error {
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="register-result">
|
||||
<a-result
|
||||
status="success"
|
||||
:title="$t('user.register-result.msg', { email: '-' })"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="$router.push({ path: '/' })">
|
||||
{{ $t('user.register-result.back-home') || '返回首页' }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RegisterResult'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-result {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user