Files
DinQuant/quantdinger_vue/src/views/ai-analysis/index.vue
T
TIANHE 0b37aa4a67 V2.1.1
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2026-01-31 02:59:49 +08:00

2011 lines
62 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="ai-analysis-container" :class="{ 'theme-dark': isDarkTheme }" :style="{ '--primary-color': primaryColor }">
<!-- 全宽主内容区域 -->
<div class="main-content-full">
<!-- 顶部指数条 -->
<div class="top-index-bar">
<!-- 情绪指标 - 独立加载 -->
<template v-if="loadingSentiment">
<div class="indicator-box skeleton-box">
<span class="skeleton-text short"></span>
<span class="skeleton-text"></span>
</div>
<div class="indicator-box skeleton-box">
<span class="skeleton-text short"></span>
<span class="skeleton-text"></span>
</div>
<div class="indicator-box skeleton-box">
<span class="skeleton-text short"></span>
<span class="skeleton-text"></span>
</div>
</template>
<template v-else>
<div class="indicator-box fear-greed" :class="getFearGreedClass(marketData.fearGreed)">
<span class="ind-label">{{ $t('globalMarket.fearGreedShort') }}</span>
<span class="ind-value">{{ marketData.fearGreed || '--' }}</span>
</div>
<div class="indicator-box vix" :class="getVixLevel(marketData.vix)">
<span class="ind-label">VIX</span>
<span class="ind-value">{{ marketData.vix || '--' }}</span>
</div>
<div class="indicator-box dxy">
<span class="ind-label">DXY</span>
<span class="ind-value">{{ marketData.dxy || '--' }}</span>
</div>
</template>
<!-- 全球指数滚动条 - 独立加载 -->
<div class="indices-marquee">
<template v-if="loadingIndices">
<div class="indices-loading">
<a-icon type="loading" /> {{ $t('common.loading') || '加载中...' }}
</div>
</template>
<template v-else-if="marketData.indices.length > 0">
<div class="marquee-track">
<div class="index-item" v-for="idx in marketData.indices" :key="'a-'+idx.symbol">
<span class="idx-flag">{{ idx.flag }}</span>
<span class="idx-symbol">{{ idx.symbol }}</span>
<span class="idx-price">{{ formatPrice(idx.price) }}</span>
<span class="idx-change" :class="idx.change >= 0 ? 'up' : 'down'">
<a-icon :type="idx.change >= 0 ? 'caret-up' : 'caret-down'" />
{{ Math.abs(idx.change).toFixed(2) }}%
</span>
</div>
<div class="index-item" v-for="idx in marketData.indices" :key="'b-'+idx.symbol">
<span class="idx-flag">{{ idx.flag }}</span>
<span class="idx-symbol">{{ idx.symbol }}</span>
<span class="idx-price">{{ formatPrice(idx.price) }}</span>
<span class="idx-change" :class="idx.change >= 0 ? 'up' : 'down'">
<a-icon :type="idx.change >= 0 ? 'caret-up' : 'caret-down'" />
{{ Math.abs(idx.change).toFixed(2) }}%
</span>
</div>
</div>
</template>
<template v-else>
<div class="indices-empty">--</div>
</template>
</div>
<a-button type="link" size="small" class="refresh-btn" :loading="loadingMarket" @click="loadMarketData">
<a-icon type="sync" :spin="loadingMarket" />
</a-button>
</div>
<!-- 主体三栏布局 -->
<div class="main-body">
<!-- 左侧热力图 + 财经日历 -->
<div class="left-panel">
<!-- 热力图 - 独立加载 -->
<div class="heatmap-box">
<div class="box-header">
<a-radio-group v-model="heatmapType" size="small" button-style="solid">
<a-radio-button value="crypto">{{ $t('globalMarket.cryptoHeatmap') }}</a-radio-button>
<a-radio-button value="commodities">{{ $t('globalMarket.commoditiesHeatmap') }}</a-radio-button>
<a-radio-button value="sectors">{{ $t('globalMarket.sectorHeatmap') }}</a-radio-button>
<a-radio-button value="forex">{{ $t('globalMarket.forexHeatmap') }}</a-radio-button>
</a-radio-group>
</div>
<div class="heatmap-grid">
<template v-if="loadingHeatmap">
<div v-for="i in 12" :key="'skel-'+i" class="heat-cell skeleton-cell">
<span class="skeleton-text short"></span>
<span class="skeleton-text"></span>
</div>
</template>
<template v-else-if="currentHeatmap.length > 0">
<div v-for="(item, i) in currentHeatmap.slice(0, 12)" :key="i" class="heat-cell" :style="getHeatmapStyle(item.value)">
<span class="heat-name">{{ getHeatmapName(item) }}</span>
<span class="heat-price" v-if="item.price">{{ formatHeatmapPrice(item.price) }}</span>
<span class="heat-val">{{ item.value >= 0 ? '+' : '' }}{{ formatNum(item.value) }}%</span>
</div>
</template>
<template v-else>
<div class="heatmap-empty">{{ $t('common.noData') || '暂无数据' }}</div>
</template>
</div>
</div>
<!-- 财经日历 - 独立加载 -->
<div class="calendar-box">
<div class="box-header">
<span class="box-title"><a-icon type="calendar" /> {{ $t('globalMarket.calendar') }}</span>
</div>
<div class="calendar-list">
<template v-if="loadingCalendar">
<div v-for="i in 5" :key="'cal-skel-'+i" class="cal-item skeleton-item">
<span class="skeleton-text short"></span>
<span class="skeleton-text short"></span>
<span class="skeleton-text"></span>
</div>
</template>
<template v-else-if="marketData.calendar.length > 0">
<div v-for="evt in marketData.calendar.slice(0, 10)" :key="evt.id" class="cal-item" :class="evt.importance">
<span class="cal-date">{{ formatCalendarDate(evt.date) }}</span>
<span class="cal-time">{{ evt.time || '--:--' }}</span>
<span class="cal-flag">{{ getCountryFlag(evt.country) }}</span>
<span class="cal-name">{{ isZhLocale ? evt.name : evt.name_en }}</span>
<span class="cal-impact" :class="getImpactClass(evt)">
<a-icon v-if="getImpactClass(evt) === 'bullish'" type="arrow-up" />
<a-icon v-else-if="getImpactClass(evt) === 'bearish'" type="arrow-down" />
<a-icon v-else type="minus" />
{{ evt.actual || evt.forecast || '--' }}
</span>
</div>
</template>
<template v-else>
<div class="cal-empty">{{ $t('globalMarket.noEvents') }}</div>
</template>
</div>
</div>
</div>
<!-- 右侧工具栏 + AI 分析 -->
<div class="right-panel">
<!-- 分析工具栏 -->
<div class="analysis-toolbar">
<a-select
v-model="selectedSymbol"
:placeholder="$t('dashboard.analysis.empty.selectSymbol')"
size="large"
show-search
allow-clear
:filter-option="filterSymbolOption"
@change="handleSymbolChange"
class="symbol-selector"
>
<a-select-option
v-for="stock in (watchlist || [])"
:key="`${stock.market}-${stock.symbol}`"
:value="`${stock.market}:${stock.symbol}`"
>
<span class="symbol-option">
<a-tag :color="getMarketColor(stock.market)" size="small">{{ getMarketName(stock.market) }}</a-tag>
<strong style="margin-left: 6px;">{{ stock.symbol }}</strong>
<span v-if="stock.name" class="symbol-name">{{ stock.name }}</span>
</span>
</a-select-option>
<a-select-option key="add-stock-option" value="__add_stock_option__" class="add-stock-option">
<div style="text-align: center; padding: 4px 0; color: #1890ff;">
<a-icon type="plus" style="margin-right: 4px;" />{{ $t('dashboard.analysis.watchlist.add') }}
</div>
</a-select-option>
</a-select>
<a-button
type="primary"
size="large"
icon="thunderbolt"
@click="startFastAnalysis"
:loading="analyzing"
:disabled="!selectedSymbol"
class="analyze-button">
{{ $t('fastAnalysis.startAnalysis') }}
</a-button>
<a-button size="large" icon="history" @click="showHistoryModal = true; loadHistoryList()" class="history-button">
{{ $t('fastAnalysis.history') }}
</a-button>
</div>
<!-- 分析结果区域 -->
<div class="analysis-main">
<div v-if="!analysisResult && !analyzing" class="analysis-placeholder">
<div class="placeholder-content">
<div class="placeholder-icon"><a-icon type="robot" /></div>
<h3>{{ $t('fastAnalysis.selectTip') }}</h3>
<p>{{ $t('fastAnalysis.selectHint') }}</p>
</div>
</div>
<FastAnalysisReport
v-if="analysisResult || analyzing"
:result="analysisResult"
:loading="analyzing"
:error="analysisError"
@retry="startFastAnalysis"
/>
</div>
</div>
<!-- 右侧自选股面板 -->
<div class="watchlist-panel">
<div class="panel-header">
<span class="panel-title"><a-icon type="star" theme="filled" /> {{ $t('dashboard.analysis.watchlist.title') }}</span>
<a-button type="link" size="small" @click="showAddStockModal = true">
<a-icon type="plus" />
</a-button>
</div>
<div class="watchlist-list">
<div
v-for="stock in (watchlist || [])"
:key="`${stock.market}-${stock.symbol}`"
class="watchlist-item"
:class="{ active: selectedSymbol === `${stock.market}:${stock.symbol}` }"
@click="selectWatchlistItem(stock)"
>
<div class="item-main">
<span class="item-symbol">{{ stock.symbol }}</span>
<span class="item-name">{{ stock.name || getMarketName(stock.market) }}</span>
</div>
<div class="item-price" v-if="watchlistPrices[`${stock.market}:${stock.symbol}`]">
<span class="price-value">{{ formatPrice(watchlistPrices[`${stock.market}:${stock.symbol}`].price) }}</span>
<span class="price-change" :class="(watchlistPrices[`${stock.market}:${stock.symbol}`]?.change || 0) >= 0 ? 'up' : 'down'">
{{ (watchlistPrices[`${stock.market}:${stock.symbol}`]?.change || 0) >= 0 ? '+' : '' }}{{ formatNum(watchlistPrices[`${stock.market}:${stock.symbol}`]?.change) }}%
</span>
</div>
<a-icon type="close" class="item-remove" @click.stop="removeFromWatchlist(stock)" />
</div>
<div v-if="!watchlist || watchlist.length === 0" class="watchlist-empty">
<a-icon type="inbox" />
<p>{{ $t('dashboard.analysis.empty.noWatchlist') }}</p>
<a-button type="primary" size="small" @click="showAddStockModal = true">
<a-icon type="plus" /> {{ $t('dashboard.analysis.watchlist.add') }}
</a-button>
</div>
</div>
</div>
</div>
</div>
<!-- 添加股票弹窗 -->
<a-modal
:title="$t('dashboard.analysis.modal.addStock.title')"
:visible="showAddStockModal"
@ok="handleAddStock"
@cancel="handleCloseAddStockModal"
:confirmLoading="addingStock"
width="600px"
:okText="$t('dashboard.analysis.modal.addStock.confirm')"
:cancelText="$t('dashboard.analysis.modal.addStock.cancel')"
>
<div class="add-stock-modal-content">
<!-- Tab标签 -->
<a-tabs v-model="selectedMarketTab" @change="handleMarketTabChange" class="market-tabs">
<a-tab-pane
v-for="marketType in marketTypes"
: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="symbolSearchKeyword"
:placeholder="$t('dashboard.analysis.modal.addStock.searchOrInputPlaceholder')"
@search="handleSearchOrInput"
@change="handleSymbolSearchInput"
:loading="searchingSymbols"
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="searchingSymbols"
size="small"
class="symbol-list"
>
<a-list-item slot="renderItem" slot-scope="item" class="symbol-list-item" @click="selectSymbol(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="selectSymbol(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="selectedSymbolForAdd" class="selected-symbol-section">
<a-alert
:message="$t('dashboard.analysis.modal.addStock.selectedSymbol')"
type="info"
show-icon
closable
@close="selectedSymbolForAdd = null"
>
<template slot="description">
<div class="selected-symbol-info">
<a-tag :color="getMarketColor(selectedSymbolForAdd.market)" style="margin-right: 8px;">
{{ $t(`dashboard.analysis.market.${selectedSymbolForAdd.market}`) }}
</a-tag>
<strong>{{ selectedSymbolForAdd.symbol }}</strong>
<span v-if="selectedSymbolForAdd.name" style="color: #999; margin-left: 8px;">{{ selectedSymbolForAdd.name }}</span>
</div>
</template>
</a-alert>
</div>
</div>
</a-modal>
<!-- 历史分析列表弹窗 -->
<a-modal
:title="$t('dashboard.analysis.modal.history.title')"
:visible="showHistoryModal"
@cancel="showHistoryModal = false"
:footer="null"
width="800px"
:bodyStyle="{ maxHeight: '60vh', overflowY: 'auto' }"
>
<a-spin :spinning="historyLoading">
<a-list
:data-source="historyList"
:pagination="{
current: historyPage,
pageSize: historyPageSize,
total: historyTotal,
onChange: (page) => { historyPage = page; loadHistoryList() },
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
onShowSizeChange: (current, size) => { historyPageSize = size; historyPage = 1; loadHistoryList() }
}"
>
<a-list-item slot="renderItem" slot-scope="item">
<a-list-item-meta>
<template slot="title">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<a-tag :color="getMarketColor(item.market)" style="margin-right: 8px;">
{{ getMarketName(item.market) }}
</a-tag>
<strong>{{ item.symbol }}</strong>
<a-tag
:color="item.decision === 'BUY' ? 'green' : (item.decision === 'SELL' ? 'red' : 'blue')"
style="margin-left: 12px;"
>
{{ item.decision }}
</a-tag>
<span style="color: #999; margin-left: 8px; font-size: 12px;">
{{ $t('fastAnalysis.confidence') }}: {{ item.confidence }}%
</span>
</div>
<div>
<a-button
type="link"
size="small"
icon="eye"
@click="viewHistoryResult(item)"
>
{{ $t('dashboard.analysis.modal.history.viewResult') }}
</a-button>
<a-popconfirm
:title="$t('dashboard.analysis.modal.history.deleteConfirm')"
:ok-text="$t('common.confirm')"
:cancel-text="$t('common.cancel')"
@confirm="deleteHistoryItem(item)"
>
<a-button
type="link"
size="small"
icon="delete"
style="color: #ff4d4f;"
>
{{ $t('dashboard.analysis.modal.history.delete') }}
</a-button>
</a-popconfirm>
</div>
</div>
</template>
<template slot="description">
<div style="color: #666; font-size: 12px;">
<span v-if="item.price">${{ formatNumber(item.price) }}</span>
<span v-if="item.summary" style="margin-left: 8px;">{{ item.summary.substring(0, 80) }}{{ item.summary.length > 80 ? '...' : '' }}</span>
</div>
<div v-if="item.created_at" style="color: #999; font-size: 12px; margin-top: 4px;">
{{ formatIsoTime(item.created_at) }}
</div>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
<a-empty v-if="!historyLoading && historyList.length === 0" :description="$t('dashboard.analysis.empty.noHistory')" />
</a-spin>
</a-modal>
</div>
</template>
<script>
import { mapGetters, mapState } from 'vuex'
import { getUserInfo } from '@/api/login'
import { getWatchlist, addWatchlist, removeWatchlist, getWatchlistPrices, getMarketTypes, searchSymbols, getHotSymbols } from '@/api/market'
import { fastAnalyze, getAllAnalysisHistory, deleteAnalysisHistory } from '@/api/fast-analysis'
import { getMarketSentiment, getMarketOverview, getMarketHeatmap, getEconomicCalendar } from '@/api/global-market'
import FastAnalysisReport from './components/FastAnalysisReport.vue'
export default {
name: 'Analysis',
components: {
FastAnalysisReport
},
data () {
return {
loadingMarket: false,
heatmapType: 'crypto',
marketData: {
fearGreed: null,
vix: null,
dxy: null,
indices: [],
heatmap: { crypto: [], commodities: [], sectors: [], forex: [] },
calendar: []
},
// 独立加载状态 - 渐进式加载
loadingSentiment: false,
loadingIndices: false,
loadingHeatmap: false,
loadingCalendar: false,
watchlistPriceTimer: null,
watchlistPrices: {},
localUserInfo: {},
loadingUserInfo: false,
userId: 1,
watchlist: [],
loadingWatchlist: false,
showAddStockModal: false,
addingStock: false,
selectedSymbol: undefined,
analyzing: false,
analysisResult: null,
analysisError: null,
showHistoryModal: false,
historyList: [],
historyLoading: false,
historyPage: 1,
historyPageSize: 20,
historyTotal: 0,
marketTypes: [],
selectedMarketTab: '',
symbolSearchKeyword: '',
symbolSearchResults: [],
searchingSymbols: false,
hotSymbols: [],
loadingHotSymbols: false,
selectedSymbolForAdd: null,
searchTimer: null,
hasSearched: false
}
},
computed: {
...mapGetters(['userInfo']),
...mapState({
navTheme: state => state.app.theme,
primaryColor: state => state.app.color || '#1890ff'
}),
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
isZhLocale () {
return this.$i18n.locale === 'zh-CN'
},
currentHeatmap () {
return this.marketData.heatmap[this.heatmapType] || []
},
storeUserInfo () {
return this.userInfo || {}
},
mergedUserInfo () {
return this.localUserInfo && this.localUserInfo.email ? this.localUserInfo : this.storeUserInfo
}
},
created () {
this.loadUserInfo()
this.loadMarketTypes()
this.loadWatchlist()
this.loadMarketData()
},
mounted () {
this.startWatchlistPriceRefresh()
},
beforeDestroy () {
if (this.watchlistPriceTimer) {
clearInterval(this.watchlistPriceTimer)
}
},
methods: {
filterSymbolOption (input, option) {
const value = option.componentOptions?.propsData?.value || ''
if (value === '__add_stock_option__') return true
return value.toLowerCase().includes(input.toLowerCase())
},
handleSymbolChange (value) {
if (value === '__add_stock_option__') {
this.showAddStockModal = true
this.$nextTick(() => {
this.selectedSymbol = undefined
})
return
}
this.selectedSymbol = value
// Clear previous result when symbol changes
this.analysisResult = null
this.analysisError = null
},
selectWatchlistItem (stock) {
this.selectedSymbol = `${stock.market}:${stock.symbol}`
this.analysisResult = null
this.analysisError = null
},
async loadMarketData () {
// 渐进式加载:每个数据块独立加载,先出来的先显示
this.loadingMarket = true
// 1. 加载情绪指标(恐惧贪婪、VIX、DXY)- 通常最快
this.loadSentimentData()
// 2. 加载全球指数 - 可能较慢
this.loadIndicesData()
// 3. 加载热力图数据
this.loadHeatmapData()
// 4. 加载财经日历
this.loadCalendarData()
},
async loadSentimentData () {
this.loadingSentiment = true
try {
const res = await getMarketSentiment()
if (res?.code === 1 && res?.data) {
this.marketData.fearGreed = res.data.fear_greed?.value || null
this.marketData.vix = res.data.vix?.value || null
this.marketData.dxy = res.data.dxy?.value || null
}
} catch (e) {
console.error('Load sentiment failed:', e)
} finally {
this.loadingSentiment = false
this.checkAllLoaded()
}
},
async loadIndicesData () {
this.loadingIndices = true
try {
const res = await getMarketOverview()
if (res?.code === 1 && res?.data) {
this.marketData.indices = res.data.indices || []
}
} catch (e) {
console.error('Load indices failed:', e)
} finally {
this.loadingIndices = false
this.checkAllLoaded()
}
},
async loadHeatmapData () {
this.loadingHeatmap = true
try {
const res = await getMarketHeatmap()
if (res?.code === 1 && res?.data) {
this.marketData.heatmap = {
crypto: res.data.crypto || [],
commodities: res.data.commodities || [],
sectors: res.data.sectors || [],
forex: res.data.forex || []
}
}
} catch (e) {
console.error('Load heatmap failed:', e)
} finally {
this.loadingHeatmap = false
this.checkAllLoaded()
}
},
async loadCalendarData () {
this.loadingCalendar = true
try {
const res = await getEconomicCalendar()
if (res?.code === 1) {
this.marketData.calendar = res.data || []
}
} catch (e) {
console.error('Load calendar failed:', e)
} finally {
this.loadingCalendar = false
this.checkAllLoaded()
}
},
checkAllLoaded () {
// 当所有数据都加载完成时,关闭总loading状态
if (!this.loadingSentiment && !this.loadingIndices && !this.loadingHeatmap && !this.loadingCalendar) {
this.loadingMarket = false
}
},
getFearGreedClass (val) {
if (!val) return ''
if (val <= 25) return 'extreme-fear'
if (val <= 45) return 'fear'
if (val <= 55) return 'neutral'
if (val <= 75) return 'greed'
return 'extreme-greed'
},
getVixLevel (val) {
if (!val) return ''
if (val < 15) return 'low'
if (val < 25) return 'medium'
return 'high'
},
formatNum (num, digits = 2) {
if (num === undefined || num === null || isNaN(num)) return '--'
return Number(num).toFixed(digits)
},
getHeatmapStyle (value) {
const v = parseFloat(value) || 0
const intensity = Math.min(Math.abs(v) / 5, 1)
if (v >= 0) {
return { background: `rgba(34, 197, 94, ${0.15 + intensity * 0.6})`, color: v > 2 ? '#fff' : '#166534' }
} else {
return { background: `rgba(239, 68, 68, ${0.15 + intensity * 0.6})`, color: v < -2 ? '#fff' : '#991b1b' }
}
},
getCountryFlag (country) {
const flags = { US: '🇺🇸', CN: '🇨🇳', EU: '🇪🇺', JP: '🇯🇵', UK: '🇬🇧', DE: '🇩🇪', AU: '🇦🇺', CA: '🇨🇦' }
return flags[country] || '🌍'
},
formatCalendarDate (dateStr) {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
// 判断是否是今天或明天
if (date.toDateString() === today.toDateString()) {
return this.isZhLocale ? '今天' : 'Today'
}
if (date.toDateString() === tomorrow.toDateString()) {
return this.isZhLocale ? '明天' : 'Tmrw'
}
// 显示月/日
const month = date.getMonth() + 1
const day = date.getDate()
return `${month}/${day}`
} catch (e) {
return dateStr
}
},
formatPrice (price) {
if (!price) return '--'
if (price >= 10000) return (price / 1000).toFixed(1) + 'K'
if (price >= 1000) return price.toFixed(0)
return price.toFixed(2)
},
formatHeatmapPrice (price) {
if (!price) return ''
if (price >= 10000) return '$' + (price / 1000).toFixed(1) + 'K'
if (price >= 1000) return '$' + price.toFixed(0)
if (price >= 1) return '$' + price.toFixed(2)
return '$' + price.toFixed(4)
},
getHeatmapName (item) {
// sectors, commodities, forex 都需要多语言适配
if (this.heatmapType === 'sectors' || this.heatmapType === 'commodities' || this.heatmapType === 'forex') {
return this.isZhLocale ? (item.name_cn || item.name) : (item.name_en || item.name)
}
return item.name
},
getImpactClass (evt) {
return evt.actual_impact || evt.expected_impact || 'neutral'
},
getMarketColor (market) {
const colors = {
'USStock': 'green',
'Crypto': 'purple',
'Forex': 'gold',
'Futures': 'cyan',
'AShare': 'blue',
'HShare': 'orange'
}
return colors[market] || 'default'
},
getCurrencySymbol (market) {
const dollarMarkets = ['USStock', 'Crypto', 'Forex', 'Futures']
return dollarMarkets.includes(market) ? '$' : '¥'
},
async startFastAnalysis () {
if (!this.selectedSymbol) {
this.$message.warning(this.$t('dashboard.analysis.message.selectSymbol'))
return
}
this.analyzing = true
this.analysisError = null
const [market, symbol] = this.selectedSymbol.split(':')
const language = this.$store.getters.lang || 'zh-CN'
try {
const res = await fastAnalyze({
market: market,
symbol: symbol,
language: language,
timeframe: '1D'
})
if (res && res.code === 1 && res.data) {
this.analysisResult = res.data
this.$message.success(this.$t('dashboard.analysis.message.analysisComplete'))
} else {
throw new Error(res?.msg || '分析失败')
}
} catch (error) {
console.error('Fast analysis failed:', error)
this.analysisError = error?.response?.data?.msg || error?.message || this.$t('dashboard.analysis.message.analysisFailed')
this.$message.error(this.analysisError)
} finally {
this.analyzing = false
}
},
async loadHistoryList () {
this.historyLoading = true
try {
const res = await getAllAnalysisHistory({
page: this.historyPage,
pagesize: this.historyPageSize
})
if (res && res.code === 1 && res.data) {
this.historyList = res.data.list || []
this.historyTotal = res.data.total || 0
}
} catch (error) {
this.$message.error(this.$t('dashboard.analysis.message.loadHistoryFailed') || '加载历史记录失败')
} finally {
this.historyLoading = false
}
},
async viewHistoryResult (item) {
// 如果有完整结果,直接显示
if (item.full_result) {
this.analysisResult = item.full_result
this.selectedSymbol = `${item.market}:${item.symbol}`
this.showHistoryModal = false
return
}
// 否则使用历史记录中的基本信息构建显示
this.analysisResult = {
decision: item.decision,
confidence: item.confidence,
summary: item.summary,
market_data: {
current_price: item.price,
change_24h: 0
},
trading_plan: {
entry_price: item.price,
stop_loss: item.price * 0.95,
take_profit: item.price * 1.05
},
scores: item.scores || {},
reasons: item.reasons || [],
risks: [],
indicators: item.indicators || {},
memory_id: item.id,
analysis_time_ms: 0
}
this.selectedSymbol = `${item.market}:${item.symbol}`
this.showHistoryModal = false
},
async deleteHistoryItem (item) {
try {
const res = await deleteAnalysisHistory(item.id)
if (res && res.code === 1) {
this.$message.success(this.$t('dashboard.analysis.message.deleteSuccess'))
this.loadHistoryList()
} else {
this.$message.error(res?.msg || this.$t('dashboard.analysis.message.deleteFailed'))
}
} catch (error) {
this.$message.error(this.$t('dashboard.analysis.message.deleteFailed'))
}
},
formatTime (timestamp) {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
return date.toLocaleString('zh-CN')
},
formatIsoTime (isoString) {
if (!isoString) return '-'
const date = new Date(isoString)
return date.toLocaleString('zh-CN')
},
getStatusColor (status) {
const colors = {
'pending': 'orange',
'processing': 'blue',
'completed': 'green',
'failed': 'red'
}
return colors[status] || 'default'
},
getStatusText (status) {
const statusMap = {
'pending': 'dashboard.analysis.status.pending',
'processing': 'dashboard.analysis.status.processing',
'completed': 'dashboard.analysis.status.completed',
'failed': 'dashboard.analysis.status.failed'
}
const key = statusMap[status]
return key ? this.$t(key) : status
},
async loadUserInfo () {
this.loadingUserInfo = true
try {
if (this.storeUserInfo && this.storeUserInfo.email) {
this.localUserInfo = this.storeUserInfo
this.userId = this.storeUserInfo.id
this.loadingUserInfo = false
this.loadWatchlist()
return
}
const res = await getUserInfo()
if (res && res.code === 1 && res.data) {
this.localUserInfo = res.data
this.userId = res.data.id
this.$store.commit('SET_INFO', res.data)
this.loadWatchlist()
}
} catch (error) {
// Silent fail
} finally {
this.loadingUserInfo = false
}
},
async loadWatchlist () {
if (!this.userId) return
this.loadingWatchlist = true
try {
const res = await getWatchlist({ userid: this.userId })
if (res && res.code === 1 && res.data) {
this.watchlist = res.data.map(item => ({
...item,
price: 0,
change: 0,
changePercent: 0
}))
await this.loadWatchlistPrices()
}
} catch (error) {
// Silent fail
} finally {
this.loadingWatchlist = false
}
},
async loadWatchlistPrices () {
if (!this.watchlist || this.watchlist.length === 0) return
try {
const watchlistData = this.watchlist.map(item => ({
market: item.market,
symbol: item.symbol
}))
const res = await getWatchlistPrices({
watchlist: watchlistData
})
if (res && res.code === 1 && res.data) {
const priceMap = {}
const pricesObj = {}
res.data.forEach(item => {
priceMap[`${item.market}-${item.symbol}`] = item
// 同时填充 watchlistPrices 对象(使用 : 作为键)
pricesObj[`${item.market}:${item.symbol}`] = {
price: item.price || 0,
change: item.changePercent || 0
}
})
this.watchlistPrices = pricesObj
this.watchlist = this.watchlist.map(item => {
const key = `${item.market}-${item.symbol}`
const priceData = priceMap[key]
if (priceData) {
return {
...item,
price: priceData.price || 0,
change: priceData.change || 0,
changePercent: priceData.changePercent || 0
}
}
return item
})
}
} catch (error) {
// Silent fail
}
},
startWatchlistPriceRefresh () {
this.watchlistPriceTimer = setInterval(() => {
if (this.watchlist && this.watchlist.length > 0) {
this.loadWatchlistPrices()
}
}, 30000)
if (this.watchlist && this.watchlist.length > 0) {
this.loadWatchlistPrices()
}
},
async handleAddStock () {
let market = ''
let symbol = ''
let name = ''
if (this.selectedSymbolForAdd) {
market = this.selectedSymbolForAdd.market
symbol = this.selectedSymbolForAdd.symbol.toUpperCase()
name = this.selectedSymbolForAdd.name || ''
} else if (this.symbolSearchKeyword && this.symbolSearchKeyword.trim()) {
if (!this.selectedMarketTab) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
market = this.selectedMarketTab
symbol = this.symbolSearchKeyword.trim().toUpperCase()
name = ''
} else {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectOrEnterSymbol'))
return
}
this.addingStock = true
try {
const res = await addWatchlist({
userid: this.userId,
market: market,
symbol: symbol,
name: name
})
if (res && res.code === 1) {
this.$message.success(this.$t('dashboard.analysis.message.addStockSuccess'))
this.handleCloseAddStockModal()
await this.loadWatchlist()
} else {
this.$message.error(res?.msg || this.$t('dashboard.analysis.message.addStockFailed'))
}
} catch (error) {
const errorMsg = error?.response?.data?.msg || error?.message || this.$t('dashboard.analysis.message.addStockFailed')
this.$message.error(errorMsg)
} finally {
this.addingStock = false
}
},
handleCloseAddStockModal () {
this.showAddStockModal = false
this.selectedSymbolForAdd = null
this.symbolSearchKeyword = ''
this.symbolSearchResults = []
this.hasSearched = false
this.selectedMarketTab = this.marketTypes.length > 0 ? this.marketTypes[0].value : ''
},
handleMarketTabChange (activeKey) {
this.selectedMarketTab = activeKey
this.symbolSearchKeyword = ''
this.symbolSearchResults = []
this.selectedSymbolForAdd = null
this.hasSearched = false
this.loadHotSymbols(activeKey)
},
handleSymbolSearchInput (e) {
const keyword = e.target.value
this.symbolSearchKeyword = keyword
if (this.searchTimer) {
clearTimeout(this.searchTimer)
}
if (!keyword || keyword.trim() === '') {
this.symbolSearchResults = []
this.hasSearched = false
this.selectedSymbolForAdd = null
return
}
this.searchTimer = setTimeout(() => {
this.searchSymbolsInModal(keyword)
}, 500)
},
handleSearchOrInput (keyword) {
if (!keyword || !keyword.trim()) return
if (!this.selectedMarketTab) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
if (this.symbolSearchResults.length > 0) return
if (this.hasSearched && this.symbolSearchResults.length === 0) {
this.handleDirectAdd()
} else {
this.searchSymbolsInModal(keyword)
}
},
async searchSymbolsInModal (keyword) {
if (!keyword || keyword.trim() === '') {
this.symbolSearchResults = []
this.hasSearched = false
return
}
if (!this.selectedMarketTab) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
this.searchingSymbols = true
this.hasSearched = true
try {
const res = await searchSymbols({
market: this.selectedMarketTab,
keyword: keyword.trim(),
limit: 20
})
if (res && res.code === 1 && res.data && res.data.length > 0) {
this.symbolSearchResults = res.data
} else {
this.symbolSearchResults = []
this.selectedSymbolForAdd = {
market: this.selectedMarketTab,
symbol: keyword.trim().toUpperCase(),
name: ''
}
}
} catch (error) {
this.symbolSearchResults = []
this.selectedSymbolForAdd = {
market: this.selectedMarketTab,
symbol: keyword.trim().toUpperCase(),
name: ''
}
} finally {
this.searchingSymbols = false
}
},
handleDirectAdd () {
if (!this.symbolSearchKeyword || !this.symbolSearchKeyword.trim()) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseEnterSymbol'))
return
}
if (!this.selectedMarketTab) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
this.selectedSymbolForAdd = {
market: this.selectedMarketTab,
symbol: this.symbolSearchKeyword.trim().toUpperCase(),
name: ''
}
},
selectSymbol (symbol) {
this.selectedSymbolForAdd = {
market: symbol.market,
symbol: symbol.symbol,
name: symbol.name || symbol.symbol
}
},
async loadHotSymbols (market) {
if (!market) {
market = this.selectedMarketTab || (this.marketTypes.length > 0 ? this.marketTypes[0].value : '')
}
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 removeFromWatchlist (stock) {
if (!this.userId) return
// 支持传入 stock 对象或单独的 symbol/market
const symbol = typeof stock === 'object' ? stock.symbol : stock
const market = typeof stock === 'object' ? stock.market : arguments[1]
try {
const res = await removeWatchlist({
userid: this.userId,
symbol: symbol,
market: market
})
if (res && res.code === 1) {
this.$message.success(this.$t('dashboard.analysis.message.removeStockSuccess'))
await this.loadWatchlist()
} else {
this.$message.error(res?.msg || this.$t('dashboard.analysis.message.removeStockFailed'))
}
} catch (error) {
this.$message.error(this.$t('dashboard.analysis.message.removeStockFailed'))
}
},
getMarketName (market) {
return this.$t(`dashboard.analysis.market.${market}`) || market
},
formatNumber (num) {
if (typeof num === 'string') return num
return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
},
async loadMarketTypes () {
try {
const res = await getMarketTypes()
if (res && res.code === 1 && res.data && Array.isArray(res.data)) {
this.marketTypes = res.data.map(item => ({
value: item.value,
i18nKey: item.i18nKey || `dashboard.analysis.market.${item.value}`
}))
} else {
this.marketTypes = [
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' }
]
}
} catch (error) {
this.marketTypes = [
{ value: 'USStock', i18nKey: 'dashboard.analysis.market.USStock' },
{ value: 'Crypto', i18nKey: 'dashboard.analysis.market.Crypto' },
{ value: 'Forex', i18nKey: 'dashboard.analysis.market.Forex' },
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' },
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' }
]
}
if (this.marketTypes.length > 0 && !this.selectedMarketTab) {
this.selectedMarketTab = this.marketTypes[0].value
}
}
},
watch: {
showAddStockModal (newVal) {
if (newVal) {
if (this.marketTypes.length > 0 && !this.selectedMarketTab) {
this.selectedMarketTab = this.marketTypes[0].value
}
if (this.selectedMarketTab) {
this.loadHotSymbols(this.selectedMarketTab)
}
} else {
this.selectedSymbolForAdd = null
this.symbolSearchKeyword = ''
this.symbolSearchResults = []
this.hasSearched = false
if (this.searchTimer) {
clearTimeout(this.searchTimer)
this.searchTimer = null
}
}
}
}
}
</script>
<style lang="less" scoped>
.ai-analysis-container {
display: flex;
height: calc(100vh - 120px);
background: #f0f2f5;
overflow: hidden;
width: 100%;
box-sizing: border-box;
}
// 全宽主内容
.main-content-full {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
border-radius: 12px;
height: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
// 顶部指数条
.top-index-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-family: 'SF Mono', Monaco, Consolas, monospace;
.indicator-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: #fff;
border-radius: 6px;
border: 1px solid #e2e8f0;
min-width: 50px;
.ind-label { font-size: 9px; color: #94a3b8; text-transform: uppercase; }
.ind-value { font-size: 13px; font-weight: 700; color: #1e293b; }
&.fear-greed.extreme-fear .ind-value { color: #dc2626; }
&.fear-greed.fear .ind-value { color: #ea580c; }
&.fear-greed.neutral .ind-value { color: #ca8a04; }
&.fear-greed.greed .ind-value { color: #65a30d; }
&.fear-greed.extreme-greed .ind-value { color: #16a34a; }
&.vix.low .ind-value { color: #16a34a; }
&.vix.medium .ind-value { color: #ca8a04; }
&.vix.high .ind-value { color: #dc2626; }
&.dxy .ind-value { color: #2563eb; }
}
.indices-marquee {
flex: 1;
overflow: hidden;
min-width: 0;
.marquee-track {
display: flex;
gap: 8px;
animation: marquee 35s linear infinite;
width: max-content;
&:hover { animation-play-state: paused; }
}
.index-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #fff;
border-radius: 4px;
border: 1px solid #e2e8f0;
font-size: 11px;
white-space: nowrap;
.idx-flag { font-size: 11px; }
.idx-symbol { color: #64748b; font-weight: 500; }
.idx-price { color: #1e293b; font-weight: 600; }
.idx-change {
font-weight: 600;
display: flex;
align-items: center;
gap: 1px;
&.up { color: #16a34a; }
&.down { color: #dc2626; }
}
}
}
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.refresh-btn {
color: #94a3b8;
flex-shrink: 0;
&:hover { color: #1e293b; }
}
}
// 主体三栏布局
.main-body {
flex: 1;
display: flex;
gap: 12px;
padding: 12px;
overflow: hidden;
min-height: 0;
}
// 左侧面板:热力图 + 财经日历
.left-panel {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
.heatmap-box {
background: #fff;
border-radius: 8px;
padding: 12px;
border: 1px solid #e2e8f0;
.box-header {
margin-bottom: 10px;
::v-deep .ant-radio-group .ant-radio-button-wrapper {
font-size: 10px;
padding: 0 6px;
height: 22px;
line-height: 20px;
&.ant-radio-button-wrapper-checked {
background: var(--primary-color, #1890ff);
border-color: var(--primary-color, #1890ff);
color: #fff;
}
}
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
.heat-cell {
padding: 6px 4px;
border-radius: 4px;
text-align: center;
font-size: 9px;
.heat-name { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 1px; }
.heat-price { display: block; font-size: 9px; opacity: 0.8; margin-bottom: 1px; }
.heat-val { font-weight: 700; font-size: 10px; }
}
}
}
.calendar-box {
flex: 1;
background: #fff;
border-radius: 8px;
padding: 12px;
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
.box-header {
margin-bottom: 8px;
.box-title {
font-size: 12px;
color: #64748b;
font-weight: 600;
.anticon { margin-right: 6px; color: var(--primary-color, #1890ff); }
}
}
.calendar-list {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 2px; }
.cal-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 0;
border-bottom: 1px solid #f1f5f9;
font-size: 10px;
&:last-child { border-bottom: none; }
&.high { border-left: 3px solid #dc2626; padding-left: 8px; margin-left: -4px; }
&.medium { border-left: 3px solid #ca8a04; padding-left: 8px; margin-left: -4px; }
&.low { border-left: 3px solid #16a34a; padding-left: 8px; margin-left: -4px; }
.cal-date {
font-size: 9px;
color: #94a3b8;
min-width: 32px;
font-weight: 500;
}
.cal-time { color: #64748b; min-width: 36px; font-weight: 500; }
.cal-flag { font-size: 12px; }
.cal-name { flex: 1; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cal-impact {
font-weight: 600;
font-size: 10px;
display: flex;
align-items: center;
gap: 2px;
&.bullish { color: #16a34a; }
&.bearish { color: #dc2626; }
&.neutral { color: #94a3b8; }
}
}
.cal-empty { text-align: center; color: #94a3b8; padding: 20px 0; font-size: 12px; }
}
}
}
// 右侧面板:工具栏 + 分析结果
// 中间分析面板
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
background: #fff;
border-radius: 8px;
border: 1px solid #e2e8f0;
.analysis-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
background: #fafafa;
border-radius: 8px 8px 0 0;
.symbol-selector {
flex: 1;
max-width: 320px;
}
.analyze-button {
background: var(--primary-color, #1890ff);
border-color: var(--primary-color, #1890ff);
}
.history-button {
border-color: #d9d9d9;
}
}
.analysis-main {
flex: 1;
overflow: auto;
padding: 16px;
min-height: 0;
.analysis-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
.placeholder-content {
text-align: center;
.placeholder-icon { font-size: 72px; color: var(--primary-color, #1890ff); opacity: 0.5; margin-bottom: 20px; }
h3 { font-size: 18px; color: #1e293b; margin-bottom: 8px; }
p { font-size: 14px; color: #64748b; }
}
}
}
}
// 右侧自选股面板
.watchlist-panel {
width: 200px;
flex-shrink: 0;
background: #fff;
border-radius: 8px;
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
overflow: hidden;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
background: #fafafa;
.panel-title {
font-size: 12px;
font-weight: 600;
color: #64748b;
.anticon { color: #facc15; margin-right: 6px; }
}
}
.watchlist-list {
flex: 1;
overflow-y: auto;
padding: 8px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 2px; }
.watchlist-item {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
&:hover { background: #f8fafc; }
&.active { background: #e6f7ff; border: 1px solid #91d5ff; }
.item-main {
flex: 1;
min-width: 0;
.item-symbol { display: block; font-size: 12px; font-weight: 600; color: #1e293b; }
.item-name { display: block; font-size: 10px; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
}
.item-price {
text-align: right;
margin-right: 8px;
font-family: 'SF Mono', Monaco, monospace;
.price-value { display: block; font-size: 11px; font-weight: 600; color: #1e293b; }
.price-change {
display: block;
font-size: 10px;
font-weight: 600;
&.up { color: #16a34a; }
&.down { color: #dc2626; }
}
}
.item-remove {
color: #cbd5e1;
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
&:hover { color: #dc2626; }
}
&:hover .item-remove { opacity: 1; }
}
.watchlist-empty {
text-align: center;
padding: 24px 12px;
color: #94a3b8;
.anticon { font-size: 32px; margin-bottom: 8px; display: block; }
p { font-size: 12px; margin-bottom: 12px; }
}
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Responsive */
@media (max-width: 992px) {
.ai-analysis-container {
height: auto;
min-height: calc(100vh - 64px);
overflow-y: auto;
}
.main-content-full {
height: auto;
min-height: auto;
}
.top-index-bar {
flex-wrap: wrap;
padding: 8px;
.indicator-box {
min-width: 45px;
padding: 3px 6px;
}
.indices-marquee {
order: 10;
width: 100%;
margin-top: 8px;
}
}
.main-body {
flex-direction: column;
padding: 12px;
}
.left-panel {
width: 100%;
flex-direction: row;
gap: 12px;
.heatmap-box, .calendar-box {
flex: 1;
min-width: 0;
}
.calendar-box {
max-height: 200px;
}
}
.right-panel {
.analysis-toolbar {
flex-wrap: wrap;
.symbol-selector { width: 100% !important; max-width: none !important; }
.analyze-button, .history-button { flex: 1; }
}
}
.watchlist-panel {
width: 100%;
max-height: 200px;
order: -1;
}
}
/* Dark Theme */
.ai-analysis-container.theme-dark {
background: #131722;
color: #d1d4dc;
.main-content-full {
background: #1e222d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.top-index-bar {
background: #1e222d;
border-bottom-color: #363c4e;
.indicator-box {
background: #2a2e39;
border-color: #363c4e;
.ind-label { color: #868993; }
.ind-value { color: #d1d4dc; }
}
.indices-marquee .index-item {
background: #2a2e39;
border-color: #363c4e;
.idx-symbol { color: #868993; }
.idx-price { color: #d1d4dc; }
}
.refresh-btn {
color: #868993;
&:hover { color: #d1d4dc; }
}
}
.watchlist-panel {
background: #2a2e39;
border-color: #363c4e;
.panel-header {
background: #1e222d;
border-bottom-color: #363c4e;
.panel-title { color: #868993; }
}
.watchlist-list {
.watchlist-item {
&:hover { background: #363c4e; }
&.active { background: rgba(24, 144, 255, 0.1); border-color: #1890ff; }
.item-main {
.item-symbol { color: #d1d4dc; }
.item-name { color: #64748b; }
}
.item-price .price-value { color: #d1d4dc; }
}
.watchlist-empty { color: #64748b; }
}
}
.watchlist-bar-legacy {
background: #1e222d;
border-bottom-color: #363c4e;
.stock-chip {
background: #2a2e39;
border-color: #363c4e;
&:hover, &.active { border-color: var(--primary-color, #1890ff); background: rgba(24, 144, 255, 0.1); }
.chip-symbol { color: #d1d4dc; }
.chip-price { color: #868993; }
}
}
.left-panel {
.heatmap-box {
background: #2a2e39;
border-color: #363c4e;
::v-deep .ant-radio-group .ant-radio-button-wrapper {
background: #1e222d;
border-color: #363c4e;
color: #868993;
&:hover { color: #d1d4dc; }
}
}
.calendar-box {
background: #2a2e39;
border-color: #363c4e;
.box-title { color: #868993; }
.cal-item {
border-bottom-color: #363c4e;
.cal-date { color: #64748b; }
.cal-time { color: #868993; }
.cal-name { color: #d1d4dc; }
}
.cal-empty { color: #64748b; }
}
}
.right-panel {
background: #2a2e39;
border-color: #363c4e;
.analysis-toolbar {
background: #1e222d;
border-bottom-color: #363c4e;
.history-button {
background: #2a2e39;
border-color: #363c4e;
color: #d1d4dc;
}
}
.analysis-main {
.analysis-placeholder .placeholder-content {
h3 { color: #d1d4dc; }
p { color: #868993; }
}
}
}
// 旧样式兼容
.watchlist-bar-compat {
background: #1e222d;
border-top-color: #363c4e;
.bar-label { color: #868993; }
.stock-chip {
background: #2a2e39;
border-color: #363c4e;
&:hover, &.active {
border-color: var(--primary-color, #1890ff);
background: rgba(24, 144, 255, 0.1);
}
.chip-symbol { color: #d1d4dc; }
.chip-price { color: #868993; }
.chip-remove { color: #64748b; }
}
}
::v-deep .symbol-selector {
.ant-select-selection {
background-color: #2a2e39;
border-color: #363c4e;
color: #d1d4dc;
}
}
}
/* Add Stock Modal */
.add-stock-modal-content {
.market-tabs { margin-bottom: 16px; }
.symbol-search-section { margin-bottom: 24px; }
.search-results-section,
.hot-symbols-section {
margin-bottom: 24px;
.section-title {
font-size: 14px;
font-weight: 600;
color: #262626;
margin-bottom: 12px;
display: flex;
align-items: center;
}
}
.symbol-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #e8e8e8;
border-radius: 4px;
.symbol-list-item {
cursor: pointer;
padding: 8px 12px;
transition: background-color 0.3s;
&:hover { background-color: #f5f5f5; }
.symbol-item-content {
display: flex;
align-items: center;
gap: 8px;
.symbol-code {
font-weight: 600;
color: #262626;
min-width: 80px;
}
.symbol-name {
color: #595959;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.selected-symbol-section {
margin-top: 16px;
.selected-symbol-info {
display: flex;
align-items: center;
}
}
}
/* 骨架屏加载动画 - 渐进式加载 */
.skeleton-box {
.skeleton-text {
display: block;
height: 12px;
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 4px;
margin: 3px 0;
&.short { width: 40px; height: 9px; }
}
}
.skeleton-cell {
background: #f8fafc !important;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 4px;
.skeleton-text {
width: 80%;
height: 10px;
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 3px;
margin: 2px 0;
&.short { width: 50%; height: 8px; }
}
}
.skeleton-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
.skeleton-text {
height: 12px;
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 4px;
flex: 1;
&.short { flex: none; width: 40px; }
}
}
.indices-loading, .indices-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 11px;
color: #94a3b8;
padding: 4px 16px;
}
.indices-loading {
.anticon { margin-right: 6px; }
}
.heatmap-empty {
grid-column: 1 / -1;
text-align: center;
padding: 20px;
color: #94a3b8;
font-size: 12px;
}
@keyframes skeleton-pulse {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 暗色主题下的骨架屏 */
.theme-dark {
.skeleton-box, .skeleton-cell, .skeleton-item {
.skeleton-text {
background: linear-gradient(90deg, #363c4e 25%, #424857 50%, #363c4e 75%);
background-size: 200% 100%;
}
}
.skeleton-cell {
background: #2a2e39 !important;
}
.indices-loading, .indices-empty, .heatmap-empty {
color: #64748b;
}
}
</style>