美化了一下UI
This commit is contained in:
@@ -20,7 +20,7 @@ import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
@@ -147,7 +147,7 @@ class AIService:
|
||||
{
|
||||
"sentiment_score": 75,
|
||||
"operation_advice": "持有观望",
|
||||
"summary": "一句话总结基金投资价值和建议",
|
||||
"summary": "详细的分析总结,包含业绩、风险、经理等维度的综合评价(200-300字)",
|
||||
"dashboard": {
|
||||
"performance_eval": "优秀/良好/一般/较差",
|
||||
"manager_ability": "优秀/良好/一般/较差",
|
||||
@@ -156,13 +156,15 @@ class AIService:
|
||||
},
|
||||
"highlights": ["亮点1", "亮点2", "亮点3"],
|
||||
"risk_factors": ["风险1", "风险2", "风险3"],
|
||||
"news_intel": ["相关市场信息1", "相关市场信息2"]
|
||||
"news_intel": ["相关市场信息1", "相关市场信息2"],
|
||||
"detailed_report": "Markdown格式的详细深度分析报告,包含:1. 业绩归因分析;2. 风险收益特征;3. 经理管理风格;4. 后市策略建议。请使用二级和三级标题组织内容。"
|
||||
}
|
||||
```
|
||||
|
||||
**评分说明**:
|
||||
- sentiment_score: 0-100 分,越高表示越值得投资
|
||||
- operation_advice: 可选值为 "强烈推荐"、"建议买入"、"持有观望"、"建议减仓"、"建议卖出"
|
||||
- detailed_report: 请提供不少于500字的深度分析,使用 Markdown 格式
|
||||
"""
|
||||
|
||||
# 调用 LLM
|
||||
@@ -207,6 +209,32 @@ class AIService:
|
||||
- 估值时间:{realtime.get('estimate_time', '未知')}
|
||||
"""
|
||||
|
||||
# 添加业绩走势数据
|
||||
total_return_trend = fund_data.get('total_return_trend', [])
|
||||
if total_return_trend:
|
||||
prompt += "\n## 业绩走势(累计收益率,近3年月度采样)\n"
|
||||
for series in total_return_trend:
|
||||
name = series.get('name', '未知')
|
||||
data = series.get('data', [])
|
||||
if not data: continue
|
||||
|
||||
prompt += f"### {name}\n"
|
||||
sorted_data = sorted(data, key=lambda x: x.get('date', ''))
|
||||
sampled_points = []
|
||||
seen_months = set()
|
||||
|
||||
for point in reversed(sorted_data):
|
||||
date_str = point.get('date', '')
|
||||
if not date_str: continue
|
||||
month = date_str[:7]
|
||||
if month not in seen_months:
|
||||
sampled_points.insert(0, point)
|
||||
seen_months.add(month)
|
||||
if len(sampled_points) >= 36: break
|
||||
|
||||
for p in sampled_points:
|
||||
prompt += f"- {p.get('date')}: {p.get('value')}%\n"
|
||||
|
||||
# 添加风险指标
|
||||
if risk_metrics:
|
||||
prompt += f"""
|
||||
@@ -263,7 +291,7 @@ class AIService:
|
||||
|
||||
# 验证必要字段
|
||||
required_fields = ['sentiment_score', 'operation_advice', 'summary',
|
||||
'dashboard', 'highlights', 'risk_factors']
|
||||
'dashboard', 'highlights', 'risk_factors', 'detailed_report']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
data[field] = self._get_default_value(field)
|
||||
@@ -285,7 +313,8 @@ class AIService:
|
||||
},
|
||||
"highlights": ["数据分析中"],
|
||||
"risk_factors": ["请谨慎投资"],
|
||||
"news_intel": []
|
||||
"news_intel": [],
|
||||
"detailed_report": result if result else "暂无详细分析"
|
||||
}
|
||||
|
||||
def _get_default_value(self, field: str) -> Any:
|
||||
@@ -302,7 +331,8 @@ class AIService:
|
||||
},
|
||||
'highlights': [],
|
||||
'risk_factors': [],
|
||||
'news_intel': []
|
||||
'news_intel': [],
|
||||
'detailed_report': '暂无详细分析报告'
|
||||
}
|
||||
return defaults.get(field, None)
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
|
||||
DB_PATH = r'c:\Users\Sebastian\Desktop\GoFundBot\MyBot\Data\funds.db'
|
||||
|
||||
def check_db():
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check daily_market_summary
|
||||
cursor.execute("SELECT date, detailed_analysis_json, hot_sectors_json FROM daily_market_summary ORDER BY date DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
date, detailed_json, hot_sectors = row
|
||||
print(f"Date: {date}")
|
||||
print(f"Hot Sectors: {hot_sectors}")
|
||||
print(f"Detailed Analysis (Raw): {detailed_json}")
|
||||
|
||||
if detailed_json:
|
||||
try:
|
||||
data = json.loads(detailed_json)
|
||||
print(f"Detailed Analysis (Parsed): {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
except:
|
||||
print("Detailed Analysis JSON parse failed")
|
||||
else:
|
||||
print("Detailed Analysis is NULL or Empty")
|
||||
else:
|
||||
print("No records found in daily_market_summary")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_db()
|
||||
@@ -1,40 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Add Backend directory to path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from llm_service import get_llm_service
|
||||
|
||||
def refresh():
|
||||
print("Starting manual refresh...")
|
||||
try:
|
||||
service = get_llm_service()
|
||||
if not service.is_available():
|
||||
print("LLM Service not available (check API keys)")
|
||||
return
|
||||
|
||||
today_key = datetime.now().strftime("%Y-%m-%d")
|
||||
today_str = datetime.now().strftime("%Y年%m月%d日")
|
||||
|
||||
print(f"Regenerating for {today_str}...")
|
||||
|
||||
# Manually trigger the generation
|
||||
service._update_progress(today_key, 1, '正在根据新模型重新分析市场...')
|
||||
result = service._do_generate_market_summary(today_key, today_str)
|
||||
|
||||
if 'error' in result:
|
||||
service._save_market_summary(today_key, None, status='error', error=result['error'])
|
||||
print(f"Error: {result['error']}")
|
||||
else:
|
||||
service._save_market_summary(today_key, result, status='completed')
|
||||
print("Market summary updated successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exception during refresh: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
refresh()
|
||||
@@ -6,10 +6,13 @@
|
||||
<h3>AI 智能分析</h3>
|
||||
<span class="badge" v-if="data">已分析</span>
|
||||
</div>
|
||||
<button v-if="!loading" @click="analyze" class="analyze-btn" :class="{ 'has-data': data }">
|
||||
<span class="btn-icon">{{ data ? '🔄' : '✨' }}</span>
|
||||
{{ data ? '重新分析' : '开始分析' }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading" @click="analyze" class="analyze-btn" :class="{ 'has-data': data }">
|
||||
<span class="btn-icon">{{ data ? '🔄' : '✨' }}</span>
|
||||
{{ data ? '重新分析' : '开始分析' }}
|
||||
</button>
|
||||
<button class="close-btn" @click="$emit('close')" title="关闭">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
@@ -94,6 +97,15 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 深度分析报告 -->
|
||||
<div class="detailed-report" v-if="data.detailed_report">
|
||||
<div class="report-header">
|
||||
<span class="report-icon">📑</span>
|
||||
<h4>深度分析报告</h4>
|
||||
</div>
|
||||
<div class="markdown-content" v-html="parsedReport"></div>
|
||||
</div>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<div class="disclaimer">
|
||||
💡 以上分析由 AI 生成,仅供参考,不构成投资建议。投资有风险,入市需谨慎。
|
||||
@@ -119,10 +131,19 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'analysis-complete'])
|
||||
|
||||
const data = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// 监听数据变化,分析完成后通知父组件
|
||||
watch(data, (newVal) => {
|
||||
if (newVal) {
|
||||
emit('analysis-complete', newVal)
|
||||
}
|
||||
})
|
||||
|
||||
// 仪表盘配置
|
||||
const dashboardItems = {
|
||||
performance_eval: { label: '业绩评价', icon: '📈' },
|
||||
@@ -158,6 +179,49 @@ const scoreProgress = computed(() => {
|
||||
return `${progress} 283`
|
||||
})
|
||||
|
||||
// 解析 Markdown
|
||||
const parsedReport = computed(() => {
|
||||
if (!data.value || !data.value.detailed_report) return ''
|
||||
const lines = data.value.detailed_report.split('\n')
|
||||
let html = ''
|
||||
let inList = false
|
||||
|
||||
lines.forEach(line => {
|
||||
line = line.trim()
|
||||
if (!line) return
|
||||
|
||||
// Header 3
|
||||
if (line.startsWith('### ')) {
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
html += `<h4>${line.substring(4)}</h4>`
|
||||
}
|
||||
// Header 2
|
||||
else if (line.startsWith('## ')) {
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
html += `<h3>${line.substring(3)}</h3>`
|
||||
}
|
||||
// List item
|
||||
else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
if (!inList) { html += '<ul>'; inList = true; }
|
||||
let content = line.substring(2)
|
||||
// Bold
|
||||
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
html += `<li>${content}</li>`
|
||||
}
|
||||
// Paragraph
|
||||
else {
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
let content = line
|
||||
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
html += `<p>${content}</p>`
|
||||
}
|
||||
})
|
||||
|
||||
if (inList) html += '</ul>'
|
||||
|
||||
return html
|
||||
})
|
||||
|
||||
// 建议样式类
|
||||
const adviceClass = computed(() => {
|
||||
if (!data.value) return ''
|
||||
@@ -207,6 +271,10 @@ watch(() => props.fundCode, () => {
|
||||
data.value = null
|
||||
error.value = null
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
analyze
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -214,7 +282,6 @@ watch(() => props.fundCode, () => {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
@@ -250,41 +317,66 @@ watch(() => props.fundCode, () => {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.analyze-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.analyze-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.5);
|
||||
.analyze-btn.mini {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.analyze-btn.has-data {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
|
||||
.analyze-btn.mini:hover {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1.1em;
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #999;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 评分区域 */
|
||||
.score-section {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.score-card {
|
||||
@@ -366,6 +458,63 @@ watch(() => props.fundCode, () => {
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
/* 深度分析报告 */
|
||||
.detailed-report {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-icon {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.detailed-report h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
color: #444;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h3) {
|
||||
font-size: 1.1em;
|
||||
color: #1890ff;
|
||||
margin: 16px 0 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h4) {
|
||||
font-size: 1em;
|
||||
color: #555;
|
||||
margin: 12px 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content :deep(p) {
|
||||
margin-bottom: 12px;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul) {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.markdown-content :deep(li) {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-section p {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
@@ -375,7 +524,7 @@ watch(() => props.fundCode, () => {
|
||||
/* 仪表盘 */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -41,20 +41,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 分析按钮区域 -->
|
||||
<div class="header-middle-group">
|
||||
<button class="ai-analysis-btn" @click="$emit('trigger-ai-analysis')">
|
||||
<span class="ai-icon">🤖</span>
|
||||
<span class="btn-text">AI 智能分析</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 涨跌幅单独展示 -->
|
||||
<div class="change-box">
|
||||
<div class="label">估算涨幅</div>
|
||||
<div class="value" :class="getChangeClass(fundInfo.gszzl)">
|
||||
{{ fundInfo.gszzl ? (fundInfo.gszzl > 0 ? '+' : '') + fundInfo.gszzl + '%' : '--' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="net-worth-box">
|
||||
<div class="label">单位净值</div>
|
||||
<div class="value">{{ fundInfo.dwjz || '--' }}</div>
|
||||
<div class="date">{{ formatDate(fundInfo.jzrq) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="estimate-box">
|
||||
<div class="label">估算净值</div>
|
||||
<div class="value" :class="getChangeClass(fundInfo.gszzl)">
|
||||
{{ fundInfo.gsz || '--' }}
|
||||
</div>
|
||||
<div class="change" :class="getChangeClass(fundInfo.gszzl)">
|
||||
{{ fundInfo.gszzl ? (fundInfo.gszzl > 0 ? '+' : '') + fundInfo.gszzl + '%' : '--' }}
|
||||
</div>
|
||||
<div class="time">{{ formatTime(fundInfo.gztime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,16 +377,52 @@ export default {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
.header-middle-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-analysis-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.ai-analysis-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 32px; /* 增加间距 */
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.change-box,
|
||||
.net-worth-box,
|
||||
.estimate-box {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.change-box .label,
|
||||
.net-worth-box .label,
|
||||
.estimate-box .label {
|
||||
font-size: 12px;
|
||||
@@ -380,6 +430,7 @@ export default {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.change-box .value,
|
||||
.net-worth-box .value,
|
||||
.estimate-box .value {
|
||||
font-size: 28px;
|
||||
@@ -387,18 +438,7 @@ export default {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.net-worth-box .date {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.estimate-box .change {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.net-worth-box .date,
|
||||
.estimate-box .time {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
<template>
|
||||
<div class="fund-detail">
|
||||
<!-- 基金基础信息组件 -->
|
||||
<FundBasicInfo :fundCode="currentFundCode" :fundData="fundDetail" :riskMetrics="riskMetrics" />
|
||||
<FundBasicInfo
|
||||
:fundCode="currentFundCode"
|
||||
:fundData="fundDetail"
|
||||
:riskMetrics="riskMetrics"
|
||||
@trigger-ai-analysis="handleStartAIAnalysis"
|
||||
/>
|
||||
|
||||
<!-- AI 智能分析区域 -->
|
||||
<div v-show="showAIAnalysis" class="ai-analysis-section">
|
||||
<FundAIAnalysis
|
||||
ref="fundAIAnalysisRef"
|
||||
:fundCode="currentFundCode"
|
||||
@close="showAIAnalysis = false"
|
||||
@analysis-complete="handleAnalysisComplete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 - Dashboard 布局 -->
|
||||
<div v-if="fundDetail" class="dashboard">
|
||||
|
||||
<!-- 左侧主区域 -->
|
||||
<div class="main-area">
|
||||
<!-- AI 分析 -->
|
||||
<FundAIAnalysis :fundCode="currentFundCode" />
|
||||
|
||||
<!-- 净值走势图 -->
|
||||
<div class="card card-chart">
|
||||
<FundChart
|
||||
@@ -196,20 +208,32 @@ export default {
|
||||
const error = ref('')
|
||||
const modalVisible = ref(false)
|
||||
const modalType = ref('')
|
||||
const fundAIAnalysisRef = ref(null)
|
||||
const showAIAnalysis = ref(false)
|
||||
const aiAnalysisData = ref(null)
|
||||
|
||||
// 处理开启AI分析
|
||||
const handleStartAIAnalysis = () => {
|
||||
showAIAnalysis.value = true
|
||||
// 等待DOM更新后调用分析方法
|
||||
setTimeout(() => {
|
||||
if (fundAIAnalysisRef.value) {
|
||||
fundAIAnalysisRef.value.analyze()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// 处理AI分析完成
|
||||
const handleAnalysisComplete = (data) => {
|
||||
aiAnalysisData.value = data
|
||||
}
|
||||
|
||||
// 计算风险指标
|
||||
const riskMetrics = computed(() => {
|
||||
if (!fundDetail.value?.net_worth_trend || fundDetail.value.net_worth_trend.length < 30) {
|
||||
return null
|
||||
}
|
||||
if (!fundDetail.value?.net_worth_trend) return null
|
||||
|
||||
try {
|
||||
const trend = fundDetail.value.net_worth_trend
|
||||
const sortedData = [...trend].sort((a, b) => {
|
||||
const dateA = new Date(a.date).getTime()
|
||||
const dateB = new Date(b.date).getTime()
|
||||
return dateA - dateB
|
||||
})
|
||||
const sortedData = [...fundDetail.value.net_worth_trend].sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
|
||||
// 转换为净值数组
|
||||
const values = sortedData.map(item => parseFloat(item.net_worth)).filter(v => !isNaN(v))
|
||||
@@ -452,7 +476,12 @@ export default {
|
||||
modalVisible,
|
||||
modalType,
|
||||
openModal,
|
||||
closeModal
|
||||
closeModal,
|
||||
fundAIAnalysisRef,
|
||||
showAIAnalysis,
|
||||
handleStartAIAnalysis,
|
||||
aiAnalysisData,
|
||||
handleAnalysisComplete
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,6 +504,17 @@ export default {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
/* AI 分析区域 */
|
||||
.ai-analysis-section {
|
||||
margin-bottom: 16px;
|
||||
animation: slideDown 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 左侧主区域 */
|
||||
.main-area {
|
||||
display: flex;
|
||||
|
||||
@@ -1,33 +1,75 @@
|
||||
<!-- 市场概览组件:指数、金价、成交量、上证分<EFBFBD>?-->
|
||||
<template>
|
||||
<template>
|
||||
<div class="market-overview-container">
|
||||
<!-- 全球市场指数 -->
|
||||
<!-- 1. 近30分钟上证指数 (置顶 & 折线图) -->
|
||||
<div class="market-section" v-if="showSSE30Min">
|
||||
<div class="section-header">
|
||||
<h3>📉 上证指数实时走势 (近30分)</h3>
|
||||
<span class="update-tag" v-if="updateTime">{{ updateTime.split(' ')[1] }} 更新</span>
|
||||
</div>
|
||||
<div class="chart-container sse-chart-container">
|
||||
<v-chart class="chart" :option="sseOption" autoresize v-if="sse30Min.length" />
|
||||
<div v-else class="empty-state">A股未开盘</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 全球市场指数 (分组展示) -->
|
||||
<div class="market-section">
|
||||
<div class="section-header">
|
||||
<h3>🌍 全球市场指数</h3>
|
||||
<h3>🌍 全球行情</h3>
|
||||
<button class="refresh-btn" @click="fetchAll" :disabled="loading">
|
||||
<span :class="{ 'spinning': loading }">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="index-grid" v-if="marketIndex.length">
|
||||
<div
|
||||
v-for="item in marketIndex"
|
||||
:key="item.name"
|
||||
class="index-card"
|
||||
:class="{
|
||||
'up': !item.change_pct.startsWith('-'),
|
||||
'down': item.change_pct.startsWith('-')
|
||||
}"
|
||||
>
|
||||
<div class="index-name">{{ item.name }}</div>
|
||||
<div class="index-price">{{ item.price }}</div>
|
||||
<div class="index-change">{{ item.change_pct }}</div>
|
||||
|
||||
<!-- A股 -->
|
||||
<div class="market-sub-section">
|
||||
<h4 class="sub-title"><span class="flag">🇨🇳</span> A股市场</h4>
|
||||
<div class="index-grid" v-if="indices.aShare.length">
|
||||
<div v-for="item in indices.aShare" :key="item.name" class="index-card" :class="getUpDnClass(item.change_pct)">
|
||||
<div class="index-name">{{ item.name }}</div>
|
||||
<div class="index-price">{{ item.price }}</div>
|
||||
<div class="index-change">{{ item.change_pct }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 港股 -->
|
||||
<div class="market-sub-section">
|
||||
<h4 class="sub-title"><span class="flag">🇭🇰</span> 港股市场</h4>
|
||||
<div class="index-grid" v-if="indices.hkShare.length">
|
||||
<div v-for="item in indices.hkShare" :key="item.name" class="index-card" :class="getUpDnClass(item.change_pct)">
|
||||
<div class="index-name">{{ item.name }}</div>
|
||||
<div class="index-price">{{ item.price }}</div>
|
||||
<div class="index-change">{{ item.change_pct }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 美股 -->
|
||||
<div class="market-sub-section">
|
||||
<h4 class="sub-title"><span class="flag">🇺🇸</span> 美股市场</h4>
|
||||
<div class="index-grid" v-if="indices.usShare.length">
|
||||
<div v-for="item in indices.usShare" :key="item.name" class="index-card" :class="getUpDnClass(item.change_pct)">
|
||||
<div class="index-name">{{ item.name }}</div>
|
||||
<div class="index-price">{{ item.price }}</div>
|
||||
<div class="index-change">{{ item.change_pct }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时贵金属 -->
|
||||
<!-- 3. 近7日A股成交量 (柱状图) -->
|
||||
<div class="market-section">
|
||||
<div class="section-header">
|
||||
<h3>📊 近7日A股成交量</h3>
|
||||
</div>
|
||||
<div class="chart-container volume-chart-container">
|
||||
<v-chart class="chart" :option="volumeOption" autoresize v-if="aVolume.length" />
|
||||
<div v-else class="empty-state">暂无成交量数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 实时贵金属 -->
|
||||
<div class="market-section">
|
||||
<div class="section-header">
|
||||
<h3>🥇 实时贵金属</h3>
|
||||
@@ -48,19 +90,14 @@
|
||||
<span>{{ item.change >= 0 ? '+' : '' }}{{ item.change }}</span>
|
||||
<span class="pct">{{ item.change_pct }}</span>
|
||||
</div>
|
||||
<div class="gold-detail">
|
||||
<span>高 {{ item.high }}</span>
|
||||
<span>低 {{ item.low }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 黄金历史价格 -->
|
||||
<!-- 5. 黄金历史价格 (保持折叠功能) -->
|
||||
<div class="market-section" v-if="showGoldHistory">
|
||||
<div class="section-header">
|
||||
<h3>📊 黄金历史价格</h3>
|
||||
<h3>📜 黄金历史价格</h3>
|
||||
<button class="toggle-btn" @click="goldHistoryExpanded = !goldHistoryExpanded">
|
||||
{{ goldHistoryExpanded ? '收起' : '展开' }}
|
||||
</button>
|
||||
@@ -70,15 +107,15 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>中国黄金基础金价</th>
|
||||
<th>中国黄金</th>
|
||||
<th>涨跌</th>
|
||||
<th>周大福金价</th>
|
||||
<th>周大福</th>
|
||||
<th>涨跌</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in goldHistory" :key="item.date">
|
||||
<td>{{ item.date }}</td>
|
||||
<td>{{ formatDate(item.date) }}</td>
|
||||
<td>{{ item.china_gold_price }}</td>
|
||||
<td :class="getChangeClass(item.china_gold_change)">{{ item.china_gold_change }}</td>
|
||||
<td>{{ item.zhoudafu_price }}</td>
|
||||
@@ -88,129 +125,28 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 近7日A股成交量 -->
|
||||
<div class="market-section">
|
||||
<div class="section-header">
|
||||
<h3>📈 近7日A股成交量</h3>
|
||||
</div>
|
||||
<div v-if="aVolume.length" class="volume-chart">
|
||||
<div class="volume-bars">
|
||||
<div
|
||||
v-for="item in aVolume"
|
||||
:key="item.date"
|
||||
class="volume-bar-wrapper"
|
||||
>
|
||||
<div
|
||||
class="volume-bar"
|
||||
:style="{ height: getBarHeight(item.total) + '%' }"
|
||||
>
|
||||
<span class="bar-value">{{ item.total }}</span>
|
||||
</div>
|
||||
<span class="bar-date">{{ formatDate(item.date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="volume-detail">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>总成交额</th>
|
||||
<th>上交所</th>
|
||||
<th>深交所</th>
|
||||
<th>北交所</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in aVolume.slice(0, 5)" :key="item.date">
|
||||
<td>{{ item.date }}</td>
|
||||
<td class="highlight">{{ item.total }}</td>
|
||||
<td>{{ item.shanghai }}</td>
|
||||
<td>{{ item.shenzhen }}</td>
|
||||
<td>{{ item.beijing }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 近30分钟上证指数 -->
|
||||
<div class="market-section" v-if="showSSE30Min">
|
||||
<div class="section-header">
|
||||
<h3>📉 近30分钟上证指数</h3>
|
||||
<button class="toggle-btn" @click="sse30MinExpanded = !sse30MinExpanded">
|
||||
{{ sse30MinExpanded ? '收起' : '展开' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="sse30MinExpanded && sse30Min.length" class="sse-chart">
|
||||
<div class="sse-mini-chart">
|
||||
<svg viewBox="0 0 300 60" class="price-line">
|
||||
<polyline
|
||||
:points="getChartPoints()"
|
||||
fill="none"
|
||||
:stroke="getChartColor()"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sse-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>指数</th>
|
||||
<th>涨跌</th>
|
||||
<th>涨跌</th>
|
||||
<th>成交量</th>
|
||||
<th>成交额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in sse30Min.slice(-10)" :key="item.time">
|
||||
<td>{{ item.time }}</td>
|
||||
<td class="price">{{ item.price }}</td>
|
||||
<td :class="getChangeClass(item.change)">{{ item.change }}</td>
|
||||
<td :class="getChangeClass(item.change)">{{ item.change_pct }}</td>
|
||||
<td>{{ item.volume }}</td>
|
||||
<td>{{ item.amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="updateTime" class="update-time">
|
||||
更新于 {{ updateTime }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { marketAPI } from '../services/api'
|
||||
import { use } from "echarts/core"
|
||||
import { CanvasRenderer } from "echarts/renderers"
|
||||
import { LineChart, BarChart } from "echarts/charts"
|
||||
import { GridComponent, TooltipComponent, TitleComponent, LegendComponent, DataZoomComponent } from "echarts/components"
|
||||
import VChart from "vue-echarts"
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, TitleComponent, LegendComponent, DataZoomComponent])
|
||||
|
||||
export default {
|
||||
name: 'MarketOverview',
|
||||
components: { VChart },
|
||||
props: {
|
||||
showGoldHistory: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showSSE30Min: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
autoRefresh: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 60000
|
||||
}
|
||||
showGoldHistory: { type: Boolean, default: true },
|
||||
showSSE30Min: { type: Boolean, default: true },
|
||||
autoRefresh: { type: Boolean, default: true },
|
||||
refreshInterval: { type: Number, default: 60000 }
|
||||
},
|
||||
setup(props) {
|
||||
const loading = ref(false)
|
||||
@@ -220,45 +156,156 @@ export default {
|
||||
const aVolume = ref([])
|
||||
const sse30Min = ref([])
|
||||
const updateTime = ref('')
|
||||
const goldHistoryExpanded = ref(false)
|
||||
const sse30MinExpanded = ref(false)
|
||||
const goldHistoryExpanded = ref(true)
|
||||
let refreshTimer = null
|
||||
|
||||
// 指数分组
|
||||
const indices = computed(() => {
|
||||
const all = marketIndex.value
|
||||
return {
|
||||
aShare: all.filter(i => ['上证指数','深证成指','创业板指','科创50','沪深300','上证50','中证500','中小100'].some(n => i.name.includes(n))),
|
||||
hkShare: all.filter(i => ['恒生'].some(n => i.name.includes(n)) || i.name === '国企指数'),
|
||||
usShare: all.filter(i => ['纳斯达克','道琼斯','标普500'].some(n => i.name.includes(n)))
|
||||
}
|
||||
})
|
||||
|
||||
// 上证指数图表配置
|
||||
const sseOption = computed(() => {
|
||||
if (!sse30Min.value.length) return {}
|
||||
|
||||
const times = sse30Min.value.map(i => i.time.split(' ')[1] || i.time)
|
||||
const prices = sse30Min.value.map(i => parseFloat(i.price))
|
||||
// 计算涨跌色:基于第一笔数据
|
||||
const basePrice = prices[0]
|
||||
const isUp = prices[prices.length - 1] >= basePrice
|
||||
const color = isUp ? '#f5222d' : '#52c41a' // 红涨绿跌
|
||||
|
||||
return {
|
||||
grid: { top: 10, right: 10, bottom: 20, left: 50, containLabel: false },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
const p = params[0]
|
||||
if (!p) return ''
|
||||
const item = sse30Min.value[p.dataIndex]
|
||||
return `
|
||||
<div>${item.time}</div>
|
||||
<div style="font-weight:bold;color:${color}">${item.price}</div>
|
||||
<div>${item.change} (${item.change_pct})</div>
|
||||
<div>量: ${item.volume}</div>
|
||||
`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: '#ddd' } },
|
||||
axisLabel: { color: '#999', fontSize: 10 },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true, // 不从0开始
|
||||
splitLine: { lineStyle: { type: 'dashed', color: '#eee' } },
|
||||
axisLabel: { color: '#999', fontSize: 10 }
|
||||
},
|
||||
series: [{
|
||||
data: prices,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: color },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: isUp ? 'rgba(245,34,45,0.2)' : 'rgba(82,196,26,0.2)' },
|
||||
{ offset: 1, color: isUp ? 'rgba(245,34,45,0)' : 'rgba(82,196,26,0)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
// 成交量图表配置
|
||||
const volumeOption = computed(() => {
|
||||
if (!aVolume.value.length) return {}
|
||||
|
||||
const dates = aVolume.value.map(i => formatDate(i.date))
|
||||
const values = aVolume.value.map(i => parseFloat(i.total.replace('亿', '')))
|
||||
|
||||
return {
|
||||
grid: { top: 30, right: 10, bottom: 20, left: 10, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
const idx = params[0].dataIndex
|
||||
const item = aVolume.value[idx]
|
||||
return `
|
||||
<b>${item.date}</b><br/>
|
||||
总成交: ${item.total}<br/>
|
||||
沪: ${item.shanghai}<br/>
|
||||
深: ${item.shenzhen}<br/>
|
||||
北: ${item.beijing}
|
||||
`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLine: { lineStyle: { color: '#ddd' } },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { type: 'dashed', color: '#eee' } }
|
||||
},
|
||||
series: [{
|
||||
data: values,
|
||||
type: 'bar',
|
||||
barWidth: '40%',
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#1890ff' },
|
||||
{ offset: 1, color: '#69c0ff' }
|
||||
]
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: '{c}亿',
|
||||
color: '#666',
|
||||
fontSize: 10
|
||||
}
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const fetchAll = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await marketAPI.getOverview()
|
||||
if (response.data.success) {
|
||||
const data = response.data
|
||||
|
||||
if (data.market_index?.success) {
|
||||
marketIndex.value = data.market_index.data
|
||||
}
|
||||
if (data.gold_realtime?.success) {
|
||||
goldRealtime.value = data.gold_realtime.data
|
||||
}
|
||||
if (data.sector_rank?.success) {
|
||||
// 板块数据<E695B0>?SectorRank 组件单独处理
|
||||
}
|
||||
if (data.a_volume_7days?.success) {
|
||||
aVolume.value = data.a_volume_7days.data
|
||||
}
|
||||
if (data.sse_30min?.success) {
|
||||
sse30Min.value = data.sse_30min.data
|
||||
}
|
||||
|
||||
if (data.market_index?.success) marketIndex.value = data.market_index.data
|
||||
if (data.gold_realtime?.success) goldRealtime.value = data.gold_realtime.data
|
||||
if (data.a_volume_7days?.success) aVolume.value = data.a_volume_7days.data.reverse() // 按时间正序
|
||||
if (data.sse_30min?.success) sse30Min.value = data.sse_30min.data
|
||||
updateTime.value = data.update_time
|
||||
}
|
||||
|
||||
// 单独获取黄金历史(不在overview 中)
|
||||
if (props.showGoldHistory) {
|
||||
const historyRes = await marketAPI.getGoldHistory(10)
|
||||
if (historyRes.data.success) {
|
||||
goldHistory.value = historyRes.data.data
|
||||
}
|
||||
if (historyRes.data.success) goldHistory.value = historyRes.data.data
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取市场数据失败:', e)
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -266,43 +313,20 @@ export default {
|
||||
|
||||
const getChangeClass = (change) => {
|
||||
if (!change) return ''
|
||||
const str = String(change)
|
||||
if (str.startsWith('-')) return 'down'
|
||||
if (str !== '0' && str !== '0%') return 'up'
|
||||
return ''
|
||||
return String(change).startsWith('-') ? 'down' : 'up'
|
||||
}
|
||||
|
||||
const getUpDnClass = (pct) => {
|
||||
if (!pct) return ''
|
||||
const val = parseFloat(pct)
|
||||
if (isNaN(val) || val === 0) return ''
|
||||
return pct.startsWith('-') ? 'down' : 'up'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const parts = dateStr.split('-')
|
||||
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
|
||||
}
|
||||
|
||||
const getBarHeight = (total) => {
|
||||
if (!total) return 0
|
||||
const num = parseFloat(total.replace('亿', ''))
|
||||
const max = Math.max(...aVolume.value.map(v => parseFloat(v.total.replace('亿', ''))))
|
||||
return max > 0 ? (num / max) * 80 : 0
|
||||
}
|
||||
|
||||
const getChartPoints = () => {
|
||||
if (!sse30Min.value.length) return ''
|
||||
const prices = sse30Min.value.map(d => parseFloat(d.price))
|
||||
const min = Math.min(...prices)
|
||||
const max = Math.max(...prices)
|
||||
const range = max - min || 1
|
||||
|
||||
return sse30Min.value.map((d, i) => {
|
||||
const x = (i / (sse30Min.value.length - 1)) * 300
|
||||
const y = 55 - ((parseFloat(d.price) - min) / range) * 50
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
const getChartColor = () => {
|
||||
if (!sse30Min.value.length) return '#999'
|
||||
const first = sse30Min.value[0]
|
||||
return first.change && first.change.startsWith('-') ? '#27ae60' : '#e74c3c'
|
||||
return parts.length >= 3 ? `${parts[1]}-${parts[2]}` : dateStr
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -313,27 +337,16 @@ export default {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
}
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
loading,
|
||||
marketIndex,
|
||||
goldRealtime,
|
||||
goldHistory,
|
||||
aVolume,
|
||||
sse30Min,
|
||||
updateTime,
|
||||
goldHistoryExpanded,
|
||||
sse30MinExpanded,
|
||||
fetchAll,
|
||||
getChangeClass,
|
||||
formatDate,
|
||||
getBarHeight,
|
||||
getChartPoints,
|
||||
getChartColor
|
||||
loading, fetchAll,
|
||||
marketIndex, indices,
|
||||
goldRealtime, goldHistory, goldHistoryExpanded,
|
||||
aVolume, updateTime, sse30Min,
|
||||
formatDate, getChangeClass, getUpDnClass,
|
||||
sseOption, volumeOption
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,10 +360,10 @@ export default {
|
||||
}
|
||||
|
||||
.market-section {
|
||||
background: var(--card-bg, #fff);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -358,254 +371,141 @@ export default {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
font-size: 1.1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.refresh-btn, .toggle-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-secondary, #666);
|
||||
/* 上证指数 */
|
||||
.sse-chart-container {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.refresh-btn:hover, .toggle-btn:hover {
|
||||
background: var(--hover-bg, #f5f5f5);
|
||||
border-color: var(--primary-color, #81D8CF);
|
||||
color: var(--primary-color, #81D8CF);
|
||||
/* 全球市场 */
|
||||
.market-sub-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
display: inline-block;
|
||||
animation: spin 1s linear infinite;
|
||||
.market-sub-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
.sub-title {
|
||||
font-size: 0.95em;
|
||||
color: #666;
|
||||
margin: 0 0 10px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 指数网格 */
|
||||
.index-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.index-card {
|
||||
background: var(--item-bg, #f9f9f9);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.index-card.up {
|
||||
border-color: rgba(231, 76, 60, 0.3);
|
||||
background: rgba(231, 76, 60, 0.05);
|
||||
.index-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.index-card.down {
|
||||
border-color: rgba(39, 174, 96, 0.3);
|
||||
background: rgba(39, 174, 96, 0.05);
|
||||
.index-card.up { background: #fff1f0; border-color: #ffa39e; }
|
||||
.index-card.down { background: #f6ffed; border-color: #b7eb8f; }
|
||||
|
||||
.index-name { font-size: 0.85em; color: #666; margin-bottom: 4px; }
|
||||
.index-price { font-weight: bold; font-size: 1.1em; color: #333; }
|
||||
.index-card.up .index-price, .index-card.up .index-change { color: #f5222d; }
|
||||
.index-card.down .index-price, .index-card.down .index-change { color: #52c41a; }
|
||||
.index-change { font-size: 0.8em; margin-top: 2px; }
|
||||
|
||||
/* 成交量图表 */
|
||||
.volume-chart-container {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.index-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.index-price {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.index-change {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.index-card.up .index-change { color: #e74c3c; }
|
||||
.index-card.down .index-change { color: #27ae60; }
|
||||
|
||||
/* 贵金属网<E5B19E>?*/
|
||||
/* 黄金 */
|
||||
.gold-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gold-card {
|
||||
background: var(--item-bg, #f9f9f9);
|
||||
padding: 12px;
|
||||
background: #fffcf0;
|
||||
border: 1px solid #ffe58f;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
border-left: 3px solid transparent;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gold-card.up { border-left-color: #e74c3c; }
|
||||
.gold-card.down { border-left-color: #27ae60; }
|
||||
.gold-name { font-size: 0.9em; color: #666; margin-bottom: 4px; }
|
||||
.gold-price { font-weight: bold; font-size: 1.2em; color: #fa8c16; }
|
||||
.gold-change { font-size: 0.85em; margin-top: 4px; display: flex; justify-content: center; gap: 6px; }
|
||||
.gold-change .pct { padding: 0 4px; border-radius: 4px; }
|
||||
.gold-card.up .pct { background: #fff1f0; color: #f5222d; }
|
||||
.gold-card.down .pct { background: #e6fffb; color: #13c2c2; } /* Try teal for down or green */
|
||||
.gold-card.down .pct { background: #f6ffed; color: #52c41a; }
|
||||
|
||||
.gold-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.gold-price {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.gold-price .unit {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary, #999);
|
||||
}
|
||||
|
||||
.gold-change {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gold-card.up .gold-change { color: #e74c3c; }
|
||||
.gold-card.down .gold-change { color: #27ae60; }
|
||||
|
||||
.gold-change .pct {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.gold-detail {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.history-table, .volume-detail, .sse-table {
|
||||
/* 历史表格 */
|
||||
.history-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px 12px;
|
||||
padding: 8px;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { color: #999; font-weight: normal; }
|
||||
td.up { color: #f5222d; }
|
||||
td.down { color: #52c41a; }
|
||||
|
||||
th {
|
||||
background: var(--table-header-bg, #f5f5f5);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
th:first-child, td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color, #81D8CF);
|
||||
}
|
||||
|
||||
.up { color: #e74c3c; }
|
||||
.down { color: #27ae60; }
|
||||
|
||||
/* 成交量柱状图 */
|
||||
.volume-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.volume-bars {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: flex-end;
|
||||
height: 100px;
|
||||
padding: 10px 0;
|
||||
background: var(--item-bg, #f9f9f9);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.volume-bar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.volume-bar {
|
||||
width: 30px;
|
||||
background: linear-gradient(180deg, #81D8CF 0%, #9FE5DE 100%);
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #666);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar-date {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #999);
|
||||
}
|
||||
|
||||
/* 上证分时<E58886>?*/
|
||||
.sse-mini-chart {
|
||||
height: 60px;
|
||||
background: var(--item-bg, #f9f9f9);
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.price-line {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.refresh-btn, .toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--text-tertiary, #bbb);
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.update-time {
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #bbb);
|
||||
.chart {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.update-tag {
|
||||
font-size: 0.8em;
|
||||
color: #999;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
123
README.md
123
README.md
@@ -1,34 +1,45 @@
|
||||
# GoFundBot
|
||||
|
||||
GoFundBot 是一个基于 Python (Flask) 和 Vue 3 构建的基金分析与可视化工具。它通过获取天天基金网的公开数据,为用户提供便捷的基金搜索、详细信息查询、业绩评估及多维度的图表分析功能。
|
||||
GoFundBot 是一个基于 Python (Flask) 和 Vue 3 构建的智能基金分析与可视化工具。它不仅提供实时的基金数据查询和可视化图表,还集成了先进的 AI 大模型(LLM),为用户提供深度的基金投资分析、风险评估及市场研判报告。
|
||||
|
||||
## 🚀 功能特性
|
||||
|
||||
* **基金搜索**:支持通过关键词快速搜索基金(基于本地缓存优化),快速定位目标基金。
|
||||
* **基本信息概览**:展示基金的单位净值、日涨幅、基金规模、费率结构等核心数据。
|
||||
* **深度数据分析**:
|
||||
* **能力评估**:通过雷达图直观展示基金的盈利能力、抗风险能力等。
|
||||
* **资产配置**:分析股票、债券、现金的资产占比。
|
||||
* **持仓分析**:查看前十大重仓股及其持仓占比变化。
|
||||
* **排名趋势**:展示基金在同类产品中的排名走势。
|
||||
* **经理信息**:展示现任基金经理的从业年限及管理业绩。
|
||||
* **交互式图表**:集成 ECharts,提供流畅的数据可视化体验。
|
||||
### 🤖 AI 智能投顾
|
||||
* **深度基金分析**:基于 LLM 生成专业的基金诊断报告,涵盖业绩归因、风险特征、经理风格及后市策略。
|
||||
* **智能仪表盘**:通过 AI 对基金的业绩、管理能力、持仓及市场前景进行多维度打分。
|
||||
* **市场情绪摘要**:每日自动生成市场行情摘要,捕捉关键市场动态与板块机会。
|
||||
|
||||
### 📊 全面数据可视化
|
||||
* **基金详情页**:
|
||||
* **基本信息**:实时净值、估算涨幅、费率结构等。
|
||||
* **业绩走势**:多周期业绩趋势图,支持同类对比。
|
||||
* **资产配置**:股票/债券/现金占比分析。
|
||||
* **持仓透视**:前十大重仓股及其占比变化。
|
||||
* **能力雷达**:直观展示基金的盈利能力、抗风险能力等 5 维指标。
|
||||
* **市场概览**:
|
||||
* **全球行情**:上证、深证、纳指、恒生等主要指数实时行情。
|
||||
* **贵金属追踪**:黄金、白银等大宗商品的历史走势与实时数据。
|
||||
|
||||
### 🛠 便捷工具
|
||||
* **基金搜索**:支持代码/名称快速搜索(本地缓存优化)。
|
||||
* **自选管理**:一键添加/移除自选基金,随时跟踪关注标的。
|
||||
* **一键部署**:提供 Windows 一键启动脚本,开箱即用。
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
### 后端 (Backend)
|
||||
* **语言**: Python 3
|
||||
* **框架**: Flask
|
||||
* **数据库/ORM**: SQLAlchemy (默认使用 SQLite), `database.py`
|
||||
* **网络请求**: Requests
|
||||
* **数据处理**: Pandas (如果用到), JSON
|
||||
* **AI/LLM**: LangChain, OpenAI SDK (适配 SiliconFlow/DeepSeek 等模型)
|
||||
* **数据存储**: SQLAlchemy (SQLite)
|
||||
* **网络请求**: Requests, Curl_cffi (处理复杂反爬)
|
||||
|
||||
### 前端 (Frontend)
|
||||
* **框架**: Vue 3 (Composition API)
|
||||
* **构建工具**: Vite
|
||||
* **可视化**: ECharts, Vue-Echarts
|
||||
* **HTTP 客户端**: Axios
|
||||
* **样式**: CSS / Scss
|
||||
* **UI 组件**: 自定义响应式组件 (FundDetail, MarketOverview 等)
|
||||
* **可视化**: ECharts
|
||||
* **Markdown**: 支持 AI 报告的 Markdown 渲染
|
||||
|
||||
## 📋 环境准备
|
||||
|
||||
@@ -39,78 +50,68 @@ GoFundBot 是一个基于 Python (Flask) 和 Vue 3 构建的基金分析与可
|
||||
|
||||
## ⚡ 快速开始
|
||||
|
||||
### 方式一:一键启动 (Windows)
|
||||
### 1. 配置环境变量 (重要)
|
||||
在 `Backend` 目录下创建一个 `.env` 文件(可复制 `.env.example`),并配置您的 LLM API 密钥:
|
||||
|
||||
如果您的环境满足以下条件,可以直接使用脚本启动:
|
||||
1. 已安装 Conda。
|
||||
2. Conda 中已创建名为 `fundbot` 的虚拟环境(或者您可以修改 `.bat` 文件适配您的环境名)。
|
||||
```ini
|
||||
LLM_API_KEY=your_api_key_here
|
||||
LLM_API_BASE=https://api.siliconflow.cn/v1
|
||||
LLM_MODEL=Qwen/Qwen2.5-7B-Instruct
|
||||
```
|
||||
|
||||
### 2. 启动方式
|
||||
|
||||
#### 方式一:一键启动 (Windows)
|
||||
确保已安装 Conda 且存在名为 `fundbot` 的虚拟环境(或修改脚本适配)。
|
||||
双击运行根目录下的:
|
||||
|
||||
```bash
|
||||
一键启动.bat
|
||||
```
|
||||
|
||||
### 方式二:手动安装与启动
|
||||
#### 方式二:手动安装与启动
|
||||
|
||||
#### 1. 后端服务 (Backend)
|
||||
**创建环境(Conda)**
|
||||
|
||||
```bash
|
||||
conda create -n fundbot python=3.9
|
||||
conda init #这步完成以后重启终端
|
||||
conda activate fundbot
|
||||
```
|
||||
|
||||
**后端服务 (Backend)**
|
||||
|
||||
```bash
|
||||
# 1. 进入后端目录
|
||||
cd Backend
|
||||
|
||||
# 2. 创建/激活虚拟环境 (可选,但在 bat 脚本中默认名为 fundbot)
|
||||
# conda create -n fundbot python=3.9
|
||||
# conda activate fundbot
|
||||
|
||||
# 3. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 启动 Flask 应用
|
||||
python app.py
|
||||
```
|
||||
后端服务启动后,默认监听 `http://localhost:5000`。
|
||||
|
||||
#### 2. 前端界面 (Frontend)
|
||||
|
||||
**前端界面 (Frontend)**
|
||||
```bash
|
||||
# 1. 进入前端目录
|
||||
cd Frontend
|
||||
|
||||
# 2. 安装 Node 依赖
|
||||
npm install
|
||||
|
||||
# 3. 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
启动成功后,控制台会显示访问地址(通常为 `http://localhost:5173`)。在浏览器中打开该地址即可使用。
|
||||
|
||||
启动成功后,访问终端显示的本地地址(通常为 `http://localhost:5173`)。
|
||||
|
||||
## 📂 项目结构
|
||||
|
||||
```text
|
||||
MyBot/
|
||||
├── Backend/ # 后端源码
|
||||
│ ├── app.py # 后端入口文件
|
||||
│ ├── models.py # 数据模型定义
|
||||
│ ├── fund_api.py # 基金数据获取接口
|
||||
│ ├── ai_service.py # AI 分析核心服务
|
||||
│ ├── fund_api.py # 基金数据接口
|
||||
│ ├── app.py # 应用入口
|
||||
│ └── ...
|
||||
├── Frontend/ # 前端源码
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Vue 组件 (FundDetail, FundChart 等)
|
||||
│ │ ├── services/ # API 请求封装
|
||||
│ │ ├── App.vue # 主组件
|
||||
│ │ └── main.js # 入口文件
|
||||
│ └── ...
|
||||
├── Data/ # 本地数据缓存 (如 fund_list_cache.json)
|
||||
├── 开发笔记.md # 项目开发过程中的笔记与接口文档
|
||||
├── 一键启动.bat # Windows 启动脚本
|
||||
└── requirements.txt # 后端依赖列表
|
||||
│ │ ├── components/ # Vue 组件
|
||||
│ │ │ ├── FundAIAnalysis.vue # AI 分析组件
|
||||
│ │ │ ├── FundBasicInfo.vue # 基础信息组件
|
||||
│ │ │ ├── MarketOverview.vue # 市场概览
|
||||
│ │ │ └── ...
|
||||
│ │ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 📝 数据来源与免责声明
|
||||
|
||||
* **数据来源**:本项目数据来源于 [天天基金网](https://fund.eastmoney.com/) 的公开接口。
|
||||
* **免责声明**:本项目仅供学习与技术交流使用,不构成任何投资建议。数据可能存在延迟或误差,投资有风险,理财需谨慎。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 或 Pull Request 来完善这个项目!
|
||||
|
||||
Reference in New Issue
Block a user