Files
DinQuant/quantdinger_vue/src/views/ai-analysis/index.vue
T

2011 lines
62 KiB
Vue
Raw Normal View History

2025-12-29 03:06:49 +08:00
<template>
<div class="ai-analysis-container" :class="{ 'theme-dark': isDarkTheme }" :style="{ '--primary-color': primaryColor }">
2026-01-31 02:59:49 +08:00
<!-- 全宽主内容区域 -->
<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>
2025-12-29 03:06:49 +08:00
</div>
2026-01-31 02:59:49 +08:00
<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>
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
<!-- 全球指数滚动条 - 独立加载 -->
<div class="indices-marquee">
<template v-if="loadingIndices">
<div class="indices-loading">
<a-icon type="loading" /> {{ $t('common.loading') || '加载中...' }}
2025-12-29 03:06:49 +08:00
</div>
2026-01-31 02:59:49 +08:00
</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>
2025-12-29 03:06:49 +08:00
</div>
2026-01-31 02:59:49 +08:00
<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) }}%
2025-12-29 03:06:49 +08:00
</span>
</div>
</div>
2026-01-31 02:59:49 +08:00
</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>
2025-12-29 03:06:49 +08:00
</div>
2026-01-31 02:59:49 +08:00
<!-- 财经日历 - 独立加载 -->
<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') }}
2025-12-29 03:06:49 +08:00
</a-button>
2026-01-31 02:59:49 +08:00
</div>
2025-12-29 03:06:49 +08:00
</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>
2026-01-31 02:59:49 +08:00
<!-- 搜索/输入框 -->
2025-12-29 03:06:49 +08:00
<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>
2026-01-31 02:59:49 +08:00
<a-tag
:color="item.decision === 'BUY' ? 'green' : (item.decision === 'SELL' ? 'red' : 'blue')"
style="margin-left: 12px;"
>
{{ item.decision }}
2025-12-29 03:06:49 +08:00
</a-tag>
2026-01-31 02:59:49 +08:00
<span style="color: #999; margin-left: 8px; font-size: 12px;">
{{ $t('fastAnalysis.confidence') }}: {{ item.confidence }}%
</span>
2025-12-29 03:06:49 +08:00
</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>
2025-12-29 03:06:49 +08:00
</div>
</div>
</template>
<template slot="description">
2026-01-31 02:59:49 +08:00
<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>
2025-12-29 03:06:49 +08:00
</div>
2026-01-31 02:59:49 +08:00
<div v-if="item.created_at" style="color: #999; font-size: 12px; margin-top: 4px;">
{{ formatIsoTime(item.created_at) }}
2025-12-29 03:06:49 +08:00
</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'
2026-01-31 02:59:49 +08:00
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'
2025-12-29 03:06:49 +08:00
export default {
name: 'Analysis',
components: {
2026-01-31 02:59:49 +08:00
FastAnalysisReport
2025-12-29 03:06:49 +08:00
},
data () {
return {
2026-01-31 02:59:49 +08:00
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: {},
2025-12-29 03:06:49 +08:00
loadingUserInfo: false,
userId: 1,
watchlist: [],
loadingWatchlist: false,
showAddStockModal: false,
addingStock: false,
2026-01-31 02:59:49 +08:00
selectedSymbol: undefined,
analyzing: false,
analysisResult: null,
analysisError: null,
2025-12-29 03:06:49 +08:00
showHistoryModal: false,
historyList: [],
historyLoading: false,
historyPage: 1,
historyPageSize: 20,
historyTotal: 0,
marketTypes: [],
2026-01-31 02:59:49 +08:00
selectedMarketTab: '',
symbolSearchKeyword: '',
symbolSearchResults: [],
searchingSymbols: false,
hotSymbols: [],
loadingHotSymbols: false,
selectedSymbolForAdd: null,
searchTimer: null,
hasSearched: false
2025-12-29 03:06:49 +08:00
}
},
computed: {
...mapGetters(['userInfo']),
...mapState({
navTheme: state => state.app.theme,
primaryColor: state => state.app.color || '#1890ff'
}),
isDarkTheme () {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
2026-01-31 02:59:49 +08:00
isZhLocale () {
return this.$i18n.locale === 'zh-CN'
},
currentHeatmap () {
return this.marketData.heatmap[this.heatmapType] || []
},
2025-12-29 03:06:49 +08:00
storeUserInfo () {
return this.userInfo || {}
},
mergedUserInfo () {
return this.localUserInfo && this.localUserInfo.email ? this.localUserInfo : this.storeUserInfo
}
},
created () {
this.loadUserInfo()
2026-01-31 02:59:49 +08:00
this.loadMarketTypes()
2025-12-29 03:06:49 +08:00
this.loadWatchlist()
2026-01-31 02:59:49 +08:00
this.loadMarketData()
2025-12-29 03:06:49 +08:00
},
mounted () {
this.startWatchlistPriceRefresh()
},
beforeDestroy () {
if (this.watchlistPriceTimer) {
clearInterval(this.watchlistPriceTimer)
}
},
methods: {
filterSymbolOption (input, option) {
const value = option.componentOptions?.propsData?.value || ''
2026-01-31 02:59:49 +08:00
if (value === '__add_stock_option__') return true
2025-12-29 03:06:49 +08:00
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
2026-01-31 02:59:49 +08:00
// 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()
2025-12-29 03:06:49 +08:00
}
},
2026-01-31 02:59:49 +08:00
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'
},
2025-12-29 03:06:49 +08:00
getMarketColor (market) {
const colors = {
'USStock': 'green',
'Crypto': 'purple',
'Forex': 'gold',
2025-12-30 18:47:42 +08:00
'Futures': 'cyan',
'AShare': 'blue',
'HShare': 'orange'
2025-12-29 03:06:49 +08:00
}
return colors[market] || 'default'
},
getCurrencySymbol (market) {
const dollarMarkets = ['USStock', 'Crypto', 'Forex', 'Futures']
return dollarMarkets.includes(market) ? '$' : '¥'
},
2026-01-31 02:59:49 +08:00
async startFastAnalysis () {
2025-12-29 03:06:49 +08:00
if (!this.selectedSymbol) {
this.$message.warning(this.$t('dashboard.analysis.message.selectSymbol'))
return
}
this.analyzing = true
2026-01-31 02:59:49 +08:00
this.analysisError = null
2025-12-29 03:06:49 +08:00
const [market, symbol] = this.selectedSymbol.split(':')
const language = this.$store.getters.lang || 'zh-CN'
try {
2026-01-31 02:59:49 +08:00
const res = await fastAnalyze({
2025-12-29 03:06:49 +08:00
market: market,
symbol: symbol,
language: language,
timeframe: '1D'
2025-12-29 03:06:49 +08:00
})
if (res && res.code === 1 && res.data) {
2026-01-31 02:59:49 +08:00
this.analysisResult = res.data
this.$message.success(this.$t('dashboard.analysis.message.analysisComplete'))
2025-12-29 03:06:49 +08:00
} else {
2026-01-31 02:59:49 +08:00
throw new Error(res?.msg || '分析失败')
2025-12-29 03:06:49 +08:00
}
} catch (error) {
2026-01-31 02:59:49 +08:00
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 {
2025-12-29 03:06:49 +08:00
this.analyzing = false
}
},
async loadHistoryList () {
this.historyLoading = true
try {
2026-01-31 02:59:49 +08:00
const res = await getAllAnalysisHistory({
2025-12-29 03:06:49 +08:00
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) {
2026-01-31 02:59:49 +08:00
this.$message.error(this.$t('dashboard.analysis.message.loadHistoryFailed') || '加载历史记录失败')
2025-12-29 03:06:49 +08:00
} finally {
this.historyLoading = false
}
},
2026-01-31 02:59:49 +08:00
async viewHistoryResult (item) {
// 如果有完整结果,直接显示
if (item.full_result) {
this.analysisResult = item.full_result
this.selectedSymbol = `${item.market}:${item.symbol}`
this.showHistoryModal = false
2025-12-29 03:06:49 +08:00
return
}
2026-01-31 02:59:49 +08:00
// 否则使用历史记录中的基本信息构建显示
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
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
this.selectedSymbol = `${item.market}:${item.symbol}`
this.showHistoryModal = false
2025-12-29 03:06:49 +08:00
},
async deleteHistoryItem (item) {
try {
2026-01-31 02:59:49 +08:00
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'))
}
},
2025-12-29 03:06:49 +08:00
formatTime (timestamp) {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
return date.toLocaleString('zh-CN')
},
2026-01-31 02:59:49 +08:00
formatIsoTime (isoString) {
if (!isoString) return '-'
const date = new Date(isoString)
return date.toLocaleString('zh-CN')
},
2025-12-29 03:06:49 +08:00
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) {
2026-01-31 02:59:49 +08:00
// Silent fail
2025-12-29 03:06:49 +08:00
} 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) {
2026-01-31 02:59:49 +08:00
// Silent fail
2025-12-29 03:06:49 +08:00
} 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 = {}
2026-01-31 02:59:49 +08:00
const pricesObj = {}
2025-12-29 03:06:49 +08:00
res.data.forEach(item => {
priceMap[`${item.market}-${item.symbol}`] = item
2026-01-31 02:59:49 +08:00
// 同时填充 watchlistPrices 对象(使用 : 作为键)
pricesObj[`${item.market}:${item.symbol}`] = {
price: item.price || 0,
change: item.changePercent || 0
}
2025-12-29 03:06:49 +08:00
})
2026-01-31 02:59:49 +08:00
this.watchlistPrices = pricesObj
2025-12-29 03:06:49 +08:00
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) {
2026-01-31 02:59:49 +08:00
// Silent fail
2025-12-29 03:06:49 +08:00
}
},
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) {
2026-01-31 02:59:49 +08:00
if (!keyword || !keyword.trim()) return
2025-12-29 03:06:49 +08:00
if (!this.selectedMarketTab) {
this.$message.warning(this.$t('dashboard.analysis.modal.addStock.pleaseSelectMarket'))
return
}
2026-01-31 02:59:49 +08:00
if (this.symbolSearchResults.length > 0) return
2025-12-29 03:06:49 +08:00
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(),
2026-01-31 02:59:49 +08:00
name: ''
2025-12-29 03:06:49 +08:00
}
}
} catch (error) {
this.symbolSearchResults = []
this.selectedSymbolForAdd = {
market: this.selectedMarketTab,
symbol: keyword.trim().toUpperCase(),
2026-01-31 02:59:49 +08:00
name: ''
2025-12-29 03:06:49 +08:00
}
} 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(),
2026-01-31 02:59:49 +08:00
name: ''
2025-12-29 03:06:49 +08:00
}
},
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 : '')
}
2026-01-31 02:59:49 +08:00
if (!market) return
2025-12-29 03:06:49 +08:00
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
}
},
2026-01-31 02:59:49 +08:00
async removeFromWatchlist (stock) {
2025-12-29 03:06:49 +08:00
if (!this.userId) return
2026-01-31 02:59:49 +08:00
// 支持传入 stock 对象或单独的 symbol/market
const symbol = typeof stock === 'object' ? stock.symbol : stock
const market = typeof stock === 'object' ? stock.market : arguments[1]
2025-12-29 03:06:49 +08:00
try {
const res = await removeWatchlist({
userid: this.userId,
2026-01-31 02:59:49 +08:00
symbol: symbol,
market: market
2025-12-29 03:06:49 +08:00
})
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) {
2026-01-31 02:59:49 +08:00
if (typeof num === 'string') return num
2025-12-29 03:06:49 +08:00
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' },
2025-12-30 18:27:55 +08:00
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' },
2025-12-30 19:20:21 +08:00
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' }
2025-12-29 03:06:49 +08:00
]
}
} 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' },
2025-12-30 18:27:55 +08:00
{ value: 'Futures', i18nKey: 'dashboard.analysis.market.Futures' },
2025-12-30 19:20:21 +08:00
{ value: 'HShare', i18nKey: 'dashboard.analysis.market.HShare' },
{ value: 'AShare', i18nKey: 'dashboard.analysis.market.AShare' }
2025-12-29 03:06:49 +08:00
]
}
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;
2026-01-31 02:59:49 +08:00
height: calc(100vh - 120px);
2025-12-29 03:06:49 +08:00
background: #f0f2f5;
overflow: hidden;
2026-01-31 02:59:49 +08:00
width: 100%;
2025-12-29 03:06:49 +08:00
box-sizing: border-box;
}
2026-01-31 02:59:49 +08:00
// 全宽主内容
.main-content-full {
2025-12-29 03:06:49 +08:00
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);
2026-01-31 02:59:49 +08:00
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
// 顶部指数条
.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;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.indicator-box {
2025-12-29 03:06:49 +08:00
display: flex;
2026-01-31 02:59:49 +08:00
flex-direction: column;
2025-12-29 03:06:49 +08:00
align-items: center;
2026-01-31 02:59:49 +08:00
padding: 4px 10px;
background: #fff;
border-radius: 6px;
border: 1px solid #e2e8f0;
min-width: 50px;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.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 {
2025-12-29 03:06:49 +08:00
display: flex;
align-items: center;
2026-01-31 02:59:49 +08:00
gap: 4px;
padding: 4px 8px;
background: #fff;
border-radius: 4px;
border: 1px solid #e2e8f0;
font-size: 11px;
white-space: nowrap;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.idx-flag { font-size: 11px; }
.idx-symbol { color: #64748b; font-weight: 500; }
.idx-price { color: #1e293b; font-weight: 600; }
.idx-change {
font-weight: 600;
2025-12-29 03:06:49 +08:00
display: flex;
align-items: center;
2026-01-31 02:59:49 +08:00
gap: 1px;
&.up { color: #16a34a; }
&.down { color: #dc2626; }
2025-12-29 03:06:49 +08:00
}
}
}
2026-01-31 02:59:49 +08:00
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.refresh-btn {
color: #94a3b8;
flex-shrink: 0;
&:hover { color: #1e293b; }
}
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
// 主体三栏布局
.main-body {
flex: 1;
display: flex;
gap: 12px;
padding: 12px;
overflow: hidden;
min-height: 0;
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
// 左侧面板:热力图 + 财经日历
.left-panel {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.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;
2025-12-29 03:06:49 +08:00
}
}
2026-01-31 02:59:49 +08:00
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.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 {
2025-12-29 03:06:49 +08:00
display: flex;
2026-01-31 02:59:49 +08:00
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; }
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.cal-date {
font-size: 9px;
color: #94a3b8;
min-width: 32px;
font-weight: 500;
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
.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 {
2025-12-29 03:06:49 +08:00
font-weight: 600;
2026-01-31 02:59:49 +08:00
font-size: 10px;
2025-12-29 03:06:49 +08:00
display: flex;
align-items: center;
2026-01-31 02:59:49 +08:00
gap: 2px;
&.bullish { color: #16a34a; }
&.bearish { color: #dc2626; }
&.neutral { color: #94a3b8; }
2025-12-29 03:06:49 +08:00
}
}
2026-01-31 02:59:49 +08:00
.cal-empty { text-align: center; color: #94a3b8; padding: 20px 0; font-size: 12px; }
}
}
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
// 右侧面板:工具栏 + 分析结果
// 中间分析面板
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
background: #fff;
border-radius: 8px;
border: 1px solid #e2e8f0;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.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;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.symbol-selector {
flex: 1;
max-width: 320px;
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.analyze-button {
background: var(--primary-color, #1890ff);
border-color: var(--primary-color, #1890ff);
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.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; }
2025-12-29 03:06:49 +08:00
}
}
}
}
2026-01-31 02:59:49 +08:00
// 右侧自选股面板
.watchlist-panel {
width: 200px;
flex-shrink: 0;
2025-12-29 03:06:49 +08:00
background: #fff;
2026-01-31 02:59:49 +08:00
border-radius: 8px;
border: 1px solid #e2e8f0;
2025-12-29 03:06:49 +08:00
display: flex;
flex-direction: column;
overflow: hidden;
2026-01-31 02:59:49 +08:00
.panel-header {
2025-12-29 03:06:49 +08:00
display: flex;
align-items: center;
justify-content: space-between;
2026-01-31 02:59:49 +08:00
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
background: #fafafa;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.panel-title {
font-size: 12px;
2025-12-29 03:06:49 +08:00
font-weight: 600;
2026-01-31 02:59:49 +08:00
color: #64748b;
.anticon { color: #facc15; margin-right: 6px; }
2025-12-29 03:06:49 +08:00
}
}
2026-01-31 02:59:49 +08:00
.watchlist-list {
2025-12-29 03:06:49 +08:00
flex: 1;
overflow-y: auto;
2026-01-31 02:59:49 +08:00
padding: 8px;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 2px; }
2025-12-29 03:06:49 +08:00
.watchlist-item {
display: flex;
align-items: center;
2026-01-31 02:59:49 +08:00
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
&:hover { background: #f8fafc; }
&.active { background: #e6f7ff; border: 1px solid #91d5ff; }
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.item-main {
2025-12-29 03:06:49 +08:00
flex: 1;
2026-01-31 02:59:49 +08:00
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; }
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
.item-price {
2025-12-29 03:06:49 +08:00
text-align: right;
2026-01-31 02:59:49 +08:00
margin-right: 8px;
font-family: 'SF Mono', Monaco, monospace;
.price-value { display: block; font-size: 11px; font-weight: 600; color: #1e293b; }
2025-12-29 03:06:49 +08:00
.price-change {
2026-01-31 02:59:49 +08:00
display: block;
font-size: 10px;
2025-12-29 03:06:49 +08:00
font-weight: 600;
2026-01-31 02:59:49 +08:00
&.up { color: #16a34a; }
&.down { color: #dc2626; }
2025-12-29 03:06:49 +08:00
}
}
2026-01-31 02:59:49 +08:00
.item-remove {
color: #cbd5e1;
2025-12-29 03:06:49 +08:00
font-size: 12px;
2026-01-31 02:59:49 +08:00
opacity: 0;
transition: opacity 0.2s;
&:hover { color: #dc2626; }
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
&:hover .item-remove { opacity: 1; }
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
.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; }
}
2025-12-29 03:06:49 +08:00
}
}
@keyframes pulse {
2026-01-31 02:59:49 +08:00
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
/* Responsive */
2025-12-29 03:06:49 +08:00
@media (max-width: 992px) {
.ai-analysis-container {
height: auto;
min-height: calc(100vh - 64px);
overflow-y: auto;
}
2026-01-31 02:59:49 +08:00
.main-content-full {
2025-12-29 03:06:49 +08:00
height: auto;
2026-01-31 02:59:49 +08:00
min-height: auto;
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
.top-index-bar {
flex-wrap: wrap;
padding: 8px;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.indicator-box {
min-width: 45px;
padding: 3px 6px;
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
.indices-marquee {
order: 10;
2025-12-29 03:06:49 +08:00
width: 100%;
2026-01-31 02:59:49 +08:00
margin-top: 8px;
2025-12-29 03:06:49 +08:00
}
}
2026-01-31 02:59:49 +08:00
.main-body {
flex-direction: column;
2025-12-29 03:06:49 +08:00
padding: 12px;
2026-01-31 02:59:49 +08:00
}
.left-panel {
width: 100%;
flex-direction: row;
2025-12-29 03:06:49 +08:00
gap: 12px;
2026-01-31 02:59:49 +08:00
.heatmap-box, .calendar-box {
flex: 1;
min-width: 0;
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
.calendar-box {
max-height: 200px;
2025-12-29 03:06:49 +08:00
}
}
2026-01-31 02:59:49 +08:00
.right-panel {
.analysis-toolbar {
flex-wrap: wrap;
.symbol-selector { width: 100% !important; max-width: none !important; }
.analyze-button, .history-button { flex: 1; }
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.watchlist-panel {
width: 100%;
max-height: 200px;
order: -1;
2025-12-29 03:06:49 +08:00
}
}
2026-01-31 02:59:49 +08:00
/* Dark Theme */
.ai-analysis-container.theme-dark {
2025-12-29 03:06:49 +08:00
background: #131722;
color: #d1d4dc;
2026-01-31 02:59:49 +08:00
.main-content-full {
2025-12-29 03:06:49 +08:00
background: #1e222d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
2026-01-31 02:59:49 +08:00
}
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.top-index-bar {
background: #1e222d;
border-bottom-color: #363c4e;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
.indicator-box {
background: #2a2e39;
border-color: #363c4e;
.ind-label { color: #868993; }
.ind-value { color: #d1d4dc; }
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
.indices-marquee .index-item {
background: #2a2e39;
border-color: #363c4e;
.idx-symbol { color: #868993; }
.idx-price { color: #d1d4dc; }
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
.refresh-btn {
color: #868993;
&:hover { color: #d1d4dc; }
2025-12-29 03:06:49 +08:00
}
}
2026-01-31 02:59:49 +08:00
.watchlist-panel {
background: #2a2e39;
2025-12-29 03:06:49 +08:00
border-color: #363c4e;
2026-01-31 02:59:49 +08:00
.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;
2025-12-29 03:06:49 +08:00
color: #d1d4dc;
}
}
2026-01-31 02:59:49 +08:00
.analysis-main {
.analysis-placeholder .placeholder-content {
h3 { color: #d1d4dc; }
p { color: #868993; }
2025-12-29 03:06:49 +08:00
}
}
}
2026-01-31 02:59:49 +08:00
// 旧样式兼容
.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;
}
}
2025-12-29 03:06:49 +08:00
}
2026-01-31 02:59:49 +08:00
/* Add Stock Modal */
2025-12-29 03:06:49 +08:00
.add-stock-modal-content {
2026-01-31 02:59:49 +08:00
.market-tabs { margin-bottom: 16px; }
.symbol-search-section { margin-bottom: 24px; }
2025-12-29 03:06:49 +08:00
.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;
2026-01-31 02:59:49 +08:00
&:hover { background-color: #f5f5f5; }
2025-12-29 03:06:49 +08:00
.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;
}
}
}
2026-01-31 02:59:49 +08:00
/* 骨架屏加载动画 - 渐进式加载 */
.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;
2025-12-29 03:06:49 +08:00
2026-01-31 02:59:49 +08:00
&.short { width: 40px; height: 9px; }
2025-12-29 03:06:49 +08:00
}
}
2026-01-31 02:59:49 +08:00
.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;
}
}
2025-12-29 03:06:49 +08:00
</style>