Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2025-12-29 03:06:49 +08:00
commit f43312a858
292 changed files with 103739 additions and 0 deletions
+15
View File
@@ -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
+255
View File
@@ -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>