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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user