Translate Chinese comments and strings to English across multiple files; update user management views for consistency in language.

This commit is contained in:
dienakdz
2026-04-07 17:31:18 +07:00
parent aa09e85199
commit 2dc29a215e
37 changed files with 5004 additions and 1912 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ export default {
},
computed: {
locale () {
// 只是为了切换语言时,更新标题
// Simply to update the title when switching languages
const { title } = this.$route.meta
title && (setDocumentTitle(`${i18nRender(title)} - ${domTitle}`))
+99 -50
View File
@@ -4,6 +4,9 @@ const api = {
// Local Python backend
strategies: '/api/strategies',
strategyDetail: '/api/strategies/detail',
strategyBacktest: '/api/strategies/backtest',
strategyBacktestHistory: '/api/strategies/backtest/history',
strategyBacktestRun: '/api/strategies/backtest/get',
createStrategy: '/api/strategies/create',
batchCreateStrategies: '/api/strategies/batch-create',
updateStrategy: '/api/strategies/update',
@@ -22,9 +25,9 @@ const api = {
}
/**
* 获取策略列表
* @param {Object} params - 查询参数
* @param {number} params.user_id - 用户ID(可选)
* Get strategy list.
* @param {Object} params - Query parameters.
* @param {number} params.user_id - User ID (optional).
*/
export function getStrategyList (params = {}) {
return request({
@@ -35,8 +38,8 @@ export function getStrategyList (params = {}) {
}
/**
* 获取策略详情
* @param {number} id - 策略ID
* Get strategy detail.
* @param {number} id - Strategy ID.
*/
export function getStrategyDetail (id) {
return request({
@@ -47,14 +50,60 @@ export function getStrategyDetail (id) {
}
/**
* 创建策略
* @param {Object} data - 策略数据
* @param {number} data.user_id - 用户ID
* @param {string} data.strategy_name - 策略名称
* @param {string} data.strategy_type - 策略类型
* @param {Object} data.llm_model_config - LLM模型配置
* @param {Object} data.exchange_config - 交易所配置
* @param {Object} data.trading_config - 交易配置
* Run a strategy backtest.
* @param {Object} data - Backtest request payload.
* @param {number} data.strategyId - Strategy ID.
* @param {string} data.startDate - Start date in YYYY-MM-DD.
* @param {string} data.endDate - End date in YYYY-MM-DD.
* @param {Object} data.overrideConfig - Optional override config.
*/
export function runStrategyBacktest (data) {
return request({
url: api.strategyBacktest,
method: 'post',
data
})
}
/**
* Get strategy backtest history.
* @param {Object} params - Query parameters.
* @param {number} params.strategyId - Strategy ID.
* @param {number} params.limit - Max record count (optional).
* @param {number} params.offset - Pagination offset (optional).
* @param {string} params.symbol - Symbol filter (optional).
* @param {string} params.market - Market filter (optional).
* @param {string} params.timeframe - Timeframe filter (optional).
*/
export function getStrategyBacktestHistory (params = {}) {
return request({
url: api.strategyBacktestHistory,
method: 'get',
params
})
}
/**
* Get a single strategy backtest run.
* @param {number} runId - Backtest run ID.
*/
export function getStrategyBacktestRun (runId) {
return request({
url: api.strategyBacktestRun,
method: 'get',
params: { runId }
})
}
/**
* Create a strategy.
* @param {Object} data - Strategy payload.
* @param {number} data.user_id - User ID.
* @param {string} data.strategy_name - Strategy name.
* @param {string} data.strategy_type - Strategy type.
* @param {Object} data.llm_model_config - LLM model config.
* @param {Object} data.exchange_config - Exchange config.
* @param {Object} data.trading_config - Trading config.
*/
export function createStrategy (data) {
return request({
@@ -65,10 +114,10 @@ export function createStrategy (data) {
}
/**
* 批量创建策略(多币种)
* @param {Object} data - 策略数据
* @param {string} data.strategy_name - 策略基础名称
* @param {Array} data.symbols - 币种数组,如 ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
* Batch create strategies for multiple symbols.
* @param {Object} data - Strategy payload.
* @param {string} data.strategy_name - Base strategy name.
* @param {Array} data.symbols - Symbol list, for example ["Crypto:BTC/USDT", "Crypto:ETH/USDT"].
*/
export function batchCreateStrategies (data) {
return request({
@@ -79,13 +128,13 @@ export function batchCreateStrategies (data) {
}
/**
* 更新策略
* @param {number} id - 策略ID
* @param {Object} data - 策略数据
* @param {string} data.strategy_name - 策略名称(可选)
* @param {Object} data.indicator_config - 技术指标配置(可选)
* @param {Object} data.exchange_config - 交易所配置(可选)
* @param {Object} data.trading_config - 交易配置(可选)
* Update a strategy.
* @param {number} id - Strategy ID.
* @param {Object} data - Strategy payload.
* @param {string} data.strategy_name - Strategy name (optional).
* @param {Object} data.indicator_config - Indicator config (optional).
* @param {Object} data.exchange_config - Exchange config (optional).
* @param {Object} data.trading_config - Trading config (optional).
*/
export function updateStrategy (id, data) {
return request({
@@ -97,8 +146,8 @@ export function updateStrategy (id, data) {
}
/**
* 停止策略
* @param {number} id - 策略ID
* Stop a strategy.
* @param {number} id - Strategy ID.
*/
export function stopStrategy (id) {
return request({
@@ -109,8 +158,8 @@ export function stopStrategy (id) {
}
/**
* 启动策略
* @param {number} id - 策略ID
* Start a strategy.
* @param {number} id - Strategy ID.
*/
export function startStrategy (id) {
return request({
@@ -121,8 +170,8 @@ export function startStrategy (id) {
}
/**
* 删除策略
* @param {number} id - 策略ID
* Delete a strategy.
* @param {number} id - Strategy ID.
*/
export function deleteStrategy (id) {
return request({
@@ -133,10 +182,10 @@ export function deleteStrategy (id) {
}
/**
* 批量启动策略
* Batch start strategies.
* @param {Object} data
* @param {Array} data.strategy_ids - 策略ID数组
* @param {string} data.strategy_group_id - 策略组ID(可选,与strategy_ids二选一)
* @param {Array} data.strategy_ids - Strategy ID list.
* @param {string} data.strategy_group_id - Strategy group ID (optional, mutually exclusive with strategy_ids).
*/
export function batchStartStrategies (data) {
return request({
@@ -147,10 +196,10 @@ export function batchStartStrategies (data) {
}
/**
* 批量停止策略
* Batch stop strategies.
* @param {Object} data
* @param {Array} data.strategy_ids - 策略ID数组
* @param {string} data.strategy_group_id - 策略组ID(可选,与strategy_ids二选一)
* @param {Array} data.strategy_ids - Strategy ID list.
* @param {string} data.strategy_group_id - Strategy group ID (optional, mutually exclusive with strategy_ids).
*/
export function batchStopStrategies (data) {
return request({
@@ -161,10 +210,10 @@ export function batchStopStrategies (data) {
}
/**
* 批量删除策略
* Batch delete strategies.
* @param {Object} data
* @param {Array} data.strategy_ids - 策略ID数组
* @param {string} data.strategy_group_id - 策略组ID(可选,与strategy_ids二选一)
* @param {Array} data.strategy_ids - Strategy ID list.
* @param {string} data.strategy_group_id - Strategy group ID (optional, mutually exclusive with strategy_ids).
*/
export function batchDeleteStrategies (data) {
return request({
@@ -175,8 +224,8 @@ export function batchDeleteStrategies (data) {
}
/**
* 测试交易所连接
* @param {Object} exchangeConfig - 交易所配置
* Test exchange connection.
* @param {Object} exchangeConfig - Exchange config.
*/
export function testExchangeConnection (exchangeConfig) {
return request({
@@ -187,8 +236,8 @@ export function testExchangeConnection (exchangeConfig) {
}
/**
* 获取策略交易记录
* @param {number} id - 策略ID
* Get strategy trade records.
* @param {number} id - Strategy ID.
*/
export function getStrategyTrades (id) {
return request({
@@ -199,8 +248,8 @@ export function getStrategyTrades (id) {
}
/**
* 获取策略持仓记录
* @param {number} id - 策略ID
* Get strategy position records.
* @param {number} id - Strategy ID.
*/
export function getStrategyPositions (id) {
return request({
@@ -211,8 +260,8 @@ export function getStrategyPositions (id) {
}
/**
* 获取策略净值曲线
* @param {number} id - 策略ID
* Get strategy equity curve.
* @param {number} id - Strategy ID.
*/
export function getStrategyEquityCurve (id) {
return request({
@@ -223,9 +272,9 @@ export function getStrategyEquityCurve (id) {
}
/**
* 获取策略运行日志
* @param {number} id - 策略ID
* @param {number} limit - 最大日志条数
* Get strategy runtime logs.
* @param {number} id - Strategy ID.
* @param {number} limit - Maximum log count.
*/
export function getStrategyLogs (id, limit = 200) {
return request({
@@ -44,7 +44,7 @@ export default {
remove (targetKey) {
this.pages = this.pages.filter(page => page.fullPath !== targetKey)
this.fullPathList = this.fullPathList.filter(path => path !== targetKey)
// 判断当前标签是否关闭,若关闭则跳转到最后一个还存在的标签页
// Check if current tab is closed; if so, navigate to the last remaining tab
if (!this.fullPathList.includes(this.activeKey)) {
this.selectedLastPath()
}
@@ -55,11 +55,11 @@ export default {
// content menu
closeThat (e) {
// 判断是否为最后一个标签页,如果是最后一个,则无法被关闭
// Check if it is the last tab; if so, it cannot be closed
if (this.fullPathList.length > 1) {
this.remove(e)
} else {
this.$message.info('这是最后一个标签了, 无法被关闭')
this.$message.info('This is the last tab and cannot be closed')
}
},
closeLeft (e) {
@@ -71,7 +71,7 @@ export default {
}
})
} else {
this.$message.info('左侧没有标签')
this.$message.info('No tabs on the left')
}
},
closeRight (e) {
@@ -83,7 +83,7 @@ export default {
}
})
} else {
this.$message.info('右侧没有标签')
this.$message.info('No tabs on the right')
}
},
closeAll (e) {
@@ -100,10 +100,10 @@ export default {
renderTabPaneMenu (e) {
return (
<a-menu {...{ on: { click: ({ key, item, domEvent }) => { this.closeMenuClick(key, e) } } }}>
<a-menu-item key="closeThat">关闭当前标签</a-menu-item>
<a-menu-item key="closeRight">关闭右侧</a-menu-item>
<a-menu-item key="closeLeft">关闭左侧</a-menu-item>
<a-menu-item key="closeAll">关闭全部</a-menu-item>
<a-menu-item key="closeThat">Close current tab</a-menu-item>
<a-menu-item key="closeRight">Close right</a-menu-item>
<a-menu-item key="closeLeft">Close left</a-menu-item>
<a-menu-item key="closeAll">Close all</a-menu-item>
</a-menu>
)
},
@@ -2,197 +2,36 @@
<a-modal
:visible="visible"
:title="tt('polymarket.analysis.title', 'Polymarket Analysis')"
:width="900"
:width="960"
:footer="null"
:destroyOnClose="true"
:wrap-class-name="isDarkTheme ? 'qd-dark-modal' : ''"
:mask-closable="false"
@cancel="handleClose"
>
<div class="polymarket-analysis-modal" :class="{ 'theme-dark': isDarkTheme }">
<a-tabs v-model="activeTab" @change="handleTabChange">
<a-tab-pane key="analyze" :tab="tt('polymarket.analysis.tabAnalyze', 'Analyze')">
<div v-if="!analysisResult" class="input-section">
<a-alert
style="margin-bottom: 16px"
type="info"
show-icon
:message="tt('polymarket.analysis.inputHint', 'Paste a Polymarket URL or market title to start analysis.')"
/>
<a-textarea
v-model="inputText"
:rows="3"
:disabled="analyzing"
:placeholder="tt('polymarket.analysis.inputPlaceholder', 'https://polymarket.com/... or a market title')"
@pressEnter="handleAnalyze"
/>
<a-button
style="width: 100%; margin-top: 12px"
type="primary"
size="large"
icon="thunderbolt"
:loading="analyzing"
:disabled="!inputText.trim()"
@click="handleAnalyze"
>
{{ tt('polymarket.analysis.analyzeButton', 'Analyze') }}
</a-button>
</div>
<div v-if="analysisResult" class="result-section">
<div class="market-info">
<h3>{{ analysisResult.market.question }}</h3>
<div class="market-meta">
<a-tag :color="getStatusColor(analysisResult.market.status)">{{ analysisResult.market.status }}</a-tag>
<span class="meta-item"><a-icon type="percentage" /> {{ analysisResult.market.current_probability }}%</span>
<span v-if="analysisResult.market.volume_24h" class="meta-item"><a-icon type="dollar" /> {{ formatVolume(analysisResult.market.volume_24h) }}</span>
<a-button
v-if="analysisResult.market.polymarket_url"
type="link"
size="small"
icon="link"
:href="analysisResult.market.polymarket_url"
target="_blank"
>
{{ tt('polymarket.analysis.viewOnPolymarket', 'Open on Polymarket') }}
</a-button>
</div>
</div>
<div class="analysis-result">
<a-divider>{{ tt('polymarket.analysis.aiAnalysis', 'AI Analysis') }}</a-divider>
<div class="probability-comparison">
<div class="prob-item">
<div class="prob-label">{{ tt('polymarket.analysis.marketProbability', 'Market probability') }}</div>
<div class="prob-value">{{ analysisResult.analysis.market_probability }}%</div>
</div>
<div class="prob-item">
<div class="prob-label">{{ tt('polymarket.analysis.aiPredictedProbability', 'AI predicted probability') }}</div>
<div class="prob-value ai-prob">{{ analysisResult.analysis.ai_predicted_probability }}%</div>
</div>
<div class="prob-item">
<div class="prob-label">{{ tt('polymarket.analysis.divergence', 'Divergence') }}</div>
<div class="prob-value" :class="getDivergenceClass(analysisResult.analysis.divergence)">
{{ analysisResult.analysis.divergence >= 0 ? '+' : '' }}{{ Number(analysisResult.analysis.divergence || 0).toFixed(2) }}%
</div>
</div>
</div>
<a-row :gutter="16" class="recommendation-section">
<a-col :span="8">
<div class="rec-card">
<div class="rec-label">{{ tt('polymarket.analysis.recommendation', 'Recommendation') }}</div>
<a-tag :color="getRecommendationColor(analysisResult.analysis.recommendation)" style="font-size: 16px; padding: 4px 12px">
{{ getRecommendationLabel(analysisResult.analysis.recommendation) }}
</a-tag>
</div>
</a-col>
<a-col :span="8">
<div class="rec-card">
<div class="rec-label">{{ tt('polymarket.analysis.confidenceScore', 'Confidence') }}</div>
<div class="rec-value">{{ Number(analysisResult.analysis.confidence_score || 0).toFixed(0) }}</div>
</div>
</a-col>
<a-col :span="8">
<div class="rec-card">
<div class="rec-label">{{ tt('polymarket.analysis.opportunityScore', 'Opportunity') }}</div>
<div class="rec-value">{{ Number(analysisResult.analysis.opportunity_score || 0).toFixed(0) }}</div>
</div>
</a-col>
</a-row>
<div v-if="analysisResult.analysis.reasoning" class="text-block">
<h4>{{ tt('polymarket.analysis.reasoning', 'Reasoning') }}</h4>
<p>{{ analysisResult.analysis.reasoning }}</p>
</div>
<div v-if="analysisResult.analysis.key_factors && analysisResult.analysis.key_factors.length > 0" class="text-block">
<h4>{{ tt('polymarket.analysis.keyFactors', 'Key factors') }}</h4>
<a-tag v-for="(factor, index) in analysisResult.analysis.key_factors" :key="index" color="blue" style="margin: 4px">
{{ factor }}
</a-tag>
</div>
<div v-if="analysisResult.credits_charged > 0" class="billing-info">
<a-alert
type="success"
show-icon
:message="tt('polymarket.analysis.creditsCharged', `Credits charged: ${analysisResult.credits_charged}`, { credits: analysisResult.credits_charged })"
/>
<div v-if="analysisResult.remaining_credits !== undefined" class="remaining-credits">
{{ tt('polymarket.analysis.remainingCredits', `Remaining credits: ${Number(analysisResult.remaining_credits).toFixed(0)}`, { credits: Number(analysisResult.remaining_credits).toFixed(0) }) }}
</div>
</div>
</div>
<div class="result-actions">
<a-button style="margin-right: 8px" @click="handleNewAnalysis">{{ tt('polymarket.analysis.newAnalysis', 'New analysis') }}</a-button>
<a-button type="primary" @click="handleClose">{{ tt('common.close', 'Close') }}</a-button>
</div>
</div>
<div v-if="analyzing" class="loading-section">
<a-spin size="large" />
<p>{{ tt('polymarket.analysis.analyzing', 'Analyzing...') }}</p>
</div>
</a-tab-pane>
<a-tab-pane key="history" :tab="tt('polymarket.analysis.tabHistory', 'History')">
<a-spin :spinning="historyLoading">
<a-table
size="small"
:columns="historyColumns"
:data-source="historyList"
:pagination="historyPagination"
:row-key="record => record.id"
@change="handleHistoryTableChange"
>
<template slot="market_title" slot-scope="text, record">
<a-button type="link" style="padding: 0" @click="viewHistoryItem(record)">{{ text }}</a-button>
</template>
<template slot="recommendation" slot-scope="text">
<a-tag :color="getRecommendationColor(text)">{{ getRecommendationLabel(text) }}</a-tag>
</template>
<template slot="created_at" slot-scope="text">
{{ formatDateTime(text) }}
</template>
</a-table>
</a-spin>
</a-tab-pane>
</a-tabs>
</div>
<polymarket-analysis-workspace
v-if="visible"
show-close-action
@close="handleClose"
/>
</a-modal>
</template>
<script>
import { mapState } from 'vuex'
import { analyzePolymarket, getPolymarketHistory } from '@/api/polymarket'
import PolymarketAnalysisWorkspace from '@/components/PolymarketAnalysisWorkspace.vue'
export default {
name: 'PolymarketAnalysisModal',
components: {
PolymarketAnalysisWorkspace
},
props: {
visible: {
type: Boolean,
default: false
}
},
data () {
return {
inputText: '',
analyzing: false,
analysisResult: null,
activeTab: 'analyze',
historyLoading: false,
historyList: [],
historyPagination: {
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showTotal: total => `Total ${total}`
},
historyColumns: []
}
},
computed: {
...mapState({
navTheme: state => state.app.theme
@@ -201,168 +40,14 @@ export default {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
}
},
mounted () {
this.historyColumns = [
{ title: this.tt('polymarket.analysis.historyMarket', 'Market'), dataIndex: 'market_title', key: 'market_title', scopedSlots: { customRender: 'market_title' } },
{ title: this.tt('polymarket.analysis.historyRecommendation', 'Recommendation'), dataIndex: 'recommendation', key: 'recommendation', width: 120, scopedSlots: { customRender: 'recommendation' } },
{ title: this.tt('polymarket.analysis.historyOpportunityScore', 'Opportunity'), dataIndex: 'opportunity_score', key: 'opportunity_score', width: 120, align: 'right' },
{ title: this.tt('polymarket.analysis.historyCreatedAt', 'Created at'), dataIndex: 'created_at', key: 'created_at', width: 180, scopedSlots: { customRender: 'created_at' } }
]
},
methods: {
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
},
async handleAnalyze () {
if (!this.inputText.trim()) {
this.$message.warning(this.tt('polymarket.analysis.inputRequired', 'Please enter a Polymarket URL or market title'))
return
}
this.analyzing = true
this.analysisResult = null
try {
const res = await analyzePolymarket({
input: this.inputText.trim(),
language: this.$i18n.locale || 'zh-CN'
})
if (res && res.code === 1) {
this.analysisResult = res.data
this.$message.success(this.tt('polymarket.analysis.success', 'Analysis completed'))
} else if (res && res.msg === 'Insufficient credits') {
this.$message.error(this.tt('polymarket.analysis.insufficientCredits', 'Insufficient credits'))
this.$router.push('/billing')
} else {
this.$message.error((res && res.msg) || this.tt('polymarket.analysis.failed', 'Analysis failed'))
}
} catch (error) {
const message = error && error.response && error.response.data && error.response.data.msg
if (message === 'Insufficient credits') {
this.$message.error(this.tt('polymarket.analysis.insufficientCredits', 'Insufficient credits'))
this.$router.push('/billing')
} else {
this.$message.error(message || this.tt('polymarket.analysis.failed', 'Analysis failed'))
}
} finally {
this.analyzing = false
}
},
handleNewAnalysis () {
this.inputText = ''
this.analysisResult = null
},
handleClose () {
this.$emit('close')
setTimeout(() => {
this.inputText = ''
this.analysisResult = null
this.analyzing = false
}, 300)
},
handleTabChange (key) {
this.activeTab = key
if (key === 'history' && this.historyList.length === 0) {
this.loadHistory()
}
},
async loadHistory () {
this.historyLoading = true
try {
const res = await getPolymarketHistory({
page: this.historyPagination.current,
page_size: this.historyPagination.pageSize
})
if (res && res.code === 1 && res.data) {
this.historyList = res.data.items || []
this.historyPagination.total = res.data.total || 0
}
} catch (error) {
this.$message.error(this.tt('polymarket.analysis.loadHistoryFailed', 'Failed to load history'))
} finally {
this.historyLoading = false
}
},
handleHistoryTableChange (pagination) {
this.historyPagination.current = pagination.current
this.historyPagination.pageSize = pagination.pageSize
this.loadHistory()
},
viewHistoryItem (record) {
this.activeTab = 'analyze'
this.analysisResult = {
market: {
question: record.market_title,
market_id: record.market_id,
polymarket_url: record.market_url,
current_probability: record.market_probability,
status: 'active'
},
analysis: {
ai_predicted_probability: record.ai_predicted_probability,
market_probability: record.market_probability,
recommendation: record.recommendation,
opportunity_score: record.opportunity_score,
confidence_score: record.confidence_score
}
}
},
getStatusColor (status) {
return { active: 'green', closed: 'default', resolved: 'blue' }[status] || 'default'
},
getRecommendationColor (value) {
return { YES: 'green', NO: 'red', HOLD: 'orange' }[value] || 'default'
},
getRecommendationLabel (value) {
const map = {
YES: this.tt('polymarket.analysis.recommendationYes', 'YES'),
NO: this.tt('polymarket.analysis.recommendationNo', 'NO'),
HOLD: this.tt('polymarket.analysis.recommendationHold', 'HOLD')
}
return map[value] || value
},
getDivergenceClass (value) {
if (value > 5) return 'divergence-positive'
if (value < -5) return 'divergence-negative'
return 'divergence-neutral'
},
formatVolume (value) {
const number = Number(value || 0)
if (number >= 1000000) return `$${(number / 1000000).toFixed(2)}M`
if (number >= 1000) return `$${(number / 1000).toFixed(2)}K`
return `$${number.toFixed(2)}`
},
formatDateTime (value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString(this.$i18n.locale === 'zh-CN' ? 'zh-CN' : 'en-US')
}
}
}
</script>
<style lang="less" scoped>
.market-info h3, .text-block h4 { color: #0f172a; }
.market-meta, .probability-comparison, .result-actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.probability-comparison { margin-bottom: 16px; }
.prob-item, .rec-card { flex: 1; min-width: 160px; padding: 16px; border-radius: 12px; background: #f8fafc; }
.prob-label, .rec-label, .remaining-credits { color: rgba(15, 23, 42, 0.6); font-size: 12px; }
.prob-value, .rec-value { color: #0f172a; font-size: 22px; font-weight: 700; margin-top: 8px; }
.ai-prob { color: #1890ff; }
.text-block { margin-top: 20px; }
.text-block p { color: rgba(15, 23, 42, 0.75); line-height: 1.7; }
.billing-info { margin-top: 16px; }
.remaining-credits { margin-top: 8px; text-align: right; }
.result-actions { justify-content: flex-end; margin-top: 20px; }
.loading-section { padding: 36px 0; text-align: center; }
.loading-section p { margin-top: 16px; color: rgba(15, 23, 42, 0.65); }
.divergence-positive { color: #16a34a; }
.divergence-negative { color: #dc2626; }
.divergence-neutral { color: #475569; }
.theme-dark {
.market-info h3, .text-block h4, .prob-value, .rec-value { color: #f8fafc; }
.prob-item, .rec-card { background: #111827; }
.prob-label, .rec-label, .remaining-credits, .text-block p, .loading-section p { color: rgba(248, 250, 252, 0.7); }
.divergence-neutral { color: #cbd5e1; }
}
</style>
@@ -0,0 +1,642 @@
<template>
<div class="polymarket-workspace" :class="{ 'theme-dark': isDarkTheme, embedded }">
<a-tabs v-model="activeTab" @change="handleTabChange">
<a-tab-pane key="analyze" :tab="tt('polymarket.analysis.tabAnalyze', 'Analyze')">
<div v-if="!analysisResult && !analyzing" class="input-shell">
<div class="input-hero">
<div>
<div class="hero-eyebrow">{{ tt('polymarket.analysis.title', 'Polymarket Analysis') }}</div>
<h3 class="hero-title">{{ tt('polymarket.analysis.heroTitle', 'Compare market pricing with AI conviction') }}</h3>
<p class="hero-desc">
{{ tt('polymarket.analysis.description', 'Analyze a prediction market by URL or title and compare market pricing with AI probability.') }}
</p>
</div>
<div class="hero-stats">
<div class="hero-stat">
<span class="stat-label">{{ tt('polymarket.analysis.statInput', 'Input') }}</span>
<strong class="stat-value">{{ tt('polymarket.analysis.statInputValue', 'URL or title') }}</strong>
</div>
<div class="hero-stat">
<span class="stat-label">{{ tt('polymarket.analysis.statOutput', 'Output') }}</span>
<strong class="stat-value">{{ tt('polymarket.analysis.statOutputValue', 'Probability gap') }}</strong>
</div>
</div>
</div>
<a-alert
class="input-alert"
type="info"
show-icon
:message="tt('polymarket.analysis.inputHint', 'Paste a Polymarket URL or market title to start analysis.')"
/>
<a-textarea
ref="inputTextarea"
v-model="inputText"
:rows="4"
:disabled="analyzing"
:placeholder="tt('polymarket.analysis.inputPlaceholder', 'https://polymarket.com/... or a market title')"
@pressEnter="handleAnalyze"
/>
<div class="input-actions">
<a-button
type="primary"
size="large"
icon="thunderbolt"
:loading="analyzing"
:disabled="!inputText.trim()"
@click="handleAnalyze"
>
{{ tt('polymarket.analysis.analyzeButton', 'Analyze') }}
</a-button>
<a-button size="large" icon="history" @click="activeTab = 'history'">
{{ tt('polymarket.analysis.tabHistory', 'History') }}
</a-button>
</div>
</div>
<div v-if="analysisResult" class="result-section">
<div class="market-info">
<h3>{{ analysisResult.market.question }}</h3>
<div class="market-meta">
<a-tag :color="getStatusColor(analysisResult.market.status)">{{ analysisResult.market.status }}</a-tag>
<span class="meta-item"><a-icon type="percentage" /> {{ analysisResult.market.current_probability }}%</span>
<span v-if="analysisResult.market.volume_24h" class="meta-item"><a-icon type="dollar" /> {{ formatVolume(analysisResult.market.volume_24h) }}</span>
<a-button
v-if="analysisResult.market.polymarket_url"
type="link"
size="small"
icon="link"
:href="analysisResult.market.polymarket_url"
target="_blank"
>
{{ tt('polymarket.analysis.viewOnPolymarket', 'Open on Polymarket') }}
</a-button>
</div>
</div>
<div class="analysis-result">
<a-divider>{{ tt('polymarket.analysis.aiAnalysis', 'AI Analysis') }}</a-divider>
<div class="probability-comparison">
<div class="prob-item">
<div class="prob-label">{{ tt('polymarket.analysis.marketProbability', 'Market probability') }}</div>
<div class="prob-value">{{ analysisResult.analysis.market_probability }}%</div>
</div>
<div class="prob-item">
<div class="prob-label">{{ tt('polymarket.analysis.aiPredictedProbability', 'AI predicted probability') }}</div>
<div class="prob-value ai-prob">{{ analysisResult.analysis.ai_predicted_probability }}%</div>
</div>
<div class="prob-item">
<div class="prob-label">{{ tt('polymarket.analysis.divergence', 'Divergence') }}</div>
<div class="prob-value" :class="getDivergenceClass(analysisResult.analysis.divergence)">
{{ analysisResult.analysis.divergence >= 0 ? '+' : '' }}{{ Number(analysisResult.analysis.divergence || 0).toFixed(2) }}%
</div>
</div>
</div>
<a-row :gutter="16" class="recommendation-section">
<a-col :span="8">
<div class="rec-card">
<div class="rec-label">{{ tt('polymarket.analysis.recommendation', 'Recommendation') }}</div>
<a-tag :color="getRecommendationColor(analysisResult.analysis.recommendation)" class="rec-tag">
{{ getRecommendationLabel(analysisResult.analysis.recommendation) }}
</a-tag>
</div>
</a-col>
<a-col :span="8">
<div class="rec-card">
<div class="rec-label">{{ tt('polymarket.analysis.confidenceScore', 'Confidence') }}</div>
<div class="rec-value">{{ Number(analysisResult.analysis.confidence_score || 0).toFixed(0) }}</div>
</div>
</a-col>
<a-col :span="8">
<div class="rec-card">
<div class="rec-label">{{ tt('polymarket.analysis.opportunityScore', 'Opportunity') }}</div>
<div class="rec-value">{{ Number(analysisResult.analysis.opportunity_score || 0).toFixed(0) }}</div>
</div>
</a-col>
</a-row>
<div v-if="analysisResult.analysis.reasoning" class="text-block">
<h4>{{ tt('polymarket.analysis.reasoning', 'Reasoning') }}</h4>
<p>{{ analysisResult.analysis.reasoning }}</p>
</div>
<div v-if="analysisResult.analysis.key_factors && analysisResult.analysis.key_factors.length > 0" class="text-block">
<h4>{{ tt('polymarket.analysis.keyFactors', 'Key factors') }}</h4>
<a-tag v-for="(factor, index) in analysisResult.analysis.key_factors" :key="index" color="blue" class="factor-tag">
{{ factor }}
</a-tag>
</div>
<div v-if="analysisResult.credits_charged > 0" class="billing-info">
<a-alert
type="success"
show-icon
:message="tt('polymarket.analysis.creditsCharged', `Credits charged: ${analysisResult.credits_charged}`, { credits: analysisResult.credits_charged })"
/>
<div v-if="analysisResult.remaining_credits !== undefined" class="remaining-credits">
{{ tt('polymarket.analysis.remainingCredits', `Remaining credits: ${Number(analysisResult.remaining_credits).toFixed(0)}`, { credits: Number(analysisResult.remaining_credits).toFixed(0) }) }}
</div>
</div>
</div>
<div class="result-actions">
<a-button @click="handleNewAnalysis">{{ tt('polymarket.analysis.newAnalysis', 'New analysis') }}</a-button>
<a-button v-if="showCloseAction" type="primary" @click="$emit('close')">{{ tt('common.close', 'Close') }}</a-button>
</div>
</div>
<div v-if="analyzing" class="loading-section">
<a-spin size="large" />
<p>{{ tt('polymarket.analysis.analyzing', 'Analyzing...') }}</p>
</div>
</a-tab-pane>
<a-tab-pane key="history" :tab="tt('polymarket.analysis.tabHistory', 'History')">
<a-spin :spinning="historyLoading">
<a-table
size="small"
:columns="historyColumns"
:data-source="historyList"
:pagination="historyPagination"
:row-key="record => record.id"
@change="handleHistoryTableChange"
>
<template slot="market_title" slot-scope="text, record">
<a-button type="link" style="padding: 0" @click="viewHistoryItem(record)">{{ text }}</a-button>
</template>
<template slot="recommendation" slot-scope="text">
<a-tag :color="getRecommendationColor(text)">{{ getRecommendationLabel(text) }}</a-tag>
</template>
<template slot="created_at" slot-scope="text">
{{ formatDateTime(text) }}
</template>
</a-table>
</a-spin>
</a-tab-pane>
</a-tabs>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { analyzePolymarket, getPolymarketHistory } from '@/api/polymarket'
export default {
name: 'PolymarketAnalysisWorkspace',
props: {
showCloseAction: {
type: Boolean,
default: false
},
embedded: {
type: Boolean,
default: false
},
initialInput: {
type: String,
default: ''
}
},
data () {
return {
inputText: '',
analyzing: false,
analysisResult: null,
activeTab: 'analyze',
historyLoading: false,
historyList: [],
historyPagination: {
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showTotal: total => `Total ${total}`
},
historyColumns: []
}
},
computed: {
...mapState({
navTheme: state => state.app.theme
}),
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
}
},
watch: {
initialInput: {
immediate: true,
handler (value) {
if (value) {
this.inputText = String(value)
}
}
}
},
mounted () {
this.historyColumns = [
{ title: this.tt('polymarket.analysis.historyMarket', 'Market'), dataIndex: 'market_title', key: 'market_title', scopedSlots: { customRender: 'market_title' } },
{ title: this.tt('polymarket.analysis.historyRecommendation', 'Recommendation'), dataIndex: 'recommendation', key: 'recommendation', width: 120, scopedSlots: { customRender: 'recommendation' } },
{ title: this.tt('polymarket.analysis.historyOpportunityScore', 'Opportunity'), dataIndex: 'opportunity_score', key: 'opportunity_score', width: 120, align: 'right' },
{ title: this.tt('polymarket.analysis.historyCreatedAt', 'Created at'), dataIndex: 'created_at', key: 'created_at', width: 180, scopedSlots: { customRender: 'created_at' } }
]
},
methods: {
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
},
focusInput () {
this.$nextTick(() => {
const textarea = this.$refs.inputTextarea
if (textarea && textarea.focus) {
textarea.focus()
}
})
},
setInput (value) {
this.inputText = value ? String(value) : ''
this.activeTab = 'analyze'
this.focusInput()
},
async startFromInput (value) {
this.inputText = value ? String(value) : ''
this.activeTab = 'analyze'
await this.handleAnalyze()
},
resetWorkspace () {
this.inputText = ''
this.analysisResult = null
this.analyzing = false
this.activeTab = 'analyze'
this.focusInput()
},
async handleAnalyze () {
if (!this.inputText.trim()) {
this.$message.warning(this.tt('polymarket.analysis.inputRequired', 'Please enter a Polymarket URL or market title'))
return
}
this.analyzing = true
this.analysisResult = null
try {
const res = await analyzePolymarket({
input: this.inputText.trim(),
language: this.$i18n.locale || 'zh-CN'
})
if (res && res.code === 1) {
this.analysisResult = res.data
this.$message.success(this.tt('polymarket.analysis.success', 'Analysis completed'))
} else if (res && res.msg === 'Insufficient credits') {
this.$message.error(this.tt('polymarket.analysis.insufficientCredits', 'Insufficient credits'))
this.$router.push('/billing')
} else {
this.$message.error((res && res.msg) || this.tt('polymarket.analysis.failed', 'Analysis failed'))
}
} catch (error) {
const message = error && error.response && error.response.data && error.response.data.msg
if (message === 'Insufficient credits') {
this.$message.error(this.tt('polymarket.analysis.insufficientCredits', 'Insufficient credits'))
this.$router.push('/billing')
} else {
this.$message.error(message || this.tt('polymarket.analysis.failed', 'Analysis failed'))
}
} finally {
this.analyzing = false
}
},
handleNewAnalysis () {
this.inputText = ''
this.analysisResult = null
this.focusInput()
},
handleTabChange (key) {
this.activeTab = key
if (key === 'history' && this.historyList.length === 0) {
this.loadHistory()
}
},
async loadHistory () {
this.historyLoading = true
try {
const res = await getPolymarketHistory({
page: this.historyPagination.current,
page_size: this.historyPagination.pageSize
})
if (res && res.code === 1 && res.data) {
this.historyList = res.data.items || []
this.historyPagination.total = res.data.total || 0
}
} catch (error) {
this.$message.error(this.tt('polymarket.analysis.loadHistoryFailed', 'Failed to load history'))
} finally {
this.historyLoading = false
}
},
handleHistoryTableChange (pagination) {
this.historyPagination.current = pagination.current
this.historyPagination.pageSize = pagination.pageSize
this.loadHistory()
},
viewHistoryItem (record) {
this.activeTab = 'analyze'
this.analysisResult = {
market: {
question: record.market_title,
market_id: record.market_id,
polymarket_url: record.market_url,
current_probability: record.market_probability,
status: 'active'
},
analysis: {
ai_predicted_probability: record.ai_predicted_probability,
market_probability: record.market_probability,
recommendation: record.recommendation,
opportunity_score: record.opportunity_score,
confidence_score: record.confidence_score
}
}
},
getStatusColor (status) {
return { active: 'green', closed: 'default', resolved: 'blue' }[status] || 'default'
},
getRecommendationColor (value) {
return { YES: 'green', NO: 'red', HOLD: 'orange' }[value] || 'default'
},
getRecommendationLabel (value) {
const map = {
YES: this.tt('polymarket.analysis.recommendationYes', 'YES'),
NO: this.tt('polymarket.analysis.recommendationNo', 'NO'),
HOLD: this.tt('polymarket.analysis.recommendationHold', 'HOLD')
}
return map[value] || value
},
getDivergenceClass (value) {
if (value > 5) return 'divergence-positive'
if (value < -5) return 'divergence-negative'
return 'divergence-neutral'
},
formatVolume (value) {
const number = Number(value || 0)
if (number >= 1000000) return `$${(number / 1000000).toFixed(2)}M`
if (number >= 1000) return `$${(number / 1000).toFixed(2)}K`
return `$${number.toFixed(2)}`
},
formatDateTime (value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString(this.$i18n.locale === 'zh-CN' ? 'zh-CN' : 'en-US')
}
}
}
</script>
<style lang="less" scoped>
.polymarket-workspace {
padding: 8px 0 0;
}
.input-shell,
.result-section {
border-radius: 20px;
border: 1px solid #e5edf5;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06);
}
.input-shell {
padding: 24px;
}
.input-hero {
display: flex;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.hero-eyebrow {
color: #2563eb;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero-title {
margin: 8px 0 10px;
color: #0f172a;
font-size: 26px;
font-weight: 700;
}
.hero-desc {
max-width: 620px;
margin: 0;
color: rgba(15, 23, 42, 0.7);
line-height: 1.7;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(120px, 1fr));
gap: 12px;
min-width: 260px;
}
.hero-stat,
.prob-item,
.rec-card {
padding: 16px;
border-radius: 14px;
background: #f8fafc;
}
.stat-label,
.prob-label,
.rec-label,
.remaining-credits {
color: rgba(15, 23, 42, 0.6);
font-size: 12px;
}
.stat-value,
.prob-value,
.rec-value {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 22px;
font-weight: 700;
}
.input-alert {
margin-bottom: 16px;
}
.input-actions,
.market-meta,
.probability-comparison,
.result-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.input-actions {
margin-top: 16px;
}
.result-section {
padding: 24px;
}
.market-info h3,
.text-block h4 {
color: #0f172a;
}
.probability-comparison {
margin-bottom: 16px;
}
.ai-prob {
color: #1890ff;
}
.rec-tag {
font-size: 16px;
padding: 4px 12px;
}
.text-block {
margin-top: 20px;
}
.text-block p {
color: rgba(15, 23, 42, 0.75);
line-height: 1.7;
}
.factor-tag {
margin: 4px;
}
.billing-info {
margin-top: 16px;
}
.remaining-credits {
margin-top: 8px;
text-align: right;
}
.result-actions {
justify-content: flex-end;
margin-top: 20px;
}
.loading-section {
padding: 48px 0;
text-align: center;
}
.loading-section p {
margin-top: 16px;
color: rgba(15, 23, 42, 0.65);
}
.divergence-positive {
color: #16a34a;
}
.divergence-negative {
color: #dc2626;
}
.divergence-neutral {
color: #475569;
}
.theme-dark {
.input-shell,
.result-section {
border-color: rgba(59, 130, 246, 0.16);
background: linear-gradient(180deg, #111827 0%, #0f172a 100%);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28);
}
.hero-eyebrow {
color: #60a5fa;
}
.hero-title,
.market-info h3,
.text-block h4,
.stat-value,
.prob-value,
.rec-value {
color: #f8fafc;
}
.hero-desc,
.text-block p,
.loading-section p,
.stat-label,
.prob-label,
.rec-label,
.remaining-credits {
color: rgba(248, 250, 252, 0.72);
}
.hero-stat,
.prob-item,
.rec-card {
background: rgba(15, 23, 42, 0.72);
}
.divergence-neutral {
color: #cbd5e1;
}
}
@media (max-width: 900px) {
.input-hero {
flex-direction: column;
}
.hero-stats {
min-width: 0;
}
}
@media (max-width: 768px) {
.input-shell,
.result-section {
padding: 18px;
}
.hero-title {
font-size: 22px;
}
.hero-stats {
grid-template-columns: 1fr;
}
.probability-comparison {
flex-direction: column;
align-items: stretch;
}
.recommendation-section /deep/ .ant-col {
width: 100%;
max-width: 100%;
flex: 0 0 100%;
}
}
</style>
+2 -2
View File
@@ -19,14 +19,14 @@ export default {
default: 'ant-pro-trend'
},
/**
* 上升下降标识:up|down
* Trend direction indicator: up|down
*/
flag: {
type: String,
required: true
},
/**
* 颜色反转
* Reverse color
*/
reverseColor: {
type: Boolean,
@@ -1,5 +1,5 @@
<template>
<!-- 两步验证 -->
<!-- Two-step verification -->
<a-modal
centered
:visible="localVisible"
@@ -7,12 +7,12 @@
@update:visible="localVisible = $event"
:maskClosable="false"
>
<div slot="title" :style="{ textAlign: 'center' }">两步验证</div>
<div slot="title" :style="{ textAlign: 'center' }">Two-Step Verification</div>
<template slot="footer">
<div :style="{ textAlign: 'center' }">
<a-button key="back" @click="handleCancel">返回</a-button>
<a-button key="back" @click="handleCancel">Back</a-button>
<a-button key="submit" type="primary" :loading="stepLoading" @click="handleStepOk">
继续
Continue
</a-button>
</div>
</template>
@@ -20,18 +20,18 @@
<a-spin :spinning="stepLoading">
<a-form layout="vertical" :auto-form-create="(form)=>{this.form = form}">
<div class="step-form-wrapper">
<p style="text-align: center" v-if="!stepLoading">请在手机中打开 Google Authenticator 或两步验证 APP<br />输入 6 位动态码</p>
<p style="text-align: center" v-else>正在验证..<br/>请稍后</p>
<p style="text-align: center" v-if="!stepLoading">Please open Google Authenticator or your two-step verification app on your phone<br />and enter the 6-digit code</p>
<p style="text-align: center" v-else>Verifying..<br/>Please wait</p>
<a-form-item
:style="{ textAlign: 'center' }"
hasFeedback
fieldDecoratorId="stepCode"
:fieldDecoratorOptions="{rules: [{ required: true, message: '请输入 6 位动态码!', pattern: /^\d{6}$/, len: 6 }]}"
:fieldDecoratorOptions="{rules: [{ required: true, message: 'Please enter the 6-digit code!', pattern: /^\d{6}$/, len: 6 }]}"
>
<a-input :style="{ textAlign: 'center' }" @keyup.enter.native="handleStepOk" placeholder="000000" />
</a-form-item>
<p style="text-align: center">
<a @click="onForgeStepCode">遗失手机?</a>
<a @click="onForgeStepCode">Lost your phone?</a>
</p>
</div>
</a-form>
+34 -20
View File
@@ -9,14 +9,14 @@ export const asyncRouterMap = [
meta: { title: 'menu.home' },
redirect: '/ai-asset-analysis',
children: [
// 仪表盘
// Dashboard
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard'),
meta: { title: 'menu.dashboard', keepAlive: true, icon: 'dashboard', permission: ['dashboard'] }
},
// AI 分析
// AI analysis
{
path: '/ai-analysis/:pageNo([1-9]\\d*)?',
name: 'Analysis',
@@ -24,35 +24,49 @@ export const asyncRouterMap = [
hidden: true,
meta: { title: 'menu.dashboard.analysis', keepAlive: false, icon: 'thunderbolt', permission: ['dashboard'] }
},
// AI资产分析(统一入口)
// AI asset analysis (unified entry)
{
path: '/ai-asset-analysis',
name: 'AIAssetAnalysis',
component: () => import('@/views/ai-asset-analysis'),
meta: { title: 'menu.dashboard.aiAssetAnalysis', keepAlive: false, icon: 'appstore', permission: ['dashboard'] }
},
// 指标分析
// Polymarket analysis
{
path: '/polymarket',
name: 'Polymarket',
component: () => import('@/views/polymarket'),
meta: { title: 'Polymarket', keepAlive: false, icon: 'radar-chart', permission: ['dashboard'] }
},
// Indicator analysis
{
path: '/indicator-analysis',
name: 'Indicator',
component: () => import('@/views/indicator-analysis'),
meta: { title: 'menu.dashboard.indicator', keepAlive: true, icon: 'line-chart', permission: ['dashboard'] }
},
// 指标市场(放在指标分析下面)
// Indicator marketplace
{
path: '/indicator-community',
name: 'IndicatorCommunity',
component: () => import('@/views/indicator-community'),
meta: { title: 'menu.dashboard.community', keepAlive: false, icon: 'shop', permission: ['dashboard'] }
},
// 交易助手
// Trading assistant
{
path: '/trading-assistant',
name: 'TradingAssistant',
component: () => import('@/views/trading-assistant'),
meta: { title: 'menu.dashboard.tradingAssistant', keepAlive: true, icon: 'robot', permission: ['dashboard'] }
},
// 资产监测
// Backtest center
{
path: '/backtest-center',
name: 'BacktestCenter',
component: () => import('@/views/backtest-center'),
meta: { title: 'Backtest Center', keepAlive: true, icon: 'experiment', permission: ['dashboard'] }
},
// Portfolio monitor
{
path: '/portfolio',
name: 'Portfolio',
@@ -60,28 +74,28 @@ export const asyncRouterMap = [
hidden: true,
meta: { title: 'menu.dashboard.portfolio', keepAlive: true, icon: 'fund', permission: ['dashboard'] }
},
// 用户管理 (admin only)
// User management (admin only)
{
path: '/user-manage',
name: 'UserManage',
component: () => import('@/views/user-manage'),
meta: { title: 'menu.userManage', keepAlive: false, icon: 'team', permission: ['admin'] }
},
// 个人中心
// Profile
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/profile'),
meta: { title: 'menu.myProfile', keepAlive: false, icon: 'user', permission: ['dashboard'] }
},
// 会员/充值
// Billing
{
path: '/billing',
name: 'Billing',
component: () => import('@/views/billing'),
meta: { title: 'menu.billing', keepAlive: false, icon: 'wallet', permission: ['dashboard'] }
},
// 系统设置 (admin only) - 放在最后
// Settings (admin only) - keep last
{
path: '/settings',
name: 'Settings',
@@ -95,7 +109,7 @@ export const asyncRouterMap = [
path: '/other',
name: 'otherPage',
component: PageView,
meta: { title: '其他组件', icon: 'slack', permission: [ 'dashboard' ] },
meta: { title: 'Other Components', icon: 'slack', permission: [ 'dashboard' ] },
redirect: '/other/icon-selector',
children: [
{
@@ -107,44 +121,44 @@ export const asyncRouterMap = [
{
path: '/other/list',
component: RouteView,
meta: { title: '业务布局', icon: 'layout', permission: [ 'support' ] },
meta: { title: 'Business Layout', icon: 'layout', permission: [ 'support' ] },
redirect: '/other/list/tree-list',
children: [
{
path: '/other/list/tree-list',
name: 'TreeList',
component: () => import('@/views/other/TreeList'),
meta: { title: '树目录表格', keepAlive: true }
meta: { title: 'Tree Table', keepAlive: true }
},
{
path: '/other/list/edit-table',
name: 'EditList',
component: () => import('@/views/other/TableInnerEditList'),
meta: { title: '内联编辑表格', keepAlive: true }
meta: { title: 'Inline Edit Table', keepAlive: true }
},
{
path: '/other/list/user-list',
name: 'UserList',
component: () => import('@/views/other/UserList'),
meta: { title: '用户列表', keepAlive: true }
meta: { title: 'User List', keepAlive: true }
},
{
path: '/other/list/role-list',
name: 'RoleList',
component: () => import('@/views/other/RoleList'),
meta: { title: '角色列表', keepAlive: true }
meta: { title: 'Role List', keepAlive: true }
},
{
path: '/other/list/system-role',
name: 'SystemRole',
component: () => import('@/views/role/RoleList'),
meta: { title: '角色列表2', keepAlive: true }
meta: { title: 'Role List 2', keepAlive: true }
},
{
path: '/other/list/permission-list',
name: 'PermissionList',
component: () => import('@/views/other/PermissionList'),
meta: { title: '权限列表', keepAlive: true }
meta: { title: 'Permission List', keepAlive: true }
}
]
}
@@ -161,7 +175,7 @@ export const asyncRouterMap = [
]
/**
* 基础路由
* Base routes
* @type { *[] }
*/
export const constantRouterMap = [
+2 -2
View File
@@ -5,11 +5,11 @@ import app from './modules/app'
import user from './modules/user'
// dynamic router permission control
// 动态路由模式(支持基于角色的菜单过滤)
// Dynamic router mode (supports role-based menu filtering)
import permission from './modules/async-router'
// static router permission control (NO filtering)
// 静态路由模式(不过滤菜单,已弃用)
// Static router mode (no menu filtering, deprecated)
// import permission from './modules/static-router'
import getters from './getters'
+2 -2
View File
@@ -20,13 +20,13 @@ const app = {
state: {
sideCollapsed: false,
isMobile: false,
theme: storage.get(TOGGLE_NAV_THEME, 'light'), // localStorage 读取主题,默认 'light'
theme: storage.get(TOGGLE_NAV_THEME, 'light'), // Read theme from localStorage, default 'light'
layout: '',
contentWidth: '',
fixedHeader: false,
fixedSidebar: false,
autoHideHeader: false,
color: storage.get(TOGGLE_COLOR, '#13C2C2'), // localStorage 读取主题色,默认 '#13C2C2'
color: storage.get(TOGGLE_COLOR, '#13C2C2'), // Read theme color from localStorage, default '#13C2C2'
weak: false,
multiTab: true,
lang: 'en-US',
@@ -1,5 +1,5 @@
/**
* 向后端请求用户的菜单,动态生成路由
* Request user menu from backend, dynamically generate routes
*/
import { constantRouterMap } from '@/config/router.config'
import { generatorDynamicRouter } from '@/router/generator-routers'
@@ -2,7 +2,7 @@ import { asyncRouterMap, constantRouterMap } from '@/config/router.config'
import cloneDeep from 'lodash.clonedeep'
/**
* 单账户多角色时,使用该方法可过滤角色不存在的菜单
* Filter menus that do not exist for the role in case of single account with multiple roles
*
* @param roles
* @param route
@@ -18,7 +18,7 @@ function hasRole(roles, route) {
}
function filterAsyncRouter (routerMap, role) {
// 不进行权限过滤,直接返回所有路由(后端会验证token
// Do not perform permission filtering, return all routes directly (backend will verify token)
return routerMap.map(route => {
if (route.children && route.children.length) {
route.children = filterAsyncRouter(route.children, role)
@@ -42,7 +42,7 @@ const permission = {
GenerateRoutes ({ commit }, data) {
return new Promise(resolve => {
const routerMap = cloneDeep(asyncRouterMap)
// 不进行权限过滤,直接返回所有路由(后端会验证token
// Do not perform permission filtering, return all routes directly (backend will verify token)
const accessedRouters = filterAsyncRouter(routerMap, null)
commit('SET_ROUTERS', accessedRouters)
resolve()
+20 -20
View File
@@ -69,11 +69,11 @@ const user = {
},
actions: {
// 登录
// Login
Login ({ commit, dispatch }, userInfo) {
return new Promise((resolve, reject) => {
login(userInfo).then(response => {
// 适配 Python 后端响应格式
// Adapt to Python backend response format
if (response && response.code === 1 && response.data) {
const result = response.data
const token = result.token
@@ -85,12 +85,12 @@ const user = {
commit('SET_INFO', info)
storage.set(USER_INFO, info, expiresAt)
// 设置基本信息
// Set basic information
const name = info.nickname || info.username || 'User'
commit('SET_NAME', { name: name, welcome: welcome() })
commit('SET_AVATAR', info.avatar || '/avatar2.jpg')
// 从服务器返回的角色信息设置角色
// Set roles based on role information returned from the server
let roles = [DEFAULT_ROLE]
if (info.role) {
// role: { id: 'admin', permissions: [...] }
@@ -104,7 +104,7 @@ const user = {
commit('SET_ROLES', roles)
storage.set(USER_ROLES, roles, expiresAt)
// 重置路由,强制重新生成(根据新用户的角色)
// Reset routes, force regeneration (based on new user's roles)
dispatch('ResetRoutes')
resolve(response)
@@ -117,13 +117,13 @@ const user = {
})
},
// Web3 登录完成后的统一处理
// Unified processing after Web3 login is completed
Web3LoginFinalize ({ commit }, payload) {
return new Promise((resolve, reject) => {
try {
const { token, userInfo } = payload
if (!token || !userInfo) {
reject(new Error('登录数据异常'))
reject(new Error('Login data exception'))
return
}
storage.set(ACCESS_TOKEN, token, new Date().getTime() + 7 * 24 * 60 * 60 * 1000)
@@ -155,7 +155,7 @@ const user = {
})
},
// 刷新用户信息
// Refresh user info
FetchUserInfo ({ commit }) {
return new Promise((resolve, reject) => {
getUserInfo().then(res => {
@@ -172,19 +172,19 @@ const user = {
}
resolve(info)
} else {
reject(new Error((res && res.msg) || '获取用户信息失败'))
reject(new Error((res && res.msg) || 'Failed to fetch user info'))
}
}).catch(err => reject(err))
})
},
// 获取用户信息(从 store 中获取,不再请求接口)
// Get user info (get from store, no longer request interface)
GetInfo ({ commit, state }) {
return new Promise((resolve, reject) => {
// 用户信息已经在登录时保存到 store 中,直接返回
// 增加 check: 必须包含 is_demo 字段,否则视为过期缓存,强制刷新
// User info is already saved in store during login, return directly
// Add check: must include is_demo field; otherwise regarded as stale cache, force refresh
if (state.info && Object.keys(state.info).length > 0 && typeof state.info.is_demo !== 'undefined') {
// 补全 Roles
// Complete Roles
const info = state.info
if (info.role) {
const roles = normalizeRoles(info.role)
@@ -200,7 +200,7 @@ const user = {
}
resolve(state.info)
} else {
// 尝试主动拉取一次
// Attempt to actively pull once
getUserInfo().then(res => {
if (res && res.code === 1 && res.data) {
const info = res.data
@@ -214,7 +214,7 @@ const user = {
if (info.avatar) {
commit('SET_AVATAR', info.avatar)
}
// 关键修复:设置角色,防止路由守卫死循环
// Critical fix: set roles to prevent infinite loop in route guard
if (info.role) {
const roles = normalizeRoles(info.role)
commit('SET_ROLES', roles)
@@ -229,14 +229,14 @@ const user = {
}
resolve(info)
} else {
reject(new Error((res && res.msg) || '用户信息不存在'))
reject(new Error((res && res.msg) || 'User info does not exist'))
}
}).catch(err => reject(err))
}
})
},
// 登出
// Logout
Logout ({ commit, dispatch }) {
return new Promise((resolve) => {
logout().then(() => {
@@ -248,15 +248,15 @@ const user = {
storage.remove(ACCESS_TOKEN)
storage.remove(USER_INFO)
storage.remove(USER_ROLES)
// 重置路由
// Reset routes
dispatch('ResetRoutes')
resolve()
}).catch(() => {
// 登出失败时也继续执行,确保清理本地状态
// Continue even if logout fails to ensure local state is cleared
storage.remove(ACCESS_TOKEN)
storage.remove(USER_INFO)
storage.remove(USER_ROLES)
// 重置路由
// Reset routes
dispatch('ResetRoutes')
resolve()
}).finally(() => {
+42 -42
View File
@@ -1,19 +1,19 @@
/**
* 指标代码解密工具
* 用于解密用户购买的加密指标代码
* Indicator code decryption tool
* Used to decrypt encrypted indicator code purchased by users
*/
import CryptoJS from 'crypto-js'
import request from '@/utils/request'
/**
* 解密指标代码
* Decrypt indicator code
*
* @param {string} encryptedCode - base64编码的加密代码
* @param {number} userId - 用户ID
* @param {number} indicatorId - 指标ID
* @param {string} serverSecret - 服务器密钥(需要从后端获取或配置)
* @returns {string} - 解密后的代码
* @param {string} encryptedCode - base64 encoded encrypted code
* @param {number} userId - User ID
* @param {number} indicatorId - Indicator ID
* @param {string} serverSecret - Server secret (needs to be from backend or config)
* @returns {string} - Decrypted code
*/
export function decryptCode (encryptedCode, userId, indicatorId, encryptedKey) {
if (!encryptedCode || !userId || !indicatorId || !encryptedKey) {
@@ -21,18 +21,18 @@ export function decryptCode (encryptedCode, userId, indicatorId, encryptedKey) {
}
try {
// 解码base64加密代码
// Decode base64 encrypted code
const combined = CryptoJS.enc.Base64.parse(encryptedCode)
// 提取IV(前16字节)和加密数据
const ivWords = CryptoJS.lib.WordArray.create(combined.words.slice(0, 4)) // 前16字节(4个word
const encryptedWords = CryptoJS.lib.WordArray.create(combined.words.slice(4)) // 剩余部分
// Extract IV (first 16 bytes) and encrypted data
const ivWords = CryptoJS.lib.WordArray.create(combined.words.slice(0, 4)) // First 16 bytes (4 words)
const encryptedWords = CryptoJS.lib.WordArray.create(combined.words.slice(4)) // Remaining part
// 解密密钥(从后端获取的base64编码密钥)
// encryptedKey 是从后端获取的 base64 编码的密钥,直接解码使用
// Decryption key (base64 encoded key from backend)
// encryptedKey is the base64 encoded key from backend, decode directly for use
const key = CryptoJS.enc.Base64.parse(encryptedKey)
// 解密
// Decrypt
const decrypted = CryptoJS.AES.decrypt(
{ ciphertext: encryptedWords },
key,
@@ -43,7 +43,7 @@ export function decryptCode (encryptedCode, userId, indicatorId, encryptedKey) {
}
)
// 转换为字符串
// Convert to string
const decryptedText = decrypted.toString(CryptoJS.enc.Utf8)
if (!decryptedText) {
@@ -52,25 +52,25 @@ export function decryptCode (encryptedCode, userId, indicatorId, encryptedKey) {
return decryptedText
} catch (error) {
// 解密失败,返回原代码(向后兼容)
// Decryption failed, return original code (backward compatibility)
return encryptedCode
}
}
/**
* 从后端获取解密密钥(动态密钥)
* Get decryption key from backend (dynamic key)
*
* @param {number} userId - 用户ID
* @param {number} indicatorId - 指标ID
* @returns {Promise<string>} - 解密密钥(base64编码)
* @param {number} userId - User ID
* @param {number} indicatorId - Indicator ID
* @returns {Promise<string>} - Decryption key (base64 encoded)
*/
export async function getDecryptKey (userId, indicatorId) {
if (!userId || !indicatorId) {
throw new Error('用户ID和指标ID不能为空')
throw new Error('User ID and Indicator ID cannot be empty')
}
try {
// 动态请求方式:从后端API获取
// Dynamic request: get from backend API
const response = await request({
url: '/api/indicator/getDecryptKey',
method: 'post',
@@ -81,56 +81,56 @@ export async function getDecryptKey (userId, indicatorId) {
})
if (response.code === 1 && response.data && response.data.key) {
// 返回base64编码的密钥
// Return base64 encoded key
return response.data.key
} else {
throw new Error(response.msg || '获取解密密钥失败')
throw new Error(response.msg || 'Failed to get decryption key')
}
} catch (error) {
// 如果后端接口失败,抛出错误,不使用备用密钥(更安全)
throw new Error('无法获取解密密钥,请检查网络连接或联系管理员: ' + (error.message || '未知错误'))
// If backend fails, throw error instead of using backup key (more secure)
throw new Error('Cannot get decryption key, please check network or contact admin: ' + (error.message || 'Unknown error'))
}
}
/**
* 智能解密代码(自动获取密钥)
* Smart code decryption (auto-fetch key)
*
* @param {string} encryptedCode - 加密的代码
* @param {number} userId - 用户ID
* @param {number} indicatorId - 指标ID
* @returns {Promise<string>} - 解密后的代码
* @param {string} encryptedCode - Encrypted code
* @param {number} userId - User ID
* @param {number} indicatorId - Indicator ID
* @returns {Promise<string>} - Decrypted code
*/
export async function decryptCodeAuto (encryptedCode, userId, indicatorId) {
// 从后端动态获取解密密钥(base64编码)
// Dynamically get decryption key from backend (base64 encoded)
const encryptedKey = await getDecryptKey(userId, indicatorId)
// 使用获取的密钥解密
// Decrypt using the fetched key
return decryptCode(encryptedCode, userId, indicatorId, encryptedKey)
}
/**
* 检查代码是否需要解密
* Check if code needs decryption
*
* @param {string} code - 代码
* @param {number} isEncrypted - 是否加密标记
* @param {string} code - The code
* @param {number} isEncrypted - Encryption flag
* @returns {boolean}
*/
export function needsDecrypt (code, isEncrypted) {
// 如果明确标记为加密,或者代码长度很长且符合base64格式,可能需要解密
// If explicitly marked as encrypted, or code is long and base64 formatted, it might need decryption
if (isEncrypted === 1 || isEncrypted === true) {
return true
}
// 简单检查:加密代码通常较长(base64编码会增大约33%
// Simple check: encrypted code is usually long (base64 increases size by ~33%)
if (code && code.length > 100) {
// 尝试base64解码检查
// Attempt base64 decode check
try {
const decoded = atob(code)
// 如果解码后的长度合理,可能是加密的
// If decoded length is reasonable, it might be encrypted
if (decoded.length > 50) {
return true
}
} catch (e) {
// 不是base64,不需要解密
// Not base64, no decryption needed
}
}
+3 -3
View File
@@ -1,13 +1,13 @@
import Vue from 'vue'
import moment from 'moment'
import 'moment/locale/zh-cn'
moment.locale('zh-cn')
import 'moment/locale/eu'
moment.locale('en-us')
Vue.filter('NumberFormat', function (value) {
if (!value) {
return '0'
}
const intPartFormat = value.toString().replace(/(\d)(?=(?:\d{3})+$)/g, '$1,') // 将整数部分逢三一断
const intPartFormat = value.toString().replace(/(\d)(?=(?:\d{3})+$)/g, '$1,') // Format integer part with thousands separators
return intPartFormat
})
+34 -34
View File
@@ -5,7 +5,7 @@ import notification from 'ant-design-vue/es/notification'
import { VueAxios } from './axios'
import { ACCESS_TOKEN, USER_INFO, USER_ROLES } from '@/store/mutation-types'
// PHPSESSID 存储键名
// PHPSESSID storage key name
const PHPSESSID_KEY = 'PHPSESSID'
// Locale storage key used by vue-i18n (see src/locales/index.js)
const LOCALE_KEY = 'lang'
@@ -14,7 +14,7 @@ const LOCALE_KEY = 'lang'
let isRedirectingToLogin = false
/**
* 获取 token,处理 token 可能是字符串或对象的情况
* Get token, handle case where token might be a string or an object
*/
function getToken () {
let token = storage.get(ACCESS_TOKEN)
@@ -22,30 +22,30 @@ function getToken () {
return null
}
if (typeof token !== 'string') {
// 如果是对象,尝试获取 token 属性
// If it is an object, try to get the token property
if (token && typeof token === 'object') {
token = token.token || token.value || null
} else {
token = null
}
}
// 确保 token 是字符串且不为空
// Ensure token is a string and not empty
return (typeof token === 'string' && token.length > 0) ? token : null
}
// 创建 axios 实例
// Create axios instance
const request = axios.create({
// API 请求的默认前缀
// 生产环境应由 Nginx 处理,开发环境由 devServer proxy 处理
// API request default prefix
// Production environment should be handled by Nginx, development environment by devServer proxy
baseURL: '/',
timeout: 30000, // Default request timeout 30s
withCredentials: true // 允许携带 cookies
withCredentials: true // Allow cookies with request
})
// Extended timeout for long-running AI analysis APIs
export const ANALYSIS_TIMEOUT = 180000 // 3 minutes for AI analysis
// 异常拦截处理器
// Exception interceptor handler
const errorHandler = (error) => {
if (error.response) {
const data = error.response.data
@@ -72,7 +72,7 @@ const errorHandler = (error) => {
description: data.msg || data.message || 'Token invalid or expired, please login again.'
})
// 项目使用 hash 模式,需要跳转到 /#/user/login
// Project uses hash mode, needs to redirect to /#/user/login
const curHash = window.location.hash || ''
if (!curHash.includes('/user/login')) {
const redirect = encodeURIComponent(curHash.replace('#', '') || '/')
@@ -86,7 +86,7 @@ const errorHandler = (error) => {
// request interceptor
request.interceptors.request.use(config => {
// 使用统一的 token 获取函数
// Use unified token acquisition function
const token = getToken()
const lang = storage.get(LOCALE_KEY) || 'en-US'
@@ -95,16 +95,16 @@ request.interceptors.request.use(config => {
config.headers['X-App-Lang'] = lang
config.headers['Accept-Language'] = lang
// 如果 token 存在,将 token 添加到请求头
// If token exists, add it to request header
if (token) {
// 使用 Authorization header,格式为 Bearer {token}
// Use Authorization header, format: Bearer {token}
config.headers['Authorization'] = `Bearer ${token}`
// 同时保留原有的 Access-Token header(如果后端需要)
// Keep original Access-Token header for backward compatibility
config.headers[ACCESS_TOKEN] = token
// 兼容后端要求的 token
// Compatible with backend expected token header
config.headers['token'] = token
} else {
// 调试:如果 token 不存在,记录日志
// Debug: if token doesn't exist, log it
if (config.url && config.url.includes('/api/auth/info')) {
const rawToken = storage.get(ACCESS_TOKEN)
console.warn('Token missing for /api/auth/info request')
@@ -114,42 +114,42 @@ request.interceptors.request.use(config => {
}
}
// 防止缓存导致的 304:为请求添加禁止缓存的头
// Prevent 304 cache issues: add cache control headers
config.headers['Cache-Control'] = 'no-cache'
config.headers['Pragma'] = 'no-cache'
config.headers['If-Modified-Since'] = '0'
// 为 GET 请求添加时间戳参数,避免缓存
// Add timestamp to GET requests to avoid caching
if ((config.method || 'get').toLowerCase() === 'get') {
const ts = Date.now()
config.params = Object.assign({}, config.params || {}, { _t: ts })
}
// 手动设置 PHPSESSID cookie,确保每次请求使用相同的 session
// 注意:浏览器不允许手动设置 Cookie 请求头,需要通过 document.cookie 设置
// 但由于跨域限制,可能无法直接设置 cookie,主要依赖 withCredentials: true
// Manually set PHPSESSID cookie to ensure same session for all requests
// Note: browser doesn't allow manual setting of Cookie header, must set via document.cookie
// Due to CORS, direct cookie setting might fail; rely on withCredentials: true
const phpsessid = storage.get(PHPSESSID_KEY)
if (phpsessid && typeof document !== 'undefined') {
// 检查当前 document.cookie 中的 PHPSESSID
// Check PHPSESSID in current document.cookie
const currentCookies = document.cookie
const currentPhpsessidMatch = currentCookies.match(/PHPSESSID=([^;]+)/i)
const currentPhpsessid = currentPhpsessidMatch ? currentPhpsessidMatch[1].trim() : null
// 如果当前 cookie 中的 PHPSESSID 与保存的不一致,尝试更新
// 注意:跨域情况下可能无法设置 cookie,这取决于 CORS 配置
// If current PHPSESSID doesn't match saved value, try to update it
// Note: CORS might prevent cookie setting depending on configuration
if (!currentPhpsessid || currentPhpsessid !== phpsessid) {
// 尝试设置 cookie(可能因为跨域而失败,但不影响 withCredentials 的工作)
// Attempt to set cookie (might fail due to CORS, but withCredentials still works)
try {
// 尝试设置带 domain 的 cookie(仅当在相同域名下时有效)
// Attempt to set cookie with domain (valid only on same domain)
if (window.location.hostname.includes('quantdinger.com')) {
document.cookie = `PHPSESSID=${phpsessid}; path=/; domain=.quantdinger.com; SameSite=None; Secure`
} else {
// 跨域情况下,只能依赖 withCredentials: true 和服务器设置
// 这里尝试设置,但可能不会成功
// In CORS cases, rely on withCredentials: true and server settings
// Attempt setting here, but it might not succeed
document.cookie = `PHPSESSID=${phpsessid}; path=/; SameSite=None; Secure`
}
} catch (e) {
// 设置失败是正常的(跨域限制),主要依赖 withCredentials
// Setting failure is expected (CORS limits); mainly rely on withCredentials
}
}
}
@@ -159,18 +159,18 @@ request.interceptors.request.use(config => {
// response interceptor
request.interceptors.response.use((response) => {
// 从响应中提取 PHPSESSID 并保存
// 由于浏览器安全限制,无法直接读取 set-cookie 头,需要通过 document.cookie 获取
// Extract PHPSESSID from response and save it
// Browser security prevents reading set-cookie header directly; use document.cookie
try {
if (typeof document !== 'undefined') {
// 从 document.cookie 获取 PHPSESSID(浏览器自动设置的)
// Get PHPSESSID from document.cookie (set automatically by browser)
const cookies = document.cookie
const phpsessidMatch = cookies.match(/PHPSESSID=([^;]+)/i)
if (phpsessidMatch && phpsessidMatch[1]) {
const phpsessid = phpsessidMatch[1].trim()
// 保存 PHPSESSID storage,有效期 24 小时
// Save PHPSESSID to storage, valid for 24 hours
const savedPhpsessid = storage.get(PHPSESSID_KEY)
// 如果 PHPSESSID 发生变化,更新保存的值
// If PHPSESSID changes, update the saved value
if (!savedPhpsessid || savedPhpsessid !== phpsessid) {
storage.set(PHPSESSID_KEY, phpsessid, new Date().getTime() + 24 * 60 * 60 * 1000)
}
+1 -1
View File
@@ -16,7 +16,7 @@ export function convertRoutes (nodes) {
if (!node.children || !node.children.length) continue
node.children.forEach(child => {
// 转化相对路径
// Convert relative path
if (child.path[0] !== '/' && !child.path.startsWith('http')) {
child.path = node.path.replace(/(\w*)[/]*$/, `$1/${child.path}`)
}
+2 -2
View File
@@ -5,13 +5,13 @@ export function timeFix () {
}
export function welcome () {
const arr = ['休息一会儿吧', '准备吃什么呢?', '要不要打一把 DOTA', '我猜你可能累了']
const arr = ['Take a break', 'What are you going to eat?', 'How about a game of DOTA?', 'I guess you might be tired']
const index = Math.floor(Math.random() * arr.length)
return arr[index]
}
/**
* 触发 window.resize
* Trigger window.resize event
*/
export function triggerWindowResizeEvent () {
const event = document.createEvent('HTMLEvents')
@@ -30,31 +30,31 @@
<div class="rc-metrics">
<template v-if="opp.market !== 'PredictionMarket'">
<div class="rc-metric">
<span class="rc-metric-label">{{ isZhLocale ? '当前价格' : 'Price' }}</span>
<span class="rc-metric-label">{{ tt('aiAssetAnalysis.opportunities.metric.price', 'Price') }}</span>
<span class="rc-metric-value">${{ formatOppPrice(opp.price) }}</span>
</div>
<div class="rc-metric">
<span class="rc-metric-label">{{ isZhLocale ? '24h涨跌' : '24h Change' }}</span>
<span class="rc-metric-label">{{ tt('aiAssetAnalysis.opportunities.metric.change24h', '24h Change') }}</span>
<span class="rc-metric-value" :class="Number(opp.change_24h) >= 0 ? 'rc-up' : 'rc-down'">
{{ Number(opp.change_24h) >= 0 ? '+' : '' }}{{ Number(opp.change_24h || 0).toFixed(2) }}%
</span>
</div>
<div class="rc-metric">
<span class="rc-metric-label">{{ isZhLocale ? '信号' : 'Signal' }}</span>
<span class="rc-metric-label">{{ tt('aiAssetAnalysis.opportunities.metric.signal', 'Signal') }}</span>
<span class="rc-metric-value rc-signal-val" :class="`rc-signal-${opp.signal || ''}`">{{ getSignalLabel(opp.signal) }}</span>
</div>
</template>
<template v-else>
<div class="rc-metric">
<span class="rc-metric-label">{{ isZhLocale ? '市场概率' : 'Probability' }}</span>
<span class="rc-metric-label">{{ tt('aiAssetAnalysis.opportunities.metric.probability', 'Probability') }}</span>
<span class="rc-metric-value">{{ Number(opp.price || 0).toFixed(1) }}%</span>
</div>
<div v-if="opp.ai_analysis" class="rc-metric">
<span class="rc-metric-label">{{ isZhLocale ? '机会评分' : 'Score' }}</span>
<span class="rc-metric-label">{{ tt('aiAssetAnalysis.opportunities.metric.score', 'Score') }}</span>
<span class="rc-metric-value rc-up">{{ Number(opp.ai_analysis.opportunity_score || 0).toFixed(0) }}</span>
</div>
<div v-if="opp.ai_analysis" class="rc-metric">
<span class="rc-metric-label">{{ isZhLocale ? '建议' : 'Rec.' }}</span>
<span class="rc-metric-label">{{ tt('aiAssetAnalysis.opportunities.metric.recommendation', 'Rec.') }}</span>
<span class="rc-metric-value" :class="getRecommendationClass(opp.ai_analysis.recommendation)">
{{ getRecommendationLabel(opp.ai_analysis.recommendation) }}
</span>
@@ -114,21 +114,27 @@
<span slot="tab"><a-icon type="radar-chart" /> {{ tt('aiAssetAnalysis.tabs.polymarket', 'Polymarket') }}</span>
<div class="tab-body">
<div class="polymarket-tab-content">
<div class="polymarket-placeholder">
<div class="placeholder-icon"><a-icon type="radar-chart" /></div>
<h3>{{ tt('polymarket.analysis.title', 'Polymarket Analysis') }}</h3>
<p>{{ tt('polymarket.analysis.description', 'Analyze a prediction market by URL or title and compare market pricing with AI probability.') }}</p>
<a-button style="margin-top: 16px" type="primary" size="large" icon="thunderbolt" @click="showPolymarketModal = true">
{{ tt('polymarket.analysis.startAnalysis', 'Start analysis') }}
</a-button>
<div class="polymarket-hero">
<div class="polymarket-hero-copy">
<div class="polymarket-hero-eyebrow">{{ tt('polymarket.analysis.title', 'Polymarket Analysis') }}</div>
<h3>{{ tt('polymarket.analysis.heroTitle', 'Compare market pricing with AI conviction') }}</h3>
<p>{{ tt('polymarket.analysis.description', 'Analyze a prediction market by URL or title and compare market pricing with AI probability.') }}</p>
</div>
<div class="polymarket-hero-actions">
<a-button type="primary" size="large" icon="thunderbolt" @click="focusPolymarketWorkspace">
{{ tt('polymarket.analysis.startAnalysis', 'Start analysis') }}
</a-button>
<a-button size="large" icon="export" @click="openPolymarketStandalone">
{{ tt('polymarket.analysis.openStandalone', 'Open standalone page') }}
</a-button>
</div>
</div>
<polymarket-analysis-workspace ref="polymarketWorkspace" embedded />
</div>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
<polymarket-analysis-modal :visible="showPolymarketModal" @close="showPolymarketModal = false" />
</div>
</template>
@@ -137,14 +143,14 @@ import { mapState } from 'vuex'
import AnalysisView from '@/views/ai-analysis'
import { getTradingOpportunities } from '@/api/global-market'
import QuickTradePanel from '@/components/QuickTradePanel.vue'
import PolymarketAnalysisModal from '@/components/PolymarketAnalysisModal.vue'
import PolymarketAnalysisWorkspace from '@/components/PolymarketAnalysisWorkspace.vue'
export default {
name: 'AIAssetAnalysis',
components: {
AnalysisView,
QuickTradePanel,
PolymarketAnalysisModal
PolymarketAnalysisWorkspace
},
data () {
return {
@@ -160,8 +166,7 @@ export default {
qtPrice: 0,
qtSource: 'ai_radar',
currentAnalysisSymbol: '',
currentAnalysisMarket: '',
showPolymarketModal: false
currentAnalysisMarket: ''
}
},
computed: {
@@ -171,9 +176,6 @@ export default {
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
isZhLocale () {
return this.$i18n && this.$i18n.locale === 'zh-CN'
},
carouselItems () {
return this.opportunities.length === 0 ? [] : [...this.opportunities, ...this.opportunities]
},
@@ -185,8 +187,20 @@ export default {
},
created () {
this.loadOpportunities()
this.applyRouteState()
},
watch: {
'$route.query.tab' () {
this.applyRouteState()
}
},
methods: {
applyRouteState () {
const query = (this.$route && this.$route.query) || {}
if (query.tab === 'polymarket' || query.tab === 'quick') {
this.activeTab = query.tab
}
},
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
@@ -238,7 +252,15 @@ export default {
analyzeOpportunity (opp) {
if (opp.market === 'PredictionMarket') {
this.activeTab = 'polymarket'
this.showPolymarketModal = true
this.$nextTick(() => {
const workspace = this.$refs.polymarketWorkspace
const input = opp.market_url || opp.name || opp.symbol
if (workspace && input && workspace.startFromInput) {
workspace.startFromInput(input)
} else if (workspace && workspace.focusInput) {
workspace.focusInput()
}
})
return
}
this.activeTab = 'quick'
@@ -283,6 +305,18 @@ export default {
handleQuickTradeSymbolChange (symbol) {
this.qtSymbol = symbol
},
focusPolymarketWorkspace () {
this.activeTab = 'polymarket'
this.$nextTick(() => {
const workspace = this.$refs.polymarketWorkspace
if (workspace && workspace.focusInput) {
workspace.focusInput()
}
})
},
openPolymarketStandalone () {
this.$router.push('/polymarket')
},
onAnalysisSymbolChange (value) {
if (!value) {
this.currentAnalysisSymbol = ''
@@ -348,16 +382,19 @@ export default {
.qt-floating-btn { position: fixed; right: 26px; bottom: 28px; z-index: 9; display: flex; align-items: center; justify-content: center; width: 52px; height: 52px; border-radius: 50%; background: linear-gradient(135deg, #0ea5e9, #2563eb); color: #fff; font-size: 22px; box-shadow: 0 16px 30px rgba(37, 99, 235, 0.35); cursor: pointer; }
.workspace-card { background: #fff; }
.tab-body { min-height: 720px; }
.polymarket-tab-content { display: flex; align-items: center; justify-content: center; min-height: 520px; padding: 24px; }
.polymarket-placeholder { max-width: 560px; padding: 36px; border-radius: 24px; text-align: center; background: linear-gradient(180deg, #f8fafc, #eff6ff); }
.placeholder-icon { display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; margin-bottom: 18px; border-radius: 50%; background: linear-gradient(135deg, #0ea5e9, #2563eb); color: #fff; font-size: 28px; }
.polymarket-placeholder h3 { margin-bottom: 12px; color: #0f172a; font-size: 24px; }
.polymarket-placeholder p { margin: 0; color: rgba(15, 23, 42, 0.66); line-height: 1.7; }
.polymarket-tab-content { min-height: 520px; padding: 24px; }
.polymarket-hero { display: flex; justify-content: space-between; gap: 18px; margin-bottom: 18px; padding: 24px; border-radius: 24px; background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 55%, #ffffff 100%); border: 1px solid #dbe7f5; }
.polymarket-hero-copy { max-width: 720px; }
.polymarket-hero-eyebrow { color: #2563eb; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.polymarket-hero h3 { margin: 10px 0 12px; color: #0f172a; font-size: 28px; font-weight: 700; }
.polymarket-hero p { margin: 0; color: rgba(15, 23, 42, 0.66); line-height: 1.7; }
.polymarket-hero-actions { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.theme-dark.ai-asset-analysis-page { background: #0b1220; }
.theme-dark .workspace-card { background: #111827; }
.theme-dark .polymarket-placeholder { background: linear-gradient(180deg, #111827, #1f2937); }
.theme-dark .polymarket-placeholder h3 { color: #f8fafc; }
.theme-dark .polymarket-placeholder p { color: rgba(248, 250, 252, 0.72); }
.theme-dark .polymarket-hero { border-color: rgba(59, 130, 246, 0.16); background: linear-gradient(135deg, #111827 0%, #0f172a 60%, #161b22 100%); }
.theme-dark .polymarket-hero-eyebrow { color: #60a5fa; }
.theme-dark .polymarket-hero h3 { color: #f8fafc; }
.theme-dark .polymarket-hero p { color: rgba(248, 250, 252, 0.72); }
@keyframes radar-scroll {
from { transform: translateX(0); }
to { transform: translateX(calc(-50% - 7px)); }
@@ -368,5 +405,6 @@ export default {
.radar-card.is-prediction { width: 280px; }
.rc-metrics { grid-template-columns: 1fr; }
.tab-body { min-height: auto; }
.polymarket-hero { flex-direction: column; }
}
</style>
@@ -0,0 +1,339 @@
<template>
<a-drawer
class="backtest-history-drawer"
:title="drawerTitle"
:visible="visible"
:width="isMobile ? '100%' : 1080"
:maskClosable="true"
@close="$emit('cancel')"
>
<div class="drawer-toolbar">
<div class="toolbar-left">
<a-button type="primary" icon="reload" size="small" :loading="loading" @click="loadRuns">
{{ tt('dashboard.indicator.backtest.historyRefresh', 'Refresh') }}
</a-button>
<a-button
v-if="canAIAnalyze"
type="primary"
ghost
size="small"
:disabled="selectedRowKeys.length === 0"
:loading="analyzing"
@click="handleAIAnalyze"
>
<a-icon type="bulb" />
{{ tt('dashboard.indicator.backtest.historyAISuggest', 'AI suggestions') }}
</a-button>
<span v-if="canAIAnalyze && selectedRowKeys.length" class="selected-tip">
{{ tt('dashboard.indicator.backtest.historySelectedCount', `${selectedRowKeys.length} runs selected`, { count: selectedRowKeys.length }) }}
</span>
</div>
<div class="toolbar-right">
<a-input
v-model="filterSymbol"
size="small"
allow-clear
:placeholder="tt('dashboard.indicator.backtest.historyFilterSymbol', 'Filter by symbol')"
style="width: 170px;"
@change="debouncedLoad"
/>
<a-select
v-model="filterTimeframe"
size="small"
allow-clear
:placeholder="tt('dashboard.indicator.backtest.historyFilterTimeframe', 'Timeframe')"
style="width: 110px;"
@change="loadRuns"
>
<a-select-option v-for="tf in timeframes" :key="tf" :value="tf">{{ tf }}</a-select-option>
</a-select>
</div>
</div>
<a-table
:columns="columns"
:data-source="runs"
:loading="loading"
size="small"
:pagination="{ pageSize: 15, size: 'small' }"
rowKey="id"
:scroll="{ x: 1000 }"
:rowSelection="canAIAnalyze ? { selectedRowKeys, onChange: onRowSelectionChange } : null"
>
<template slot="symbol" slot-scope="text, record">
<span class="symbol-cell">
<strong>{{ record.symbol || '-' }}</strong>
<a-tag v-if="record.market" size="small">{{ record.market }}</a-tag>
</span>
</template>
<template slot="range" slot-scope="text, record">
<span>{{ formatRange(record) }}</span>
</template>
<template slot="returnPct" slot-scope="text">
<span v-if="text !== null && text !== undefined" :class="Number(text) >= 0 ? 'positive' : 'negative'">
{{ Number(text) >= 0 ? '+' : '' }}{{ Number(text).toFixed(2) }}%
</span>
<span v-else>-</span>
</template>
<template slot="createdAt" slot-scope="text">
<span>{{ formatLocalDateTime(text) }}</span>
</template>
<template slot="status" slot-scope="text">
<a-tag :color="text === 'success' ? 'green' : text === 'failed' ? 'red' : 'blue'">
{{ text === 'success' ? tt('dashboard.indicator.backtest.historyStatusSuccess', 'Success') : text === 'failed' ? tt('dashboard.indicator.backtest.historyStatusFailed', 'Failed') : text }}
</a-tag>
</template>
<template slot="actions" slot-scope="text, record">
<a-button type="link" size="small" :loading="detailLoadingId === record.id" @click="viewRun(record)">
{{ tt('dashboard.indicator.backtest.historyView', 'View') }}
</a-button>
</template>
</a-table>
<a-empty v-if="!loading && runs.length === 0" :description="tt('dashboard.indicator.backtest.historyNoData', 'No backtest runs yet')" />
<a-modal
:title="tt('dashboard.indicator.backtest.historyAIAnalyzeTitle', 'AI suggestions')"
:visible="showAIResult"
:footer="null"
:width="isMobile ? '100%' : 900"
@cancel="showAIResult = false"
>
<div v-if="analyzing" class="ai-result-loading">
<a-spin />
</div>
<div v-else class="ai-result-text">
{{ aiResult || tt('dashboard.indicator.backtest.historyNoAIResult', 'No AI result available.') }}
</div>
</a-modal>
</a-drawer>
</template>
<script>
import request from '@/utils/request'
import { getStrategyBacktestHistory, getStrategyBacktestRun } from '@/api/strategy'
export default {
name: 'BacktestHistoryDrawer',
props: {
visible: { type: Boolean, default: false },
userId: { type: [Number, String], default: 1 },
indicatorId: { type: [Number, String], default: null },
strategyId: { type: [Number, String], default: null },
runType: { type: String, default: 'indicator' },
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: '',
selectedRowKeys: [],
filterSymbol: '',
filterTimeframe: '',
searchTimer: null,
runs: [],
timeframes: ['1m', '5m', '15m', '30m', '1H', '4H', '1D', '1W']
}
},
computed: {
canAIAnalyze () {
return this.runType === 'indicator'
},
drawerTitle () {
return this.runType === 'strategy'
? this.tt('backtest-center.strategy.history', 'Strategy backtest history')
: this.tt('backtest-center.indicator.history', 'Indicator backtest history')
},
columns () {
return [
{ title: this.tt('dashboard.indicator.backtest.historyRunId', 'Run ID'), dataIndex: 'id', key: 'id', width: 86 },
{ title: this.tt('dashboard.indicator.backtest.symbol', 'Symbol'), key: 'symbol', width: 180, scopedSlots: { customRender: 'symbol' } },
{ title: this.tt('dashboard.indicator.backtest.timeframe', 'Timeframe'), dataIndex: 'timeframe', key: 'timeframe', width: 96 },
{ title: this.tt('dashboard.indicator.backtest.historyRange', 'Date range'), key: 'range', width: 220, scopedSlots: { customRender: 'range' } },
{ title: this.tt('dashboard.indicator.backtest.totalReturn', 'Total return'), dataIndex: 'total_return', key: 'total_return', width: 120, scopedSlots: { customRender: 'returnPct' } },
{ title: this.tt('dashboard.indicator.backtest.historyCreatedAt', 'Created at'), dataIndex: 'created_at', key: 'created_at', width: 170, scopedSlots: { customRender: 'createdAt' } },
{ title: this.tt('dashboard.indicator.backtest.historyStatus', 'Status'), dataIndex: 'status', key: 'status', width: 100, scopedSlots: { customRender: 'status' } },
{ title: this.tt('dashboard.indicator.backtest.historyActions', 'Actions'), key: 'actions', width: 90, scopedSlots: { customRender: 'actions' } }
]
}
},
watch: {
visible (value) {
if (!value) return
this.filterSymbol = this.symbol || ''
this.filterTimeframe = this.timeframe || ''
this.selectedRowKeys = []
this.aiResult = ''
this.showAIResult = false
this.loadRuns()
}
},
beforeDestroy () {
if (this.searchTimer) {
clearTimeout(this.searchTimer)
this.searchTimer = null
}
},
methods: {
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
},
onRowSelectionChange (keys) {
this.selectedRowKeys = keys || []
},
formatRange (record) {
const start = (record && record.start_date) ? String(record.start_date).slice(0, 10) : ''
const end = (record && record.end_date) ? String(record.end_date).slice(0, 10) : ''
return `${start} ~ ${end}`
},
formatLocalDateTime (value) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString()
},
debouncedLoad () {
if (this.searchTimer) {
clearTimeout(this.searchTimer)
}
this.searchTimer = setTimeout(() => {
this.loadRuns()
}, 350)
},
async loadRuns () {
this.loading = true
try {
const params = {
limit: 100,
offset: 0,
symbol: (this.filterSymbol || '').trim(),
market: this.market || '',
timeframe: this.filterTimeframe || ''
}
let res
if (this.runType === 'strategy') {
res = await getStrategyBacktestHistory({
...params,
strategyId: this.strategyId
})
} else {
res = await request({
url: '/api/indicator/backtest/history',
method: 'get',
params: {
...params,
userid: this.userId,
indicatorId: this.indicatorId
}
})
}
this.runs = res && res.code === 1 && Array.isArray(res.data) ? res.data : []
} finally {
this.loading = false
}
},
async viewRun (record) {
if (!record || !record.id) return
this.detailLoadingId = record.id
try {
let res
if (this.runType === 'strategy') {
res = await getStrategyBacktestRun(record.id)
} else {
res = await request({
url: '/api/indicator/backtest/get',
method: 'get',
params: { 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.canAIAnalyze || !this.selectedRowKeys.length) return
this.analyzing = true
this.showAIResult = true
this.aiResult = ''
try {
const lang = (this.$i18n && this.$i18n.locale) ? this.$i18n.locale : 'en-US'
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 && res.msg) || this.tt('dashboard.indicator.backtest.historyNoAIResult', 'No AI result available.')
}
} finally {
this.analyzing = false
}
}
}
}
</script>
<style lang="less" scoped>
.drawer-toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar-left,
.toolbar-right,
.symbol-cell {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.selected-tip {
color: #64748b;
font-size: 12px;
}
.ai-result-loading {
padding: 16px 0;
}
.ai-result-text {
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
line-height: 1.6;
}
.positive {
color: #16a34a;
font-weight: 600;
}
.negative {
color: #dc2626;
font-weight: 600;
}
</style>
@@ -0,0 +1,489 @@
<template>
<div class="result-view" :class="{ 'theme-dark': isDark }">
<div v-if="running" class="result-state result-state--loading">
<a-spin size="large" />
<div class="result-state-title">{{ tt('backtest-center.running', 'Running backtest...') }}</div>
<div class="result-state-desc">{{ runTip || tt('backtest-center.runningDesc', 'This can take longer for larger time ranges and lower timeframes.') }}</div>
</div>
<div v-else-if="!hasResult || !safeResult" class="result-state">
<div class="result-state-icon">
<a-icon type="line-chart" />
</div>
<div class="result-state-title">{{ tt('backtest-center.result.placeholderTitle', 'Your backtest results will appear here') }}</div>
<div class="result-state-desc">{{ tt('backtest-center.result.placeholderDesc', 'Configure the left panel, run a backtest, then inspect equity, trades, and risk metrics in one place.') }}</div>
</div>
<div v-else class="result-body">
<div class="result-meta" v-if="symbol || timeframe || market">
<a-tag v-if="market" size="small">{{ market }}</a-tag>
<a-tag v-if="symbol" size="small">{{ symbol }}</a-tag>
<a-tag v-if="timeframe" size="small">{{ timeframe }}</a-tag>
<a-tag v-if="backtestRunId" color="blue" size="small">Run #{{ backtestRunId }}</a-tag>
</div>
<div class="metrics-grid">
<div class="metric-card" :class="{ positive: metricValue('totalReturn') > 0, negative: metricValue('totalReturn') < 0 }">
<div class="metric-label">{{ tt('dashboard.indicator.backtest.totalReturn', 'Total return') }}</div>
<div class="metric-value">{{ formatPercent(safeResult.totalReturn) }}</div>
<div class="metric-sub">{{ formatMoney(safeResult.totalProfit) }}</div>
</div>
<div class="metric-card" :class="{ positive: metricValue('annualReturn') > 0, negative: metricValue('annualReturn') < 0 }">
<div class="metric-label">{{ tt('dashboard.indicator.backtest.annualReturn', 'Annual return') }}</div>
<div class="metric-value">{{ formatPercent(safeResult.annualReturn) }}</div>
</div>
<div class="metric-card negative">
<div class="metric-label">{{ tt('dashboard.indicator.backtest.maxDrawdown', 'Max drawdown') }}</div>
<div class="metric-value">{{ formatPercent(safeResult.maxDrawdown) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ tt('dashboard.indicator.backtest.sharpeRatio', 'Sharpe ratio') }}</div>
<div class="metric-value">{{ formatNumber(safeResult.sharpeRatio, 2) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ tt('dashboard.indicator.backtest.winRate', 'Win rate') }}</div>
<div class="metric-value">{{ formatPercent(safeResult.winRate) }}</div>
</div>
<div class="metric-card" :class="{ positive: metricValue('profitFactor') >= 1.5, negative: metricValue('profitFactor') > 0 && metricValue('profitFactor') < 1 }">
<div class="metric-label">{{ tt('dashboard.indicator.backtest.profitFactor', 'Profit factor') }}</div>
<div class="metric-value">{{ formatNumber(safeResult.profitFactor, 2) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ tt('dashboard.indicator.backtest.totalTrades', 'Total trades') }}</div>
<div class="metric-value">{{ metricInteger('totalTrades') }}</div>
</div>
<div class="metric-card negative">
<div class="metric-label">{{ tt('dashboard.indicator.backtest.totalCommission', 'Total commission') }}</div>
<div class="metric-value">{{ formatMoney(-(Number(safeResult.totalCommission) || 0)) }}</div>
</div>
</div>
<div class="panel-card">
<div class="panel-title">{{ tt('dashboard.indicator.backtest.equityCurve', 'Equity curve') }}</div>
<div ref="equityChartRef" class="equity-chart"></div>
</div>
<div class="panel-card">
<div class="panel-title">{{ tt('dashboard.indicator.backtest.tradeHistory', 'Trade history') }}</div>
<a-table
:columns="tradeColumns"
:data-source="safeResult.trades || []"
:pagination="{ pageSize: 10, size: 'small' }"
size="small"
:scroll="{ x: 820 }"
: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 class="money-emphasis">
{{ formatMoney(text, false) }}
</span>
</template>
<template slot="profit" slot-scope="text">
<span :class="text > 0 ? 'positive' : text < 0 ? 'negative' : 'muted'">
{{ formatMoney(text) }}
</span>
</template>
</a-table>
</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'BacktestResultView',
props: {
running: { type: Boolean, default: false },
runTip: { type: String, default: '' },
hasResult: { type: Boolean, default: false },
result: { type: Object, default: null },
backtestRunId: { type: [Number, String], default: null },
symbol: { type: String, default: '' },
market: { type: String, default: '' },
timeframe: { type: String, default: '' },
isDark: { type: Boolean, default: false }
},
data () {
return {
equityChart: null
}
},
computed: {
safeResult () {
if (!this.result) return null
return this.result.result || this.result
},
tradeColumns () {
return [
{ title: this.tt('dashboard.indicator.backtest.tradeTime', 'Trade time'), dataIndex: 'time', key: 'time', width: 170 },
{ title: this.tt('dashboard.indicator.backtest.tradeType', 'Trade type'), dataIndex: 'type', key: 'type', width: 150, scopedSlots: { customRender: 'type' } },
{ title: this.tt('dashboard.indicator.backtest.price', 'Price'), dataIndex: 'price', key: 'price', width: 120 },
{ title: this.tt('dashboard.indicator.backtest.amount', 'Amount'), dataIndex: 'amount', key: 'amount', width: 110 },
{ title: this.tt('dashboard.indicator.backtest.profit', 'Profit'), dataIndex: 'profit', key: 'profit', width: 120, scopedSlots: { customRender: 'profit' } },
{ title: this.tt('dashboard.indicator.backtest.balance', 'Balance'), dataIndex: 'balance', key: 'balance', width: 140, scopedSlots: { customRender: 'balance' } }
]
}
},
watch: {
result: {
deep: true,
handler () {
this.$nextTick(() => {
this.renderEquityChart()
})
}
},
isDark () {
this.$nextTick(() => {
this.renderEquityChart()
})
}
},
mounted () {
window.addEventListener('resize', this.handleResize)
this.$nextTick(() => {
this.renderEquityChart()
})
},
beforeDestroy () {
window.removeEventListener('resize', this.handleResize)
this.disposeChart()
},
methods: {
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
},
rowKey (record, index) {
return (record && record.id) || index
},
metricValue (key) {
return Number(this.safeResult && this.safeResult[key]) || 0
},
metricInteger (key) {
return Number(this.safeResult && this.safeResult[key]) || 0
},
formatNumber (value, precision = 2) {
const number = Number(value)
if (!Number.isFinite(number)) return '--'
return number.toFixed(precision)
},
formatPercent (value) {
const number = Number(value)
if (!Number.isFinite(number)) return '--'
const sign = number >= 0 ? '+' : ''
return `${sign}${number.toFixed(2)}%`
},
formatMoney (value, signed = true) {
const number = Number(value)
if (!Number.isFinite(number)) return '--'
const abs = Math.abs(number).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (!signed) return `$${abs}`
const sign = number >= 0 ? '+' : '-'
return `${sign}$${abs}`
},
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 map = {
open_long: this.tt('dashboard.indicator.backtest.openLong', 'Open long'),
add_long: this.tt('dashboard.indicator.backtest.addLong', 'Add long'),
close_long: this.tt('dashboard.indicator.backtest.closeLong', 'Close long'),
close_long_stop: this.tt('dashboard.indicator.backtest.closeLongStop', 'Close long stop'),
close_long_profit: this.tt('dashboard.indicator.backtest.closeLongProfit', 'Close long take-profit'),
close_long_trailing: this.tt('dashboard.indicator.backtest.closeLongTrailing', 'Close long trailing'),
reduce_long: this.tt('dashboard.indicator.backtest.reduceLong', 'Reduce long'),
open_short: this.tt('dashboard.indicator.backtest.openShort', 'Open short'),
add_short: this.tt('dashboard.indicator.backtest.addShort', 'Add short'),
close_short: this.tt('dashboard.indicator.backtest.closeShort', 'Close short'),
close_short_stop: this.tt('dashboard.indicator.backtest.closeShortStop', 'Close short stop'),
close_short_profit: this.tt('dashboard.indicator.backtest.closeShortProfit', 'Close short take-profit'),
close_short_trailing: this.tt('dashboard.indicator.backtest.closeShortTrailing', 'Close short trailing'),
reduce_short: this.tt('dashboard.indicator.backtest.reduceShort', 'Reduce short'),
liquidation: this.tt('dashboard.indicator.backtest.liquidation', 'Liquidation')
}
return map[type] || type
},
disposeChart () {
if (this.equityChart) {
this.equityChart.dispose()
this.equityChart = null
}
},
handleResize () {
if (this.equityChart) {
this.equityChart.resize()
}
},
renderEquityChart () {
if (!this.$refs.equityChartRef || !this.safeResult || !Array.isArray(this.safeResult.equityCurve)) {
this.disposeChart()
return
}
const data = this.safeResult.equityCurve || []
if (!data.length) {
this.disposeChart()
return
}
if (this.equityChart) {
this.equityChart.dispose()
}
this.equityChart = echarts.init(this.$refs.equityChartRef)
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] || 0
const finalValue = equity[equity.length - 1] || initialValue
const positive = finalValue >= initialValue
const lineColor = positive ? '#22c55e' : '#ef4444'
const axisColor = this.isDark ? '#94a3b8' : '#64748b'
const splitColor = this.isDark ? 'rgba(148, 163, 184, 0.14)' : 'rgba(148, 163, 184, 0.18)'
const areaStops = positive
? [{ offset: 0, color: 'rgba(34, 197, 94, 0.30)' }, { offset: 1, color: 'rgba(34, 197, 94, 0.02)' }]
: [{ offset: 0, color: 'rgba(239, 68, 68, 0.30)' }, { offset: 1, color: 'rgba(239, 68, 68, 0.02)' }]
this.equityChart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
valueFormatter: value => (Number.isFinite(Number(value)) ? `$${Number(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : value)
},
grid: { left: 20, right: 20, bottom: 24, top: 16, containLabel: true },
xAxis: {
type: 'category',
data: dates,
boundaryGap: false,
axisLabel: { color: axisColor },
axisLine: { lineStyle: { color: splitColor } }
},
yAxis: {
type: 'value',
axisLabel: {
color: axisColor,
formatter: value => `$${Number(value).toLocaleString('en-US', { maximumFractionDigits: 0 })}`
},
splitLine: { lineStyle: { color: splitColor } }
},
series: [
{
name: this.tt('dashboard.indicator.backtest.strategy', 'Strategy'),
type: 'line',
data: equity,
smooth: 0.35,
symbol: 'none',
sampling: 'lttb',
lineStyle: { width: 2.5, color: lineColor },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, areaStops)
}
}
]
})
}
}
}
</script>
<style lang="less" scoped>
.result-view {
min-height: 560px;
}
.result-state {
min-height: 560px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 32px 24px;
border: 1px dashed #cbd5e1;
border-radius: 24px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.96));
&--loading {
border-style: solid;
}
}
.result-state-icon {
width: 64px;
height: 64px;
border-radius: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #2563eb;
background: rgba(37, 99, 235, 0.12);
margin-bottom: 16px;
}
.result-state-title {
font-size: 20px;
font-weight: 700;
color: #0f172a;
margin-top: 14px;
}
.result-state-desc {
color: #64748b;
margin-top: 8px;
max-width: 520px;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.metric-card {
border-radius: 18px;
border: 1px solid #e2e8f0;
background: #fff;
padding: 16px;
}
.metric-label {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric-value {
margin-top: 8px;
font-size: 24px;
font-weight: 700;
color: #0f172a;
}
.metric-sub {
margin-top: 6px;
color: #64748b;
font-size: 13px;
}
.metric-card.positive .metric-value,
.positive {
color: #16a34a;
}
.metric-card.negative .metric-value,
.negative {
color: #dc2626;
}
.muted {
color: #64748b;
}
.panel-card {
margin-top: 14px;
border-radius: 22px;
border: 1px solid #e2e8f0;
background: #fff;
padding: 16px;
}
.panel-title {
font-size: 15px;
font-weight: 700;
color: #0f172a;
margin-bottom: 12px;
}
.equity-chart {
height: 320px;
}
.money-emphasis {
font-weight: 600;
color: #2563eb;
}
.theme-dark {
.result-state {
border-color: rgba(148, 163, 184, 0.2);
background: linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(30, 41, 59, 0.96));
}
.result-state-title,
.metric-value,
.panel-title {
color: #e2e8f0;
}
.result-state-desc,
.metric-label,
.metric-sub,
.muted {
color: #94a3b8;
}
.metric-card,
.panel-card {
border-color: rgba(148, 163, 184, 0.16);
background: rgba(15, 23, 42, 0.86);
}
}
@media (max-width: 1200px) {
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 767px) {
.result-view,
.result-state {
min-height: 420px;
}
.metrics-grid {
grid-template-columns: 1fr;
}
.equity-chart {
height: 260px;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -201,7 +201,7 @@ export default {
this.showAIResult = true
this.aiResult = ''
try {
const lang = (this.$i18n && this.$i18n.locale) ? this.$i18n.locale : 'zh-CN'
const lang = (this.$i18n && this.$i18n.locale) ? this.$i18n.locale : 'en-US'
const res = await request({
url: '/api/indicator/backtest/aiAnalyze',
method: 'post',
@@ -309,7 +309,7 @@
<!-- Step 2: trading settings -->
<div v-show="currentStep === 1">
<!-- 合并的信息提示交易对信息 + 精度信息 -->
<!-- Merged info tips: Symbol information + Precision information -->
<a-alert
:type="combinedAlertType"
show-icon
@@ -317,7 +317,7 @@
>
<template slot="message">
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<!-- 基本信息 -->
<!-- Basic info -->
<span>
<strong>Symbol:</strong> {{ symbol || '-' }}
<span style="color: #999; margin: 0 6px;">|</span>
@@ -325,7 +325,7 @@
<span style="color: #999; margin: 0 6px;">|</span>
<strong>Timeframe:</strong> {{ selectedTimeframe || timeframe || '-' }}
</span>
<!-- 精度信息 -->
<!-- Precision info -->
<span v-if="precisionInfo && precisionInfo.enabled" style="margin-left: 12px; border-left: 1px solid #d9d9d9; padding-left: 12px;">
<a-icon :type="precisionInfo.precision === 'high' ? 'thunderbolt' : 'clock-circle'" style="margin-right: 4px;" />
{{ $t('dashboard.indicator.backtest.precisionMode') }}:
@@ -352,9 +352,9 @@
</template>
</a-alert>
<!-- 快捷日期选择按钮 -->
<!-- Quick date selection buttons -->
<div class="date-quick-select" style="margin-bottom: 12px;">
<span style="margin-right: 8px; color: #666; font-size: 13px;">{{ $t('dashboard.indicator.backtest.quickSelect') || '快速选择' }}:</span>
<span style="margin-right: 8px; color: #666; font-size: 13px;">{{ $t('dashboard.indicator.backtest.quickSelect') || 'Quick Select' }}:</span>
<a-button-group size="small">
<a-button
v-for="preset in datePresets"
@@ -487,7 +487,7 @@
</a-form>
</div>
<!-- 回测结果区域 -->
<!-- Backtest results area -->
<div v-show="currentStep === 2 && hasResult" class="result-section">
<a-alert
v-if="backtestRunId"
@@ -497,7 +497,7 @@
:message="$t('dashboard.indicator.backtest.savedRunId', { id: backtestRunId })"
/>
<!-- 关键指标卡片 -->
<!-- Key metrics cards -->
<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>
@@ -534,13 +534,13 @@
</div>
</div>
<!-- 收益曲线图表 -->
<!-- Equity curve chart -->
<div class="chart-section">
<div class="chart-title">{{ $t('dashboard.indicator.backtest.equityCurve') }}</div>
<div ref="equityChartRef" class="equity-chart"></div>
</div>
<!-- 交易记录表格 -->
<!-- Trade history table -->
<div class="trades-section">
<div class="chart-title">{{ $t('dashboard.indicator.backtest.tradeHistory') }}</div>
<a-table
@@ -569,7 +569,7 @@
</div>
</div>
<!-- 加载状态 - 增强版动画 -->
<!-- Loading state - Enhanced animation -->
<div v-if="loading" class="loading-overlay">
<div class="loading-content">
<div class="loading-animation">
@@ -667,9 +667,9 @@ export default {
// Step1 UI state (Ant Form getFieldValue is not reactive)
trailingEnabledUi: false,
entryPctMaxUi: 100,
precisionInfo: null, //
selectedDatePreset: null, //
selectedTimeframe: '1D', // 使props
precisionInfo: null, // Backtest precision info
selectedDatePreset: null, // Currently selected quick date
selectedTimeframe: '1D', // User selected timeframe (defaulting to prop value)
result: {
totalReturn: 0,
annualReturn: 0,
@@ -688,29 +688,29 @@ export default {
}
},
computed: {
//
// Calculate max backtest time range based on timeframe
maxBacktestRange () {
// 1线1
// 1 minute: max 1 month
const tf = this.selectedTimeframe || this.timeframe || '1D'
if (tf === '1m') {
return { days: 30, label: '1个月' }
return { days: 30, label: '1 month' }
}
// 5线6
// 5 minutes: max 6 months
if (tf === '5m') {
return { days: 180, label: '6个月' }
return { days: 180, label: '6 months' }
}
// 15301
// 15 and 30 minutes: max 1 year
if (['15m', '30m'].includes(tf)) {
return { days: 365, label: '1' }
return { days: 365, label: '1 year' }
}
// 13
return { days: 1095, label: '3' }
// 1 hour and above: max 3 years
return { days: 1095, label: '3 years' }
},
// - 30
// Recommended default date range - consistent 30 days
recommendedRange () {
return { days: 30, label: '30', key: '30d' }
return { days: 30, label: '30 days', key: '30d' }
},
//
// Combined alert type
combinedAlertType () {
if (this.precisionInfo && this.precisionInfo.enabled) {
return this.precisionInfo.precision === 'high' ? 'success' : 'info'
@@ -720,12 +720,12 @@ export default {
}
return 'info'
},
// - 30
// Quick date options - all periods include 30 days as default
datePresets () {
const presets = []
const tf = this.selectedTimeframe || this.timeframe || '1D'
//
// 使7D, 14D, 30D, 3M, 6M, 1Y
// Dynamically generate reasonable quick options based on timeframe
// Use international standard format: 7D, 14D, 30D, 3M, 6M, 1Y
if (tf === '1m') {
presets.push({ key: '7d', days: 7, label: '7D' })
presets.push({ key: '14d', days: 14, label: '14D' })
@@ -750,14 +750,14 @@ export default {
return presets
},
defaultStartDate () {
// 使
// Default start date: use recommended range
return moment().subtract(this.recommendedRange.days, 'days')
},
defaultEndDate () {
//
// Default end date: today
return moment()
},
//
// Earliest selectable date
earliestDate () {
return moment().subtract(this.maxBacktestRange.days, 'days')
},
@@ -775,7 +775,7 @@ export default {
watch: {
visible (val) {
if (val) {
//
// Reset state when modal opens
this.currentStep = 0
this.hasResult = false
this.backtestRunId = null
@@ -784,7 +784,7 @@ export default {
this.entryPctMaxUi = 100
this.precisionInfo = null
this.selectedDatePreset = null
this.selectedTimeframe = this.timeframe || '1D' // props
this.selectedTimeframe = this.timeframe || '1D' // Initialize from prop value
this.result = {
totalReturn: 0,
annualReturn: 0,
@@ -804,14 +804,14 @@ export default {
// Sync non-reactive form values into UI state
this.trailingEnabledUi = !!this.form.getFieldValue('trailingEnabled')
this.recalcEntryPctMaxUi()
// 30
// Default select 30 days
this.selectedDatePreset = '30d'
// 使
// Get precision info immediately on modal open (using default range)
this.fetchPrecisionInfo()
}
})
} else {
//
// Destroy chart when modal closes
if (this.equityChart) {
this.equityChart.dispose()
this.equityChart = null
@@ -820,7 +820,7 @@ export default {
}
},
created () {
// created使$t
// Initialize table columns (needs created hook for $t)
this.tradeColumns = [
{
title: this.$t('dashboard.indicator.backtest.tradeTime'),
@@ -930,14 +930,14 @@ export default {
this.form.setFieldsValue({ trailingStopPct: 0, trailingActivationPct: 0 })
}
},
//
// Get trade type color
getTradeTypeColor (type) {
const colorMap = {
//
// Old format
'buy': 'green',
'sell': 'red',
'liquidation': 'orange',
// -
// New format - Long
'open_long': 'green',
'add_long': 'cyan',
'close_long': 'orange',
@@ -945,7 +945,7 @@ export default {
'close_long_profit': 'lime',
'close_long_trailing': 'gold',
'reduce_long': 'volcano',
// -
// New format - Short
'open_short': 'red',
'add_short': 'magenta',
'close_short': 'blue',
@@ -956,14 +956,14 @@ export default {
}
return colorMap[type] || 'default'
},
//
// Get trade type text
getTradeTypeText (type) {
const textMap = {
//
// Old format
'buy': this.$t('dashboard.indicator.backtest.buy'),
'sell': this.$t('dashboard.indicator.backtest.sell'),
'liquidation': this.$t('dashboard.indicator.backtest.liquidation'),
// -
// 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'),
@@ -971,7 +971,7 @@ export default {
'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'),
@@ -984,20 +984,20 @@ export default {
},
disabledStartDate (current) {
if (!current) return false
//
// Cannot select dates after today
if (current > moment().endOf('day')) return true
//
// Cannot select dates before earliest date
if (current < this.earliestDate.startOf('day')) return true
return false
},
disabledEndDate (current) {
if (!current) return false
//
// Cannot select dates after today
if (current > moment().endOf('day')) return true
//
// Cannot select dates before earliest date
if (current < this.earliestDate.startOf('day')) return true
// +
// If start date is selected, limit end date to start date + max range
const startDate = this.form.getFieldValue('startDate')
if (startDate) {
const maxDays = this.maxBacktestRange.days || 365
@@ -1007,7 +1007,7 @@ export default {
return false
},
//
// Apply quick date selection
applyDatePreset (preset) {
this.selectedDatePreset = preset.key
const endDate = moment()
@@ -1016,21 +1016,21 @@ export default {
startDate: startDate,
endDate: endDate
})
//
// Update precision info
this.fetchPrecisionInfo(startDate, endDate)
},
//
// Get precision info
async fetchPrecisionInfo (startDate, endDate) {
// 使
// If no date provided, try from form or use default
if (!startDate || !endDate) {
startDate = this.form ? this.form.getFieldValue('startDate') : null
endDate = this.form ? this.form.getFieldValue('endDate') : null
}
// 使
// Use defaults if still null
if (!startDate) startDate = this.defaultStartDate
if (!endDate) endDate = this.defaultEndDate
//
// Only crypto market supports high precision backtest
if (!this.market || this.market.toLowerCase() !== 'crypto') {
this.precisionInfo = {
enabled: false,
@@ -1055,13 +1055,13 @@ export default {
this.precisionInfo = response.data
}
} catch (e) {
// 使
// Fail silently
this.precisionInfo = null
}
},
//
// Re-fetch precision info and update quick dates when timeframe changes
onTimeframeChange () {
// 30
// Reset date selection to default 30 days
this.selectedDatePreset = '30d'
const endDate = moment()
const startDate = moment().subtract(30, 'days')
@@ -1069,17 +1069,17 @@ export default {
startDate: startDate,
endDate: endDate
})
//
// Re-fetch precision info
this.fetchPrecisionInfo(startDate, endDate)
},
//
// Get precision info when date changes
onDateChange () {
this.selectedDatePreset = null //
this.selectedDatePreset = null // Clear quick select state
this.$nextTick(() => {
this.fetchPrecisionInfo()
})
},
//
// Validate date range
validateDateRange (startDate, endDate) {
if (!startDate || !endDate) return true
const diffDays = endDate.diff(startDate, 'days')
@@ -1096,13 +1096,13 @@ export default {
},
formatPercent (value) {
if (value === null || value === undefined) return '--'
// 59.3459.34%100
// Backend returns percentage values already (e.g. 59.34 for 59.34%)
const sign = value >= 0 ? '+' : ''
return `${sign}${value.toFixed(2)}%`
},
formatMoney (value) {
if (value === null || value === undefined) return '--'
// +-
// Positive shows +, negative shows -
const sign = value >= 0 ? '+' : '-'
return `${sign}$${Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
},
@@ -1132,14 +1132,14 @@ export default {
this.hasResult = false
this.backtestRunId = null
},
//
// Loading animation carousel
startLoadingAnimation () {
const tips = [
this.$t('dashboard.indicator.backtest.loadingTip1') || '正在获取历史K线数据...',
this.$t('dashboard.indicator.backtest.loadingTip2') || '正在执行策略信号计算...',
this.$t('dashboard.indicator.backtest.loadingTip3') || '正在模拟交易执行...',
this.$t('dashboard.indicator.backtest.loadingTip4') || '正在计算回测指标...',
this.$t('dashboard.indicator.backtest.loadingTip5') || '即将完成,请稍候...'
this.$t('dashboard.indicator.backtest.loadingTip1') || 'Getting historical K-line data...',
this.$t('dashboard.indicator.backtest.loadingTip2') || 'Calculating strategy signals...',
this.$t('dashboard.indicator.backtest.loadingTip3') || 'Simulating trade execution...',
this.$t('dashboard.indicator.backtest.loadingTip4') || 'Computing backtest metrics...',
this.$t('dashboard.indicator.backtest.loadingTip5') || 'Finishing up, please wait...'
]
let idx = 0
this.loadingTip = tips[0]
@@ -1243,7 +1243,7 @@ export default {
leverage: values.leverage || 1,
tradeDirection: values.tradeDirection || 'long',
strategyConfig,
//
// Enable multi-timeframe high-precision backtest (Crypto market)
enableMtf: this.market && this.market.toLowerCase() === 'crypto'
}
@@ -1254,7 +1254,7 @@ export default {
})
if (response.code === 1 && response.data) {
// Backward compatible: data can be { runId, result } or raw result
// Backward compatible
if (response.data.runId) {
this.backtestRunId = response.data.runId
}
@@ -1286,12 +1286,12 @@ export default {
this.equityChart = echarts.init(this.$refs.equityChartRef)
const data = this.result.equityCurve || []
// { time: "2025-06-01 00:00", value: 100000 }
// dates, equity (value), benchmark ()
// Backend format: { time: "2025-06-01 00:00", value: 100000 }
// Frontend needs: dates, equity (value field), benchmark (optional)
const dates = data.map(item => item.time || item.date)
const equity = data.map(item => item.value !== undefined ? item.value : item.equity)
//
// Determine if return is positive or negative for gradient color
const initialValue = equity[0] || 100000
const finalValue = equity[equity.length - 1] || initialValue
const isPositive = finalValue >= initialValue
@@ -1345,7 +1345,7 @@ export default {
color: '#8c8c8c',
fontSize: 11,
rotate: 0,
interval: Math.floor(dates.length / 6) //
interval: Math.floor(dates.length / 6) // Auto interval display
}
},
yAxis: {
@@ -1373,9 +1373,9 @@ export default {
name: this.$t('dashboard.indicator.backtest.strategy'),
type: 'line',
data: equity,
smooth: 0.4, // 0-1
symbol: 'none', //
sampling: 'lttb', // 使 LTTB 线
smooth: 0.4, // Smoothing factor, between 0-1, larger means smoother
symbol: 'none', // Do not show data points
sampling: 'lttb', // Use LTTB algorithm for downsampling
lineStyle: {
width: 2.5,
color: mainColor,
@@ -1397,7 +1397,7 @@ export default {
this.equityChart.setOption(option)
//
// Responsive adjustment
window.addEventListener('resize', () => {
if (this.equityChart) {
this.equityChart.resize()
@@ -17,7 +17,7 @@
>
<div class="editor-content">
<a-row :gutter="16" class="editor-layout" :class="{ 'mobile-layout': isMobile }">
<!-- 左侧代码编辑器和智能生成 -->
<!-- Left: Code editor and AI generation -->
<a-col :span="24" :xs="24" :sm="24" :md="24" class="code-editor-column">
<div class="code-section">
<div class="section-header">
@@ -50,14 +50,14 @@
:description="$t('dashboard.indicator.boundary.indicatorRule')"
/>
<!-- 代码编辑器模式 -->
<!-- Code editor mode -->
<div class="code-mode-split">
<a-row :gutter="16" class="code-mode-row">
<!-- 代码编辑器 -->
<!-- Left: Code editor -->
<a-col :xs="24" :sm="24" :md="18" class="code-pane">
<div ref="codeEditorContainer" class="code-editor-container"></div>
</a-col>
<!-- AI 生成 -->
<!-- Right: AI generation -->
<a-col :xs="24" :sm="24" :md="6" class="ai-pane">
<div class="ai-panel">
<div class="ai-panel-title">
@@ -104,12 +104,12 @@
<script>
import CodeMirror from 'codemirror'
import 'codemirror/lib/codemirror.css'
// Python
// Python mode
import 'codemirror/mode/python/python'
//
// Themes
import 'codemirror/theme/monokai.css'
import 'codemirror/theme/eclipse.css'
//
// Common addons
import 'codemirror/addon/edit/closebrackets'
import 'codemirror/addon/edit/matchbrackets'
import 'codemirror/addon/selection/active-line'
@@ -147,20 +147,20 @@ export default {
watch: {
visible (val) {
if (val) {
// Modal DOM
// Modal opens, wait for DOM update to initialize editor
this.$nextTick(() => {
// Modal
// Delay a bit to ensure Modal is fully rendered
setTimeout(() => {
if (!this.codeEditor && this.$refs.codeEditorContainer) {
this.initCodeEditor()
}
//
// Initialize form data
this.initFormData()
}, 200)
})
} else {
// Modal
// Modal closes, refresh editor to ensure correct display next time
if (this.codeEditor) {
this.codeEditor.refresh()
}
@@ -169,7 +169,7 @@ export default {
indicator: {
handler (val) {
if (val && this.visible) {
// indicator
// When indicator changes and modal is visible, wait before updating form data
this.$nextTick(() => {
setTimeout(() => {
this.initFormData()
@@ -181,11 +181,11 @@ export default {
}
},
mounted () {
//
// Check if mobile
this.checkMobile()
window.addEventListener('resize', this.checkMobile)
// visible true
// If visible is initially true, initialize
if (this.visible) {
this.$nextTick(() => {
setTimeout(() => {
@@ -242,170 +242,17 @@ export default {
#}
`
},
//
// Check if mobile
checkMobile () {
this.isMobile = window.innerWidth <= 768
},
/* Visual editor removed (code mode only).
// Helper function for crossovers
code += `def crossover(series1, series2):\n`
code += ` return (series1 > series2) & (series1.shift(1) <= series2.shift(1))\n\n`
code += `def crossunder(series1, series2):\n`
code += ` return (series1 < series2) & (series1.shift(1) >= series2.shift(1))\n\n`
modules.forEach((mod, idx) => {
const id = mod.id || (idx + 1)
const s = mod.style
if (mod.type === 'SMA') {
code += `# Module ${id}: SMA\n`
code += `sma_${id} = df['${mod.params.source}'].rolling(${mod.params.period}).mean()\n`
code += `output_plots.append({ "name": "SMA ${mod.params.period} (#${id})", "data": sma_${id}.tolist(), "color": "${s.color}", "overlay": ${s.overlay ? 'True' : 'False'} })\n\n`
} else if (mod.type === 'EMA') {
code += `# Module ${id}: EMA\n`
code += `ema_${id} = df['${mod.params.source}'].ewm(span=${mod.params.period}, adjust=False).mean()\n`
code += `output_plots.append({ "name": "EMA ${mod.params.period} (#${id})", "data": ema_${id}.tolist(), "color": "${s.color}", "overlay": ${s.overlay ? 'True' : 'False'} })\n\n`
} else if (mod.type === 'RSI') {
code += `# Module ${id}: RSI\n`
code += `delta_${id} = df['close'].diff()\n`
code += `gain_${id} = (delta_${id}.where(delta_${id} > 0, 0)).ewm(alpha=1/${mod.params.period}, adjust=False).mean()\n`
code += `loss_${id} = (-delta_${id}.where(delta_${id} < 0, 0)).ewm(alpha=1/${mod.params.period}, adjust=False).mean()\n`
code += `rs_${id} = gain_${id} / loss_${id}\n`
code += `rsi_${id} = 100 - (100 / (1 + rs_${id}))\n`
code += `output_plots.append({ "name": "RSI ${mod.params.period} (#${id})", "data": rsi_${id}.tolist(), "color": "${s.color}", "overlay": False })\n`
code += `output_plots.append({ "name": "Overbought", "data": [70]*len(df), "color": "#999", "style": "dashed", "overlay": False })\n`
code += `output_plots.append({ "name": "Oversold", "data": [30]*len(df), "color": "#999", "style": "dashed", "overlay": False })\n\n`
} else if (mod.type === 'MACD') {
code += `# Module ${id}: MACD\n`
code += `exp1_${id} = df['close'].ewm(span=${mod.params.fast}, adjust=False).mean()\n`
code += `exp2_${id} = df['close'].ewm(span=${mod.params.slow}, adjust=False).mean()\n`
code += `macd_${id} = exp1_${id} - exp2_${id}\n`
code += `signal_${id} = macd_${id}.ewm(span=${mod.params.signal}, adjust=False).mean()\n`
code += `hist_${id} = macd_${id} - signal_${id}\n`
code += `output_plots.append({ "name": "MACD (#${id})", "data": macd_${id}.tolist(), "color": "${s.color}", "overlay": False })\n`
code += `output_plots.append({ "name": "Signal (#${id})", "data": signal_${id}.tolist(), "color": "#ff9f43", "overlay": False })\n`
code += `output_plots.append({ "name": "Hist (#${id})", "data": hist_${id}.tolist(), "color": "#ccc", "type": "bar", "overlay": False })\n\n`
} else if (mod.type === 'BOLL') {
code += `# Module ${id}: BOLL\n`
code += `mid_${id} = df['close'].rolling(${mod.params.period}).mean()\n`
code += `std_${id} = df['close'].rolling(${mod.params.period}).std()\n`
code += `upper_${id} = mid_${id} + (${mod.params.std} * std_${id})\n`
code += `lower_${id} = mid_${id} - (${mod.params.std} * std_${id})\n`
code += `output_plots.append({ "name": "Boll Upper (#${id})", "data": upper_${id}.tolist(), "color": "${s.color}", "overlay": True })\n`
code += `output_plots.append({ "name": "Boll Lower (#${id})", "data": lower_${id}.tolist(), "color": "${s.color}", "overlay": True })\n`
code += `output_plots.append({ "name": "Boll Mid (#${id})", "data": mid_${id}.tolist(), "color": "${s.color}", "style": "dashed", "overlay": True })\n\n`
} else if (mod.type === 'KDJ') {
code += `# Module ${id}: KDJ\n`
code += `low_min_${id} = df['low'].rolling(${mod.params.period}).min()\n`
code += `high_max_${id} = df['high'].rolling(${mod.params.period}).max()\n`
code += `rsv_${id} = (df['close'] - low_min_${id}) / (high_max_${id} - low_min_${id}) * 100\n`
code += `k_${id} = rsv_${id}.ewm(alpha=1/${mod.params.m1}, adjust=False).mean()\n`
code += `d_${id} = k_${id}.ewm(alpha=1/${mod.params.m2}, adjust=False).mean()\n`
code += `j_${id} = 3 * k_${id} - 2 * d_${id}\n`
code += `output_plots.append({ "name": "K (#${id})", "data": k_${id}.tolist(), "color": "${s.color}", "overlay": False })\n`
code += `output_plots.append({ "name": "D (#${id})", "data": d_${id}.tolist(), "color": "#ff9f43", "overlay": False })\n`
code += `output_plots.append({ "name": "J (#${id})", "data": j_${id}.tolist(), "color": "#ffec3d", "overlay": False })\n\n`
} else if (mod.type === 'CCI') {
code += `# Module ${id}: CCI\n`
code += `tp_${id} = (df['high'] + df['low'] + df['close']) / 3\n`
code += `ma_${id} = tp_${id}.rolling(${mod.params.period}).mean()\n`
code += `md_${id} = tp_${id}.rolling(${mod.params.period}).apply(lambda x: np.mean(np.abs(x - np.mean(x))))\n`
code += `cci_${id} = (tp_${id} - ma_${id}) / (0.015 * md_${id})\n`
code += `output_plots.append({ "name": "CCI (#${id})", "data": cci_${id}.tolist(), "color": "${s.color}", "overlay": False })\n`
code += `output_plots.append({ "name": "Upper", "data": [100]*len(df), "color": "#999", "style": "dashed", "overlay": False })\n`
code += `output_plots.append({ "name": "Lower", "data": [-100]*len(df), "color": "#999", "style": "dashed", "overlay": False })\n\n`
} else if (mod.type === 'ATR') {
code += `# Module ${id}: ATR\n`
code += `tr1_${id} = df['high'] - df['low']\n`
code += `tr2_${id} = (df['high'] - df['close'].shift(1)).abs()\n`
code += `tr3_${id} = (df['low'] - df['close'].shift(1)).abs()\n`
code += `tr_${id} = pd.concat([tr1_${id}, tr2_${id}, tr3_${id}], axis=1).max(axis=1)\n`
code += `atr_${id} = tr_${id}.rolling(${mod.params.period}).mean()\n`
code += `output_plots.append({ "name": "ATR (#${id})", "data": atr_${id}.tolist(), "color": "${s.color}", "overlay": False })\n\n`
} else if (mod.type === 'SIGNAL') {
code += `# Module ${id}: Signal Logic\n`
// Helper to get variable name or default to 'close' if not found or 'close'
const getVar = (val) => {
if (!val || val === 'close') return "df['close']"
// Check if it's a numeric constant
if (!isNaN(parseFloat(val))) return parseFloat(val)
return val
}
const leftBuy = getVar(mod.params.buy_cond_left)
const rightBuy = getVar(mod.params.buy_cond_right)
let buyCond = ''
if (mod.params.buy_op === '>') buyCond = `(${leftBuy} > ${rightBuy})`
else if (mod.params.buy_op === '<') buyCond = `(${leftBuy} < ${rightBuy})`
else if (mod.params.buy_op === 'cross_up') buyCond = `crossover(${leftBuy}, ${rightBuy})`
else if (mod.params.buy_op === 'cross_down') buyCond = `crossunder(${leftBuy}, ${rightBuy})`
const leftSell = getVar(mod.params.sell_cond_left)
const rightSell = getVar(mod.params.sell_cond_right)
let sellCond = ''
if (mod.params.sell_op === '>') sellCond = `(${leftSell} > ${rightSell})`
else if (mod.params.sell_op === '<') sellCond = `(${leftSell} < ${rightSell})`
else if (mod.params.sell_op === 'cross_up') sellCond = `crossover(${leftSell}, ${rightSell})`
else if (mod.params.sell_op === 'cross_down') sellCond = `crossunder(${leftSell}, ${rightSell})`
code += `buy_signal_${id} = ${buyCond}\n`
code += `sell_signal_${id} = ${sellCond}\n`
code += `output_signals.append({\n`
code += ` "type": "buy",\n`
code += ` "text": "B",\n`
code += ` "data": [df['low'].iloc[i] * 0.995 if buy_signal_${id}.iloc[i] else None for i in range(len(df))],\n`
code += ` "color": "#00E676"\n`
code += `})\n`
code += `output_signals.append({\n`
code += ` "type": "sell",\n`
code += ` "text": "S",\n`
code += ` "data": [df['high'].iloc[i] * 1.005 if sell_signal_${id}.iloc[i] else None for i in range(len(df))],\n`
code += ` "color": "#FF5252"\n`
code += `})\n\n`
}
})
code += `output = {\n`
code += ` "name": my_indicator_name,\n`
code += ` "plots": output_plots,\n`
code += ` "signals": output_signals\n`
code += `}\n`
this.codeEditor.setValue(code)
this.editMode = 'code'
this.$message.success('代码生成成功!')
},
parseConfigFromCode (code) {
if (!code) return
const regex = /# <VISUAL_CONF>\s*\n# (.*?)\s*\n# <\/VISUAL_CONF>/s
const match = code.match(regex)
if (match && match[1]) {
try {
this.visualModules = JSON.parse(match[1])
this.editMode = 'visual' // Auto-switch to visual if config found
} catch (e) {
console.error('Failed to parse visual config', e)
}
} else {
this.editMode = 'code'
this.visualModules = []
}
},
*/
//
// Jump to docs
goToDocs () {
window.open('https://github.com/brokermr810/QuantDinger/blob/main/docs/STRATEGY_DEV_GUIDE.md', '_blank')
},
//
// Verify code
handleVerifyCode () {
const code = this.codeEditor ? this.codeEditor.getValue() : ''
if (!code || !code.trim()) {
@@ -414,7 +261,7 @@ export default {
}
this.verifying = true
// 使 request axios baseURL token
// Use request tool (axios) which handles baseURL and token
request({
url: '/api/indicator/verifyCode',
method: 'post',
@@ -424,7 +271,7 @@ export default {
const data = res.data || {}
this.$message.success(`${this.$t('dashboard.indicator.editor.verifyCodeSuccess')} (${data.plots_count || 0} plots, ${data.signals_count || 0} signals)`)
} else {
//
// Show detailed error
const errorData = res.data || {}
this.$error({
title: this.$t('dashboard.indicator.editor.verifyCodeFailed'),
@@ -454,7 +301,7 @@ export default {
})
},
// markdown
// Clean markdown code block markers
cleanMarkdownCodeBlocks (code) {
if (!code || typeof code !== 'string') {
return code
@@ -462,42 +309,41 @@ export default {
let cleanedCode = code.trim()
//
// Check for code block markers
const hasCodeBlockMarkers = /```/.test(cleanedCode)
if (!hasCodeBlockMarkers) {
//
// If no markers, return
return cleanedCode
}
// ```python```py```
// ``` python, py, python3
// Remove starting marker (e.g. ```python, ```py, ``` etc.)
cleanedCode = cleanedCode.replace(/^```[\w]*\s*\n?/i, '')
//
// If still has opening marker, try again
if (cleanedCode.startsWith('```')) {
cleanedCode = cleanedCode.replace(/^```\s*\n?/g, '')
}
// ```
// Remove ending marker
if (cleanedCode.endsWith('```')) {
cleanedCode = cleanedCode.replace(/\n?```\s*$/g, '')
}
// ```
// ```
// Remove internal markers (usually errors)
// Match lines with only markers
cleanedCode = cleanedCode.replace(/^\s*```[\w]*\s*$/gm, '')
cleanedCode = cleanedCode.replace(/^\s*```\s*$/gm, '')
//
// Clean extra newlines
cleanedCode = cleanedCode.replace(/\n{3,}/g, '\n\n')
//
// Trim again
cleanedCode = cleanedCode.trim()
return cleanedCode
},
// /
// Initialize modal data (edit/new)
initFormData () {
if (!this.visible) {
return
@@ -515,7 +361,6 @@ export default {
this.codeEditor.setValue(code)
this.codeEditor.refresh()
}
// Visual editor removed
}, 50)
})
},
@@ -524,7 +369,7 @@ export default {
return
}
//
// If editor exists, destroy first
if (this.codeEditor) {
try {
if (typeof this.codeEditor.toTextArea === 'function') {
@@ -541,12 +386,13 @@ export default {
}
try {
//
// Clear container
this.$refs.codeEditorContainer.innerHTML = ''
//
// Create new editor instance
this.codeEditor = CodeMirror(this.$refs.codeEditorContainer, {
value: (() => {
const lang = (this.$i18n && this.$i18n.locale) ? this.$i18n.locale : 'en-US'
const existing = this.indicator ? (this.indicator.code || '') : ''
return existing && String(existing).trim() ? existing : this.getDefaultIndicatorCode()
})(),
@@ -566,13 +412,13 @@ export default {
viewportMargin: Infinity
})
//
// Listen for code changes
this.codeEditor.on('change', (editor) => {
// no-op: form fields removed; code is read from editor on save
editor.getValue()
})
//
// Refresh editor
this.$nextTick(() => {
if (this.codeEditor) {
this.codeEditor.refresh()
@@ -582,7 +428,7 @@ export default {
}
},
handleSave () {
//
// Get code from editor
const code = this.codeEditor ? this.codeEditor.getValue() : ''
const finalCode = code || ''
if (!finalCode.trim()) {
@@ -591,7 +437,7 @@ export default {
}
this.saving = true
// name/description
// Trigger save event
this.$emit('save', {
id: this.indicator ? this.indicator.id : 0,
code: finalCode,
@@ -605,7 +451,7 @@ export default {
this.$emit('cancel')
},
handleAfterClose () {
// Modal
// Refresh editor after modal closed
if (this.codeEditor) {
this.$nextTick(() => {
if (this.codeEditor) {
@@ -614,7 +460,7 @@ export default {
})
}
// AI
// Clear AI prompt
this.aiPrompt = ''
},
async handleAIGenerate () {
@@ -625,13 +471,13 @@ export default {
this.aiGenerating = true
//
// Get existing code as context
let existingCode = ''
if (this.codeEditor) {
existingCode = this.codeEditor.getValue() || ''
}
//
// Provide feedback
if (this.codeEditor) {
this.codeEditor.setValue('# AI generating...\n')
this.codeEditor.refresh()
@@ -643,20 +489,20 @@ export default {
// Local python API (SSE)
const url = '/api/indicator/aiGenerate'
// token
// Get token
const token = storage.get(ACCESS_TOKEN)
//
// Build request body
const requestBody = {
prompt: this.aiPrompt.trim()
}
//
// Pass existing code as context if available
if (existingCode.trim()) {
requestBody.existingCode = existingCode.trim()
}
// 使 fetch
// Use fetch for streaming response
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -674,9 +520,9 @@ export default {
throw new Error(text || `HTTP error! status: ${response.status}`)
}
//
// Handle streaming response
if (!response.body || typeof response.body.getReader !== 'function') {
throw new Error('AI 服务未返回可读取的流(response.body 不存在)')
throw new Error('AI service did not return a readable stream (response.body missing)')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
@@ -689,22 +535,21 @@ export default {
break
}
//
// Decode chunk
buffer += decoder.decode(value, { stream: true })
// SSE
// Process SSE messages
const lines = buffer.split('\n\n')
buffer = lines.pop() || '' //
buffer = lines.pop() || '' // Keep incomplete message
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) {
continue
}
const data = line.substring(6) // "data: "
const data = line.substring(6) // Remove "data: " prefix
if (data === '[DONE]') {
//
break
}
@@ -716,16 +561,16 @@ export default {
}
if (json.content) {
//
// Append content
generatedCode += json.content
// markdown
// Clean markdown markers
const cleanedCode = this.cleanMarkdownCodeBlocks(generatedCode)
//
// Real-time update
if (this.codeEditor) {
this.codeEditor.setValue(cleanedCode)
//
// Scroll to end
const lineCount = this.codeEditor.lineCount()
this.codeEditor.setCursor({ line: lineCount - 1, ch: 0 })
this.codeEditor.refresh()
@@ -736,19 +581,19 @@ export default {
}
}
// - markdown
// Final update - clean markers
if (this.codeEditor && generatedCode) {
const cleanedCode = this.cleanMarkdownCodeBlocks(generatedCode)
this.codeEditor.setValue(cleanedCode)
this.codeEditor.refresh()
this.$message.success(this.$t('dashboard.indicator.editor.aiGenerateSuccess'))
} else if (!generatedCode) {
this.$message.warning('未生成任何代码,请尝试更详细的提示词')
this.$message.warning('No code generated, please try more detailed prompts')
}
} catch (error) {
this.$message.error(error.message || this.$t('dashboard.indicator.editor.aiGenerateError'))
// markdown
// If partial code generated, keep it
if (generatedCode && this.codeEditor) {
const cleanedCode = this.cleanMarkdownCodeBlocks(generatedCode)
this.codeEditor.setValue(cleanedCode)
@@ -757,7 +602,7 @@ export default {
this.aiGenerating = false
}
}
// / /
// Community / Pricing / Preview upload removed (not needed for local version)
}
}
</script>
@@ -842,7 +687,7 @@ export default {
border-top: 1px solid #e8e8e8;
}
/* 手机端适配 */
/* Mobile adaptation */
@media (max-width: 768px) {
.visual-editor-container {
height: auto;
@@ -1072,7 +917,7 @@ export default {
}
}
/* 手机端适配 */
/* Mobile adaptation */
@media (max-width: 768px) {
.indicator-editor-modal {
:deep(.ant-modal) {
@@ -1123,13 +968,13 @@ export default {
min-height: auto !important;
}
/* 左右布局改为上下布局 */
/* Switch left-right layout to top-bottom layout */
.code-editor-column {
width: 100% !important;
margin-bottom: 16px;
}
/* 代码编辑器区域 */
/* Code editor area */
.code-section {
margin-bottom: 16px;
@@ -1158,13 +1003,13 @@ export default {
}
}
/* 智能生成区域 */
/* AI generation area */
.ai-panel {
height: auto !important;
min-height: auto !important;
}
/* 底部按钮 */
/* Footer buttons */
.editor-footer {
flex-direction: column-reverse;
gap: 8px;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
<template>
<div class="polymarket-page" :class="{ 'theme-dark': isDarkTheme }">
<div class="page-hero">
<div class="hero-copy">
<div class="hero-eyebrow">{{ tt('polymarket.analysis.title', 'Polymarket Analysis') }}</div>
<h1>{{ tt('polymarket.analysis.heroTitle', 'Compare market pricing with AI conviction') }}</h1>
<p>{{ tt('polymarket.analysis.description', 'Analyze a prediction market by URL or title and compare market pricing with AI probability.') }}</p>
</div>
<div class="hero-actions">
<a-button size="large" icon="arrow-left" @click="$router.push('/ai-asset-analysis?tab=polymarket')">
{{ tt('polymarket.analysis.backToAssetAnalysis', 'Back to AI Asset Analysis') }}
</a-button>
</div>
</div>
<polymarket-analysis-workspace ref="workspace" embedded />
</div>
</template>
<script>
import { mapState } from 'vuex'
import PolymarketAnalysisWorkspace from '@/components/PolymarketAnalysisWorkspace.vue'
export default {
name: 'PolymarketPage',
components: {
PolymarketAnalysisWorkspace
},
computed: {
...mapState({
navTheme: state => state.app.theme
}),
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
}
},
watch: {
'$route.query.input': {
immediate: true,
handler (value) {
if (!value) return
this.$nextTick(() => {
const workspace = this.$refs.workspace
if (workspace && workspace.startFromInput) {
workspace.startFromInput(value)
}
})
}
}
},
methods: {
tt (key, fallback, params) {
const translated = this.$t(key, params)
return translated !== key ? translated : fallback
}
}
}
</script>
<style lang="less" scoped>
.polymarket-page {
min-height: calc(100vh - 120px);
padding: 16px;
background: linear-gradient(180deg, #f3f6fb 0%, #eef4fb 100%);
}
.page-hero {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
margin-bottom: 18px;
padding: 26px;
border: 1px solid #dbe7f5;
border-radius: 28px;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.16), transparent 34%),
linear-gradient(135deg, #ffffff 0%, #f8fbff 52%, #eef7ff 100%);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
}
.hero-copy {
max-width: 760px;
}
.hero-eyebrow {
color: #2563eb;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-hero h1 {
margin: 10px 0 12px;
color: #0f172a;
font-size: 30px;
font-weight: 700;
}
.page-hero p {
margin: 0;
color: rgba(15, 23, 42, 0.68);
line-height: 1.7;
}
.hero-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.theme-dark.polymarket-page {
background: linear-gradient(180deg, #0b1220 0%, #111827 100%);
}
.theme-dark .page-hero {
border-color: rgba(59, 130, 246, 0.16);
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.22), transparent 34%),
linear-gradient(135deg, #111827 0%, #0f172a 58%, #161b22 100%);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
}
.theme-dark .hero-eyebrow {
color: #60a5fa;
}
.theme-dark .page-hero h1 {
color: #f8fafc;
}
.theme-dark .page-hero p {
color: rgba(248, 250, 252, 0.72);
}
@media (max-width: 900px) {
.page-hero {
flex-direction: column;
}
}
</style>
@@ -76,7 +76,7 @@ export default {
key: 'created_at',
width: 180,
customRender: (text) => {
return new Date(text * 1000).toLocaleString('zh-CN')
return new Date(text * 1000).toLocaleString('en-US')
}
},
{
@@ -128,19 +128,19 @@ export default {
limit: this.pagination.pageSize
})
if (res.code === 1) {
//
// Handle returned data format
const data = res.data
let decisions = []
let total = 0
//
// Compatible with new and old formats
if (data && typeof data === 'object') {
if (Array.isArray(data)) {
//
// Old format: direct array
decisions = data
total = data.length
} else if (data.decisions) {
// decisions total
// New format: includes decisions and total
decisions = data.decisions || []
total = data.total || 0
}
@@ -209,7 +209,7 @@ export default {
color: var(--text-color, #1f1f1f);
}
// hold cyan
// Ensure hold tag (cyan) has enough contrast in all themes
/deep/ .ant-tag {
&.ant-tag-cyan {
background-color: #13c2c2 !important;
@@ -218,12 +218,12 @@ export default {
}
}
//
// Table container horizontal scroll
/deep/ .ant-table-wrapper {
min-width: 100%;
}
//
// Adapt to theme colors
/deep/ .ant-table {
background: var(--table-row-bg, #fff);
color: var(--text-color, #1f1f1f);
@@ -269,7 +269,7 @@ export default {
}
}
// 使
// Progress bar uses theme color
/deep/ .ant-progress {
.ant-progress-bg {
background-color: var(--primary-color, #1890ff);
@@ -284,7 +284,7 @@ export default {
}
}
//
// Custom scrollbar style
&::-webkit-scrollbar {
height: 8px;
}
@@ -303,7 +303,7 @@ export default {
}
}
//
// Pager theme adaptation
/deep/ .ant-pagination {
color: var(--text-color, #1f1f1f);
@@ -363,7 +363,7 @@ export default {
</style>
<style lang="less">
//
// Global styles: tag optimization under dark theme
.trading-assistant.theme-dark {
.ai-decision-records {
/deep/ .ant-table {
@@ -410,14 +410,14 @@ export default {
}
/deep/ .ant-tag {
// hold 使
// hold tag uses cyan, ensure visible on dark background
&.ant-tag-cyan {
background-color: #13c2c2 !important;
border-color: #13c2c2 !important;
color: #fff !important;
}
//
// Other tags optimization under dark theme
&.ant-tag-green {
background-color: #52c41a !important;
border-color: #52c41a !important;
@@ -442,7 +442,7 @@ export default {
color: #fff !important;
}
// default
// default tag optimization under dark theme
&.ant-tag-default {
background-color: #434343 !important;
border-color: #434343 !important;
@@ -450,7 +450,7 @@ export default {
}
}
// 使
// Progress bar use theme color under dark theme
/deep/ .ant-progress {
.ant-progress-bg {
background-color: var(--primary-color, #1890ff);
@@ -465,7 +465,7 @@ export default {
}
}
//
// Scrollbar style under dark theme
&::-webkit-scrollbar-track {
background: #2a2e39;
}
@@ -478,7 +478,7 @@ export default {
}
}
//
// Pager under dark theme
/deep/ .ant-pagination {
color: var(--text-color, #d1d4dc);
@@ -364,7 +364,7 @@ export default {
? new Date(raw < 1e12 ? raw * 1000 : raw)
: new Date(time)
if (Number.isNaN(date.getTime())) return '--'
return date.toLocaleDateString(this.$i18n.locale === 'zh-CN' ? 'zh-CN' : 'en-US', {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})
@@ -139,7 +139,7 @@ export default {
handler (val) {
if (val) {
this.loadPositions()
// 5
// Refresh positions every 5 seconds
this.startPolling()
} else {
this.stopPolling()
@@ -158,7 +158,7 @@ export default {
try {
const res = await getStrategyPositions(this.strategyId)
if (res.code === 1) {
//
// Ensure data format is correct, handle potential field name inconsistencies
const rawPositions = res.data.positions || []
this.positions = rawPositions.map((position, index) => {
@@ -167,7 +167,7 @@ export default {
if (!isFinite(lev) || lev <= 0) lev = 1
if (mt === 'spot') lev = 1
// entry_price退 current_price=
// Handle entry_price: do not fall back to current_price, to avoid misleadingly showing entry price = current price
const entryPrice = parseFloat(position.entry_price || position.entryPrice || 0)
const size = parseFloat(position.size || '0') || 0
const pnl = parseFloat(position.unrealized_pnl || position.unrealizedPnl || '0') || 0
@@ -195,7 +195,7 @@ export default {
return mapped
})
} else {
//
// Do not show error, strategy might not have positions yet
this.positions = []
}
} catch (error) {
@@ -223,7 +223,7 @@ export default {
</script>
<style lang="less" scoped>
//
// Color variables
@primary-color: #1890ff;
@success-color: #0ecb81;
@danger-color: #f6465d;
@@ -249,7 +249,7 @@ export default {
color: #333;
}
//
// Custom thin scrollbar
::v-deep .ant-table-body {
overflow-x: auto;
scrollbar-width: thin;
@@ -271,7 +271,7 @@ export default {
}
}
//
// Scrollbar style for table container
::v-deep .ant-table-container {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
@@ -292,7 +292,7 @@ export default {
}
}
//
// Scrollbar style for all possible table scroll containers
::v-deep .ant-table-content,
::v-deep .ant-table-wrapper {
scrollbar-width: thin;
@@ -341,7 +341,7 @@ export default {
}
}
//
// Direction tag beautification
::v-deep .ant-tag {
border-radius: 6px;
padding: 2px 10px;
@@ -362,7 +362,7 @@ export default {
}
}
//
// Dark theme adaptation
&.theme-dark,
.theme-dark & {
::v-deep .ant-table {
@@ -436,12 +436,12 @@ export default {
}
}
//
// Mobile adaptation
@media (max-width: 768px) {
min-height: 200px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
// 使
// Mobile also uses thin scrollbar
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
&::-webkit-scrollbar {
@@ -467,10 +467,10 @@ export default {
::v-deep .ant-table {
font-size: 12px;
min-width: 700px; //
min-width: 700px; // Ensure minimum table width to trigger horizontal scroll
}
// 使
// Mobile also uses thin scrollbar
::v-deep .ant-table-body,
::v-deep .ant-table-container,
::v-deep .ant-table-wrapper {
@@ -535,8 +535,8 @@ export default {
</style>
<style lang="less">
// - 使 scoped
// 使 scoped
// Dark theme adaptation - Use higher priority selectors to override scoped styles
// Must use full scoped selector path to override
.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,
@@ -578,7 +578,7 @@ body.realdark .position-records .ant-table-tbody > tr:hover > td {
background: #2a2e39 !important;
}
//
// Ensure table header text is visible
.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,
@@ -588,7 +588,7 @@ body.realdark .position-records .ant-table-thead > tr > th {
}
}
//
// Universal selector as fallback
.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;
@@ -605,11 +605,11 @@ body.realdark .position-records .ant-table-thead > tr > th {
</style>
<style lang="less">
// - 使 scoped
// 使 scoped theme-dark
// 使 scoped
// Dark theme adaptation - Use highest priority selectors to override scoped styles
// Key: Must use exactly the same selector structure as scoped styles, plus theme-dark prefix
// Use attribute selector exact matching to override scoped styles
// 1 data-v-6c1eb557
// Method 1: Exact match 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 {
@@ -618,7 +618,7 @@ body.realdark .position-records .ant-table-thead > tr > th {
border-bottom-color: #363c4e !important;
}
// 2使
// Method 2: Use attribute selector prefix matching (more universal)
.theme-dark [data-v-6c1eb557] .ant-table-tbody > tr > td {
color: #d1d4dc !important;
background: #1e222d !important;
@@ -643,7 +643,7 @@ body.realdark .position-records .ant-table-thead > tr > th {
background: #2a2e39 !important;
}
// body.dark body.realdark
// body.dark and body.realdark support
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,
@@ -655,7 +655,7 @@ body.realdark [data-v-6c1eb557] .position-records .ant-table-tbody > tr > td {
border-bottom-color: #363c4e !important;
}
// 2使
// Method 2: Use attribute selector prefix matching (more universal)
body.dark [data-v-6c1eb557] .ant-table-tbody > tr > td,
body.realdark [data-v-6c1eb557] .ant-table-tbody > tr > td {
color: #d1d4dc !important;
@@ -674,7 +674,7 @@ body.realdark [data-v-6c1eb557] .position-records .ant-table-thead > tr > th {
border-bottom-color: #363c4e !important;
}
// 2使
// Method 2: Use attribute selector prefix matching (more universal)
body.dark [data-v-6c1eb557] .ant-table-thead > tr > th,
body.realdark [data-v-6c1eb557] .ant-table-thead > tr > th {
background: #2a2e39 !important;
@@ -682,7 +682,7 @@ body.realdark [data-v-6c1eb557] .ant-table-thead > tr > th {
border-bottom-color: #363c4e !important;
}
// data-v
// Universal fallback selector (if data-v value changes)
.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 {
@@ -699,7 +699,7 @@ body.realdark .position-records[data-v] .ant-table-thead > tr > th {
border-bottom-color: #363c4e !important;
}
//
// Other styles
.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 {
@@ -720,8 +720,8 @@ body.realdark .position-records[data-v-6c1eb557] .loss {
</style>
<style lang="less">
// 使
// scoped
// Final override solution: Use longer selector chain to ensure highest priority
// Directly match the full selector path generated by scoped styles
.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,
@@ -744,7 +744,7 @@ body.realdark .trading-assistant [data-v-6c1eb557].position-records .ant-table-t
border-bottom-color: #363c4e !important;
}
//
// Dark theme scrollbar style
.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,
@@ -776,7 +776,7 @@ body.realdark .position-records[data-v-6c1eb557] .ant-table-wrapper {
}
}
//
// Universal fallback selector
.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,
@@ -138,10 +138,10 @@ export default {
try {
const res = await getStrategyTrades(this.strategyId)
if (res.code === 1) {
//
// Ensure data format is correct
this.records = (res.data.trades || []).map(trade => ({
...trade,
//
// Ensure time field exists
time: trade.created_at || trade.time
}))
} else {
@@ -157,37 +157,31 @@ export default {
let date
if (typeof time === 'number') {
//
// Number type: determine if it's second or millisecond timestamp
const timestampMs = time < 1e12 ? time * 1000 : time
date = new Date(timestampMs)
} else if (typeof time === 'string') {
//
// String type
if (/^\d+$/.test(time)) {
//
// Pure numeric string (timestamp)
const timestamp = parseInt(time, 10)
const timestampMs = timestamp < 1e12 ? timestamp * 1000 : timestamp
date = new Date(timestampMs)
} else {
// ISO
// ISO date string or other format, parse directly
date = new Date(time)
}
} else {
return '--'
}
//
// Check if date is valid
if (isNaN(date.getTime())) {
return '--'
}
// 使24
const locale = this.$i18n.locale || 'zh-CN'
const localeMap = {
'zh-CN': 'zh-CN',
'zh-TW': 'zh-TW',
'en-US': 'en-US'
}
return date.toLocaleString(localeMap[locale] || 'zh-CN', {
// Format time using 24-hour clock
return date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -200,20 +194,20 @@ export default {
return '--'
}
},
//
// Get trade type color
getTradeTypeColor (type) {
const colorMap = {
//
// Old format
'buy': 'green',
'sell': 'red',
'liquidation': 'orange',
// -
// New format - Long
'open_long': 'green',
'add_long': 'cyan',
'close_long': 'orange',
'close_long_stop': 'red',
'close_long_profit': 'lime',
// -
// New format - Short
'open_short': 'red',
'add_short': 'magenta',
'close_short': 'blue',
@@ -222,20 +216,20 @@ export default {
}
return colorMap[type] || 'default'
},
//
// Get trade type text
getTradeTypeText (type) {
const textMap = {
//
// Old format
'buy': this.$t('dashboard.indicator.backtest.buy'),
'sell': this.$t('dashboard.indicator.backtest.sell'),
'liquidation': this.$t('dashboard.indicator.backtest.liquidation'),
// -
// 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'),
// -
// 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'),
@@ -244,31 +238,31 @@ export default {
}
return textMap[type] || type
},
//
// Format money (profit/loss)
formatMoney (value) {
if (value === null || value === undefined) return '--'
// +-
// Positive shows +, negative shows -
const sign = value >= 0 ? '+' : '-'
return `${sign}$${Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
},
//
// Format profit (handle cases in signal mode with no live trades)
formatProfit (value, record) {
// profit0null--
// profit0record.is_signal_onlytrue
// If signal mode (no live trades), show -- when profit is 0 or null
// Criteria: If entry signal and profit is 0, or record.is_signal_only is true
if (value === null || value === undefined) return '--'
const numValue = parseFloat(value)
// 0open_long/open_short--
//
// If value is 0 and it's an entry signal (open_long/open_short), show --
// Because there's no profit/loss at entry
const openTypes = ['open_long', 'open_short', 'add_long', 'add_short']
if (numValue === 0 && record && openTypes.includes(record.type)) {
return '--'
}
// 0E-80
// If value is extremely small (scientific notation like 0E-8), treat as 0
if (Math.abs(numValue) < 0.000001) {
// --$0.00
// Entry types show --, cover types show $0.00
if (record && openTypes.includes(record.type)) {
return '--'
}
@@ -277,18 +271,18 @@ export default {
return this.formatMoney(numValue)
},
// 0E-8
// Format commission (avoid scientific notation like 0E-8)
formatCommission (value) {
if (value === null || value === undefined) return '--'
const numValue = parseFloat(value)
// 0E-80--
// If value is extremely small (scientific notation like 0E-8), show as 0 or --
if (isNaN(numValue) || Math.abs(numValue) < 0.000001) {
return '--'
}
//
// Normal formatting display
return `$${numValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 })}`
}
}
@@ -296,7 +290,7 @@ export default {
</script>
<style lang="less" scoped>
//
// Color variables
@primary-color: #1890ff;
@success-color: #0ecb81;
@danger-color: #f6465d;
@@ -446,7 +440,7 @@ export default {
}
}
//
// Trade type tag beautification
::v-deep .ant-tag {
border-radius: 6px;
padding: 3px 10px;
@@ -480,7 +474,7 @@ export default {
}
}
//
// Pager beautification
::v-deep .ant-pagination {
margin-top: 16px;
display: flex;
@@ -524,7 +518,7 @@ export default {
}
}
//
// Dark theme adaptation
&.theme-dark,
.theme-dark & {
::v-deep .ant-table {
@@ -558,7 +552,7 @@ export default {
background: #fafafa;
}
//
// Mobile adaptation
@media (max-width: 768px) {
min-height: 200px;
overflow-x: visible;
@@ -567,7 +561,7 @@ export default {
font-size: 12px;
}
// 使
// Mobile also uses thin scrollbar
::v-deep .ant-table-body,
::v-deep .ant-table-container,
::v-deep .ant-table-wrapper {
@@ -635,12 +629,12 @@ export default {
}
}
// - scoped
// Dark theme - handled in scoped to ensure high priority
</style>
<style lang="less">
// - 使 scoped
// 使 scoped theme-dark
// Dark theme adaptation - Use highest priority selectors to override scoped styles
// Key: Must use exactly the same selector structure as scoped styles, plus theme-dark prefix
.theme-dark .trading-records .ant-table-tbody > tr > td,
.theme-dark .trading-records[data-v] .ant-table-tbody > tr > td,
body.dark .trading-records .ant-table-tbody > tr > td,
@@ -682,7 +676,7 @@ body.realdark .trading-records .ant-table-tbody > tr:hover > td {
background: #2a2e39 !important;
}
//
// Ensure table header text is visible
.theme-dark .trading-records .ant-table-thead > tr > th,
.theme-dark .trading-records[data-v] .ant-table-thead > tr > th,
body.dark .trading-records .ant-table-thead > tr > th,
@@ -696,7 +690,7 @@ body.realdark .trading-records .ant-table-thead > tr > th {
background: #2a2e39 !important;
}
// body.dark body.realdark
// body.dark and body.realdark support
body.dark .trading-records[data-v-8a68b65a] .ant-table-tbody > tr > td,
body.realdark .trading-records[data-v-8a68b65a] .ant-table-tbody > tr > td {
color: #d1d4dc !important;
@@ -711,7 +705,7 @@ body.realdark .trading-records[data-v-8a68b65a] .ant-table-thead > tr > th {
border-bottom-color: #363c4e !important;
}
// data-v
// Universal fallback selector (if data-v value changes)
.theme-dark .trading-records[data-v] .ant-table-tbody > tr > td,
body.dark .trading-records[data-v] .ant-table-tbody > tr > td,
body.realdark .trading-records[data-v] .ant-table-tbody > tr > td {
@@ -728,7 +722,7 @@ body.realdark .trading-records[data-v] .ant-table-thead > tr > th {
border-bottom-color: #363c4e !important;
}
//
// Pager styles
.theme-dark .trading-records[data-v-8a68b65a] .ant-pagination-item,
body.dark .trading-records[data-v-8a68b65a] .ant-pagination-item,
body.realdark .trading-records[data-v-8a68b65a] .ant-pagination-item {
@@ -780,7 +774,7 @@ body.realdark .trading-records[data-v-8a68b65a] .ant-pagination-next:hover .ant-
color: #1890ff !important;
}
//
// Universal fallback selector
.theme-dark .trading-records[data-v] .ant-pagination-item,
body.dark .trading-records[data-v] .ant-pagination-item,
body.realdark .trading-records[data-v] .ant-pagination-item {
@@ -832,7 +826,7 @@ body.realdark .trading-records[data-v] .ant-pagination-next:hover .ant-paginatio
color: #1890ff !important;
}
//
// Dark theme scrollbar styles
.theme-dark .trading-records[data-v-8a68b65a] .ant-table-body,
.theme-dark .trading-records[data-v-8a68b65a] .ant-table-container,
.theme-dark .trading-records[data-v-8a68b65a] .ant-table-content,
@@ -864,7 +858,7 @@ body.realdark .trading-records[data-v-8a68b65a] .ant-table-wrapper {
}
}
//
// Universal fallback selector
.theme-dark .trading-records[data-v] .ant-table-body,
.theme-dark .trading-records[data-v] .ant-table-container,
.theme-dark .trading-records[data-v] .ant-table-content,
@@ -898,7 +892,7 @@ body.realdark .trading-records[data-v] .ant-table-wrapper {
</style>
<style lang="less">
// - 使
// Dark theme adaptation - Use global styles to ensure overrides
.theme-dark .trading-records {
::v-deep .ant-table {
background: #1e222d !important;
@@ -942,7 +936,7 @@ body.realdark .trading-records[data-v] .ant-table-wrapper {
color: #868993 !important;
}
//
// Dark theme scrollbar styles
::v-deep .ant-table-body,
::v-deep .ant-table-container,
::v-deep .ant-table-content,
@@ -1063,7 +1057,7 @@ body.realdark .trading-records {
color: #868993 !important;
}
//
// Dark theme scrollbar styles
::v-deep .ant-table-body,
::v-deep .ant-table-container,
::v-deep .ant-table-content,
@@ -1142,7 +1136,7 @@ body.realdark .trading-records {
</style>
<style lang="less">
/* 暗黑主题适配 - 使用更高优先级的选择器 */
/* Dark theme adaptation - Use higher priority selectors */
.theme-dark .trading-records,
.theme-dark .trading-records *,
body.dark .trading-records,
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -141,9 +141,9 @@
<div class="summary-info">
<div class="summary-value">{{ strategySummary.running_strategies || 0 }}</div>
<div class="summary-sub">
{{ $t('systemOverview.live') || '实盘' }}: {{ strategySummary.running_live_strategies || 0 }}
{{ $t('systemOverview.live') || 'Live' }}: {{ strategySummary.running_live_strategies || 0 }}
/
{{ $t('systemOverview.signal') || '仅通知' }}: {{ strategySummary.running_signal_strategies || 0 }}
{{ $t('systemOverview.signal') || 'Signal Only' }}: {{ strategySummary.running_signal_strategies || 0 }}
</div>
<div class="summary-label">{{ $t('systemOverview.runningStrategies') || 'Running' }}</div>
</div>
@@ -155,9 +155,9 @@
<div class="summary-info">
<div class="summary-value">{{ formatNumber(strategySummary.total_capital) }}</div>
<div class="summary-sub">
{{ $t('systemOverview.live') || '实盘' }}: {{ formatNumber(strategySummary.live_capital) }}
{{ $t('systemOverview.live') || 'Live' }}: {{ formatNumber(strategySummary.live_capital) }}
/
{{ $t('systemOverview.signal') || '仅通知' }}: {{ formatNumber(strategySummary.signal_capital) }}
{{ $t('systemOverview.signal') || 'Signal Only' }}: {{ formatNumber(strategySummary.signal_capital) }}
</div>
<div class="summary-label">{{ $t('systemOverview.totalCapital') || 'Total Capital' }}</div>
</div>
@@ -172,9 +172,9 @@
<span class="roi-badge">{{ strategySummary.total_roi || 0 }}%</span>
</div>
<div class="summary-sub">
{{ $t('systemOverview.live') || '实盘' }}: {{ formatPnl(strategySummary.live_pnl) }}
{{ $t('systemOverview.live') || 'Live' }}: {{ strategySummary.live_pnl }}
/
{{ $t('systemOverview.signal') || '仅通知' }}: {{ formatPnl(strategySummary.signal_pnl) }}
{{ $t('systemOverview.signal') || 'Signal Only' }}: {{ strategySummary.signal_pnl }}
</div>
<div class="summary-label">{{ $t('systemOverview.totalPnl') || 'Total PnL' }}</div>
</div>
+34 -34
View File
@@ -912,39 +912,39 @@ export default {
})
if (res.code === 1 && res.data?.token) {
// token storage
// Save token (save to storage first to ensure request interceptor can read it)
const expiresAt = new Date().getTime() + 7 * 24 * 60 * 60 * 1000
storage.set(ACCESS_TOKEN, res.data.token, expiresAt)
this.$store.commit('SET_TOKEN', res.data.token)
// userinfo
// Save user information (userinfo returned from the login interface)
if (res.data.userinfo) {
const userInfoData = { ...res.data.userinfo }
// is_demo GetInfo
// Ensure is_demo field exists to avoid GetInfo considering cache as expired
if (typeof userInfoData.is_demo === 'undefined') {
userInfoData.is_demo = false
}
// storage GetInfo
// Save to storage to ensure GetInfo can read it
storage.set(USER_INFO, userInfoData, expiresAt)
this.$store.commit('SET_INFO', userInfoData)
//
// Set username
if (userInfoData.nickname) {
this.$store.commit('SET_NAME', { name: userInfoData.nickname, welcome: timeFix() })
} else if (userInfoData.username) {
this.$store.commit('SET_NAME', { name: userInfoData.username, welcome: timeFix() })
}
//
// Set avatar
if (userInfoData.avatar) {
this.$store.commit('SET_AVATAR', userInfoData.avatar)
}
//
// Set roles (if any)
let roles = []
if (userInfoData.role) {
// role
// Handle case where role might be an object or an array
if (Array.isArray(userInfoData.role)) {
roles = userInfoData.role
} else if (typeof userInfoData.role === 'object') {
@@ -953,37 +953,37 @@ export default {
roles = [{ id: userInfoData.role, permissionList: [] }]
}
} else {
//
// If no role information, set a default role object to avoid route guard getting stuck
roles = [{ id: 'default', permissionList: [] }]
}
this.$store.commit('SET_ROLES', roles)
storage.set(USER_ROLES, roles, expiresAt)
}
// roles 使 Vue.nextTick
// Ensure roles have been correctly set (use Vue.nextTick to ensure state is updated)
await this.$nextTick()
// token roles
// Verify if token and roles are correctly set
const currentToken = storage.get(ACCESS_TOKEN)
const currentRoles = this.$store.getters.roles
console.log('Token after save:', currentToken ? (typeof currentToken === 'string' ? 'string' : typeof currentToken) : 'missing')
console.log('Roles after save:', currentRoles.length > 0 ? `has ${currentRoles.length} roles` : 'empty')
// roles
// If roles are empty, set default role
if (currentRoles.length === 0) {
const defaultRoles = [{ id: 'default', permissionList: [] }]
this.$store.commit('SET_ROLES', defaultRoles)
storage.set(USER_ROLES, defaultRoles, expiresAt)
}
// token
// Wait a bit to ensure token is set in request interceptor
await new Promise(resolve => setTimeout(resolve, 200))
//
// ResetRoutes
// Reset routes, force regeneration (based on new user's roles)
// Note: ResetRoutes only clears routes, it doesn't trigger route guards
this.$store.dispatch('ResetRoutes')
// roles roles GetInfo
// Jump directly; route guard will check roles, if roles are set it won't call GetInfo
const isNew = res.data.is_new_user
this.$router.push({ path: '/' }).then(() => {
this.$notification.success({
@@ -994,7 +994,7 @@ export default {
})
}).catch(err => {
console.error('Router push error:', err)
// 使
// Show success message even if navigation fails
this.$notification.success({
message: isNew ? (this.$t('user.login.welcomeNew') || 'Welcome!') : 'Welcome',
description: isNew
@@ -1111,39 +1111,39 @@ export default {
this.$message.success(this.$t('user.register.success') || 'Registration successful')
if (res.data?.token) {
// token storage
// Save token (save to storage first to ensure request interceptor can read it)
const expiresAt = new Date().getTime() + 7 * 24 * 60 * 60 * 1000
storage.set(ACCESS_TOKEN, res.data.token, expiresAt)
this.$store.commit('SET_TOKEN', res.data.token)
// userinfo
// Save user information (userinfo returned from the registration interface)
if (res.data.userinfo) {
const userInfoData = { ...res.data.userinfo }
// is_demo GetInfo
// Ensure is_demo field exists to avoid GetInfo considering cache as expired
if (typeof userInfoData.is_demo === 'undefined') {
userInfoData.is_demo = false
}
// storage GetInfo
// Save to storage to ensure GetInfo can read it
storage.set(USER_INFO, userInfoData, expiresAt)
this.$store.commit('SET_INFO', userInfoData)
//
// Set username
if (userInfoData.nickname) {
this.$store.commit('SET_NAME', { name: userInfoData.nickname, welcome: timeFix() })
} else if (userInfoData.username) {
this.$store.commit('SET_NAME', { name: userInfoData.username, welcome: timeFix() })
}
//
// Set avatar
if (userInfoData.avatar) {
this.$store.commit('SET_AVATAR', userInfoData.avatar)
}
//
// Set roles (if any)
let roles = []
if (userInfoData.role) {
// role
// Handle case where role might be an object or an array
if (Array.isArray(userInfoData.role)) {
roles = userInfoData.role
} else if (typeof userInfoData.role === 'object') {
@@ -1152,37 +1152,37 @@ export default {
roles = [{ id: userInfoData.role, permissionList: [] }]
}
} else {
//
// If no role information, set a default role object to avoid route guard getting stuck
roles = [{ id: 'default', permissionList: [] }]
}
this.$store.commit('SET_ROLES', roles)
storage.set(USER_ROLES, roles, expiresAt)
}
// roles 使 Vue.nextTick
// Ensure roles have been correctly set (use Vue.nextTick to ensure state is updated)
await this.$nextTick()
// token roles
// Verify if token and roles are correctly set
const currentToken = storage.get(ACCESS_TOKEN)
const currentRoles = this.$store.getters.roles
console.log('Register - Token after save:', currentToken ? (typeof currentToken === 'string' ? 'string' : typeof currentToken) : 'missing')
console.log('Register - Roles after save:', currentRoles.length > 0 ? `has ${currentRoles.length} roles` : 'empty')
// roles
// If roles are empty, set default role
if (currentRoles.length === 0) {
const defaultRoles = [{ id: 'default', permissionList: [] }]
this.$store.commit('SET_ROLES', defaultRoles)
storage.set(USER_ROLES, defaultRoles, expiresAt)
}
// token
// Wait a bit to ensure token is set in request interceptor
await new Promise(resolve => setTimeout(resolve, 200))
//
// ResetRoutes
// Reset routes, force regeneration (based on new user's roles)
// Note: ResetRoutes only clears routes, it doesn't trigger route guards
this.$store.dispatch('ResetRoutes')
// roles roles GetInfo
// Jump directly; route guard will check roles, if roles are set it won't call GetInfo
this.$router.push({ path: '/' }).then(() => {
this.$notification.success({
message: 'Welcome',
@@ -1190,7 +1190,7 @@ export default {
})
}).catch(err => {
console.error('Router push error:', err)
// 使
// Show success message even if navigation fails
this.$notification.success({
message: 'Welcome',
description: `${timeFix()}, welcome to QuantDinger!`
@@ -6,7 +6,7 @@
>
<template #extra>
<a-button type="primary" @click="$router.push({ path: '/' })">
{{ $t('user.register-result.back-home') || '返回首页' }}
{{ $t('user.register-result.back-home') || 'Back Home' }}
</a-button>
</template>
</a-result>
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""
Translate Chinese comments/docstrings in Python files and Chinese prose in
selected Markdown files into English.
This intentionally skips:
- generated frontend assets
- dedicated localized docs such as *_CN.md / *_CH.md / *_TW.md
- regular code string literals that are not docstrings, to avoid changing
application behavior
"""
from __future__ import annotations
import argparse
import io
import json
import pathlib
import re
import sys
import time
import tokenize
import urllib.parse
import urllib.request
from dataclasses import dataclass
ROOT = pathlib.Path(__file__).resolve().parents[1]
HAN_RE = re.compile(r"[\u3400-\u9FFF]")
TRANSLATE_URL = (
"https://translate.googleapis.com/translate_a/single"
"?client=gtx&sl=auto&tl=en&dt=t&q={query}"
)
PYTHON_GLOBS = [
"backend_api_python/*.py",
"backend_api_python/app/**/*.py",
"scripts/*.py",
]
MARKDOWN_TARGETS = [
"backend_api_python/README.md",
"docs/FRONTEND_FAST_ANALYSIS.md",
"docs/CHANGELOG.md",
"docs/THIRD_PARTY_INTEGRATIONS.md",
]
SKIP_PATH_PATTERNS = [
"/frontend/dist/",
"/docs/README_CN.md",
"_CN.md",
"_CH.md",
"_TW.md",
"_JA.md",
"_KO.md",
]
@dataclass(order=True)
class Replacement:
start: int
end: int
text: str
def contains_han(text: str) -> bool:
return bool(HAN_RE.search(text))
_CACHE: dict[str, str] = {}
def translate_text(text: str) -> str:
text = text.strip("\n")
if not text or not contains_han(text):
return text
if text in _CACHE:
return _CACHE[text]
url = TRANSLATE_URL.format(query=urllib.parse.quote(text))
last_error: Exception | None = None
for attempt in range(3):
try:
with urllib.request.urlopen(url, timeout=20) as resp:
payload = json.loads(resp.read().decode("utf-8"))
translated = "".join(part[0] for part in payload[0] if part and part[0])
translated = normalize_translation(translated)
_CACHE[text] = translated
return translated
except Exception as exc: # pragma: no cover - best effort script
last_error = exc
time.sleep(0.5 * (attempt + 1))
raise RuntimeError(f"Translation failed for text: {text!r}") from last_error
def normalize_translation(text: str) -> str:
text = text.replace("K line", "K-line")
text = text.replace("K Line", "K-line")
text = text.replace("candlestick line", "candlestick")
text = text.replace("real-time market", "realtime market")
text = text.replace("real-time quote", "realtime quote")
return text
def line_start_offsets(text: str) -> list[int]:
starts = [0]
for match in re.finditer(r"\n", text):
starts.append(match.end())
return starts
def to_offset(line_starts: list[int], lineno: int, col: int) -> int:
return line_starts[lineno - 1] + col
def translate_preserving_indent(text: str) -> str:
lines = text.splitlines(keepends=True)
out: list[str] = []
for line in lines:
body = line.rstrip("\r\n")
newline = line[len(body):]
if not contains_han(body):
out.append(line)
continue
leading = re.match(r"\s*", body).group(0)
translated = translate_text(body[len(leading):])
out.append(f"{leading}{translated}{newline}")
return "".join(out)
DOCSTRING_ONE_LINE_RE = re.compile(
r'^(?P<indent>\s*)(?P<prefix>[rubfRUBF]*)(?P<quote>"""|\'\'\')(?P<body>.*?)(?P=quote)(?P<trailing>\s*(?:#.*)?)$'
)
DOCSTRING_START_RE = re.compile(
r'^(?P<indent>\s*)(?P<prefix>[rubfRUBF]*)(?P<quote>"""|\'\'\')(?P<body>.*)$'
)
def translate_python_file(path: pathlib.Path) -> bool:
src = path.read_text(encoding="utf-8")
docstring_translated = translate_python_docstrings(src)
line_starts = line_start_offsets(docstring_translated)
replacements: list[Replacement] = []
for token in tokenize.generate_tokens(io.StringIO(docstring_translated).readline):
if token.type != tokenize.COMMENT or not contains_han(token.string):
continue
leading = re.match(r"#\s*", token.string).group(0)
translated = translate_text(token.string[len(leading):])
replacement = f"{leading}{translated}"
replacements.append(
Replacement(
start=to_offset(line_starts, token.start[0], token.start[1]),
end=to_offset(line_starts, token.end[0], token.end[1]),
text=replacement,
)
)
if not replacements and docstring_translated == src:
return False
new_src = apply_replacements(docstring_translated, replacements)
if new_src != src:
path.write_text(new_src, encoding="utf-8")
return True
return False
def translate_python_docstrings(src: str) -> str:
lines = src.splitlines(keepends=True)
out: list[str] = []
in_docstring = False
active_quote = ""
for line in lines:
stripped = line.lstrip()
if not in_docstring:
one_line = DOCSTRING_ONE_LINE_RE.match(line.rstrip("\n"))
if one_line and stripped.startswith(one_line.group("prefix") + one_line.group("quote")):
body = one_line.group("body")
if contains_han(body):
body = translate_text(body)
newline = "\n" if line.endswith("\n") else ""
out.append(
f"{one_line.group('indent')}{one_line.group('prefix')}{one_line.group('quote')}"
f"{body}{one_line.group('quote')}{one_line.group('trailing')}{newline}"
)
continue
start = DOCSTRING_START_RE.match(line.rstrip("\n"))
if start and stripped.startswith(start.group("prefix") + start.group("quote")):
in_docstring = True
active_quote = start.group("quote")
body = start.group("body")
if contains_han(body):
body = translate_text(body)
newline = "\n" if line.endswith("\n") else ""
out.append(
f"{start.group('indent')}{start.group('prefix')}{start.group('quote')}{body}{newline}"
)
continue
out.append(line)
continue
line_no_newline = line.rstrip("\n")
if active_quote in line_no_newline:
before, after = line_no_newline.split(active_quote, 1)
if contains_han(before):
before = translate_preserving_indent(before).rstrip("\n")
out.append(f"{before}{active_quote}{after}{'\n' if line.endswith('\n') else ''}")
in_docstring = False
active_quote = ""
else:
out.append(translate_preserving_indent(line))
return "".join(out)
def translate_markdown_file(path: pathlib.Path) -> bool:
src = path.read_text(encoding="utf-8")
lines = src.splitlines(keepends=True)
out: list[str] = []
in_fence = False
changed = False
for line in lines:
stripped = line.strip()
if stripped.startswith("```"):
in_fence = not in_fence
out.append(line)
continue
if in_fence or not contains_han(line):
out.append(line)
continue
translated = translate_preserving_indent(line)
out.append(translated)
changed = changed or translated != line
if changed:
path.write_text("".join(out), encoding="utf-8")
return changed
def apply_replacements(text: str, replacements: list[Replacement]) -> str:
dedup: list[Replacement] = []
seen: set[tuple[int, int]] = set()
for item in sorted(replacements, reverse=True):
key = (item.start, item.end)
if key in seen:
continue
seen.add(key)
dedup.append(item)
for item in dedup:
text = text[: item.start] + item.text + text[item.end :]
return text
def should_skip(path: pathlib.Path) -> bool:
posix = path.as_posix()
return any(pattern in posix for pattern in SKIP_PATH_PATTERNS)
def iter_python_targets() -> list[pathlib.Path]:
paths: set[pathlib.Path] = set()
for pattern in PYTHON_GLOBS:
paths.update(ROOT.glob(pattern))
return sorted(path for path in paths if path.is_file() and not should_skip(path))
def iter_markdown_targets() -> list[pathlib.Path]:
return [ROOT / target for target in MARKDOWN_TARGETS if (ROOT / target).exists()]
def resolve_targets(args: argparse.Namespace) -> tuple[list[pathlib.Path], list[pathlib.Path]]:
if args.paths:
python_files: list[pathlib.Path] = []
markdown_files: list[pathlib.Path] = []
for raw in args.paths:
path = (ROOT / raw).resolve() if not pathlib.Path(raw).is_absolute() else pathlib.Path(raw)
if not path.exists():
continue
if path.suffix == ".py":
python_files.append(path)
elif path.suffix == ".md":
markdown_files.append(path)
return python_files, markdown_files
return iter_python_targets(), iter_markdown_targets()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("paths", nargs="*")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
python_targets, markdown_targets = resolve_targets(args)
changed_files: list[str] = []
for path in python_targets:
if args.verbose:
print(f"[py] {path.relative_to(ROOT).as_posix()}", file=sys.stderr)
if translate_python_file(path):
changed_files.append(path.relative_to(ROOT).as_posix())
for path in markdown_targets:
if args.verbose:
print(f"[md] {path.relative_to(ROOT).as_posix()}", file=sys.stderr)
if translate_markdown_file(path):
changed_files.append(path.relative_to(ROOT).as_posix())
for relpath in changed_files:
print(relpath)
return 0
if __name__ == "__main__":
raise SystemExit(main())