增强可视化表达能力
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -15,58 +15,51 @@ fund_api = FundAPI()
|
||||
|
||||
@app.route('/')
|
||||
def hello():
|
||||
"""测试接口是否可用"""
|
||||
return jsonify({"message": "Fund Analysis API is running!"})
|
||||
|
||||
@app.route('/api/fund/search', methods=['GET'])
|
||||
def search_funds():
|
||||
"""搜索基金"""
|
||||
keyword = request.args.get('q', '')
|
||||
"""根据关键词搜索基金列表"""
|
||||
keyword = request.args.get('q', '') # 获取查询参数 基金名称或代码
|
||||
if not keyword:
|
||||
return jsonify({"error": "Keyword is required"}), 400
|
||||
|
||||
funds = fund_api.search_funds(keyword)
|
||||
funds = fund_api.search_funds(keyword) # 调用API搜索基金
|
||||
return jsonify({"data": funds})
|
||||
|
||||
@app.route('/api/fund/<fund_code>', methods=['GET'])
|
||||
def get_fund_detail(fund_code):
|
||||
"""获取基金详细信息"""
|
||||
if not fund_code:
|
||||
return jsonify({"error": "Fund code is required"}), 400
|
||||
return jsonify({"error": "Fund code is required"}), 400
|
||||
db = next(get_db()) # 获取数据库会话
|
||||
|
||||
db = next(get_db())
|
||||
# 使用新的 get_fund_data 方法获取清洗后的完整数据
|
||||
fund_data = fund_api.get_fund_data(fund_code)
|
||||
|
||||
# 直接从API获取最新数据
|
||||
detail_data = fund_api.get_fund_detail(fund_code)
|
||||
basic_info = fund_api.get_fund_basic_info(fund_code)
|
||||
|
||||
if detail_data and basic_info:
|
||||
# 合并数据
|
||||
detail_data['basic_info'] = basic_info
|
||||
|
||||
if fund_data:
|
||||
# 检查数据库是否存在记录
|
||||
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
|
||||
|
||||
# 更新或新增记录
|
||||
if cached_fund:
|
||||
cached_fund.data_json = json.dumps(detail_data, ensure_ascii=False)
|
||||
cached_fund.net_worth_trend = json.dumps(detail_data.get('net_worth_trend', []), ensure_ascii=False)
|
||||
cached_fund.basic_info = json.dumps(basic_info, ensure_ascii=False)
|
||||
cached_fund.data_json = json.dumps(fund_data, ensure_ascii=False)
|
||||
cached_fund.net_worth_trend = json.dumps(fund_data.get('net_worth_trend', []), ensure_ascii=False)
|
||||
cached_fund.basic_info = json.dumps(fund_data.get('basic_info', {}), ensure_ascii=False)
|
||||
else:
|
||||
fund_detail = FundDetail(
|
||||
fund_code=fund_code,
|
||||
data_json=json.dumps(detail_data, ensure_ascii=False),
|
||||
net_worth_trend=json.dumps(detail_data.get('net_worth_trend', []), ensure_ascii=False),
|
||||
basic_info=json.dumps(basic_info, ensure_ascii=False)
|
||||
data_json=json.dumps(fund_data, ensure_ascii=False),
|
||||
net_worth_trend=json.dumps(fund_data.get('net_worth_trend', []), ensure_ascii=False),
|
||||
basic_info=json.dumps(fund_data.get('basic_info', {}), ensure_ascii=False)
|
||||
)
|
||||
db.add(fund_detail)
|
||||
|
||||
db.add(fund_detail)
|
||||
try:
|
||||
db.commit()
|
||||
db.commit() # 提交事务
|
||||
except Exception as e:
|
||||
print(f"Error saving to database: {e}")
|
||||
db.rollback()
|
||||
|
||||
return jsonify(detail_data)
|
||||
return jsonify(fund_data)
|
||||
|
||||
# 如果API获取失败,尝试从数据库获取缓存数据作为兜底
|
||||
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
|
||||
@@ -81,27 +74,31 @@ def get_fund_detail(fund_code):
|
||||
|
||||
@app.route('/api/fund/<fund_code>/basic', methods=['GET'])
|
||||
def get_fund_basic(fund_code):
|
||||
"""获取基金基础信息"""
|
||||
"""获取基金基础信息 实时调用API"""
|
||||
if not fund_code:
|
||||
return jsonify({"error": "Fund code is required"}), 400
|
||||
|
||||
basic_info = fund_api.get_fund_basic_info(fund_code)
|
||||
if basic_info:
|
||||
return jsonify(basic_info)
|
||||
fund_data = fund_api.get_fund_data(fund_code)
|
||||
if fund_data and fund_data.get('basic_info'):
|
||||
# 合并 basic_info 和 performance 数据
|
||||
result = {
|
||||
**fund_data.get('basic_info', {}),
|
||||
**fund_data.get('performance', {})
|
||||
}
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify({"error": "Fund basic info not found"}), 404
|
||||
|
||||
@app.route('/api/fund/<fund_code>/trend', methods=['GET'])
|
||||
def get_fund_trend(fund_code):
|
||||
"""获取基金走势数据"""
|
||||
"""获取基金走势数据 实时调用API"""
|
||||
if not fund_code:
|
||||
return jsonify({"error": "Fund code is required"}), 400
|
||||
|
||||
detail_data = fund_api.get_fund_detail(fund_code)
|
||||
if detail_data and 'net_worth_trend' in detail_data:
|
||||
fund_data = fund_api.get_fund_data(fund_code)
|
||||
if fund_data and 'net_worth_trend' in fund_data:
|
||||
return jsonify({
|
||||
"net_worth_trend": detail_data['net_worth_trend'],
|
||||
"ac_worth_trend": detail_data.get('ac_worth_trend', [])
|
||||
"net_worth_trend": fund_data['net_worth_trend'],
|
||||
"accumulated_net_worth": fund_data.get('accumulated_net_worth', [])
|
||||
})
|
||||
else:
|
||||
return jsonify({"error": "Fund trend data not found"}), 404
|
||||
|
||||
@@ -10,7 +10,7 @@ PROJECT_ROOT = BACKEND_DIR.parent
|
||||
# 数据库路径:PROJECT_ROOT / Data / funds.db
|
||||
DATABASE_PATH = PROJECT_ROOT / "Data" / "funds.db"
|
||||
|
||||
# 构造 SQLite URL(注意:Windows 和 Linux/macOS 都兼容)
|
||||
# 构造 SQLite URL
|
||||
DATABASE_URL = f"sqlite:///{DATABASE_PATH.as_posix()}"
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
@@ -2,107 +2,315 @@ import requests
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Union
|
||||
|
||||
# --- 数据清洗器 (原 api_handler.py) ---
|
||||
|
||||
class FundDataCleaner:
|
||||
def __init__(self):
|
||||
self.cleaned_data = {}
|
||||
|
||||
def clean_js_variable(self, value: str) -> Any:
|
||||
"""清洗JavaScript变量值"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
value_str = str(value).strip()
|
||||
|
||||
# 处理布尔值
|
||||
if value_str.lower() in ['true', 'false']:
|
||||
return value_str.lower() == 'true'
|
||||
|
||||
# 处理数字
|
||||
if re.match(r'^-?\d+\.?\d*$', value_str):
|
||||
try:
|
||||
return float(value_str) if '.' in value_str else int(value_str)
|
||||
except (ValueError, TypeError):
|
||||
return value_str
|
||||
|
||||
# 处理字符串(去除引号)
|
||||
if (value_str.startswith('"') and value_str.endswith('"')) or \
|
||||
(value_str.startswith("'") and value_str.endswith("'")):
|
||||
return value_str[1:-1]
|
||||
|
||||
return value_str
|
||||
|
||||
def parse_timestamp(self, timestamp: int) -> str:
|
||||
"""将时间戳转换为日期字符串"""
|
||||
try:
|
||||
return datetime.fromtimestamp(timestamp / 1000).strftime('%Y-%m-%d')
|
||||
except (ValueError, TypeError):
|
||||
return str(timestamp)
|
||||
|
||||
def clean_rate(self, value: Any) -> Any:
|
||||
"""清洗费率数据,统一返回数字或 None"""
|
||||
if value is None:
|
||||
return None
|
||||
value_str = str(value).strip()
|
||||
if not value_str or value_str in ['--', '-', 'null', 'undefined']:
|
||||
return None
|
||||
value_str = value_str.replace('%', '').strip()
|
||||
try:
|
||||
return float(value_str)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def clean_array_data(self, data: Any, data_type: str = 'general') -> Any:
|
||||
"""清洗数组数据"""
|
||||
if not data:
|
||||
return []
|
||||
|
||||
if data_type == 'net_worth':
|
||||
# 处理单位净值走势数据
|
||||
cleaned = []
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
cleaned.append({
|
||||
'date': self.parse_timestamp(item.get('x')),
|
||||
'net_worth': item.get('y'),
|
||||
'equity_return': item.get('equityReturn'),
|
||||
'dividend': item.get('unitMoney')
|
||||
})
|
||||
return cleaned
|
||||
|
||||
elif data_type == 'position':
|
||||
# 处理股票仓位数据
|
||||
cleaned = []
|
||||
for item in data:
|
||||
if isinstance(item, list) and len(item) >= 2:
|
||||
cleaned.append({
|
||||
'date': self.parse_timestamp(item[0]),
|
||||
'position_percentage': item[1]
|
||||
})
|
||||
return cleaned
|
||||
|
||||
elif data_type == 'performance':
|
||||
# 处理业绩比较数据
|
||||
cleaned = []
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
series_data = []
|
||||
for data_point in item.get('data', []):
|
||||
if isinstance(data_point, list) and len(data_point) >= 2:
|
||||
series_data.append({
|
||||
'date': self.parse_timestamp(data_point[0]),
|
||||
'value': data_point[1]
|
||||
})
|
||||
|
||||
cleaned.append({
|
||||
'name': item.get('name'),
|
||||
'data': series_data
|
||||
})
|
||||
return cleaned
|
||||
|
||||
elif data_type == 'ranking':
|
||||
# 处理排名数据
|
||||
cleaned = []
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
cleaned.append({
|
||||
'date': self.parse_timestamp(item.get('x')),
|
||||
'rank': item.get('y'),
|
||||
'total_funds': item.get('sc')
|
||||
})
|
||||
return cleaned
|
||||
|
||||
else:
|
||||
# 通用数组处理
|
||||
return [self.clean_js_variable(item) for item in data]
|
||||
|
||||
def clean_fund_info(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""清洗基金基本信息"""
|
||||
info = {
|
||||
'fund_name': self.clean_js_variable(raw_data.get('fS_name')),
|
||||
'fund_code': self.clean_js_variable(raw_data.get('fS_code')),
|
||||
'fund_type': '混合型',
|
||||
'original_rate': self.clean_rate(raw_data.get('fund_sourceRate')),
|
||||
'current_rate': self.clean_rate(raw_data.get('fund_Rate')),
|
||||
'min_subscription_amount': self.clean_js_variable(raw_data.get('fund_minsg')),
|
||||
'is_hb': self.clean_js_variable(raw_data.get('ishb'))
|
||||
}
|
||||
return info
|
||||
|
||||
def clean_performance_data(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""清洗业绩数据"""
|
||||
performance = {
|
||||
'1_year_return': self.clean_js_variable(raw_data.get('syl_1n')),
|
||||
'6_month_return': self.clean_js_variable(raw_data.get('syl_6y')),
|
||||
'3_month_return': self.clean_js_variable(raw_data.get('syl_3y')),
|
||||
'1_month_return': self.clean_js_variable(raw_data.get('syl_1y'))
|
||||
}
|
||||
return performance
|
||||
|
||||
def clean_portfolio_data(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""清洗投资组合数据"""
|
||||
portfolio = {
|
||||
'stock_codes': self.clean_array_data(raw_data.get('stockCodes')),
|
||||
'bond_codes': self.clean_array_data(raw_data.get('zqCodes')),
|
||||
'stock_codes_new': self.clean_array_data(raw_data.get('stockCodesNew')),
|
||||
'bond_codes_new': self.clean_array_data(raw_data.get('zqCodesNew'))
|
||||
}
|
||||
return portfolio
|
||||
|
||||
def clean_asset_allocation(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""清洗资产配置数据"""
|
||||
asset_data = raw_data.get('Data_assetAllocation', {})
|
||||
cleaned = {
|
||||
'categories': asset_data.get('categories', []),
|
||||
'series': []
|
||||
}
|
||||
|
||||
for series in asset_data.get('series', []):
|
||||
cleaned_series = {
|
||||
'name': series.get('name'),
|
||||
'type': series.get('type'),
|
||||
'data': series.get('data', []),
|
||||
'yAxis': series.get('yAxis')
|
||||
}
|
||||
cleaned['series'].append(cleaned_series)
|
||||
|
||||
return cleaned
|
||||
|
||||
def clean_fund_manager(self, raw_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""清洗基金经理数据"""
|
||||
managers_data = raw_data.get('Data_currentFundManager', [])
|
||||
cleaned_managers = []
|
||||
|
||||
for manager in managers_data:
|
||||
cleaned_manager = {
|
||||
'id': manager.get('id'),
|
||||
'name': manager.get('name'),
|
||||
'photo_url': manager.get('pic'),
|
||||
'star_rating': manager.get('star'),
|
||||
'work_experience': manager.get('workTime'),
|
||||
'managed_fund_size': manager.get('fundSize'),
|
||||
'ability_assessment': {
|
||||
'average_score': manager.get('power', {}).get('avr'),
|
||||
'categories': manager.get('power', {}).get('categories', []),
|
||||
'scores': manager.get('power', {}).get('data', []),
|
||||
'assessment_date': manager.get('power', {}).get('jzrq')
|
||||
},
|
||||
'performance': {
|
||||
'categories': manager.get('profit', {}).get('categories', []),
|
||||
'series': manager.get('profit', {}).get('series', []),
|
||||
'assessment_date': manager.get('profit', {}).get('jzrq')
|
||||
}
|
||||
}
|
||||
cleaned_managers.append(cleaned_manager)
|
||||
|
||||
return cleaned_managers
|
||||
|
||||
def clean_holder_structure(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""清洗持有人结构数据"""
|
||||
holder_data = raw_data.get('Data_holderStructure', {})
|
||||
cleaned = {
|
||||
'categories': holder_data.get('categories', []),
|
||||
'series': []
|
||||
}
|
||||
|
||||
for series in holder_data.get('series', []):
|
||||
cleaned_series = {
|
||||
'name': series.get('name'),
|
||||
'data': series.get('data', [])
|
||||
}
|
||||
cleaned['series'].append(cleaned_series)
|
||||
|
||||
return cleaned
|
||||
|
||||
def clean_same_type_funds(self, raw_data: Dict[str, Any]) -> List[List[Dict[str, Any]]]:
|
||||
"""清洗同类型基金数据"""
|
||||
same_type_data = raw_data.get('swithSameType', [])
|
||||
cleaned_categories = []
|
||||
|
||||
for category in same_type_data:
|
||||
cleaned_funds = []
|
||||
for fund_str in category:
|
||||
parts = fund_str.split('_')
|
||||
if len(parts) >= 3:
|
||||
fund_info = {
|
||||
'code': parts[0],
|
||||
'name': parts[1],
|
||||
'return_rate': self.clean_js_variable(parts[2])
|
||||
}
|
||||
cleaned_funds.append(fund_info)
|
||||
cleaned_categories.append(cleaned_funds)
|
||||
|
||||
return cleaned_categories
|
||||
|
||||
def clean_all_data(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""清洗所有数据"""
|
||||
cleaned_data = {
|
||||
'basic_info': self.clean_fund_info(raw_data),
|
||||
'performance': self.clean_performance_data(raw_data),
|
||||
'portfolio': self.clean_portfolio_data(raw_data),
|
||||
# 实时估值数据(来自 fundgz 接口)
|
||||
'realtime_estimate': {
|
||||
'name': raw_data.get('name'), # 基金名称
|
||||
'fund_code': raw_data.get('fundcode'), # 基金代码
|
||||
'net_worth': raw_data.get('dwjz'), # 单位净值
|
||||
'net_worth_date': raw_data.get('jzrq'), # 净值日期
|
||||
'estimate_value': raw_data.get('gsz'), # 估算净值
|
||||
'estimate_change': raw_data.get('gszzl'), # 估算涨跌幅
|
||||
'estimate_time': raw_data.get('gztime'), # 估值时间
|
||||
},
|
||||
'net_worth_trend': self.clean_array_data(
|
||||
raw_data.get('Data_netWorthTrend'), 'net_worth'
|
||||
),
|
||||
'accumulated_net_worth': self.clean_array_data(
|
||||
raw_data.get('Data_ACWorthTrend'), 'position'
|
||||
),
|
||||
'position_trend': self.clean_array_data(
|
||||
raw_data.get('Data_fundSharesPositions'), 'position'
|
||||
),
|
||||
'total_return_trend': self.clean_array_data(
|
||||
raw_data.get('Data_grandTotal'), 'performance'
|
||||
),
|
||||
'ranking_trend': self.clean_array_data(
|
||||
raw_data.get('Data_rateInSimilarType'), 'ranking'
|
||||
),
|
||||
'ranking_percentage': self.clean_array_data(
|
||||
raw_data.get('Data_rateInSimilarPersent'), 'position'
|
||||
),
|
||||
'scale_fluctuation': raw_data.get('Data_fluctuationScale', {}),
|
||||
'holder_structure': self.clean_holder_structure(raw_data),
|
||||
'asset_allocation': self.clean_asset_allocation(raw_data),
|
||||
'performance_evaluation': raw_data.get('Data_performanceEvaluation', {}),
|
||||
'fund_managers': self.clean_fund_manager(raw_data),
|
||||
'subscription_redemption': raw_data.get('Data_buySedemption', {}),
|
||||
'same_type_funds': self.clean_same_type_funds(raw_data),
|
||||
'cleaning_timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return cleaned_data
|
||||
|
||||
# --- 基金 API 客户端 ---
|
||||
|
||||
class FundAPI:
|
||||
def __init__(self):
|
||||
self.headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
|
||||
def get_fund_basic_info(self, fund_code):
|
||||
self.cleaner = FundDataCleaner()
|
||||
|
||||
def get_fund_data(self, fund_code: str) -> Union[Dict[str, Any], None]:
|
||||
"""
|
||||
获取基金综合信息,包括实时估值、基本资料、持仓等
|
||||
获取单只基金的完整清洗后数据。
|
||||
包括基本信息、业绩、持仓、净值走势等。
|
||||
"""
|
||||
info = {}
|
||||
raw_data = self._fetch_raw_data(fund_code)
|
||||
if not raw_data:
|
||||
return None
|
||||
|
||||
# 1. 获取实时估值信息
|
||||
# 使用 cleaner 清洗数据
|
||||
try:
|
||||
# 天天基金实时估值接口
|
||||
real_time_url = f"http://fundgz.1234567.com.cn/js/{fund_code}.js"
|
||||
response = requests.get(real_time_url, headers=self.headers, timeout=5)
|
||||
if response.status_code == 200:
|
||||
content = response.text
|
||||
# 提取 jsonpgz({...}) 中的 json 部分
|
||||
match = re.search(r"jsonpgz\((.*?)\);", content)
|
||||
if match:
|
||||
real_time_data = json.loads(match.group(1))
|
||||
info.update(real_time_data)
|
||||
return self.cleaner.clean_all_data(raw_data)
|
||||
except Exception as e:
|
||||
print(f"获取实时估值信息失败: {e}")
|
||||
|
||||
# 2. 获取基本资料
|
||||
try:
|
||||
basic_url = f"https://fund.eastmoney.com/pingzhongdata/{fund_code}.js"
|
||||
response = requests.get(basic_url, headers=self.headers, timeout=5)
|
||||
if response.status_code == 200:
|
||||
content = response.text
|
||||
|
||||
# 基金名称
|
||||
if 'name' not in info:
|
||||
name_match = re.search(r'var fS_name\s*=\s*"(.*?)";', content)
|
||||
if name_match:
|
||||
info['name'] = name_match.group(1)
|
||||
|
||||
# 现费率
|
||||
rate_match = re.search(r'var fund_Rate\s*=\s*"(.*?)";', content)
|
||||
if rate_match:
|
||||
info['fund_rate'] = rate_match.group(1)
|
||||
|
||||
# 最小申购金额
|
||||
min_match = re.search(r'var fund_minsg\s*=\s*"(.*?)";', content)
|
||||
if min_match:
|
||||
info['fund_min_subscription'] = min_match.group(1)
|
||||
|
||||
# 前十大持仓
|
||||
stock_codes_match = re.search(r'var stockCodes\s*=\s*(\[.*?\]);', content)
|
||||
if stock_codes_match:
|
||||
try:
|
||||
codes_raw = json.loads(stock_codes_match.group(1))
|
||||
# 天天基金返回的code有时候带有交易所后缀(如0025580),取前6位
|
||||
info['stock_codes'] = [code[:6] for code in codes_raw]
|
||||
except:
|
||||
info['stock_codes'] = []
|
||||
|
||||
# 基金经理
|
||||
manager_match = re.search(r'var Data_currentFundManager\s*=\s*(\[.*?\]);', content, re.DOTALL)
|
||||
if manager_match:
|
||||
try:
|
||||
managers = json.loads(manager_match.group(1))
|
||||
info['managers'] = managers
|
||||
except:
|
||||
pass
|
||||
|
||||
# 基金规模
|
||||
asset_alloc_match = re.search(r'var Data_assetAllocation\s*=\s*(\{.*?\});', content, re.DOTALL)
|
||||
if asset_alloc_match:
|
||||
try:
|
||||
asset_data = json.loads(asset_alloc_match.group(1))
|
||||
net_asset_series = next((s for s in asset_data.get('series', []) if s.get('name') == '净资产'), None)
|
||||
if net_asset_series and net_asset_series.get('data'):
|
||||
info['fund_size'] = net_asset_series['data'][-1]
|
||||
except:
|
||||
info['fund_size'] = None
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取基本资料失败: {e}")
|
||||
|
||||
return info if info else None
|
||||
|
||||
def get_fund_detail(self, fund_code):
|
||||
"""获取基金详细信息,包括净值走势等"""
|
||||
url = f"https://fund.eastmoney.com/pingzhongdata/{fund_code}.js"
|
||||
try:
|
||||
response = requests.get(url, headers=self.headers, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return self._parse_fund_js(response.text, fund_code)
|
||||
print(f"Error cleaning data for {fund_code}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error fetching detail for {fund_code}: {e}")
|
||||
return None
|
||||
|
||||
def search_funds(self, keyword):
|
||||
|
||||
def search_funds(self, keyword: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
搜索基金
|
||||
搜索基金(返回列表)
|
||||
"""
|
||||
url = "https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx"
|
||||
params = {
|
||||
@@ -122,158 +330,89 @@ class FundAPI:
|
||||
print(f"Search error: {e}")
|
||||
return []
|
||||
|
||||
def _parse_fund_js(self, js_content, fund_code):
|
||||
"""解析基金详细数据的JS文件"""
|
||||
data = {
|
||||
'fund_code': fund_code,
|
||||
'net_worth_trend': [],
|
||||
'ac_worth_trend': [],
|
||||
'stock_codes': [],
|
||||
'grand_total': [],
|
||||
'rate_in_similar_type': [],
|
||||
'rate_in_similar_percent': [],
|
||||
'asset_allocation': {},
|
||||
'fluctuation_scale': {},
|
||||
'holder_structure': {},
|
||||
'fund_managers': [],
|
||||
'performance_evaluation': {},
|
||||
'buy_sedemption': {},
|
||||
'syl_1y': '',
|
||||
'syl_3y': '',
|
||||
'syl_6y': '',
|
||||
'syl_1n': '',
|
||||
'fund_rate': '',
|
||||
'fund_minsg': '',
|
||||
'update_time': datetime.now().isoformat()
|
||||
}
|
||||
def _fetch_raw_data(self, fund_code: str) -> Union[Dict[str, Any], None]:
|
||||
"""
|
||||
获取原始基金数据(字典形式),包含所有JS变量。
|
||||
"""
|
||||
data = {}
|
||||
|
||||
# 1. 抓取 pingzhongdata 详细数据
|
||||
url = f"https://fund.eastmoney.com/pingzhongdata/{fund_code}.js"
|
||||
try:
|
||||
# 解析单位净值走势Data_netWorthTrend
|
||||
net_worth_match = re.search(r'var Data_netWorthTrend\s*=\s*(\[.*?\]);', js_content, re.DOTALL)
|
||||
if net_worth_match:
|
||||
try:
|
||||
data['net_worth_trend'] = json.loads(net_worth_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析累计净值走势Data_ACWorthTrend
|
||||
ac_worth_match = re.search(r'var Data_ACWorthTrend\s*=\s*(\[.*?\]);', js_content, re.DOTALL)
|
||||
if ac_worth_match:
|
||||
try:
|
||||
data['ac_worth_trend'] = json.loads(ac_worth_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析股票代码 stockCodes
|
||||
stock_codes_match = re.search(r'var stockCodes\s*=\s*(\[.*?\]);', js_content)
|
||||
if stock_codes_match:
|
||||
try:
|
||||
codes_raw = json.loads(stock_codes_match.group(1))
|
||||
# 天天基金返回的code有时候带有交易所后缀(如0025580),取前6位
|
||||
data['stock_codes'] = [code[:6] for code in codes_raw]
|
||||
except:
|
||||
data['stock_codes'] = []
|
||||
|
||||
# 解析累计收益率走势对比 Data_grandTotal
|
||||
grand_total_match = re.search(r'var Data_grandTotal\s*=\s*(\[.*?\]);', js_content, re.DOTALL)
|
||||
if grand_total_match:
|
||||
try:
|
||||
data['grand_total'] = json.loads(grand_total_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析同类排名走势 Data_rateInSimilarType
|
||||
rate_similar_match = re.search(r'var Data_rateInSimilarType\s*=\s*(\[.*?\]);', js_content, re.DOTALL)
|
||||
if rate_similar_match:
|
||||
try:
|
||||
data['rate_in_similar_type'] = json.loads(rate_similar_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析同类排名百分比 Data_rateInSimilarPersent
|
||||
rate_percent_match = re.search(r'var Data_rateInSimilarPersent\s*=\s*(\[.*?\]);', js_content, re.DOTALL)
|
||||
if rate_percent_match:
|
||||
try:
|
||||
data['rate_in_similar_percent'] = json.loads(rate_percent_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析资产配置 Data_assetAllocation
|
||||
asset_alloc_match = re.search(r'var Data_assetAllocation\s*=\s*(\{.*?\});', js_content, re.DOTALL)
|
||||
if asset_alloc_match:
|
||||
try:
|
||||
data['asset_allocation'] = json.loads(asset_alloc_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析规模变动 Data_fluctuationScale
|
||||
scale_match = re.search(r'var Data_fluctuationScale\s*=\s*(\{.*?\});', js_content, re.DOTALL)
|
||||
if scale_match:
|
||||
try:
|
||||
data['fluctuation_scale'] = json.loads(scale_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析持有人结构 Data_holderStructure
|
||||
holder_match = re.search(r'var Data_holderStructure\s*=\s*(\{.*?\});', js_content, re.DOTALL)
|
||||
if holder_match:
|
||||
try:
|
||||
data['holder_structure'] = json.loads(holder_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析基金经理 Data_currentFundManager
|
||||
manager_match = re.search(r'var Data_currentFundManager\s*=\s*(\[.*?\]);', js_content, re.DOTALL)
|
||||
if manager_match:
|
||||
try:
|
||||
data['fund_managers'] = json.loads(manager_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析业绩评价 Data_performanceEvaluation
|
||||
performance_match = re.search(r'var Data_performanceEvaluation\s*=\s*(\{.*?\});', js_content, re.DOTALL)
|
||||
if performance_match:
|
||||
try:
|
||||
data['performance_evaluation'] = json.loads(performance_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析申购赎回 Data_buySedemption
|
||||
buy_sed_match = re.search(r'var Data_buySedemption\s*=\s*(\{.*?\});', js_content, re.DOTALL)
|
||||
if buy_sed_match:
|
||||
try:
|
||||
data['buy_sedemption'] = json.loads(buy_sed_match.group(1))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 解析收益率
|
||||
syl_1y_match = re.search(r'var syl_1y\s*=\s*"(.*?)";', js_content)
|
||||
if syl_1y_match:
|
||||
data['syl_1y'] = syl_1y_match.group(1)
|
||||
|
||||
syl_3y_match = re.search(r'var syl_3y\s*=\s*"(.*?)";', js_content)
|
||||
if syl_3y_match:
|
||||
data['syl_3y'] = syl_3y_match.group(1)
|
||||
|
||||
syl_6y_match = re.search(r'var syl_6y\s*=\s*"(.*?)";', js_content)
|
||||
if syl_6y_match:
|
||||
data['syl_6y'] = syl_6y_match.group(1)
|
||||
|
||||
syl_1n_match = re.search(r'var syl_1n\s*=\s*"(.*?)";', js_content)
|
||||
if syl_1n_match:
|
||||
data['syl_1n'] = syl_1n_match.group(1)
|
||||
|
||||
# 解析现费率
|
||||
rate_match = re.search(r'var fund_Rate\s*=\s*"(.*?)";', js_content)
|
||||
if rate_match:
|
||||
data['fund_rate'] = rate_match.group(1)
|
||||
|
||||
# 解析最小申购金额
|
||||
minsg_match = re.search(r'var fund_minsg\s*=\s*"(.*?)";', js_content)
|
||||
if minsg_match:
|
||||
data['fund_minsg'] = minsg_match.group(1)
|
||||
response = requests.get(url, headers=self.headers, timeout=10)
|
||||
if response.status_code == 200:
|
||||
js_content = response.text
|
||||
|
||||
# 提取所有 var xxx = ...; 也就是 JS 变量
|
||||
# 兼容值可能是数字、字符串、数组[]、对象{}
|
||||
# 正则解析:var (变量名) = (值);
|
||||
# 值可能跨多行,非贪婪匹配
|
||||
var_matches = re.findall(r'var\s+(\w+)\s*=\s*(.*?);', js_content, re.DOTALL)
|
||||
|
||||
for var_name, var_value in var_matches:
|
||||
var_name = var_name.strip()
|
||||
var_value = var_value.strip()
|
||||
|
||||
try:
|
||||
# 尝试 JSON 解析 (如果是数组或对象)
|
||||
if var_value.startswith('[') or var_value.startswith('{'):
|
||||
data[var_name] = json.loads(var_value)
|
||||
# 尝试去引号 (如果是字符串)
|
||||
elif var_value.startswith('"') and var_value.endswith('"'):
|
||||
data[var_name] = var_value[1:-1]
|
||||
elif var_value.startswith("'") and var_value.endswith("'"):
|
||||
data[var_name] = var_value[1:-1]
|
||||
# 数字或其它
|
||||
else:
|
||||
data[var_name] = var_value
|
||||
except:
|
||||
# 解析失败保底保留原始字符串
|
||||
data[var_name] = var_value
|
||||
|
||||
except Exception as e:
|
||||
print(f"解析JS数据失败: {e}")
|
||||
print(f"Error fetching detail for {fund_code}: {e}")
|
||||
return None
|
||||
|
||||
# 2. 抓取实时估值数据 (可选,用于补充实时信息)
|
||||
try:
|
||||
real_time_url = f"http://fundgz.1234567.com.cn/js/{fund_code}.js"
|
||||
response = requests.get(real_time_url, headers=self.headers, timeout=3)
|
||||
if response.status_code == 200:
|
||||
match = re.search(r"jsonpgz\((.*?)\);", response.text)
|
||||
if match:
|
||||
rt_data = json.loads(match.group(1))
|
||||
if rt_data:
|
||||
# 这里的 key 可能和 pingzhongdata 不一样,如果需要合并,要注意 key 冲突
|
||||
# 暂时作为一个子字段,或者直接合并
|
||||
data.update(rt_data)
|
||||
except Exception:
|
||||
pass # 实时数据获取失败不影响整体
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
# 确保 fS_code 存在
|
||||
if 'fS_code' not in data:
|
||||
data['fS_code'] = fund_code
|
||||
|
||||
return data
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试代码
|
||||
api = FundAPI()
|
||||
code = "019127"
|
||||
print(f"Fetching data for {code}...")
|
||||
fund_data = api.get_fund_data(code)
|
||||
|
||||
if fund_data:
|
||||
print("\n=== Data Fetch Success ===")
|
||||
print(f"Name: {fund_data['basic_info']['fund_name']}")
|
||||
print(f"Manager: {len(fund_data['fund_managers'])} managers recorded")
|
||||
print(f"Latest Net Worth: {fund_data['net_worth_trend'][-1] if fund_data['net_worth_trend'] else 'N/A'}")
|
||||
|
||||
# 保存测试数据
|
||||
with open(f"fund_{code}_full.json", 'w', encoding='utf-8') as f:
|
||||
json.dump(fund_data, f, ensure_ascii=False, indent=2)
|
||||
print(f"Saved to fund_{code}_full.json")
|
||||
else:
|
||||
print("Failed to fetch data.")
|
||||
|
||||
BIN
Data/funds.db
BIN
Data/funds.db
Binary file not shown.
@@ -6,28 +6,12 @@
|
||||
<div class="card-body">
|
||||
<div v-if="hasData" class="allocation-content">
|
||||
<div ref="chartEl" class="allocation-chart"></div>
|
||||
<div class="allocation-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="min-width: 100px;">时间</th>
|
||||
<th v-for="(serie, index) in series" :key="index" style="text-align: center; min-width: 100px;">
|
||||
<div class="type-cell" style="justify-content: center;">
|
||||
<span class="type-dot" :style="{ background: getColor(index) }"></span>
|
||||
{{ serie.name }}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(date, dateIndex) in categories" :key="dateIndex">
|
||||
<td style="font-weight: bold;">{{ date }}</td>
|
||||
<td v-for="(serie, index) in series" :key="index" class="value-cell">
|
||||
{{ formatValue(serie.data[dateIndex], serie.name) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="legend-info">
|
||||
<div v-for="(serie, index) in displaySeries" :key="index" class="legend-item">
|
||||
<span class="legend-dot" :style="{ background: getColor(index) }"></span>
|
||||
<span class="legend-name">{{ serie.name }}</span>
|
||||
<span class="legend-value">{{ formatValue(serie.data[serie.data.length - 1], serie.name) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
@@ -180,10 +164,14 @@ export default {
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
// 用于显示的系列(排除净资产)
|
||||
const displaySeries = computed(() => series.value.filter(s => s.name !== '净资产'))
|
||||
|
||||
return {
|
||||
chartEl,
|
||||
categories,
|
||||
series,
|
||||
displaySeries,
|
||||
hasData,
|
||||
getColor,
|
||||
formatValue
|
||||
@@ -194,89 +182,81 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.asset-allocation-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
padding: 12px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.allocation-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.allocation-chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.allocation-table {
|
||||
overflow-x: auto;
|
||||
.legend-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.allocation-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.allocation-table th,
|
||||
.allocation-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.allocation-table th {
|
||||
background: #f5f5f5;
|
||||
.legend-name {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.legend-value {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.type-cell {
|
||||
.no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.type-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.no-data p {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,11 +51,11 @@
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">现费率</div>
|
||||
<div class="metric-value rate">{{ fundInfo.fund_rate || fundInfo.fund_Rate ? (fundInfo.fund_rate || fundInfo.fund_Rate) + '%' : '--' }}</div>
|
||||
<div class="metric-value rate">{{ formatRate(fundInfo.fund_rate) }}</div>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-label">最小申购</div>
|
||||
<div class="metric-value">{{ fundInfo.fund_minsg || fundInfo.fund_min_subscription ? (fundInfo.fund_minsg || fundInfo.fund_min_subscription) + '元' : '--' }}</div>
|
||||
<div class="metric-value">{{ formatMinSubscription(fundInfo.fund_minsg) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,11 @@ export default {
|
||||
fundCode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
// 新增:接收父组件传递的基金数据,避免重复请求
|
||||
fundData: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -81,25 +86,57 @@ export default {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 监听父组件传递的数据
|
||||
fundData: {
|
||||
immediate: true,
|
||||
handler(newData) {
|
||||
if (newData) {
|
||||
this.processFundData(newData)
|
||||
}
|
||||
}
|
||||
},
|
||||
fundCode: {
|
||||
immediate: true,
|
||||
handler(newCode) {
|
||||
if (newCode) {
|
||||
// 只有在没有父组件传递数据时才自己请求
|
||||
if (newCode && !this.fundData) {
|
||||
this.fetchFundInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 处理基金数据(可来自父组件传递或自己请求)
|
||||
processFundData(data) {
|
||||
const realtime = data.realtime_estimate || {}
|
||||
this.fundInfo = {
|
||||
...data,
|
||||
...data.basic_info,
|
||||
// 映射新字段名到模板使用的字段名
|
||||
name: data.basic_info?.fund_name || realtime.name,
|
||||
fund_rate: data.basic_info?.current_rate,
|
||||
fund_Rate: data.basic_info?.current_rate,
|
||||
fund_minsg: data.basic_info?.min_subscription_amount,
|
||||
fund_min_subscription: data.basic_info?.min_subscription_amount,
|
||||
// 映射业绩数据(新格式使用下划线分隔)
|
||||
syl_1y: data.performance?.['1_month_return'],
|
||||
syl_3y: data.performance?.['3_month_return'],
|
||||
syl_6y: data.performance?.['6_month_return'],
|
||||
syl_1n: data.performance?.['1_year_return'],
|
||||
// 映射实时估值数据
|
||||
dwjz: realtime.net_worth, // 单位净值
|
||||
jzrq: realtime.net_worth_date, // 净值日期
|
||||
gsz: realtime.estimate_value, // 估算净值
|
||||
gszzl: realtime.estimate_change, // 估算涨跌幅
|
||||
gztime: realtime.estimate_time // 估值时间
|
||||
}
|
||||
},
|
||||
async fetchFundInfo() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await fundAPI.getFundDetail(this.fundCode)
|
||||
// 合并 basic_info 到主对象
|
||||
this.fundInfo = {
|
||||
...response.data,
|
||||
...response.data.basic_info
|
||||
}
|
||||
const data = response.data
|
||||
this.processFundData(data)
|
||||
} catch (error) {
|
||||
console.error('获取基金信息失败:', error)
|
||||
this.fundInfo = null
|
||||
@@ -119,6 +156,24 @@ export default {
|
||||
formatTime(timeStr) {
|
||||
if (!timeStr) return '--'
|
||||
return timeStr
|
||||
},
|
||||
formatRate(value) {
|
||||
// 处理费率显示:null/undefined/空值显示 '--',数字显示带百分号
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--'
|
||||
}
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) {
|
||||
return '--'
|
||||
}
|
||||
return num + '%'
|
||||
},
|
||||
formatMinSubscription(value) {
|
||||
// 处理最小申购显示
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--'
|
||||
}
|
||||
return value + '元'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<div ref="chartEl" style="width: 100%; height: 350px;"></div>
|
||||
<div ref="chartEl" class="chart-el"></div>
|
||||
</div>
|
||||
|
||||
<div class="time-ranges">
|
||||
@@ -153,6 +153,13 @@ export default {
|
||||
startDate = new Date(now.setFullYear(now.getFullYear() - 3))
|
||||
}
|
||||
|
||||
// 支持新格式: [{date: '2024-01-01', value: 1.23}]
|
||||
if (data.length > 0 && data[0].date !== undefined) {
|
||||
return data
|
||||
.map(item => [new Date(item.date).getTime(), item.value])
|
||||
.filter(item => item[0] >= startDate.getTime())
|
||||
}
|
||||
// 旧格式: [[timestamp, value]]
|
||||
return data.filter(item => item[0] >= startDate.getTime())
|
||||
}
|
||||
|
||||
@@ -454,15 +461,11 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.fund-chart-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
padding-bottom: 10px;
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.top-tabs {
|
||||
@@ -561,7 +564,15 @@ export default {
|
||||
.text-green { color: #52c41a; }
|
||||
|
||||
.chart-container {
|
||||
padding: 0 10px;
|
||||
padding: 0 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-el {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.time-ranges {
|
||||
|
||||
@@ -1,38 +1,98 @@
|
||||
<template>
|
||||
<div class="fund-detail">
|
||||
<!-- 基金基础信息组件 -->
|
||||
<FundBasicInfo :fundCode="currentFundCode" />
|
||||
<FundBasicInfo :fundCode="currentFundCode" :fundData="fundDetail" />
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div v-if="fundDetail" class="detail-content">
|
||||
<!-- 主要内容区域 - Dashboard 布局 -->
|
||||
<div v-if="fundDetail" class="dashboard">
|
||||
|
||||
<!-- 中心区域:图表展示 -->
|
||||
<div class="charts-section">
|
||||
<!-- 净值走势图 (含收益对比、回撤修复) -->
|
||||
<FundChart
|
||||
:netWorthTrend="processedNetWorthTrend"
|
||||
:acWorthTrend="processedAcWorthTrend"
|
||||
:grandTotal="fundDetail.grand_total"
|
||||
/>
|
||||
<!-- 左侧主区域 -->
|
||||
<div class="main-area">
|
||||
<!-- 净值走势图 -->
|
||||
<div class="card card-chart">
|
||||
<FundChart
|
||||
:netWorthTrend="processedNetWorthTrend"
|
||||
:acWorthTrend="processedAcWorthTrend"
|
||||
:grandTotal="fundDetail.total_return_trend"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 同类排名走势 -->
|
||||
<FundRankingTrend
|
||||
:rateInSimilarType="fundDetail.rate_in_similar_type"
|
||||
:rateInSimilarPercent="fundDetail.rate_in_similar_percent"
|
||||
/>
|
||||
<!-- 中间两列区域 -->
|
||||
<div class="grid-2">
|
||||
<div class="card card-md clickable" @click="openModal('ranking')">
|
||||
<FundRankingTrend
|
||||
:rateInSimilarType="fundDetail.ranking_trend"
|
||||
:rateInSimilarPercent="fundDetail.ranking_percentage"
|
||||
/>
|
||||
</div>
|
||||
<div class="card card-md clickable" @click="openModal('asset')">
|
||||
<FundAssetAllocation
|
||||
:assetAllocation="fundDetail.asset_allocation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部两列区域 -->
|
||||
<div class="grid-2">
|
||||
<div class="card card-md clickable" @click="openModal('holder')">
|
||||
<FundHolderStructure
|
||||
:holderStructure="fundDetail.holder_structure"
|
||||
/>
|
||||
</div>
|
||||
<div class="card card-md clickable" @click="openModal('scale')">
|
||||
<FundScaleChange
|
||||
:fluctuationScale="fundDetail.scale_fluctuation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息区域 -->
|
||||
<div class="detail-sections">
|
||||
<!-- 资产配置 -->
|
||||
<FundAssetAllocation
|
||||
:assetAllocation="fundDetail.asset_allocation"
|
||||
/>
|
||||
|
||||
<!-- 基金规模变动 -->
|
||||
<FundScaleChange
|
||||
:fluctuationScale="fundDetail.fluctuation_scale"
|
||||
/>
|
||||
|
||||
<!-- 右侧边栏 -->
|
||||
<div class="sidebar">
|
||||
<div class="card card-sidebar clickable" @click="openModal('portfolio')">
|
||||
<FundPortfolio
|
||||
:portfolio="fundDetail.portfolio"
|
||||
/>
|
||||
</div>
|
||||
<div class="card card-sidebar clickable" @click="openModal('manager')">
|
||||
<FundManagerInfo
|
||||
:fundManagers="fundDetail.fund_managers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 放大模态框 -->
|
||||
<div v-if="modalVisible" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" @click="closeModal">×</button>
|
||||
<div class="modal-body">
|
||||
<FundRankingTrend
|
||||
v-if="modalType === 'ranking'"
|
||||
:rateInSimilarType="fundDetail.ranking_trend"
|
||||
:rateInSimilarPercent="fundDetail.ranking_percentage"
|
||||
/>
|
||||
<FundAssetAllocation
|
||||
v-if="modalType === 'asset'"
|
||||
:assetAllocation="fundDetail.asset_allocation"
|
||||
/>
|
||||
<FundHolderStructure
|
||||
v-if="modalType === 'holder'"
|
||||
:holderStructure="fundDetail.holder_structure"
|
||||
/>
|
||||
<FundScaleChange
|
||||
v-if="modalType === 'scale'"
|
||||
:fluctuationScale="fundDetail.scale_fluctuation"
|
||||
/>
|
||||
<FundPortfolio
|
||||
v-if="modalType === 'portfolio'"
|
||||
:portfolio="fundDetail.portfolio"
|
||||
/>
|
||||
<FundManagerInfo
|
||||
v-if="modalType === 'manager'"
|
||||
:fundManagers="fundDetail.fund_managers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,6 +124,9 @@ import FundChart from './FundChart.vue'
|
||||
import FundRankingTrend from './FundRankingTrend.vue'
|
||||
import FundAssetAllocation from './FundAssetAllocation.vue'
|
||||
import FundScaleChange from './FundScaleChange.vue'
|
||||
import FundManagerInfo from './FundManagerInfo.vue'
|
||||
import FundHolderStructure from './FundHolderStructure.vue'
|
||||
import FundPortfolio from './FundPortfolio.vue'
|
||||
import { fundAPI } from '../services/api'
|
||||
|
||||
export default {
|
||||
@@ -73,7 +136,10 @@ export default {
|
||||
FundChart,
|
||||
FundRankingTrend,
|
||||
FundAssetAllocation,
|
||||
FundScaleChange
|
||||
FundScaleChange,
|
||||
FundManagerInfo,
|
||||
FundHolderStructure,
|
||||
FundPortfolio
|
||||
},
|
||||
props: {
|
||||
fundCode: {
|
||||
@@ -86,6 +152,22 @@ export default {
|
||||
const fundDetail = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const modalVisible = ref(false)
|
||||
const modalType = ref('')
|
||||
|
||||
// 打开模态框
|
||||
const openModal = (type) => {
|
||||
modalType.value = type
|
||||
modalVisible.value = true
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
modalVisible.value = false
|
||||
modalType.value = ''
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
// 处理净值走势数据格式
|
||||
const processedNetWorthTrend = computed(() => {
|
||||
@@ -95,14 +177,21 @@ export default {
|
||||
// 处理不同的数据格式
|
||||
const trend = fundDetail.value.net_worth_trend
|
||||
if (Array.isArray(trend) && trend.length > 0) {
|
||||
// 格式1: [{x: timestamp, y: value}]
|
||||
// 新格式: [{date: '2024-01-01', net_worth: 1.23}]
|
||||
if (trend[0].date && trend[0].net_worth !== undefined) {
|
||||
return trend.map(item => ({
|
||||
x: new Date(item.date).getTime(),
|
||||
y: parseFloat(item.net_worth) || 0
|
||||
}))
|
||||
}
|
||||
// 旧格式1: [{x: timestamp, y: value}]
|
||||
if (trend[0].x && trend[0].y) {
|
||||
return trend.map(item => ({
|
||||
x: item.x,
|
||||
y: parseFloat(item.y) || 0
|
||||
}))
|
||||
}
|
||||
// 格式2: [timestamp, value]
|
||||
// 旧格式2: [timestamp, value]
|
||||
else if (Array.isArray(trend[0]) && trend[0].length >= 2) {
|
||||
return trend.map(item => ({
|
||||
x: item[0],
|
||||
@@ -119,11 +208,19 @@ export default {
|
||||
|
||||
// 处理累计净值走势数据
|
||||
const processedAcWorthTrend = computed(() => {
|
||||
if (!fundDetail.value?.ac_worth_trend) return []
|
||||
if (!fundDetail.value?.accumulated_net_worth) return []
|
||||
|
||||
try {
|
||||
const trend = fundDetail.value.ac_worth_trend
|
||||
const trend = fundDetail.value.accumulated_net_worth
|
||||
if (Array.isArray(trend) && trend.length > 0) {
|
||||
// 新格式: [{date: '2024-01-01', position_percentage: 1.23}]
|
||||
if (trend[0].date !== undefined) {
|
||||
return trend.map(item => [
|
||||
new Date(item.date).getTime(),
|
||||
parseFloat(item.position_percentage) || 0
|
||||
])
|
||||
}
|
||||
// 旧格式: [[timestamp, value]]
|
||||
return trend.map(item => {
|
||||
if (Array.isArray(item) && item.length >= 2) {
|
||||
return [item[0], parseFloat(item[1]) || 0]
|
||||
@@ -186,7 +283,11 @@ export default {
|
||||
error,
|
||||
processedNetWorthTrend,
|
||||
processedAcWorthTrend,
|
||||
retry
|
||||
retry,
|
||||
modalVisible,
|
||||
modalType,
|
||||
openModal,
|
||||
closeModal
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,30 +297,70 @@ export default {
|
||||
.fund-detail {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
padding: 0 16px;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
/* Dashboard 主布局 */
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
/* 图表区域 */
|
||||
.charts-section {
|
||||
/* 左侧主区域 */
|
||||
.main-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin-bottom: 0;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 详细信息区域 */
|
||||
.detail-sections {
|
||||
/* 右侧边栏 */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 两列网格 */
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 卡片基础样式 */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 图表卡片 - 固定高度 */
|
||||
.card-chart {
|
||||
height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 中等高度卡片 - 增加高度 */
|
||||
.card-md {
|
||||
height: 450px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 侧边栏卡片 */
|
||||
.card-sidebar {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
max-height: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: #f5f5f5;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
@@ -312,35 +453,145 @@ export default {
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.fund-detail {
|
||||
padding: 0;
|
||||
@media (max-width: 1400px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr 340px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-sections {
|
||||
padding: 16px;
|
||||
.sidebar {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.card-sidebar {
|
||||
flex: 1;
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-md {
|
||||
height: auto;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-sidebar {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fund-detail {
|
||||
padding: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.detail-sections {
|
||||
padding: 12px;
|
||||
.dashboard {
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
.main-area, .sidebar {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
.grid-2 {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 可点击卡片样式 */
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.clickable:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
height: 80vh;
|
||||
max-height: 700px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
animation: modalIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: #f0f0f0;
|
||||
border-radius: 50%;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.modal-body > * {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
279
Frontend/src/components/FundHolderStructure.vue
Normal file
279
Frontend/src/components/FundHolderStructure.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="holder-structure-card">
|
||||
<div class="card-header">
|
||||
<h3>👥 持有人结构</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="hasData" class="holder-content">
|
||||
<div class="chart-container">
|
||||
<div ref="chartEl" class="holder-chart"></div>
|
||||
</div>
|
||||
<div class="holder-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th v-for="serie in series" :key="serie.name">
|
||||
<span class="legend-dot" :style="{ background: getColor(serie.name) }"></span>
|
||||
{{ serie.name }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(date, dateIndex) in categories" :key="dateIndex">
|
||||
<td class="date-cell">{{ date }}</td>
|
||||
<td v-for="serie in series" :key="serie.name" class="value-cell">
|
||||
{{ formatValue(serie.data[dateIndex]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<p>暂无持有人结构数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
name: 'FundHolderStructure',
|
||||
props: {
|
||||
holderStructure: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const chartEl = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const categories = computed(() => props.holderStructure?.categories || [])
|
||||
const series = computed(() => props.holderStructure?.series || [])
|
||||
const hasData = computed(() => categories.value.length > 0 && series.value.length > 0)
|
||||
|
||||
const colors = {
|
||||
'机构持有': '#667eea',
|
||||
'个人持有': '#91cc75',
|
||||
'内部持有': '#fac858'
|
||||
}
|
||||
|
||||
const getColor = (name) => {
|
||||
return colors[name] || '#5470c6'
|
||||
}
|
||||
|
||||
const formatValue = (value) => {
|
||||
if (value === null || value === undefined) return '--'
|
||||
return value.toFixed(2) + '%'
|
||||
}
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartEl.value || !hasData.value) return
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
}
|
||||
|
||||
chartInstance = echarts.init(chartEl.value)
|
||||
|
||||
// 准备堆叠柱状图数据
|
||||
const seriesData = series.value.map(serie => ({
|
||||
name: serie.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
barWidth: '50%',
|
||||
data: serie.data,
|
||||
itemStyle: {
|
||||
color: getColor(serie.name)
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: (params) => params.value > 10 ? params.value.toFixed(1) + '%' : ''
|
||||
}
|
||||
}))
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
},
|
||||
formatter: (params) => {
|
||||
let result = `<div style="font-weight: bold; margin-bottom: 8px;">${params[0].axisValue}</div>`
|
||||
params.forEach(param => {
|
||||
result += `<div style="margin: 4px 0;">
|
||||
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
|
||||
${param.seriesName}: <strong>${param.value?.toFixed(2) || '--'}%</strong>
|
||||
</div>`
|
||||
})
|
||||
return result
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: series.value.map(s => s.name),
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories.value,
|
||||
axisLabel: {
|
||||
rotate: 30
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '占比(%)',
|
||||
max: 100,
|
||||
axisLabel: {
|
||||
formatter: '{value}%'
|
||||
}
|
||||
},
|
||||
series: seriesData
|
||||
}
|
||||
|
||||
chartInstance.setOption(option)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.holderStructure, () => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
chartEl,
|
||||
categories,
|
||||
series,
|
||||
hasData,
|
||||
getColor,
|
||||
formatValue
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.holder-structure-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 12px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.holder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.holder-chart {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.holder-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.holder-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.holder-table th,
|
||||
.holder-table td {
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.holder-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.holder-table th .legend-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.value-cell {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.holder-chart {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.holder-table th,
|
||||
.holder-table td {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
430
Frontend/src/components/FundManagerInfo.vue
Normal file
430
Frontend/src/components/FundManagerInfo.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<div class="fund-manager-card">
|
||||
<div class="card-header">
|
||||
<h3>👨💼 基金经理</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="hasManagers" class="managers-container">
|
||||
<div
|
||||
v-for="(manager, index) in managers"
|
||||
:key="manager.id || index"
|
||||
class="manager-item"
|
||||
>
|
||||
<!-- 经理基本信息 -->
|
||||
<div class="manager-header">
|
||||
<div class="manager-basic">
|
||||
<div class="manager-name">
|
||||
{{ manager.name || '未知' }}
|
||||
<span v-if="manager.star_rating" class="star-rating">
|
||||
<span v-for="i in 5" :key="i" class="star" :class="{ filled: i <= manager.star_rating }">★</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="manager-meta">
|
||||
<span class="meta-item" v-if="manager.work_experience">
|
||||
<i class="icon">📅</i> 从业 {{ manager.work_experience }}
|
||||
</span>
|
||||
<span class="meta-item" v-if="manager.managed_fund_size">
|
||||
<i class="icon">💰</i> 管理规模 {{ manager.managed_fund_size }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 能力评估雷达图 -->
|
||||
<div class="manager-ability" v-if="hasAbilityData(manager)">
|
||||
<div class="section-title">能力评估</div>
|
||||
<div class="ability-chart-container">
|
||||
<div :ref="el => setChartRef(el, index)" class="ability-chart"></div>
|
||||
</div>
|
||||
<div class="ability-score" v-if="manager.ability_assessment?.average_score">
|
||||
综合评分: <strong>{{ manager.ability_assessment.average_score }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任职业绩 -->
|
||||
<div class="manager-performance" v-if="hasPerformanceData(manager)">
|
||||
<div class="section-title">任职业绩</div>
|
||||
<div class="performance-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>收益</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in getPerformanceItems(manager)" :key="idx">
|
||||
<td class="serie-name">{{ item.name }}</td>
|
||||
<td :class="getValueClass(item.value)">
|
||||
{{ formatPercent(item.value) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<p>暂无基金经理信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
name: 'FundManagerInfo',
|
||||
props: {
|
||||
fundManagers: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const chartRefs = ref({})
|
||||
const chartInstances = {}
|
||||
|
||||
const managers = computed(() => props.fundManagers || [])
|
||||
const hasManagers = computed(() => managers.value.length > 0)
|
||||
|
||||
const setChartRef = (el, index) => {
|
||||
if (el) {
|
||||
chartRefs.value[index] = el
|
||||
}
|
||||
}
|
||||
|
||||
const hasAbilityData = (manager) => {
|
||||
const ability = manager.ability_assessment
|
||||
return ability && ability.categories?.length > 0 && ability.scores?.length > 0
|
||||
}
|
||||
|
||||
const hasPerformanceData = (manager) => {
|
||||
const perf = manager.performance
|
||||
// 检查 series 和 categories 是否存在
|
||||
if (!perf || !perf.categories?.length) return false
|
||||
// series 可能是数组,且里面的 data 也是数组
|
||||
if (perf.series?.length > 0 && perf.series[0]?.data?.length > 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取业绩数据项
|
||||
const getPerformanceItems = (manager) => {
|
||||
const perf = manager.performance
|
||||
if (!perf || !perf.categories?.length) return []
|
||||
|
||||
const categories = perf.categories
|
||||
const series = perf.series
|
||||
|
||||
// series[0].data 是一个对象数组 [{y: value, name: null, color: xxx}]
|
||||
if (series?.length > 0 && series[0]?.data?.length > 0) {
|
||||
const dataArr = series[0].data
|
||||
return categories.map((cat, idx) => {
|
||||
const item = dataArr[idx]
|
||||
// item 可能是对象 {y: value} 或直接是数值
|
||||
const value = typeof item === 'object' ? (item?.y ?? item?.value ?? null) : item
|
||||
return {
|
||||
name: cat,
|
||||
value: value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const handleImageError = (e) => {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
const getValueClass = (val) => {
|
||||
if (val === null || val === undefined || val === '--') return ''
|
||||
const num = parseFloat(val)
|
||||
return num > 0 ? 'positive' : num < 0 ? 'negative' : ''
|
||||
}
|
||||
|
||||
const formatPercent = (val) => {
|
||||
if (val === null || val === undefined || val === '--') return '--'
|
||||
const num = parseFloat(val)
|
||||
if (isNaN(num)) return '--'
|
||||
return (num > 0 ? '+' : '') + num.toFixed(2) + '%'
|
||||
}
|
||||
|
||||
const initRadarChart = (index) => {
|
||||
const el = chartRefs.value[index]
|
||||
const manager = managers.value[index]
|
||||
|
||||
if (!el || !hasAbilityData(manager)) return
|
||||
|
||||
if (chartInstances[index]) {
|
||||
chartInstances[index].dispose()
|
||||
}
|
||||
|
||||
chartInstances[index] = echarts.init(el)
|
||||
|
||||
const ability = manager.ability_assessment
|
||||
const indicators = ability.categories.map((cat, i) => ({
|
||||
name: cat,
|
||||
max: 100
|
||||
}))
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
radar: {
|
||||
indicator: indicators,
|
||||
shape: 'polygon',
|
||||
splitNumber: 4,
|
||||
radius: '60%',
|
||||
center: ['50%', '50%'],
|
||||
axisName: {
|
||||
color: '#666',
|
||||
fontSize: 11,
|
||||
padding: [3, 5]
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: ['#e5e5e5']
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: ['rgba(102, 126, 234, 0.05)', 'rgba(102, 126, 234, 0.1)']
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
type: 'radar',
|
||||
data: [{
|
||||
value: ability.scores,
|
||||
name: '能力评估',
|
||||
areaStyle: {
|
||||
color: 'rgba(102, 126, 234, 0.3)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#667eea',
|
||||
width: 2
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#667eea'
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
|
||||
chartInstances[index].setOption(option)
|
||||
}
|
||||
|
||||
const initAllCharts = () => {
|
||||
managers.value.forEach((_, index) => {
|
||||
nextTick(() => {
|
||||
initRadarChart(index)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initAllCharts()
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.fundManagers, () => {
|
||||
nextTick(() => {
|
||||
initAllCharts()
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
managers,
|
||||
hasManagers,
|
||||
setChartRef,
|
||||
hasAbilityData,
|
||||
hasPerformanceData,
|
||||
getPerformanceItems,
|
||||
getValueClass,
|
||||
formatPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fund-manager-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 12px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.managers-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.manager-item {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.manager-header {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.manager-basic {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manager-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.star-rating {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.star {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.star.filled {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.manager-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 6px;
|
||||
border-left: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.manager-ability {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ability-chart-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ability-chart {
|
||||
width: 280px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.ability-score {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ability-score strong {
|
||||
color: #667eea;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.manager-performance {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.performance-table {
|
||||
overflow-x: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.performance-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.performance-table th,
|
||||
.performance-table td {
|
||||
padding: 5px 6px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.performance-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.performance-table .serie-name {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
323
Frontend/src/components/FundPortfolio.vue
Normal file
323
Frontend/src/components/FundPortfolio.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="portfolio-card">
|
||||
<div class="card-header">
|
||||
<h3>📈 持仓明细</h3>
|
||||
<div class="tab-switch">
|
||||
<button
|
||||
:class="{ active: activeTab === 'stock' }"
|
||||
@click="activeTab = 'stock'"
|
||||
>
|
||||
股票持仓
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: activeTab === 'bond' }"
|
||||
@click="activeTab = 'bond'"
|
||||
>
|
||||
债券持仓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 股票持仓 -->
|
||||
<div v-if="activeTab === 'stock'" class="portfolio-content">
|
||||
<div v-if="hasStockData" class="stock-list">
|
||||
<div class="portfolio-header">
|
||||
<span class="col-rank">排名</span>
|
||||
<span class="col-code">代码</span>
|
||||
<span class="col-name">名称</span>
|
||||
<span class="col-ratio">占比</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(stock, index) in stockList"
|
||||
:key="stock.code || index"
|
||||
class="portfolio-item"
|
||||
>
|
||||
<span class="col-rank">{{ index + 1 }}</span>
|
||||
<span class="col-code">{{ stock.code }}</span>
|
||||
<span class="col-name">{{ stock.name }}</span>
|
||||
<span class="col-ratio">
|
||||
<div class="ratio-bar">
|
||||
<div class="ratio-fill" :style="{ width: getRatioWidth(stock.ratio) }"></div>
|
||||
</div>
|
||||
<span class="ratio-text">{{ formatRatio(stock.ratio) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<p>暂无股票持仓数据</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 债券持仓 -->
|
||||
<div v-if="activeTab === 'bond'" class="portfolio-content">
|
||||
<div v-if="hasBondData" class="bond-list">
|
||||
<div class="portfolio-header">
|
||||
<span class="col-rank">排名</span>
|
||||
<span class="col-code">代码</span>
|
||||
<span class="col-name">名称</span>
|
||||
<span class="col-ratio">占比</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(bond, index) in bondList"
|
||||
:key="bond.code || index"
|
||||
class="portfolio-item"
|
||||
>
|
||||
<span class="col-rank">{{ index + 1 }}</span>
|
||||
<span class="col-code">{{ bond.code }}</span>
|
||||
<span class="col-name">{{ bond.name }}</span>
|
||||
<span class="col-ratio">
|
||||
<div class="ratio-bar bond-bar">
|
||||
<div class="ratio-fill" :style="{ width: getRatioWidth(bond.ratio) }"></div>
|
||||
</div>
|
||||
<span class="ratio-text">{{ formatRatio(bond.ratio) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<p>暂无债券持仓数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'FundPortfolio',
|
||||
props: {
|
||||
portfolio: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const activeTab = ref('stock')
|
||||
|
||||
// 解析持仓数据 - 格式: ["代码", "名称", "占比", ...]
|
||||
const parseHoldings = (codes) => {
|
||||
if (!codes || !Array.isArray(codes)) return []
|
||||
|
||||
const holdings = []
|
||||
// 每3个元素为一组: [代码, 名称, 占比]
|
||||
for (let i = 0; i < codes.length; i += 3) {
|
||||
if (i + 2 < codes.length) {
|
||||
holdings.push({
|
||||
code: codes[i],
|
||||
name: codes[i + 1],
|
||||
ratio: parseFloat(codes[i + 2]) || 0
|
||||
})
|
||||
}
|
||||
}
|
||||
return holdings.sort((a, b) => b.ratio - a.ratio)
|
||||
}
|
||||
|
||||
// 优先使用最新数据 (stock_codes_new),否则使用旧数据
|
||||
const stockList = computed(() => {
|
||||
const newCodes = props.portfolio?.stock_codes_new
|
||||
const oldCodes = props.portfolio?.stock_codes
|
||||
return parseHoldings(newCodes?.length ? newCodes : oldCodes)
|
||||
})
|
||||
|
||||
const bondList = computed(() => {
|
||||
const newCodes = props.portfolio?.bond_codes_new
|
||||
const oldCodes = props.portfolio?.bond_codes
|
||||
return parseHoldings(newCodes?.length ? newCodes : oldCodes)
|
||||
})
|
||||
|
||||
const hasStockData = computed(() => stockList.value.length > 0)
|
||||
const hasBondData = computed(() => bondList.value.length > 0)
|
||||
|
||||
const formatRatio = (ratio) => {
|
||||
if (ratio === null || ratio === undefined) return '--'
|
||||
return ratio.toFixed(2) + '%'
|
||||
}
|
||||
|
||||
const getRatioWidth = (ratio) => {
|
||||
if (!ratio) return '0%'
|
||||
// 最大占比假设为20%,计算相对宽度
|
||||
const maxRatio = 20
|
||||
const width = Math.min((ratio / maxRatio) * 100, 100)
|
||||
return width + '%'
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
stockList,
|
||||
bondList,
|
||||
hasStockData,
|
||||
hasBondData,
|
||||
formatRatio,
|
||||
getRatioWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.portfolio-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-switch {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-switch button {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-switch button.active {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-switch button:hover:not(.active) {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 10px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portfolio-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.portfolio-header {
|
||||
display: flex;
|
||||
padding: 8px 0;
|
||||
border-bottom: 2px solid #eee;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.portfolio-item {
|
||||
display: flex;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.portfolio-item:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.col-rank {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portfolio-item .col-rank {
|
||||
width: 32px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.portfolio-item:nth-child(2) .col-rank { background: #ffd700; color: #fff; }
|
||||
.portfolio-item:nth-child(3) .col-rank { background: #c0c0c0; color: #fff; }
|
||||
.portfolio-item:nth-child(4) .col-rank { background: #cd7f32; color: #fff; }
|
||||
|
||||
.col-code {
|
||||
width: 65px;
|
||||
color: #667eea;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-ratio {
|
||||
width: 90px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ratio-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ratio-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.bond-bar .ratio-fill {
|
||||
background: linear-gradient(90deg, #52c41a 0%, #73d13d 100%);
|
||||
}
|
||||
|
||||
.ratio-text {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,15 @@
|
||||
<div class="fund-ranking-card">
|
||||
<div class="card-header">
|
||||
<h3>🏆 同类排名走势</h3>
|
||||
<div class="time-ranges">
|
||||
<span
|
||||
v-for="range in timeRanges"
|
||||
:key="range.value"
|
||||
class="range-btn"
|
||||
:class="{ active: selectedRange === range.value }"
|
||||
@click="setTimeRange(range.value)"
|
||||
>{{ range.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="hasRankingData" class="ranking-content">
|
||||
@@ -12,17 +21,17 @@
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>排名</th>
|
||||
<th>同类基金总数</th>
|
||||
<th>同类总数</th>
|
||||
<th>击败同类</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in recentRankings" :key="index">
|
||||
<td>{{ formatDate(item.x) }}</td>
|
||||
<td class="rank-value">{{ item.y }}/{{ item.sc }}</td>
|
||||
<td>{{ item.sc }}</td>
|
||||
<td :class="getPercentClass((1 - item.y / item.sc) * 100)">
|
||||
{{ ((1 - item.y / item.sc) * 100).toFixed(2) }}%
|
||||
<td>{{ item.dateFormatted }}</td>
|
||||
<td class="rank-value">{{ item.rank }}/{{ item.total_funds }}</td>
|
||||
<td>{{ item.total_funds }}</td>
|
||||
<td :class="getPercentClass((1 - item.rank / item.total_funds) * 100)">
|
||||
{{ ((1 - item.rank / item.total_funds) * 100).toFixed(2) }}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -55,6 +64,14 @@ export default {
|
||||
setup(props) {
|
||||
const rankingChartEl = ref(null)
|
||||
let rankingChartInstance = null
|
||||
const selectedRange = ref('1y')
|
||||
|
||||
const timeRanges = [
|
||||
{ label: '近1年', value: '1y' },
|
||||
{ label: '近3年', value: '3y' },
|
||||
{ label: '近5年', value: '5y' },
|
||||
{ label: '全部', value: 'all' }
|
||||
]
|
||||
|
||||
const hasRankingData = computed(() =>
|
||||
props.rateInSimilarType && props.rateInSimilarType.length > 0
|
||||
@@ -66,27 +83,73 @@ export default {
|
||||
|
||||
const percentMap = new Map()
|
||||
props.rateInSimilarPercent?.forEach(item => {
|
||||
percentMap.set(item[0], item[1])
|
||||
// 新格式: {date, position_percentage}
|
||||
if (item.date !== undefined) {
|
||||
percentMap.set(item.date, item.position_percentage)
|
||||
} else if (Array.isArray(item)) {
|
||||
// 旧格式: [timestamp, value]
|
||||
percentMap.set(item[0], item[1])
|
||||
}
|
||||
})
|
||||
|
||||
return props.rateInSimilarType.map(item => ({
|
||||
...item,
|
||||
percent: percentMap.get(item.x) || 0
|
||||
}))
|
||||
return props.rateInSimilarType.map(item => {
|
||||
// 新格式: {date, rank, total_funds}
|
||||
if (item.date !== undefined) {
|
||||
const timestamp = new Date(item.date).getTime()
|
||||
return {
|
||||
x: timestamp,
|
||||
rank: item.rank,
|
||||
total_funds: item.total_funds,
|
||||
dateFormatted: item.date,
|
||||
percent: percentMap.get(item.date) || 0
|
||||
}
|
||||
}
|
||||
// 旧格式: {x, y, sc}
|
||||
return {
|
||||
x: item.x,
|
||||
rank: item.y,
|
||||
total_funds: item.sc,
|
||||
dateFormatted: formatDate(item.x),
|
||||
percent: percentMap.get(item.x) || 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 最近10条记录用于表格显示
|
||||
const recentRankings = computed(() => {
|
||||
return combinedData.value.slice(-10).reverse()
|
||||
// 根据时间范围过滤数据
|
||||
const filteredData = computed(() => {
|
||||
if (!combinedData.value.length) return []
|
||||
|
||||
const now = new Date()
|
||||
let startDate = new Date(0)
|
||||
|
||||
if (selectedRange.value === '1y') {
|
||||
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
|
||||
} else if (selectedRange.value === '3y') {
|
||||
startDate = new Date(now.getFullYear() - 3, now.getMonth(), now.getDate())
|
||||
} else if (selectedRange.value === '5y') {
|
||||
startDate = new Date(now.getFullYear() - 5, now.getMonth(), now.getDate())
|
||||
}
|
||||
|
||||
return combinedData.value.filter(item => item.x >= startDate.getTime())
|
||||
})
|
||||
|
||||
// 最近5条记录用于表格显示
|
||||
const recentRankings = computed(() => {
|
||||
return filteredData.value.slice(-5).reverse()
|
||||
})
|
||||
|
||||
const setTimeRange = (range) => {
|
||||
selectedRange.value = range
|
||||
nextTick(() => {
|
||||
initRankingChart()
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const getPercentClass = (defeatPercent) => {
|
||||
// defeatPercent is 100 - rankPercent
|
||||
// higher is better
|
||||
if (defeatPercent >= 80) return 'excellent'
|
||||
if (defeatPercent >= 50) return 'good'
|
||||
return 'normal'
|
||||
@@ -101,8 +164,8 @@ export default {
|
||||
|
||||
rankingChartInstance = echarts.init(rankingChartEl.value)
|
||||
|
||||
// 准备排名百分比数据(Y轴反转,越小越好)
|
||||
const percentData = combinedData.value.map(item => [item.x, item.percent])
|
||||
// 准备排名百分比数据(Y轴反转,越小越好)- 使用过滤后的数据
|
||||
const percentData = filteredData.value.map(item => [item.x, item.percent])
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
@@ -110,20 +173,20 @@ export default {
|
||||
formatter: (params) => {
|
||||
const dataIndex = params[0].dataIndex
|
||||
const item = combinedData.value[dataIndex]
|
||||
const defeated = ((1 - item.y / item.sc) * 100).toFixed(2);
|
||||
const defeated = ((1 - item.rank / item.total_funds) * 100).toFixed(2);
|
||||
return `
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">${formatDate(item.x)}</div>
|
||||
<div>排名: <strong>${item.y}/${item.sc}</strong></div>
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">${item.dateFormatted}</div>
|
||||
<div>排名: <strong>${item.rank}/${item.total_funds}</strong></div>
|
||||
<div>击败同类: <strong>${defeated}%</strong></div>
|
||||
`
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '10%',
|
||||
containLabel: true
|
||||
left: '11%',
|
||||
right: '12%',
|
||||
bottom: '12%',
|
||||
top: '4%',
|
||||
containLabel: false
|
||||
},
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
@@ -131,7 +194,7 @@ export default {
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '排名百分比(%)',
|
||||
name: '前百分之',
|
||||
inverse: true, // 反转Y轴,越小越好
|
||||
axisLabel: {
|
||||
formatter: '{value}%'
|
||||
@@ -194,7 +257,10 @@ export default {
|
||||
hasRankingData,
|
||||
recentRankings,
|
||||
formatDate,
|
||||
getPercentClass
|
||||
getPercentClass,
|
||||
timeRanges,
|
||||
selectedRange,
|
||||
setTimeRange
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,53 +268,87 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.fund-ranking-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 16px 20px;
|
||||
padding: 10px 16px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time-ranges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.range-btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background: rgba(255,255,255,0.2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.range-btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.range-btn.active {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ranking-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ranking-chart {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
height: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ranking-table {
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ranking-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ranking-table th,
|
||||
.ranking-table td {
|
||||
padding: 12px;
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ export default {
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '10%',
|
||||
bottom: '15%',
|
||||
top: '18%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
@@ -151,53 +151,60 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.fund-scale-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 16px 20px;
|
||||
padding: 12px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scale-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.scale-chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
height: 240px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scale-table {
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scale-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.scale-table th,
|
||||
.scale-table td {
|
||||
padding: 12px;
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
@@ -206,6 +213,8 @@ export default {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.scale-value {
|
||||
@@ -226,12 +235,10 @@ export default {
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.no-data p {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
26
run_fundbot.bat
Normal file
26
run_fundbot.bat
Normal file
@@ -0,0 +1,26 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
echo =========================================
|
||||
echo GoFundBot 一键启动 (conda: fundbot)
|
||||
echo =========================================
|
||||
|
||||
REM 检查 conda 是否可用
|
||||
where conda >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] 未找到 conda,请先在系统 PATH 中配置 conda。
|
||||
echo 可以先在 Anaconda Prompt 中运行此脚本。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 启动后端 (新窗口)
|
||||
start "GoFundBot Backend" cmd /k "call conda activate fundbot && python Backend\app.py"
|
||||
|
||||
REM 启动前端 (新窗口)
|
||||
start "GoFundBot Frontend" cmd /k "cd Frontend && npm run dev"
|
||||
|
||||
echo [OK] 已启动后端与前端。
|
||||
echo 后端: http://127.0.0.1:5000
|
||||
echo 前端: http://127.0.0.1:5173
|
||||
pause
|
||||
179
使用指南.md
179
使用指南.md
@@ -1,179 +0,0 @@
|
||||
# 基金分析系统 - 快速使用指南
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
#### 后端依赖
|
||||
```bash
|
||||
cd Backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
#### 前端依赖
|
||||
```bash
|
||||
cd Frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
#### 启动后端(终端1)
|
||||
```bash
|
||||
cd Backend
|
||||
python app.py
|
||||
```
|
||||
后端将运行在:`http://localhost:5000`
|
||||
|
||||
#### 启动前端(终端2)
|
||||
```bash
|
||||
cd Frontend
|
||||
npm run dev
|
||||
```
|
||||
前端将运行在:`http://localhost:5173`
|
||||
|
||||
### 3. 访问应用
|
||||
打开浏览器访问:`http://localhost:5173`
|
||||
|
||||
---
|
||||
|
||||
## 📖 功能使用说明
|
||||
|
||||
### 基金搜索
|
||||
1. 在顶部搜索框输入基金代码(如:019127)或基金名称
|
||||
2. 点击搜索结果中的基金项
|
||||
|
||||
### 查看基金信息
|
||||
|
||||
#### 顶部信息区
|
||||
- **单位净值**:最新交易日的基金净值
|
||||
- **估算净值**:当日实时估算的净值
|
||||
- **近期收益**:1月、3月、6月、1年的收益率
|
||||
- **费率信息**:现费率和最小申购金额
|
||||
|
||||
#### 中部图表区
|
||||
|
||||
**净值走势图**
|
||||
- 切换"业绩走势"和"回撤修复"两个标签
|
||||
- 选择时间范围:近3月、6月、1年、3年、全部
|
||||
- 鼠标悬停查看详细数据
|
||||
|
||||
**累计收益率对比图**
|
||||
- 对比本基金、同类平均、沪深300的收益表现
|
||||
- 折线图展示,不同颜色区分
|
||||
|
||||
**同类排名走势图**
|
||||
- 查看基金在同类中的排名变化
|
||||
- 前10%、前25%、中位数标记线
|
||||
- 底部表格显示最近10次排名记录
|
||||
|
||||
#### 底部详细区
|
||||
|
||||
**资产配置**
|
||||
- 堆叠柱状图:股票、债券、现金占比
|
||||
- 折线图:净资产变化
|
||||
- 表格:详细配置数据
|
||||
|
||||
**基金经理**
|
||||
- 基本信息:姓名、星级、工作时间、管理规模
|
||||
- 能力雷达图:5个维度评估
|
||||
- 收益对比图:任期收益 vs 同类平均 vs 沪深300
|
||||
|
||||
**规模变动**
|
||||
- 柱状图:基金规模变化
|
||||
- 表格:规模数据和环比变化
|
||||
|
||||
---
|
||||
|
||||
## 💡 使用技巧
|
||||
|
||||
### 1. 如何判断基金好坏?
|
||||
|
||||
**看收益率**
|
||||
- 查看顶部的近期收益率,与同类平均对比
|
||||
- 在累计收益率对比图中,看是否跑赢同类和大盘
|
||||
|
||||
**看排名**
|
||||
- 在同类排名走势图中,排名越靠前越好
|
||||
- 持续在前25%是优秀基金的标志
|
||||
|
||||
**看经理**
|
||||
- 查看基金经理的星级(4星及以上为佳)
|
||||
- 看能力雷达图,各项指标是否均衡
|
||||
- 任期收益是否超过同类平均
|
||||
|
||||
**看规模**
|
||||
- 规模不宜过小(易被清盘)也不宜过大(影响灵活性)
|
||||
- 规模稳定增长是好信号
|
||||
|
||||
**看配置**
|
||||
- 资产配置是否合理
|
||||
- 股票仓位变化趋势
|
||||
|
||||
### 2. 常见问题
|
||||
|
||||
**Q: 为什么有些模块显示"暂无数据"?**
|
||||
A: 部分新成立的基金或特殊类型基金,某些数据可能不完整。
|
||||
|
||||
**Q: 数据多久更新一次?**
|
||||
A: 实时估值每分钟更新,其他数据每交易日更新。
|
||||
|
||||
**Q: 可以对比多个基金吗?**
|
||||
A: 当前版本暂不支持,此功能在开发计划中。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐基金分析流程
|
||||
|
||||
1. **初步筛选**
|
||||
- 输入基金代码或搜索基金名称
|
||||
- 查看顶部基础信息,了解费率和收益
|
||||
|
||||
2. **收益分析**
|
||||
- 查看累计收益率对比图
|
||||
- 确认是否跑赢同类和大盘
|
||||
|
||||
3. **排名分析**
|
||||
- 查看同类排名走势
|
||||
- 确认排名是否稳定在前列
|
||||
|
||||
4. **风险评估**
|
||||
- 查看净值走势图的回撤情况
|
||||
- 评估最大回撤和修复能力
|
||||
|
||||
5. **基金经理**
|
||||
- 查看经理能力评估
|
||||
- 确认经理稳定性和业绩
|
||||
|
||||
6. **综合判断**
|
||||
- 结合所有数据做出投资决策
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 系统要求
|
||||
|
||||
- **浏览器**:Chrome、Firefox、Safari、Edge(最新版本)
|
||||
- **Python**:3.8+
|
||||
- **Node.js**:14+
|
||||
- **网络**:需要连接互联网获取基金数据
|
||||
|
||||
---
|
||||
|
||||
## 🔍 示例基金代码
|
||||
|
||||
可以尝试以下基金代码进行测试:
|
||||
- `019127` - 华泰柏瑞港股通医疗精选混合发起式C
|
||||
- `001186` - 富国文体健康股票
|
||||
- `110022` - 易方达消费行业股票
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本系统仅用于学习和研究目的,所展示的数据和分析结果仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
|
||||
|
||||
---
|
||||
|
||||
## 📞 反馈与支持
|
||||
|
||||
如遇到问题或有改进建议,欢迎反馈!
|
||||
249
更新日志.md
249
更新日志.md
@@ -1,249 +0,0 @@
|
||||
# 基金分析系统功能扩展 - 更新日志
|
||||
|
||||
## 更新日期:2026年1月15日
|
||||
|
||||
### 📋 更新概述
|
||||
|
||||
本次更新对基金分析系统进行了全面的功能扩展与界面优化,增加了丰富的数据展示模块,提升了用户体验。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 后端更新
|
||||
|
||||
### 1. fund_api.py 扩展
|
||||
|
||||
**新增数据解析字段:**
|
||||
- ✅ 累计收益率走势对比 (Data_grandTotal)
|
||||
- ✅ 同类排名走势 (Data_rateInSimilarType)
|
||||
- ✅ 同类排名百分比 (Data_rateInSimilarPersent)
|
||||
- ✅ 资产配置 (Data_assetAllocation)
|
||||
- ✅ 规模变动 (Data_fluctuationScale)
|
||||
- ✅ 持有人结构 (Data_holderStructure)
|
||||
- ✅ 基金经理信息 (Data_currentFundManager)
|
||||
- ✅ 业绩评价 (Data_performanceEvaluation)
|
||||
- ✅ 申购赎回 (Data_buySedemption)
|
||||
- ✅ 收益率数据 (syl_1y, syl_3y, syl_6y, syl_1n)
|
||||
- ✅ 现费率 (fund_Rate)
|
||||
- ✅ 最小申购金额 (fund_minsg)
|
||||
|
||||
**代码位置:** `Backend/fund_api.py` 的 `_parse_fund_js()` 方法
|
||||
|
||||
---
|
||||
|
||||
## 🎨 前端更新
|
||||
|
||||
### 2. 基金顶部信息卡片优化
|
||||
|
||||
**组件:** `FundBasicInfo.vue`
|
||||
|
||||
**新增功能:**
|
||||
- 🎯 渐变背景设计,提升视觉效果
|
||||
- 📊 显示单位净值与估算净值对比
|
||||
- 📈 实时净值涨跌幅展示(带颜色标识)
|
||||
- 📅 近1月、3月、6月、1年收益率展示
|
||||
- 💰 现费率和最小申购金额信息
|
||||
- ⏰ 估值时间实时显示
|
||||
|
||||
**视觉特色:**
|
||||
- 紫色渐变背景
|
||||
- 数据卡片式布局
|
||||
- 涨跌颜色区分(红涨绿跌)
|
||||
|
||||
---
|
||||
|
||||
### 3. 中心图表区域
|
||||
|
||||
#### 3.1 净值走势图(保留原有功能)
|
||||
**组件:** `FundChart.vue`
|
||||
- 保持原有的业绩走势和回撤修复功能
|
||||
|
||||
#### 3.2 累计收益率对比图(新增)
|
||||
**组件:** `FundPerformanceComparison.vue`
|
||||
|
||||
**功能:**
|
||||
- 📉 对比展示:本基金 vs 同类平均 vs 沪深300
|
||||
- 📊 多条折线图,清晰对比收益表现
|
||||
- 🎨 不同颜色区分不同对比对象
|
||||
- 💡 鼠标悬停显示详细数据
|
||||
|
||||
#### 3.3 同类排名走势图(新增)
|
||||
**组件:** `FundRankingTrend.vue`
|
||||
|
||||
**功能:**
|
||||
- 🏆 显示基金在同类中的排名变化
|
||||
- 📊 Y轴反转设计(越低越好)
|
||||
- 🎨 颜色分区:
|
||||
- 前10%:绿色
|
||||
- 10-25%:浅绿
|
||||
- 25-50%:黄色
|
||||
- 50%后:红色
|
||||
- 📋 最近10条排名记录表格
|
||||
- 🔖 标记线:前10%、前25%、中位数
|
||||
|
||||
---
|
||||
|
||||
### 4. 详细数据区域
|
||||
|
||||
#### 4.1 资产配置展示(新增)
|
||||
**组件:** `FundAssetAllocation.vue`
|
||||
|
||||
**功能:**
|
||||
- 📊 堆叠柱状图展示资产配置比例
|
||||
- 📈 折线图展示净资产变化
|
||||
- 📋 配置数据表格
|
||||
- 🎨 包含:股票占比、债券占比、现金占比、净资产
|
||||
|
||||
#### 4.2 基金经理卡片(新增)
|
||||
**组件:** `FundManagerCard.vue`
|
||||
|
||||
**功能:**
|
||||
- 👨💼 基金经理头像与基本信息
|
||||
- ⭐ 星级评分显示
|
||||
- 📊 能力评估雷达图(5个维度)
|
||||
- 经验值
|
||||
- 收益率
|
||||
- 抗风险
|
||||
- 稳定性
|
||||
- 择时能力
|
||||
- 📈 收益对比柱状图
|
||||
- 任期收益
|
||||
- 同类平均
|
||||
- 沪深300
|
||||
|
||||
#### 4.3 基金规模变动(新增)
|
||||
**组件:** `FundScaleChange.vue`
|
||||
|
||||
**功能:**
|
||||
- 📊 规模变动柱状图
|
||||
- 📋 详细的规模数据表格
|
||||
- 📈 环比变化百分比
|
||||
- 🎨 渐变柱状图设计
|
||||
|
||||
---
|
||||
|
||||
### 5. 整体布局优化
|
||||
|
||||
**组件:** `FundDetail.vue`
|
||||
|
||||
**布局结构:**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 基金基础信息卡片(顶部) │
|
||||
├─────────────────────────────────────┤
|
||||
│ 净值走势图 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 累计收益率对比图 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 同类排名走势图 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 资产配置 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 基金经理卡片 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 基金规模变动 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**设计特点:**
|
||||
- 卡片式设计,层次分明
|
||||
- 统一的紫色渐变主题
|
||||
- 响应式布局,适配不同屏幕
|
||||
- 流畅的用户体验
|
||||
|
||||
---
|
||||
|
||||
## 📦 新增组件列表
|
||||
|
||||
| 组件名称 | 文件路径 | 功能描述 |
|
||||
|---------|---------|---------|
|
||||
| FundPerformanceComparison | Frontend/src/components/FundPerformanceComparison.vue | 累计收益率对比图 |
|
||||
| FundRankingTrend | Frontend/src/components/FundRankingTrend.vue | 同类排名走势 |
|
||||
| FundAssetAllocation | Frontend/src/components/FundAssetAllocation.vue | 资产配置展示 |
|
||||
| FundManagerCard | Frontend/src/components/FundManagerCard.vue | 基金经理卡片 |
|
||||
| FundScaleChange | Frontend/src/components/FundScaleChange.vue | 基金规模变动 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 技术栈
|
||||
|
||||
**前端:**
|
||||
- Vue 3 (Composition API)
|
||||
- ECharts 5 - 图表库
|
||||
- Axios - HTTP请求
|
||||
|
||||
**后端:**
|
||||
- Python Flask
|
||||
- 天天基金API
|
||||
|
||||
---
|
||||
|
||||
## 🚀 如何使用
|
||||
|
||||
### 启动后端服务
|
||||
```bash
|
||||
cd Backend
|
||||
python app.py
|
||||
```
|
||||
|
||||
### 启动前端服务
|
||||
```bash
|
||||
cd Frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 访问应用
|
||||
打开浏览器访问:`http://localhost:5173`(或对应的端口)
|
||||
|
||||
---
|
||||
|
||||
## 📝 使用说明
|
||||
|
||||
1. **搜索基金**:在搜索框输入基金代码或名称
|
||||
2. **查看详情**:点击搜索结果查看基金详细信息
|
||||
3. **分析数据**:
|
||||
- 顶部查看基金基本信息和实时估值
|
||||
- 中部查看各类走势图表
|
||||
- 底部查看详细的配置、经理和规模数据
|
||||
|
||||
---
|
||||
|
||||
## 🎨 界面特色
|
||||
|
||||
- ✨ 现代化卡片式设计
|
||||
- 🎨 统一的紫色渐变主题
|
||||
- 📊 丰富的数据可视化
|
||||
- 📱 响应式布局
|
||||
- 🎯 清晰的信息层次
|
||||
- 🚀 流畅的交互体验
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据来源
|
||||
|
||||
所有数据均来自天天基金网(https://fund.eastmoney.com/)的官方API。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. 首次使用需要安装依赖
|
||||
2. 确保后端服务正常运行
|
||||
3. 数据更新取决于天天基金API的更新频率
|
||||
4. 部分基金可能因数据不完整导致某些模块无数据显示
|
||||
|
||||
---
|
||||
|
||||
## 🔜 未来计划
|
||||
|
||||
- [ ] 添加基金对比功能
|
||||
- [ ] 添加自选基金列表
|
||||
- [ ] 添加数据导出功能
|
||||
- [ ] 添加更多技术指标
|
||||
- [ ] 优化移动端体验
|
||||
|
||||
---
|
||||
|
||||
## 👨💻 开发者
|
||||
|
||||
如有问题或建议,欢迎提出反馈!
|
||||
Reference in New Issue
Block a user