- 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
845 lines
21 KiB
Vue
845 lines
21 KiB
Vue
<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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
// 标题
|
||
.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>
|