Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-01-31 02:59:49 +08:00
parent 2853e83885
commit 0b37aa4a67
58 changed files with 11231 additions and 8698 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -202,6 +202,14 @@
@click.stop="handleOpenBacktestHistory(indicator)"
/>
</a-tooltip>
<!-- 发布到社区 -->
<a-tooltip :title="indicator.publish_to_community ? $t('dashboard.indicator.action.unpublish') : $t('dashboard.indicator.action.publish')">
<a-icon
:type="indicator.publish_to_community ? 'cloud' : 'cloud-upload'"
:class="['action-icon', 'publish-icon', { published: indicator.publish_to_community }]"
@click.stop="handlePublishIndicator(indicator)"
/>
</a-tooltip>
</div>
</div>
<span class="card-desc">{{ indicator.description || '' }}</span>
@@ -215,9 +223,66 @@
</div>
</div>
<!-- 我购买的指标 -->
<div class="indicator-section" :class="{ 'section-empty': purchasedIndicators.length === 0 }">
<div class="section-label">
<div class="section-label-left" @click="purchasedSectionCollapsed = !purchasedSectionCollapsed">
<a-icon :type="purchasedSectionCollapsed ? 'right' : 'down'" class="collapse-icon" />
<span>{{ $t('dashboard.indicator.section.purchased') }} ({{ purchasedIndicators.length }})</span>
</div>
</div>
<div v-show="!purchasedSectionCollapsed" class="section-content custom-scrollbar">
<div
v-for="indicator in purchasedIndicators"
:key="'purchased-' + indicator.id"
:class="['indicator-card', 'purchased-indicator', { 'indicator-active': isIndicatorActive('purchased-' + indicator.id) }]"
@click="toggleIndicator(indicator, 'purchased')"
>
<div class="card-content">
<div class="card-header">
<span class="card-name">
<a-icon type="shopping" class="purchased-icon" />
{{ indicator.name }}
</span>
<div class="card-actions">
<!-- 购买的指标只能启动/停止和回测不能编辑删除 -->
<a-tooltip :title="isIndicatorActive('purchased-' + indicator.id) ? $t('dashboard.indicator.action.stop') : $t('dashboard.indicator.action.start')">
<a-icon
:type="isIndicatorActive('purchased-' + indicator.id) ? 'pause-circle' : 'play-circle'"
:class="['action-icon', 'toggle-icon', { active: isIndicatorActive('purchased-' + indicator.id) }]"
@click.stop="toggleIndicator(indicator, 'purchased')"
/>
</a-tooltip>
<a-tooltip :title="$t('dashboard.indicator.backtest.title')">
<a-icon
type="experiment"
class="action-icon backtest-icon"
@click.stop="handleOpenBacktest(indicator)"
/>
</a-tooltip>
<a-tooltip :title="$t('dashboard.indicator.backtest.historyTitle')">
<a-icon
type="clock-circle"
class="action-icon backtest-history-icon"
@click.stop="handleOpenBacktestHistory(indicator)"
/>
</a-tooltip>
</div>
</div>
<span class="card-desc">{{ indicator.description || '' }}</span>
</div>
</div>
<!-- 空状态 -->
<div v-if="purchasedIndicators.length === 0" class="empty-indicators">
<a-icon type="shopping" />
<span>{{ $t('dashboard.indicator.emptyPurchased') }}</span>
</div>
</div>
</div>
</template>
<!-- 手机端显示我创建的指标 -->
<!-- 手机端显示指标列表 -->
<template v-else>
<div class="mobile-tab-content">
<div class="section-content custom-scrollbar">
@@ -271,6 +336,14 @@
@click.stop="handleOpenBacktestHistory(indicator)"
/>
</a-tooltip>
<!-- 发布到社区 -->
<a-tooltip :title="indicator.publish_to_community ? $t('dashboard.indicator.action.unpublish') : $t('dashboard.indicator.action.publish')">
<a-icon
:type="indicator.publish_to_community ? 'cloud' : 'cloud-upload'"
:class="['action-icon', 'publish-icon', { published: indicator.publish_to_community }]"
@click.stop="handlePublishIndicator(indicator)"
/>
</a-tooltip>
</div>
</div>
<span class="card-desc">{{ indicator.description || '' }}</span>
@@ -332,6 +405,61 @@
@cancel="showBacktestRunViewer = false; selectedBacktestRun = null"
/>
<!-- 发布到社区弹窗 -->
<a-modal
:title="publishIndicator && publishIndicator.publish_to_community ? $t('dashboard.indicator.publish.editTitle') : $t('dashboard.indicator.publish.title')"
:visible="showPublishModal"
@ok="handleConfirmPublish"
@cancel="showPublishModal = false; publishIndicator = null"
:confirmLoading="publishing"
width="500px"
:okText="publishIndicator && publishIndicator.publish_to_community ? $t('dashboard.indicator.publish.update') : $t('dashboard.indicator.publish.confirm')"
:cancelText="$t('common.cancel')"
>
<a-form-model ref="publishForm" :model="publishForm" :rules="publishRules" layout="vertical">
<a-alert
type="info"
show-icon
:message="$t('dashboard.indicator.publish.hint')"
style="margin-bottom: 16px;"
/>
<a-form-model-item :label="$t('dashboard.indicator.publish.pricingType')" prop="pricingType">
<a-radio-group v-model="publishPricingType">
<a-radio value="free">{{ $t('dashboard.indicator.publish.free') }}</a-radio>
<a-radio value="paid">{{ $t('dashboard.indicator.publish.paid') }}</a-radio>
</a-radio-group>
</a-form-model-item>
<a-form-model-item
v-if="publishPricingType === 'paid'"
:label="$t('dashboard.indicator.publish.price')"
prop="price"
>
<a-input-number
v-model="publishPrice"
:min="1"
:max="10000"
:precision="0"
style="width: 200px;"
/>
<span style="margin-left: 8px;">{{ $t('community.credits') }}</span>
</a-form-model-item>
<a-form-model-item :label="$t('dashboard.indicator.publish.description')" prop="description">
<a-textarea
v-model="publishDescription"
:placeholder="$t('dashboard.indicator.publish.descriptionPlaceholder')"
:rows="4"
:maxLength="500"
/>
</a-form-model-item>
<div v-if="publishIndicator && publishIndicator.publish_to_community" style="margin-top: 16px;">
<a-button type="danger" ghost @click="handleUnpublish" :loading="unpublishing">
<a-icon type="close-circle" />
{{ $t('dashboard.indicator.publish.unpublish') }}
</a-button>
</div>
</a-form-model>
</a-modal>
<!-- 添加股票弹窗 -->
<a-modal
:title="$t('dashboard.analysis.modal.addStock.title')"
@@ -552,12 +680,14 @@ export default {
{ id: 'ema30', name: 'EMA30 (30日指数均线)', shortName: 'EMA30', type: 'line', defaultParams: { length: 30 } }
]
//
//
const customIndicators = ref([]) // is_buy=0
const purchasedIndicators = ref([]) // is_buy=1
const loadingIndicators = ref(false)
//
const customSectionCollapsed = ref(false) //
const purchasedSectionCollapsed = ref(false) //
//
const showIndicatorEditor = ref(false)
@@ -573,6 +703,21 @@ export default {
const showBacktestRunViewer = ref(false)
const selectedBacktestRun = ref(null)
//
const showPublishModal = ref(false)
const publishIndicator = ref(null)
const publishing = ref(false)
const unpublishing = ref(false)
// 使 ref v-model
const publishPricingType = ref('free')
const publishPrice = ref(10)
const publishDescription = ref('')
const publishRules = {
price: [
{ required: true, message: '请输入价格', trigger: 'blur', type: 'number' }
]
}
//
const realtimeEnabled = ref(false) //
@@ -1090,14 +1235,22 @@ export default {
})
if (res.code === 1 && res.data) {
// is_buy=1
const customItems = res.data.filter(item => item.is_buy === 0 || item.is_buy === '0' || item.is_buy === undefined || item.is_buy === null)
// is_buy=0
const customItems = res.data.filter(item => !item.is_buy || item.is_buy === 0 || item.is_buy === '0')
// is_buy=1
const purchasedItems = res.data.filter(item => item.is_buy === 1 || item.is_buy === '1')
customIndicators.value = customItems.map(item => ({
...item,
type: 'python', // Python
type: 'python',
source: 'custom'
}))
purchasedIndicators.value = purchasedItems.map(item => ({
...item,
type: 'python',
source: 'purchased'
}))
}
} catch (error) {
} finally {
@@ -1329,6 +1482,91 @@ export default {
showBacktestRunViewer.value = true
}
//
const handlePublishIndicator = (indicator) => {
publishIndicator.value = { ...indicator }
//
publishPricingType.value = indicator.pricing_type || 'free'
publishPrice.value = indicator.price || 10
publishDescription.value = indicator.description || ''
showPublishModal.value = true
}
//
const handleConfirmPublish = async () => {
if (!userId.value) {
message.error(proxy.$t('dashboard.indicator.error.pleaseLogin'))
return
}
publishing.value = true
try {
const res = await request({
url: '/api/indicator/saveIndicator',
method: 'post',
data: {
userid: userId.value,
id: publishIndicator.value.id,
code: publishIndicator.value.code,
name: publishIndicator.value.name,
description: publishDescription.value,
publishToCommunity: true,
pricingType: publishPricingType.value,
price: publishPricingType.value === 'paid' ? publishPrice.value : 0
}
})
if (res.code === 1) {
message.success(proxy.$t('dashboard.indicator.publish.success'))
showPublishModal.value = false
publishIndicator.value = null
await loadIndicators()
} else {
message.error(res.msg || proxy.$t('dashboard.indicator.publish.failed'))
}
} catch (error) {
message.error(proxy.$t('dashboard.indicator.publish.failed') + ': ' + (error.message || ''))
} finally {
publishing.value = false
}
}
//
const handleUnpublish = async () => {
if (!userId.value || !publishIndicator.value) return
unpublishing.value = true
try {
const res = await request({
url: '/api/indicator/saveIndicator',
method: 'post',
data: {
userid: userId.value,
id: publishIndicator.value.id,
code: publishIndicator.value.code,
name: publishIndicator.value.name,
description: publishIndicator.value.description,
publishToCommunity: false,
pricingType: 'free',
price: 0
}
})
if (res.code === 1) {
message.success(proxy.$t('dashboard.indicator.publish.unpublishSuccess'))
showPublishModal.value = false
publishIndicator.value = null
await loadIndicators()
} else {
message.error(res.msg || proxy.$t('dashboard.indicator.publish.unpublishFailed'))
}
} catch (error) {
message.error(proxy.$t('dashboard.indicator.publish.unpublishFailed'))
} finally {
unpublishing.value = false
}
}
//
const handleSaveIndicator = async (data) => {
if (!userId.value) {
@@ -1556,6 +1794,7 @@ loadingWatchlist,
trendIndicators,
oscillatorIndicators,
customIndicators,
purchasedIndicators,
loadingIndicators,
realtimeEnabled,
toggleRealtime,
@@ -1588,6 +1827,7 @@ getMarketColor,
handleDeleteIndicator,
toggleCustomSection,
customSectionCollapsed,
purchasedSectionCollapsed,
handlePriceChange,
handleChartRetry,
handleIndicatorToggle,
@@ -1602,6 +1842,18 @@ getMarketColor,
showBacktestRunViewer,
selectedBacktestRun,
handleViewBacktestRun,
//
showPublishModal,
publishIndicator,
publishing,
unpublishing,
publishPricingType,
publishPrice,
publishDescription,
publishRules,
handlePublishIndicator,
handleConfirmPublish,
handleUnpublish,
// 使
selectedSymbol: currentSymbol,
selectedMarket: currentMarket,
@@ -2113,6 +2365,19 @@ getMarketColor,
}
}
&.publish-icon {
color: #1890ff;
&:hover {
color: #40a9ff;
}
&.published {
color: #52c41a;
&:hover {
color: #73d13d;
}
}
}
&.status-icon {
&.status-normal {
color: #52c41a;
@@ -2739,6 +3004,19 @@ getMarketColor,
}
}
&.publish-icon {
color: #1890ff;
&:hover {
color: #40a9ff;
}
&.published {
color: #52c41a;
&:hover {
color: #73d13d;
}
}
}
&.toggle-icon.active {
color: #52c41a;
}
@@ -0,0 +1,402 @@
<template>
<div class="comment-list">
<!-- 评论输入框可评论/可编辑时显示 -->
<div v-if="canComment || isEditing" class="comment-form">
<div class="form-header" v-if="isEditing">
<span class="edit-label">{{ $t('community.editComment') }}</span>
<a-button type="link" size="small" @click="cancelEdit">{{ $t('community.cancelEdit') }}</a-button>
</div>
<div class="rating-input">
<span class="label">{{ $t('community.yourRating') }}:</span>
<a-rate v-model="formData.rating" />
</div>
<a-textarea
v-model="formData.content"
:placeholder="$t('community.commentPlaceholder')"
:rows="3"
:max-length="500"
/>
<div class="form-actions">
<span class="char-count">{{ formData.content.length }}/500</span>
<a-button type="primary" size="small" :loading="submitting" @click="submitComment">
{{ isEditing ? $t('community.updateComment') : $t('community.submitComment') }}
</a-button>
</div>
</div>
<!-- 已评论提示不能再次评论但可以编辑 -->
<div v-else-if="myComment && !canComment && !isEditing" class="my-comment-hint">
<a-icon type="check-circle" theme="twoTone" two-tone-color="#52c41a" />
<span>{{ $t('community.alreadyCommented') }}</span>
<a-button type="link" size="small" @click="startEdit(myComment)">
{{ $t('community.editMyComment') }}
</a-button>
</div>
<!-- 评论列表 -->
<a-spin :spinning="loading">
<div v-if="comments.length === 0" class="empty-comments">
<a-empty :description="$t('community.noComments')" />
</div>
<div v-else class="comments">
<div v-for="comment in comments" :key="comment.id" class="comment-item" :class="{ 'is-mine': comment.user && comment.user.id === currentUserId }">
<div class="comment-header">
<a-avatar :src="comment.user && comment.user.avatar" :size="36" />
<div class="comment-meta">
<div class="user-name">
{{ comment.user && comment.user.nickname }}
<a-tag v-if="comment.user && comment.user.id === currentUserId" size="small" color="blue">{{ $t('community.me') }}</a-tag>
</div>
<div class="comment-info">
<a-rate :value="comment.rating" disabled :style="{ fontSize: '12px' }" />
<span class="comment-time">{{ formatTime(comment.created_at) }}</span>
<span v-if="comment.updated_at && comment.updated_at !== comment.created_at" class="edited-tag">
({{ $t('community.edited') }})
</span>
</div>
</div>
<!-- 编辑按钮只有自己的评论显示 -->
<div v-if="comment.user && comment.user.id === currentUserId" class="comment-actions">
<a-button type="link" size="small" @click="startEdit(comment)">
<a-icon type="edit" />
</a-button>
</div>
</div>
<div class="comment-content">{{ comment.content }}</div>
</div>
</div>
</a-spin>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more">
<a-button type="link" @click="$emit('load-more')">
{{ $t('community.loadMore') }}
</a-button>
</div>
</div>
</template>
<script>
export default {
name: 'CommentList',
props: {
comments: {
type: Array,
default: () => []
},
total: {
type: Number,
default: 0
},
loading: {
type: Boolean,
default: false
},
canComment: {
type: Boolean,
default: false
},
currentUserId: {
type: [Number, String],
default: null
},
myComment: {
type: Object,
default: null
}
},
data () {
return {
submitting: false,
isEditing: false,
editingCommentId: null,
formData: {
rating: 5,
content: ''
}
}
},
computed: {
hasMore () {
return this.comments.length < this.total
}
},
watch: {
// myComment
myComment: {
immediate: true,
handler (val) {
if (val && this.isEditing) {
this.formData.rating = val.rating || 5
this.formData.content = val.content || ''
this.editingCommentId = val.id
}
}
}
},
methods: {
startEdit (comment) {
this.isEditing = true
this.editingCommentId = comment.id
this.formData.rating = comment.rating || 5
this.formData.content = comment.content || ''
},
cancelEdit () {
this.isEditing = false
this.editingCommentId = null
this.formData = { rating: 5, content: '' }
},
async submitComment () {
if (this.formData.rating < 1) {
this.$message.warning(this.$t('community.pleaseRate'))
return
}
this.submitting = true
try {
const data = {
rating: this.formData.rating,
content: this.formData.content.trim()
}
if (this.isEditing && this.editingCommentId) {
//
await this.$emit('update-comment', {
comment_id: this.editingCommentId,
...data
})
} else {
//
await this.$emit('add-comment', data)
}
//
this.cancelEdit()
} finally {
this.submitting = false
}
},
formatTime (dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const now = new Date()
const diff = now - date
// 1
if (diff < 60000) {
return this.$t('community.justNow')
}
// 1
if (diff < 3600000) {
const mins = Math.floor(diff / 60000)
return `${mins} ${this.$t('community.minutesAgo')}`
}
// 24
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000)
return `${hours} ${this.$t('community.hoursAgo')}`
}
// 30
if (diff < 2592000000) {
const days = Math.floor(diff / 86400000)
return `${days} ${this.$t('community.daysAgo')}`
}
//
return date.toLocaleDateString()
}
}
}
</script>
<style lang="less" scoped>
.comment-list {
.comment-form {
margin-bottom: 20px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.edit-label {
font-size: 14px;
font-weight: 500;
color: #1890ff;
}
}
.rating-input {
margin-bottom: 12px;
.label {
margin-right: 8px;
font-size: 14px;
}
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
.char-count {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
}
}
.my-comment-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
margin-bottom: 16px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 8px;
span {
color: #52c41a;
font-size: 14px;
}
}
.empty-comments {
padding: 40px 0;
}
.comments {
.comment-item {
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&.is-mine {
background: rgba(24, 144, 255, 0.02);
padding: 16px;
margin: 0 -16px;
border-radius: 8px;
}
.comment-header {
display: flex;
align-items: flex-start;
margin-bottom: 8px;
.comment-meta {
flex: 1;
margin-left: 12px;
.user-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.comment-info {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
.comment-time {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.edited-tag {
font-size: 12px;
color: rgba(0, 0, 0, 0.35);
font-style: italic;
}
}
}
.comment-actions {
opacity: 0;
transition: opacity 0.2s;
}
}
&:hover .comment-actions {
opacity: 1;
}
.comment-content {
font-size: 14px;
line-height: 1.6;
color: rgba(0, 0, 0, 0.65);
margin-left: 48px;
white-space: pre-wrap;
word-break: break-word;
}
}
}
.load-more {
text-align: center;
padding: 12px 0;
}
}
//
[data-theme='dark'] {
.comment-list {
.comment-form {
background: #262626;
}
.my-comment-hint {
background: rgba(82, 196, 26, 0.1);
border-color: rgba(82, 196, 26, 0.3);
span {
color: #95de64;
}
}
.comments .comment-item {
border-color: #303030;
&.is-mine {
background: rgba(24, 144, 255, 0.05);
}
.comment-header .comment-meta {
.user-name {
color: rgba(255, 255, 255, 0.85);
}
.comment-info {
.comment-time {
color: rgba(255, 255, 255, 0.45);
}
.edited-tag {
color: rgba(255, 255, 255, 0.35);
}
}
}
.comment-content {
color: rgba(255, 255, 255, 0.65);
}
}
}
}
</style>
@@ -0,0 +1,342 @@
<template>
<a-card
hoverable
class="indicator-card"
:body-style="{ padding: '12px' }"
@click="$emit('click', indicator)"
>
<!-- 预览图 / 默认生成封面 -->
<div class="card-cover" :style="coverStyle">
<img
v-if="indicator.preview_image && !imageError"
:src="indicator.preview_image"
:alt="indicator.name"
@error="handleImageError"
/>
<!-- 默认封面使用渐变背景 + 标题 -->
<div v-else class="default-cover" :style="{ background: coverGradient }">
<span class="cover-title">{{ indicatorInitials }}</span>
<span class="cover-subtitle">{{ indicator.name }}</span>
</div>
<div class="price-tag" :class="isPaid ? 'paid' : 'free'">
{{ isPaid ? `${indicator.price} ${$t('community.credits')}` : $t('community.free') }}
</div>
<div v-if="indicator.is_own" class="own-tag">
{{ $t('community.myIndicator') }}
</div>
<div v-else-if="indicator.is_purchased" class="purchased-tag">
<a-icon type="check-circle" /> {{ $t('community.purchased') }}
</div>
</div>
<!-- 内容 -->
<div class="card-content">
<h3 class="card-title" :title="indicator.name">{{ indicator.name }}</h3>
<p class="card-desc">{{ indicator.description || $t('community.noDescription') }}</p>
<!-- 作者信息 -->
<div class="card-author">
<a-avatar :src="indicator.author.avatar" :size="24" />
<span class="author-name">{{ indicator.author.nickname || indicator.author.username }}</span>
</div>
<!-- 统计信息 -->
<div class="card-stats">
<span class="stat-item">
<a-icon type="download" />
{{ indicator.purchase_count || 0 }}
</span>
<span class="stat-item">
<a-icon type="star" theme="filled" :style="{ color: '#faad14' }" />
{{ formatRating(indicator.avg_rating) }}
</span>
<span class="stat-item">
<a-icon type="eye" />
{{ indicator.view_count || 0 }}
</span>
</div>
</div>
</a-card>
</template>
<script>
//
const GRADIENT_PRESETS = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)',
'linear-gradient(135deg, #d299c2 0%, #fef9d7 100%)',
'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)',
'linear-gradient(135deg, #fddb92 0%, #d1fdff 100%)',
'linear-gradient(135deg, #9890e3 0%, #b1f4cf 100%)',
'linear-gradient(135deg, #ebc0fd 0%, #d9ded8 100%)',
'linear-gradient(135deg, #f6d365 0%, #fda085 100%)'
]
export default {
name: 'IndicatorCard',
props: {
indicator: {
type: Object,
required: true
}
},
data () {
return {
imageError: false
}
},
computed: {
isPaid () {
return this.indicator.pricing_type !== 'free' && this.indicator.price > 0
},
// ID
coverGradient () {
const index = (this.indicator.id || 0) % GRADIENT_PRESETS.length
return GRADIENT_PRESETS[index]
},
//
indicatorInitials () {
const name = this.indicator.name || 'I'
//
if (/[\u4e00-\u9fa5]/.test(name)) {
return name.slice(0, 2)
}
//
const words = name.split(/\s+/)
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase()
}
return name.slice(0, 2).toUpperCase()
},
coverStyle () {
return {
background: (!this.indicator.preview_image || this.imageError) ? this.coverGradient : '#f5f5f5'
}
}
},
methods: {
formatRating (rating) {
const r = parseFloat(rating) || 0
return r > 0 ? r.toFixed(1) : '-'
},
handleImageError () {
this.imageError = true
}
}
}
</script>
<style lang="less" scoped>
.indicator-card {
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
height: 100%;
display: flex;
flex-direction: column;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.card-cover {
position: relative;
width: 100%;
height: 140px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.default-cover {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
position: relative;
overflow: hidden;
//
&::before {
content: '';
position: absolute;
top: -20%;
right: -20%;
width: 80%;
height: 80%;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
}
&::after {
content: '';
position: absolute;
bottom: -30%;
left: -20%;
width: 60%;
height: 60%;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
}
.cover-title {
font-size: 36px;
font-weight: 700;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 1;
letter-spacing: 2px;
}
.cover-subtitle {
font-size: 12px;
margin-top: 8px;
opacity: 0.9;
max-width: 80%;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 1;
}
}
.price-tag {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
z-index: 2;
&.free {
background: #52c41a;
color: #fff;
}
&.paid {
background: linear-gradient(135deg, #f5af19 0%, #f12711 100%);
color: #fff;
}
}
.own-tag,
.purchased-tag {
position: absolute;
bottom: 8px;
left: 8px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
z-index: 2;
}
.purchased-tag {
background: rgba(82, 196, 26, 0.85);
}
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 4px 0;
.card-title {
font-size: 14px;
font-weight: 600;
margin: 0 0 6px 0;
color: rgba(0, 0, 0, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.5;
min-height: 36px;
}
.card-author {
display: flex;
align-items: center;
margin-bottom: 8px;
.author-name {
margin-left: 8px;
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.card-stats {
display: flex;
gap: 12px;
margin-top: auto;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
.anticon {
font-size: 14px;
}
}
}
}
}
//
.theme-dark .indicator-card,
.dark-theme .indicator-card,
[data-theme='dark'] .indicator-card {
background: #1f1f1f;
border-color: #303030;
.card-content {
.card-title {
color: rgba(255, 255, 255, 0.85);
}
.card-desc {
color: rgba(255, 255, 255, 0.45);
}
.card-author .author-name {
color: rgba(255, 255, 255, 0.65);
}
.card-stats .stat-item {
color: rgba(255, 255, 255, 0.45);
}
}
}
</style>
@@ -0,0 +1,603 @@
<template>
<a-modal
:visible="visible"
:title="null"
:footer="null"
:width="720"
:body-style="{ padding: 0 }"
@cancel="$emit('close')"
class="indicator-detail-modal"
>
<a-spin :spinning="loading">
<div v-if="detail" class="detail-container">
<!-- 头部区域 -->
<div class="detail-header" :style="headerStyle">
<div class="header-cover" v-if="detail.preview_image">
<img :src="detail.preview_image" :alt="detail.name" @error="imageError = true" />
</div>
<div class="header-cover default-cover" v-else>
<span class="cover-initials">{{ indicatorInitials }}</span>
</div>
<div class="header-info">
<h2 class="indicator-name">{{ detail.name }}</h2>
<div class="indicator-meta">
<div class="author-info">
<a-avatar :src="detail.author.avatar" :size="32" />
<span class="author-name">{{ detail.author.nickname || detail.author.username }}</span>
</div>
<div class="publish-time">
{{ $t('community.publishedAt') }}: {{ formatDate(detail.created_at) }}
</div>
</div>
<div class="indicator-stats">
<a-statistic :title="$t('community.downloads')" :value="detail.purchase_count || 0">
<template #prefix>
<a-icon type="download" />
</template>
</a-statistic>
<a-statistic :title="$t('community.rating')">
<template #formatter>
<a-rate :value="detail.avg_rating" disabled allow-half :style="{ fontSize: '14px' }" />
<span class="rating-text">({{ detail.rating_count || 0 }})</span>
</template>
</a-statistic>
<a-statistic :title="$t('community.views')" :value="detail.view_count || 0">
<template #prefix>
<a-icon type="eye" />
</template>
</a-statistic>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="detail-body">
<!-- 描述 -->
<div class="section">
<h3>{{ $t('community.description') }}</h3>
<p class="description">{{ detail.description || $t('community.noDescription') }}</p>
</div>
<!-- 实盘表现 -->
<div class="section" v-if="performance">
<h3>{{ $t('community.performance') }}</h3>
<div class="performance-grid">
<div class="perf-item">
<div class="perf-label">{{ $t('community.strategyCount') }}</div>
<div class="perf-value">{{ performance.strategy_count }}</div>
</div>
<div class="perf-item">
<div class="perf-label">{{ $t('community.tradeCount') }}</div>
<div class="perf-value">{{ performance.trade_count }}</div>
</div>
<div class="perf-item">
<div class="perf-label">{{ $t('community.winRate') }}</div>
<div class="perf-value" :class="performance.win_rate >= 50 ? 'positive' : 'negative'">
{{ performance.win_rate }}%
</div>
</div>
<div class="perf-item">
<div class="perf-label">{{ $t('community.totalProfit') }}</div>
<div class="perf-value" :class="performance.total_profit >= 0 ? 'positive' : 'negative'">
{{ performance.total_profit >= 0 ? '+' : '' }}{{ performance.total_profit }}
</div>
</div>
</div>
</div>
<!-- 评论区域 -->
<div class="section">
<h3>{{ $t('community.reviews') }} ({{ comments.total || 0 }})</h3>
<comment-list
:comments="comments.items"
:loading="commentsLoading"
:can-comment="detail.is_purchased && !detail.is_own && !myComment"
:current-user-id="currentUserId"
:my-comment="myComment"
:total="comments.total"
@add-comment="handleAddComment"
@update-comment="handleUpdateComment"
@load-more="loadMoreComments"
/>
</div>
</div>
<!-- 底部操作区域 -->
<div class="detail-footer">
<div class="price-info">
<span v-if="detail.pricing_type === 'free' || detail.price <= 0" class="free-badge">
{{ $t('community.free') }}
</span>
<span v-else class="price-badge">
{{ detail.price }} {{ $t('community.credits') }}
</span>
</div>
<div class="action-buttons">
<a-button v-if="detail.is_own" disabled>
{{ $t('community.myIndicator') }}
</a-button>
<a-button v-else-if="detail.is_purchased" type="primary" @click="goToUse">
<a-icon type="code" /> {{ $t('community.useNow') }}
</a-button>
<a-button
v-else
type="primary"
:loading="purchasing"
@click="handlePurchase"
>
<a-icon type="shopping-cart" />
{{ detail.pricing_type === 'free' || detail.price <= 0 ? $t('community.getFree') : $t('community.buyNow') }}
</a-button>
</div>
</div>
</div>
</a-spin>
</a-modal>
</template>
<script>
import CommentList from './CommentList.vue'
import request from '@/utils/request'
export default {
name: 'IndicatorDetail',
components: {
CommentList
},
props: {
visible: {
type: Boolean,
default: false
},
indicatorId: {
type: Number,
default: null
},
currentUserId: {
type: [Number, String],
default: null
}
},
data () {
return {
loading: false,
purchasing: false,
commentsLoading: false,
detail: null,
performance: null,
comments: {
items: [],
total: 0,
page: 1
},
myComment: null,
imageError: false
}
},
computed: {
//
headerStyle () {
if (!this.detail) return {}
// ID
const gradients = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)'
]
const index = (this.detail.id || 0) % gradients.length
return { background: gradients[index] }
},
//
indicatorInitials () {
if (!this.detail) return ''
const name = this.detail.name || 'I'
if (/[\u4e00-\u9fa5]/.test(name)) {
return name.slice(0, 2)
}
const words = name.split(/\s+/)
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase()
}
return name.slice(0, 2).toUpperCase()
}
},
watch: {
visible (val) {
if (val && this.indicatorId) {
this.loadDetail()
this.loadPerformance()
this.loadComments(1)
this.loadMyComment()
} else {
this.resetData()
}
}
},
methods: {
resetData () {
this.detail = null
this.performance = null
this.comments = { items: [], total: 0, page: 1 }
this.myComment = null
},
async loadDetail () {
this.loading = true
try {
const res = await request({
url: `/api/community/indicators/${this.indicatorId}`,
method: 'get'
})
if (res.code === 1) {
this.detail = res.data
} else {
this.$message.error(res.msg || this.$t('community.loadFailed'))
}
} catch (e) {
this.$message.error(this.$t('community.loadFailed'))
} finally {
this.loading = false
}
},
async loadPerformance () {
try {
const res = await request({
url: `/api/community/indicators/${this.indicatorId}/performance`,
method: 'get'
})
if (res.code === 1) {
this.performance = res.data
}
} catch (e) {
console.error('Load performance failed:', e)
}
},
async loadComments (page = 1) {
this.commentsLoading = true
try {
const res = await request({
url: `/api/community/indicators/${this.indicatorId}/comments`,
method: 'get',
params: { page, page_size: 10 }
})
if (res.code === 1) {
if (page === 1) {
this.comments.items = res.data.items
} else {
this.comments.items = [...this.comments.items, ...res.data.items]
}
this.comments.total = res.data.total
this.comments.page = page
}
} catch (e) {
console.error('Load comments failed:', e)
} finally {
this.commentsLoading = false
}
},
loadMoreComments () {
if (this.comments.items.length < this.comments.total) {
this.loadComments(this.comments.page + 1)
}
},
async loadMyComment () {
if (!this.currentUserId) return
try {
const res = await request({
url: `/api/community/indicators/${this.indicatorId}/my-comment`,
method: 'get'
})
if (res.code === 1) {
this.myComment = res.data
}
} catch (e) {
console.error('Load my comment failed:', e)
}
},
async handleAddComment (data) {
try {
const res = await request({
url: `/api/community/indicators/${this.indicatorId}/comments`,
method: 'post',
data
})
if (res.code === 1) {
this.$message.success(this.$t('community.commentSuccess'))
this.loadComments(1)
this.loadMyComment()
//
this.loadDetail()
} else {
const msgKey = `community.${res.msg}`
this.$message.error(this.$te(msgKey) ? this.$t(msgKey) : res.msg)
}
} catch (e) {
this.$message.error(this.$t('community.commentFailed'))
}
},
async handleUpdateComment (data) {
try {
const res = await request({
url: `/api/community/indicators/${this.indicatorId}/comments/${data.comment_id}`,
method: 'put',
data: {
rating: data.rating,
content: data.content
}
})
if (res.code === 1) {
this.$message.success(this.$t('community.commentUpdateSuccess'))
this.loadComments(1)
this.loadMyComment()
//
this.loadDetail()
} else {
const msgKey = `community.${res.msg}`
this.$message.error(this.$te(msgKey) ? this.$t(msgKey) : res.msg)
}
} catch (e) {
this.$message.error(this.$t('community.commentUpdateFailed'))
}
},
async handlePurchase () {
this.purchasing = true
try {
const res = await request({
url: `/api/community/indicators/${this.indicatorId}/purchase`,
method: 'post'
})
if (res.code === 1) {
this.$message.success(this.$t('community.purchaseSuccess'))
this.loadDetail()
this.$emit('purchased')
} else {
const msgKey = `community.${res.msg}`
this.$message.error(this.$te(msgKey) ? this.$t(msgKey) : res.msg)
}
} catch (e) {
this.$message.error(this.$t('community.purchaseFailed'))
} finally {
this.purchasing = false
}
},
goToUse () {
this.$emit('close')
this.$router.push('/indicator-analysis')
},
formatDate (dateStr) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleDateString()
}
}
}
</script>
<style lang="less" scoped>
.indicator-detail-modal {
.detail-container {
display: flex;
flex-direction: column;
max-height: 80vh;
}
.detail-header {
display: flex;
gap: 20px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
.header-cover {
width: 180px;
height: 120px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&.default-cover {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
border: 2px solid rgba(255, 255, 255, 0.3);
.cover-initials {
font-size: 42px;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
letter-spacing: 2px;
}
}
}
.header-info {
flex: 1;
.indicator-name {
font-size: 20px;
font-weight: 600;
margin: 0 0 12px 0;
color: #fff;
}
.indicator-meta {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
.author-info {
display: flex;
align-items: center;
gap: 8px;
.author-name {
font-size: 14px;
}
}
.publish-time {
font-size: 12px;
opacity: 0.8;
}
}
.indicator-stats {
display: flex;
gap: 24px;
/deep/ .ant-statistic {
.ant-statistic-title {
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
}
.ant-statistic-content {
color: #fff;
font-size: 16px;
}
}
.rating-text {
font-size: 12px;
margin-left: 4px;
opacity: 0.8;
}
}
}
}
.detail-body {
flex: 1;
overflow-y: auto;
padding: 20px;
.section {
margin-bottom: 24px;
h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.description {
font-size: 14px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
white-space: pre-wrap;
}
}
.performance-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
.perf-item {
text-align: center;
padding: 12px;
background: #f5f5f5;
border-radius: 8px;
.perf-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 4px;
}
.perf-value {
font-size: 18px;
font-weight: 600;
&.positive {
color: #52c41a;
}
&.negative {
color: #f5222d;
}
}
}
}
}
.detail-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
.price-info {
.free-badge {
font-size: 20px;
font-weight: 600;
color: #52c41a;
}
.price-badge {
font-size: 20px;
font-weight: 600;
color: #f5222d;
}
}
.action-buttons {
display: flex;
gap: 12px;
}
}
}
//
[data-theme='dark'] {
.indicator-detail-modal {
.detail-body {
.section h3 {
border-color: #303030;
}
.description {
color: rgba(255, 255, 255, 0.65);
}
.performance-grid .perf-item {
background: #262626;
.perf-label {
color: rgba(255, 255, 255, 0.45);
}
}
}
.detail-footer {
background: #1f1f1f;
border-color: #303030;
}
}
}
</style>
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -962,7 +962,10 @@ export default {
vip_grant: 'gold',
vip_revoke: 'default',
register_bonus: 'cyan',
referral_bonus: 'purple'
referral_bonus: 'purple',
//
indicator_purchase: 'volcano',
indicator_sale: 'lime'
}
return colors[action] || 'default'
},
@@ -976,7 +979,10 @@ export default {
vip_grant: this.$t('profile.creditsLog.actionVipGrant') || 'VIP授予',
vip_revoke: this.$t('profile.creditsLog.actionVipRevoke') || 'VIP取消',
register_bonus: this.$t('profile.creditsLog.actionRegisterBonus') || '注册奖励',
referral_bonus: this.$t('profile.creditsLog.actionReferralBonus') || '邀请奖励'
referral_bonus: this.$t('profile.creditsLog.actionReferralBonus') || '邀请奖励',
//
indicator_purchase: this.$t('profile.creditsLog.actionIndicatorPurchase') || '购买指标',
indicator_sale: this.$t('profile.creditsLog.actionIndicatorSale') || '出售指标'
}
return labels[action] || action
},
@@ -413,6 +413,13 @@
<span v-if="item.name" class="symbol-name-extra">{{ item.name }}</span>
</div>
</a-select-option>
<!-- 添加交易对选项 -->
<a-select-option key="__add_symbol_option__" value="__add_symbol_option__" class="add-symbol-option">
<div style="width: 100%; text-align: center; padding: 4px 0; color: #1890ff; cursor: pointer;">
<a-icon type="plus" style="margin-right: 4px;" />
<span>{{ $t('trading-assistant.form.addSymbol') }}</span>
</div>
</a-select-option>
</a-select>
<!-- 创建模式多选 -->
<a-select
@@ -421,9 +428,9 @@
mode="multiple"
:placeholder="$t('trading-assistant.placeholders.selectSymbols')"
show-search
:filter-option="filterWatchlistOption"
:filter-option="filterWatchlistOptionWithAdd"
:loading="loadingWatchlist"
@change="handleMultiSymbolChange"
@change="handleMultiSymbolChangeWithAdd"
:getPopupContainer="(triggerNode) => triggerNode.parentNode"
:maxTagCount="3">
<a-select-option
@@ -438,6 +445,13 @@
<span v-if="item.name" class="symbol-name-extra">{{ item.name }}</span>
</div>
</a-select-option>
<!-- 添加交易对选项 -->
<a-select-option key="__add_symbol_option__" value="__add_symbol_option__" class="add-symbol-option">
<div style="width: 100%; text-align: center; padding: 4px 0; color: #1890ff; cursor: pointer;">
<a-icon type="plus" style="margin-right: 4px;" />
<span>{{ $t('trading-assistant.form.addSymbol') }}</span>
</div>
</a-select-option>
</a-select>
<div class="form-item-hint">
{{ isEditMode ? $t('trading-assistant.form.symbolHintCrypto') :
@@ -925,6 +939,12 @@
<!-- IBKR specific configuration -->
<template v-if="currentBrokerId === 'ibkr'">
<a-alert
type="warning"
show-icon
style="margin-bottom: 16px;"
:message="$t('trading-assistant.form.localDeploymentRequired')"
:description="$t('trading-assistant.form.localDeploymentHint')" />
<a-alert
type="info"
show-icon
@@ -992,6 +1012,12 @@
<!-- MT5 specific configuration -->
<template v-if="currentBrokerId === 'mt5'">
<a-alert
type="warning"
show-icon
style="margin-bottom: 16px;"
:message="$t('trading-assistant.form.localDeploymentRequired')"
:description="$t('trading-assistant.form.localDeploymentHint')" />
<a-alert
type="info"
show-icon
@@ -1158,12 +1184,120 @@
</a-button>
</template>
</a-modal>
<!-- 添加交易对弹窗 -->
<a-modal
:title="$t('trading-assistant.form.addSymbolTitle')"
:visible="showAddSymbolModal"
@ok="handleConfirmAddSymbol"
@cancel="handleCloseAddSymbolModal"
:confirmLoading="addingSymbol"
width="600px"
:okText="$t('trading-assistant.form.confirmAdd')"
:cancelText="$t('trading-assistant.form.cancel')">
<div class="add-symbol-modal-content">
<!-- 市场类型Tab -->
<a-tabs v-model="addSymbolMarket" @change="handleAddSymbolMarketChange" class="market-tabs">
<a-tab-pane
v-for="marketType in addSymbolMarketTypes"
:key="marketType.value"
:tab="$t(marketType.i18nKey || `dashboard.analysis.market.${marketType.value}`)">
</a-tab-pane>
</a-tabs>
<!-- 搜索输入框 -->
<div class="symbol-search-section">
<a-input-search
v-model="addSymbolKeyword"
:placeholder="$t('dashboard.analysis.modal.addStock.searchOrInputPlaceholder')"
@search="handleSearchSymbol"
@change="handleSymbolSearchInputChange"
:loading="searchingSymbol"
size="large"
allow-clear>
<a-button slot="enterButton" type="primary" icon="search">
{{ $t('dashboard.analysis.modal.addStock.search') }}
</a-button>
</a-input-search>
</div>
<!-- 搜索结果 -->
<div v-if="symbolSearchResults.length > 0" class="search-results-section">
<div class="section-title">
<a-icon type="search" style="margin-right: 4px;" />
{{ $t('dashboard.analysis.modal.addStock.searchResults') }}
</div>
<a-list
:data-source="symbolSearchResults"
:loading="searchingSymbol"
size="small"
class="symbol-list">
<a-list-item slot="renderItem" slot-scope="item" class="symbol-list-item" @click="handleSelectAddSymbol(item)">
<a-list-item-meta>
<template slot="title">
<div class="symbol-item-content">
<span class="symbol-code">{{ item.symbol }}</span>
<span class="symbol-name">{{ item.name }}</span>
<a-tag v-if="item.exchange" size="small" color="blue" style="margin-left: 8px;">
{{ item.exchange }}
</a-tag>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</div>
<!-- 热门标的 -->
<div class="hot-symbols-section">
<div class="section-title">
<a-icon type="fire" style="color: #ff4d4f; margin-right: 4px;" />
{{ $t('dashboard.analysis.modal.addStock.hotSymbols') }}
</div>
<a-spin :spinning="loadingHotSymbols">
<a-list
v-if="hotSymbols.length > 0"
:data-source="hotSymbols"
size="small"
class="symbol-list">
<a-list-item slot="renderItem" slot-scope="item" class="symbol-list-item" @click="handleSelectAddSymbol(item)">
<a-list-item-meta>
<template slot="title">
<div class="symbol-item-content">
<span class="symbol-code">{{ item.symbol }}</span>
<span class="symbol-name">{{ item.name }}</span>
<a-tag v-if="item.exchange" size="small" color="orange" style="margin-left: 8px;">
{{ item.exchange }}
</a-tag>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
<a-empty v-else :description="$t('dashboard.analysis.modal.addStock.noHotSymbols')" :image="false" />
</a-spin>
</div>
<!-- 选中的标的显示 -->
<div v-if="selectedAddSymbol" class="selected-symbol-section">
<div class="section-title">
<a-icon type="check-circle" style="color: #52c41a; margin-right: 4px;" />
{{ $t('dashboard.analysis.modal.addStock.selectedSymbol') }}
</div>
<div class="selected-symbol-info">
<a-tag :color="getMarketColor(addSymbolMarket)" style="margin-right: 8px;">{{ addSymbolMarket }}</a-tag>
<span class="symbol-code">{{ selectedAddSymbol.symbol }}</span>
<span v-if="selectedAddSymbol.name" class="symbol-name">{{ selectedAddSymbol.name }}</span>
</div>
</div>
</div>
</a-modal>
</div>
</template>
<script>
import { getStrategyList, startStrategy, stopStrategy, deleteStrategy, updateStrategy, testExchangeConnection, getStrategyEquityCurve, batchCreateStrategies, batchStartStrategies, batchStopStrategies, batchDeleteStrategies } from '@/api/strategy'
import { getWatchlist } from '@/api/market'
import { getWatchlist, addWatchlist, searchSymbols, getHotSymbols } from '@/api/market'
import { listExchangeCredentials, getExchangeCredential, createExchangeCredential } from '@/api/credentials'
import { getNotificationSettings } from '@/api/user'
import { baseMixin } from '@/store/app-mixin'
@@ -1511,7 +1645,27 @@ export default {
//
selectedSymbols: [],
//
collapsedGroups: {}
collapsedGroups: {},
//
showAddSymbolModal: false,
addSymbolMarket: 'Crypto',
addSymbolMarketTypes: [
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' }
],
addSymbolKeyword: '',
searchingSymbol: false,
symbolSearchResults: [],
selectedAddSymbol: null,
hasSearchedSymbol: false,
addingSymbol: false,
hotSymbols: [],
loadingHotSymbols: false,
searchTimer: null
// Market category is inferred from Step 1 watchlist symbol ("Market:SYMBOL").
}
},
@@ -1561,10 +1715,238 @@ export default {
this.loadingWatchlist = false
}
},
// ====== ======
handleCloseAddSymbolModal () {
this.showAddSymbolModal = false
this.addSymbolKeyword = ''
this.symbolSearchResults = []
this.selectedAddSymbol = null
this.hasSearchedSymbol = false
},
handleAddSymbolMarketChange (market) {
this.addSymbolMarket = market
this.addSymbolKeyword = ''
this.symbolSearchResults = []
this.selectedAddSymbol = null
this.hasSearchedSymbol = false
//
this.loadHotSymbols(market)
},
//
handleSymbolSearchInputChange (e) {
const keyword = e.target.value
this.addSymbolKeyword = keyword
//
if (this.searchTimer) {
clearTimeout(this.searchTimer)
}
//
if (!keyword || keyword.trim() === '') {
this.symbolSearchResults = []
this.hasSearchedSymbol = false
this.selectedAddSymbol = null
return
}
// 500ms
this.searchTimer = setTimeout(() => {
this.searchSymbolsInModal(keyword)
}, 500)
},
//
async handleSearchSymbol (keyword) {
if (!keyword || !keyword.trim()) {
return
}
if (!this.addSymbolMarket) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
//
if (this.symbolSearchResults.length > 0) {
return
}
//
if (this.hasSearchedSymbol && this.symbolSearchResults.length === 0) {
this.handleDirectAdd()
} else {
//
this.searchSymbolsInModal(keyword)
}
},
//
async searchSymbolsInModal (keyword) {
if (!keyword || keyword.trim() === '') {
this.symbolSearchResults = []
this.hasSearchedSymbol = false
return
}
if (!this.addSymbolMarket) {
return
}
this.searchingSymbol = true
this.hasSearchedSymbol = true
try {
const res = await searchSymbols({
market: this.addSymbolMarket,
keyword: keyword.trim()
})
if (res && res.code === 1 && Array.isArray(res.data)) {
this.symbolSearchResults = res.data
} else {
this.symbolSearchResults = []
}
} catch (e) {
this.symbolSearchResults = []
} finally {
this.searchingSymbol = false
}
},
//
handleDirectAdd () {
if (!this.addSymbolKeyword || !this.addSymbolKeyword.trim()) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseEnterSymbol'))
return
}
if (!this.addSymbolMarket) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
//
this.selectedAddSymbol = {
market: this.addSymbolMarket,
symbol: this.addSymbolKeyword.trim().toUpperCase(),
name: '' // API
}
},
handleSelectAddSymbol (item) {
this.selectedAddSymbol = {
market: this.addSymbolMarket,
symbol: item.symbol,
name: item.name || ''
}
},
//
async loadHotSymbols (market) {
if (!market) {
market = this.addSymbolMarket || 'Crypto'
}
if (!market) {
return
}
this.loadingHotSymbols = true
try {
const res = await getHotSymbols({
market: market,
limit: 10
})
if (res && res.code === 1 && res.data) {
this.hotSymbols = res.data
} else {
this.hotSymbols = []
}
} catch (error) {
this.hotSymbols = []
} finally {
this.loadingHotSymbols = false
}
},
async handleConfirmAddSymbol () {
//
let market = ''
let symbol = ''
//
if (this.selectedAddSymbol) {
market = this.selectedAddSymbol.market
symbol = this.selectedAddSymbol.symbol.toUpperCase()
} else if (this.addSymbolKeyword && this.addSymbolKeyword.trim()) {
// 使
if (!this.addSymbolMarket) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
market = this.addSymbolMarket
symbol = this.addSymbolKeyword.trim().toUpperCase()
} else {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol'))
return
}
this.addingSymbol = true
try {
// API
const res = await addWatchlist({
userid: 1,
market: market,
symbol: symbol
})
if (res && res.code === 1) {
this.$message.success(this.$t('dashboard.analysis.message.addStockSuccess'))
//
await this.loadWatchlist()
//
const newValue = `${market}:${symbol}`
if (this.isEditMode) {
this.form.setFieldsValue({ symbol: newValue })
this.handleWatchlistSymbolChange(newValue)
} else {
//
if (!this.selectedSymbols.includes(newValue)) {
this.selectedSymbols = [...this.selectedSymbols, newValue]
}
this.handleMultiSymbolChange(this.selectedSymbols)
}
//
this.handleCloseAddSymbolModal()
} else {
this.$message.error(res?.msg || this.$t('dashboard.analysis.message.addStockFailed'))
}
} catch (e) {
const errorMsg = e?.response?.data?.msg || e?.message || this.$t('dashboard.analysis.message.addStockFailed')
this.$message.error(errorMsg)
} finally {
this.addingSymbol = false
}
},
// ====== END ======
filterWatchlistOption (input, option) {
const value = option.componentOptions?.propsData?.value || ''
// ""
if (value === '__add_symbol_option__') return true
return String(value).toLowerCase().includes(String(input || '').toLowerCase())
},
filterWatchlistOptionWithAdd (input, option) {
const value = option.componentOptions?.propsData?.value || ''
// ""
if (value === '__add_symbol_option__') return true
return String(value).toLowerCase().includes(String(input || '').toLowerCase())
},
handleMultiSymbolChangeWithAdd (vals) {
// ""
if (vals && vals.includes('__add_symbol_option__')) {
//
this.selectedSymbols = vals.filter(v => v !== '__add_symbol_option__')
//
this.showAddSymbolModal = true
//
this.loadHotSymbols(this.addSymbolMarket)
return
}
this.handleMultiSymbolChange(vals)
},
getMarketColor (market) {
const colors = {
USStock: 'green',
@@ -1577,6 +1959,18 @@ export default {
return colors[market] || 'default'
},
handleWatchlistSymbolChange (val) {
// ""
if (val === '__add_symbol_option__') {
//
this.$nextTick(() => {
this.form.setFieldsValue({ symbol: undefined })
})
//
this.showAddSymbolModal = true
//
this.loadHotSymbols(this.addSymbolMarket)
return
}
// val format: "Market:SYMBOL" (same as indicator-analysis page)
if (!val || typeof val !== 'string' || !val.includes(':')) {
return
@@ -2704,7 +3098,8 @@ export default {
account: account
})
if (res.data && res.data.success) {
// Note: request.js interceptor returns response.data directly, so res is the JSON object
if (res && res.success) {
this.testResult = {
success: true,
message: this.$t('trading-assistant.exchange.ibkrConnectionSuccess')
@@ -2713,14 +3108,15 @@ export default {
} else {
this.testResult = {
success: false,
message: res.data?.error || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
message: res?.error || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
}
this.$message.error(this.testResult.message)
}
} catch (error) {
const baseError = error.response?.data?.error || error?.error || error.message || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
this.testResult = {
success: false,
message: error.response?.data?.error || error.message || this.$t('trading-assistant.exchange.ibkrConnectionFailed')
message: `${baseError} - ${this.$t('trading-assistant.exchange.checkLocalDeployment')}`
}
this.$message.error(this.testResult.message)
} finally {
@@ -2756,7 +3152,8 @@ export default {
terminal_path: terminalPath
})
if (res.data && res.data.success) {
// Note: request.js interceptor returns response.data directly, so res is the JSON object
if (res && res.success) {
this.testResult = {
success: true,
message: this.$t('trading-assistant.exchange.mt5ConnectionSuccess')
@@ -2765,14 +3162,15 @@ export default {
} else {
this.testResult = {
success: false,
message: res.data?.error || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
message: res?.error || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
}
this.$message.error(this.testResult.message)
}
} catch (error) {
const baseError = error.response?.data?.error || error?.error || error.message || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
this.testResult = {
success: false,
message: error.response?.data?.error || error.message || this.$t('trading-assistant.exchange.mt5ConnectionFailed')
message: `${baseError} - ${this.$t('trading-assistant.exchange.checkLocalDeployment')}`
}
this.$message.error(this.testResult.message)
} finally {
@@ -4695,4 +5093,78 @@ export default {
}
}
}
//
.add-symbol-modal-content {
.market-tabs {
margin-bottom: 16px;
}
.symbol-search-section {
margin-bottom: 16px;
}
.section-title {
font-weight: 500;
margin-bottom: 8px;
color: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
}
.search-results-section,
.hot-symbols-section {
margin-bottom: 16px;
}
.symbol-list {
.symbol-list-item {
cursor: pointer;
transition: background-color 0.3s;
padding: 8px 12px;
border-radius: 4px;
&:hover {
background-color: #f5f5f5;
}
}
}
.symbol-item-content {
display: flex;
align-items: center;
.symbol-code {
font-weight: 500;
margin-right: 8px;
}
.symbol-name {
color: rgba(0, 0, 0, 0.45);
}
}
.selected-symbol-section {
padding: 12px;
background-color: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 4px;
margin-top: 16px;
.selected-symbol-info {
display: flex;
align-items: center;
margin-top: 8px;
.symbol-code {
font-weight: 500;
margin-right: 8px;
}
.symbol-name {
color: rgba(0, 0, 0, 0.45);
}
}
}
}
</style>