Translate Chinese comments and strings to English across multiple files; update user management views for consistency in language.
This commit is contained in:
@@ -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}`))
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
// 15分钟和30分钟:最多1年
|
||||
// 15 and 30 minutes: max 1 year
|
||||
if (['15m', '30m'].includes(tf)) {
|
||||
return { days: 365, label: '1年' }
|
||||
return { days: 365, label: '1 year' }
|
||||
}
|
||||
// 1小时及以上:最多3年
|
||||
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.34表示59.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
@@ -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) {
|
||||
// 如果是信号模式(没有实盘交易),profit为0或null时显示--
|
||||
// 判断依据:如果是开仓信号且profit为0,或者record.is_signal_only为true
|
||||
// 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)
|
||||
|
||||
// 如果值为0且是开仓信号(open_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-8),视为0
|
||||
// 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-8),显示为0或--
|
||||
// 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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user