Files
DinQuant/quantdinger_vue/src/components/NoticeIcon/NoticeIcon.vue
T
TIANHE f4e5a9f8e0 fix: Multiple bug fixes and improvements
- Fix Invalid Date display in Dashboard notifications
- Fix timezone offset (8 hours) in Trading Records time display
- Fix position closing failures due to commission discrepancies (fetch actual exchange position size for reduce_only orders)
- Fix IBKR connection error 'no current event loop in thread' by ensuring asyncio event loop exists
- Fix duplicate orders on same candle by extending signal deduplication to close signals
- Add responsive design for Profile page (mobile-friendly)
- Remove unused strategy_code module and database table
- Fix LLM service to support multiple providers (OpenRouter, OpenAI, DeepSeek, Grok, Google)
- Add auto-detection of configured LLM provider based on API key availability
- Fix AI code generation to use unified LLMService with proper provider selection
- Fix crypto symbol format handling (ETH/USDT no longer becomes ETH/USDT/USDT)
- Fix Commission display showing '0E-8' in Trading Records
- Fix P&L display for signal-only trades (show '--' for unrealized P&L)
- Fix OAuth login not updating last_login_at for new users
- Add migration script for notification_settings column
- Update env.example with new LLM provider configurations
- Remove ESLint rule that was not defined in config
2026-01-24 03:22:14 +08:00

845 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="notice-icon-wrapper">
<a-popover
v-model="visible"
trigger="click"
placement="bottomRight"
overlayClassName="header-notice-wrapper"
:getPopupContainer="() => $refs.noticeRef.parentElement"
:autoAdjustOverflow="true"
:arrowPointAtCenter="true"
:overlayStyle="{ width: '380px', top: '50px' }"
>
<template slot="content">
<div class="notice-header">
<span class="notice-title">{{ $t('notice.title') }}</span>
<a v-if="notifications.length > 0" @click="markAllRead" class="notice-action">
{{ $t('notice.markAllRead') }}
</a>
</div>
<a-spin :spinning="loading">
<div class="notice-list" v-if="notifications.length > 0">
<div
v-for="item in notifications"
:key="item.id"
class="notice-item"
:class="{ unread: !item.is_read }"
@click="handleNoticeClick(item)"
>
<div class="notice-item-icon">
<a-icon :type="getNoticeIcon(item.signal_type)" :style="{ color: getNoticeColor(item.signal_type) }" />
</div>
<div class="notice-item-content">
<div class="notice-item-title">{{ item.title }}</div>
<div class="notice-item-desc">{{ truncateMessage(item.message) }}</div>
<div class="notice-item-time">{{ formatTime(item.created_at) }}</div>
</div>
</div>
</div>
<div class="notice-empty" v-else>
<a-empty :description="$t('notice.empty')" />
</div>
</a-spin>
<div class="notice-footer" v-if="notifications.length > 0">
<a @click="clearNotifications">{{ $t('notice.clear') }}</a>
</div>
</template>
<span @click="fetchNotice" class="header-notice" ref="noticeRef">
<a-badge :count="unreadCount" :overflowCount="99">
<a-icon style="font-size: 16px; padding: 4px" type="bell" />
</a-badge>
</span>
</a-popover>
<!-- 通知详情弹窗 -->
<a-modal
v-model="detailVisible"
:title="detailNotice ? detailNotice.title : ''"
:footer="null"
:width="isHtmlReport ? 900 : 600"
:wrapClassName="isHtmlReport ? 'notice-detail-modal html-report-modal' : 'notice-detail-modal'"
centered
>
<div v-if="detailNotice" class="notice-detail">
<div class="notice-detail-meta">
<div class="notice-detail-type">
<a-icon :type="getNoticeIcon(detailNotice.signal_type)" :style="{ color: getNoticeColor(detailNotice.signal_type) }" />
<span class="type-label">{{ getNoticeTypeLabel(detailNotice.signal_type) }}</span>
</div>
<div class="notice-detail-time">
<a-icon type="clock-circle" />
<span>{{ formatFullTime(detailNotice.created_at) }}</span>
</div>
</div>
<a-divider />
<!-- 消息内容 - 支持 HTML 报告或 Markdown 格式 -->
<div class="notice-detail-content" :class="{ 'html-report': isHtmlReport }">
<div v-html="formatMessageHtml(detailNotice.message)" class="message-body"></div>
</div>
<!-- 如果有额外的 payload 信息 HTML 报告时显示 -->
<template v-if="!isHtmlReport && detailNotice.payload && Object.keys(detailNotice.payload).length > 0">
<a-divider />
<div class="notice-detail-extra">
<div class="extra-title">{{ $t('notice.detailInfo') }}</div>
<!-- AI分析结果 -->
<template v-if="detailNotice.signal_type === 'ai_monitor'">
<div v-if="detailNotice.payload.final_decision" class="extra-item decision">
<span class="label">{{ $t('notice.aiDecision') }}:</span>
<a-tag :color="getDecisionColor(detailNotice.payload.final_decision)">
{{ detailNotice.payload.final_decision }}
</a-tag>
<span v-if="detailNotice.payload.confidence" class="confidence">
({{ $t('notice.confidence') }}: {{ detailNotice.payload.confidence }}%)
</span>
</div>
<div v-if="detailNotice.payload.reasoning" class="extra-item">
<span class="label">{{ $t('notice.reasoning') }}:</span>
<span class="value">{{ detailNotice.payload.reasoning }}</span>
</div>
</template>
<!-- 价格提醒 -->
<template v-if="detailNotice.signal_type === 'price_alert'">
<div v-if="detailNotice.payload.symbol" class="extra-item">
<span class="label">{{ $t('notice.symbol') }}:</span>
<span class="value">{{ detailNotice.payload.symbol }}</span>
</div>
<div v-if="detailNotice.payload.price" class="extra-item">
<span class="label">{{ $t('notice.currentPrice') }}:</span>
<span class="value">${{ detailNotice.payload.price }}</span>
</div>
<div v-if="detailNotice.payload.trigger_price" class="extra-item">
<span class="label">{{ $t('notice.triggerPrice') }}:</span>
<span class="value">${{ detailNotice.payload.trigger_price }}</span>
</div>
</template>
<!-- 交易信号 -->
<template v-if="detailNotice.signal_type === 'signal' || detailNotice.signal_type === 'trade'">
<div v-if="detailNotice.payload.symbol" class="extra-item">
<span class="label">{{ $t('notice.symbol') }}:</span>
<span class="value">{{ detailNotice.payload.symbol }}</span>
</div>
<div v-if="detailNotice.payload.action" class="extra-item">
<span class="label">{{ $t('notice.action') }}:</span>
<a-tag :color="detailNotice.payload.action === 'BUY' ? 'green' : 'red'">
{{ detailNotice.payload.action }}
</a-tag>
</div>
<div v-if="detailNotice.payload.quantity" class="extra-item">
<span class="label">{{ $t('notice.quantity') }}:</span>
<span class="value">{{ detailNotice.payload.quantity }}</span>
</div>
</template>
</div>
</template>
<!-- 操作按钮 -->
<div class="notice-detail-actions">
<a-button v-if="detailNotice.payload && detailNotice.payload.monitor_id" type="primary" @click="goToPortfolio">
<a-icon type="fund" />
{{ $t('notice.viewPortfolio') }}
</a-button>
<a-button @click="detailVisible = false">
{{ $t('notice.close') }}
</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script>
import { getStrategyNotifications } from '@/api/strategy'
import request from '@/utils/request'
export default {
name: 'HeaderNotice',
data () {
return {
loading: false,
visible: false,
detailVisible: false,
detailNotice: null,
notifications: [],
lastFetchId: 0,
pollingTimer: null
}
},
computed: {
unreadCount () {
return this.notifications.filter(n => !n.is_read).length
},
isHtmlReport () {
if (!this.detailNotice || !this.detailNotice.message) return false
return this.detailNotice.message.includes('<div class="qd-report">') ||
this.detailNotice.message.includes('<style>')
}
},
mounted () {
this.fetchNotifications()
this.startPolling()
},
beforeDestroy () {
this.stopPolling()
},
methods: {
startPolling () {
this.stopPolling()
// 每30秒轮询一次
this.pollingTimer = setInterval(() => {
this.fetchNotifications(true)
}, 30000)
},
stopPolling () {
if (this.pollingTimer) {
clearInterval(this.pollingTimer)
this.pollingTimer = null
}
},
async fetchNotifications (silent = false) {
if (!silent) {
this.loading = true
}
try {
const res = await getStrategyNotifications({ limit: 50 })
if (res.code === 1 && res.data?.items) {
// 解析 payload_json 如果是字符串
this.notifications = res.data.items.map(item => {
let payload = item.payload_json
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload)
} catch (e) {
payload = {}
}
}
return {
...item,
payload,
is_read: item.is_read === 1 || item.is_read === true
}
})
if (this.notifications.length > 0) {
this.lastFetchId = Math.max(...this.notifications.map(n => n.id))
}
}
} catch (e) {
console.error('Failed to fetch notifications:', e)
} finally {
this.loading = false
}
},
fetchNotice () {
if (!this.visible) {
this.fetchNotifications()
}
this.visible = !this.visible
},
getNoticeIcon (signalType) {
const iconMap = {
'ai_monitor': 'robot',
'price_alert': 'bell',
'signal': 'thunderbolt',
'buy': 'rise',
'sell': 'fall',
'hold': 'pause-circle',
'trade': 'swap'
}
return iconMap[signalType] || 'notification'
},
getNoticeColor (signalType) {
const colorMap = {
'ai_monitor': '#722ed1',
'price_alert': '#faad14',
'signal': '#1890ff',
'buy': '#52c41a',
'sell': '#f5222d',
'hold': '#faad14',
'trade': '#13c2c2'
}
return colorMap[signalType] || '#1890ff'
},
getNoticeTypeLabel (signalType) {
const labelMap = {
'ai_monitor': this.$t('notice.type.aiMonitor'),
'price_alert': this.$t('notice.type.priceAlert'),
'signal': this.$t('notice.type.signal'),
'buy': this.$t('notice.type.buy'),
'sell': this.$t('notice.type.sell'),
'hold': this.$t('notice.type.hold'),
'trade': this.$t('notice.type.trade')
}
return labelMap[signalType] || this.$t('notice.type.notification')
},
getDecisionColor (decision) {
const colorMap = {
'BUY': 'green',
'SELL': 'red',
'HOLD': 'orange'
}
return colorMap[decision] || 'blue'
},
truncateMessage (message) {
if (!message) return ''
return message.length > 80 ? message.substring(0, 80) + '...' : message
},
formatTime (timestamp) {
if (!timestamp) return ''
// 支持多种时间格式:ISO字符串、秒级时间戳、毫秒级时间戳
let date
if (typeof timestamp === 'number') {
// 数字类型:判断是秒级还是毫秒级时间戳
date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp)
} else if (typeof timestamp === 'string') {
// 字符串类型
if (/^\d+$/.test(timestamp)) {
// 纯数字字符串(时间戳)
const ts = parseInt(timestamp, 10)
date = new Date(ts < 1e12 ? ts * 1000 : ts)
} else {
// ISO 日期字符串或其他格式
date = new Date(timestamp)
}
} else {
return ''
}
// 检查日期是否有效
if (isNaN(date.getTime())) {
return ''
}
const now = new Date()
const diff = now - date
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) {
return this.$t('notice.justNow')
} else if (minutes < 60) {
return `${minutes} ${this.$t('notice.minutesAgo')}`
} else if (hours < 24) {
return `${hours} ${this.$t('notice.hoursAgo')}`
} else if (days < 7) {
return `${days} ${this.$t('notice.daysAgo')}`
} else {
return date.toLocaleDateString()
}
},
formatFullTime (timestamp) {
if (!timestamp) return ''
// 支持多种时间格式:ISO字符串、秒级时间戳、毫秒级时间戳
let date
if (typeof timestamp === 'number') {
date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp)
} else if (typeof timestamp === 'string') {
if (/^\d+$/.test(timestamp)) {
const ts = parseInt(timestamp, 10)
date = new Date(ts < 1e12 ? ts * 1000 : ts)
} else {
date = new Date(timestamp)
}
} else {
return ''
}
if (isNaN(date.getTime())) {
return ''
}
return date.toLocaleString()
},
formatMessageHtml (message) {
if (!message) return ''
// 检查是否已经是 HTML 格式(AI Monitor 的报告)
if (message.includes('<div class="qd-report">') || message.includes('<style>')) {
// 已经是 HTML,直接返回
return message
}
// 简单的 Markdown 转换
const html = message
// 转义 HTML
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 标题
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
// 粗体
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// 斜体
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// 列表项
.replace(/^- (.+)$/gm, '<li>$1</li>')
// 换行
.replace(/\n/g, '<br>')
return html
},
handleNoticeClick (item) {
// 标记为已读
this.markAsRead(item.id)
// 打开详情弹窗
this.detailNotice = item
this.detailVisible = true
this.visible = false
},
goToPortfolio () {
this.detailVisible = false
this.$router.push({ path: '/portfolio' }).catch(() => {})
},
async markAsRead (id) {
const item = this.notifications.find(n => n.id === id)
if (item) {
item.is_read = true
}
// 调用后端API标记已读
try {
await request({
url: '/api/strategies/notifications/read',
method: 'post',
data: { id }
})
} catch (e) {
// 忽略错误,前端已标记
}
},
async markAllRead () {
this.notifications.forEach(n => { n.is_read = true })
try {
await request({
url: '/api/strategies/notifications/read-all',
method: 'post'
})
} catch (e) {
// 忽略错误
}
},
async clearNotifications () {
this.notifications = []
try {
await request({
url: '/api/strategies/notifications/clear',
method: 'delete'
})
} catch (e) {
// 忽略错误
}
this.visible = false
}
}
}
</script>
<style lang="less" scoped>
@import '~ant-design-vue/es/style/themes/default.less';
.notice-icon-wrapper {
display: inline-block;
vertical-align: top;
}
.header-notice {
display: inline-flex;
align-items: center;
justify-content: center;
height: @layout-header-height;
line-height: @layout-header-height;
transition: all 0.3s;
cursor: pointer;
padding: 0 12px;
vertical-align: top;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
span {
vertical-align: initial;
}
}
/* 手机端适配 */
@media (max-width: 768px) {
.header-notice {
padding: 0 8px;
}
}
.notice-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
.notice-title {
font-weight: 500;
font-size: 14px;
}
.notice-action {
font-size: 12px;
color: #1890ff;
cursor: pointer;
&:hover {
color: #40a9ff;
}
}
}
.notice-list {
max-height: 400px;
overflow-y: auto;
}
.notice-item {
display: flex;
padding: 12px 16px;
cursor: pointer;
transition: background 0.3s;
&:hover {
background: #f5f5f5;
}
&.unread {
background: #e6f7ff;
&:hover {
background: #bae7ff;
}
}
.notice-item-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
border-radius: 50%;
margin-right: 12px;
font-size: 16px;
}
.notice-item-content {
flex: 1;
min-width: 0;
.notice-item-title {
font-weight: 500;
font-size: 13px;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notice-item-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
margin-bottom: 4px;
}
.notice-item-time {
font-size: 11px;
color: rgba(0, 0, 0, 0.25);
}
}
}
.notice-empty {
padding: 48px 0;
}
.notice-footer {
text-align: center;
padding: 12px;
border-top: 1px solid #f0f0f0;
a {
color: #1890ff;
cursor: pointer;
&:hover {
color: #40a9ff;
}
}
}
/* 详情弹窗内容 */
.notice-detail {
.notice-detail-meta {
display: flex;
justify-content: space-between;
align-items: center;
.notice-detail-type {
display: flex;
align-items: center;
gap: 8px;
.type-label {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
}
.notice-detail-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
}
.notice-detail-content {
.message-body {
font-size: 14px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.85);
max-height: 300px;
overflow-y: auto;
padding: 8px 0;
h2, h3, h4 {
margin: 12px 0 8px;
font-weight: 600;
}
h2 { font-size: 18px; }
h3 { font-size: 16px; }
h4 { font-size: 14px; }
li {
margin-left: 20px;
list-style: disc;
}
strong {
font-weight: 600;
}
}
// HTML 报告样式
&.html-report {
.message-body {
max-height: 70vh;
padding: 0;
margin: -16px -24px;
overflow-y: auto;
}
}
}
.notice-detail-extra {
.extra-title {
font-weight: 500;
font-size: 14px;
margin-bottom: 12px;
color: rgba(0, 0, 0, 0.85);
}
.extra-item {
display: flex;
align-items: flex-start;
margin-bottom: 8px;
font-size: 13px;
.label {
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
margin-right: 8px;
}
.value {
color: rgba(0, 0, 0, 0.85);
word-break: break-word;
}
&.decision {
align-items: center;
.confidence {
margin-left: 8px;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
}
}
}
.notice-detail-actions {
margin-top: 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
}
</style>
<style lang="less">
.header-notice-wrapper {
top: 50px !important;
.ant-popover-inner-content {
padding: 0;
}
}
/* 详情弹窗样式 */
.notice-detail-modal {
.ant-modal-header {
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-body {
padding: 16px 24px;
}
// HTML 报告模式
&.html-report-modal {
.ant-modal-body {
padding: 0;
}
}
}
/* 暗黑主题支持 */
body.dark,
body.realdark,
.ant-layout.dark,
.ant-layout.realdark {
.header-notice-wrapper {
.ant-popover-inner {
background: #1f1f1f;
}
.ant-popover-arrow {
border-color: #1f1f1f;
}
.notice-header {
border-color: #303030;
.notice-title {
color: rgba(255, 255, 255, 0.85);
}
}
.notice-item {
&:hover {
background: #303030;
}
&.unread {
background: rgba(24, 144, 255, 0.15);
&:hover {
background: rgba(24, 144, 255, 0.25);
}
}
.notice-item-icon {
background: #303030;
}
.notice-item-content {
.notice-item-title {
color: rgba(255, 255, 255, 0.85);
}
.notice-item-desc {
color: rgba(255, 255, 255, 0.45);
}
.notice-item-time {
color: rgba(255, 255, 255, 0.25);
}
}
}
.notice-footer {
border-color: #303030;
}
.ant-empty-description {
color: rgba(255, 255, 255, 0.45);
}
}
/* 详情弹窗暗黑主题 */
.notice-detail-modal {
.ant-modal-content {
background: #1f1f1f;
}
.ant-modal-header {
background: #1f1f1f;
border-color: #303030;
.ant-modal-title {
color: rgba(255, 255, 255, 0.85);
}
}
.ant-modal-close-x {
color: rgba(255, 255, 255, 0.45);
}
.ant-divider {
border-color: #303030;
}
.notice-detail {
.notice-detail-meta {
.notice-detail-type .type-label {
color: rgba(255, 255, 255, 0.65);
}
.notice-detail-time {
color: rgba(255, 255, 255, 0.45);
}
}
.notice-detail-content .message-body {
color: rgba(255, 255, 255, 0.85);
}
.notice-detail-extra {
.extra-title {
color: rgba(255, 255, 255, 0.85);
}
.extra-item {
.label {
color: rgba(255, 255, 255, 0.45);
}
.value {
color: rgba(255, 255, 255, 0.85);
}
.confidence {
color: rgba(255, 255, 255, 0.45);
}
}
}
}
}
}
</style>