美化了一下UI

This commit is contained in:
Sebastian
2026-02-01 23:47:12 +08:00
parent b745011a8d
commit 3a2c265ee8
8 changed files with 693 additions and 616 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
View File

@@ -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 来完善这个项目!