增强可视化表达能力
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('/')
|
@app.route('/')
|
||||||
def hello():
|
def hello():
|
||||||
|
"""测试接口是否可用"""
|
||||||
return jsonify({"message": "Fund Analysis API is running!"})
|
return jsonify({"message": "Fund Analysis API is running!"})
|
||||||
|
|
||||||
@app.route('/api/fund/search', methods=['GET'])
|
@app.route('/api/fund/search', methods=['GET'])
|
||||||
def search_funds():
|
def search_funds():
|
||||||
"""搜索基金"""
|
"""根据关键词搜索基金列表"""
|
||||||
keyword = request.args.get('q', '')
|
keyword = request.args.get('q', '') # 获取查询参数 基金名称或代码
|
||||||
if not keyword:
|
if not keyword:
|
||||||
return jsonify({"error": "Keyword is required"}), 400
|
return jsonify({"error": "Keyword is required"}), 400
|
||||||
|
funds = fund_api.search_funds(keyword) # 调用API搜索基金
|
||||||
funds = fund_api.search_funds(keyword)
|
|
||||||
return jsonify({"data": funds})
|
return jsonify({"data": funds})
|
||||||
|
|
||||||
@app.route('/api/fund/<fund_code>', methods=['GET'])
|
@app.route('/api/fund/<fund_code>', methods=['GET'])
|
||||||
def get_fund_detail(fund_code):
|
def get_fund_detail(fund_code):
|
||||||
"""获取基金详细信息"""
|
"""获取基金详细信息"""
|
||||||
if not 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获取最新数据
|
if fund_data:
|
||||||
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
|
|
||||||
|
|
||||||
# 检查数据库是否存在记录
|
# 检查数据库是否存在记录
|
||||||
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
|
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
|
||||||
|
|
||||||
# 更新或新增记录
|
# 更新或新增记录
|
||||||
if cached_fund:
|
if cached_fund:
|
||||||
cached_fund.data_json = json.dumps(detail_data, ensure_ascii=False)
|
cached_fund.data_json = json.dumps(fund_data, ensure_ascii=False)
|
||||||
cached_fund.net_worth_trend = json.dumps(detail_data.get('net_worth_trend', []), 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(basic_info, ensure_ascii=False)
|
cached_fund.basic_info = json.dumps(fund_data.get('basic_info', {}), ensure_ascii=False)
|
||||||
else:
|
else:
|
||||||
fund_detail = FundDetail(
|
fund_detail = FundDetail(
|
||||||
fund_code=fund_code,
|
fund_code=fund_code,
|
||||||
data_json=json.dumps(detail_data, ensure_ascii=False),
|
data_json=json.dumps(fund_data, ensure_ascii=False),
|
||||||
net_worth_trend=json.dumps(detail_data.get('net_worth_trend', []), ensure_ascii=False),
|
net_worth_trend=json.dumps(fund_data.get('net_worth_trend', []), ensure_ascii=False),
|
||||||
basic_info=json.dumps(basic_info, ensure_ascii=False)
|
basic_info=json.dumps(fund_data.get('basic_info', {}), ensure_ascii=False)
|
||||||
)
|
)
|
||||||
db.add(fund_detail)
|
db.add(fund_detail)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit() # 提交事务
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving to database: {e}")
|
print(f"Error saving to database: {e}")
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
return jsonify(detail_data)
|
return jsonify(fund_data)
|
||||||
|
|
||||||
# 如果API获取失败,尝试从数据库获取缓存数据作为兜底
|
# 如果API获取失败,尝试从数据库获取缓存数据作为兜底
|
||||||
cached_fund = db.query(FundDetail).filter(FundDetail.fund_code == fund_code).first()
|
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'])
|
@app.route('/api/fund/<fund_code>/basic', methods=['GET'])
|
||||||
def get_fund_basic(fund_code):
|
def get_fund_basic(fund_code):
|
||||||
"""获取基金基础信息"""
|
"""获取基金基础信息 实时调用API"""
|
||||||
if not fund_code:
|
if not fund_code:
|
||||||
return jsonify({"error": "Fund code is required"}), 400
|
return jsonify({"error": "Fund code is required"}), 400
|
||||||
|
fund_data = fund_api.get_fund_data(fund_code)
|
||||||
basic_info = fund_api.get_fund_basic_info(fund_code)
|
if fund_data and fund_data.get('basic_info'):
|
||||||
if basic_info:
|
# 合并 basic_info 和 performance 数据
|
||||||
return jsonify(basic_info)
|
result = {
|
||||||
|
**fund_data.get('basic_info', {}),
|
||||||
|
**fund_data.get('performance', {})
|
||||||
|
}
|
||||||
|
return jsonify(result)
|
||||||
else:
|
else:
|
||||||
return jsonify({"error": "Fund basic info not found"}), 404
|
return jsonify({"error": "Fund basic info not found"}), 404
|
||||||
|
|
||||||
@app.route('/api/fund/<fund_code>/trend', methods=['GET'])
|
@app.route('/api/fund/<fund_code>/trend', methods=['GET'])
|
||||||
def get_fund_trend(fund_code):
|
def get_fund_trend(fund_code):
|
||||||
"""获取基金走势数据"""
|
"""获取基金走势数据 实时调用API"""
|
||||||
if not fund_code:
|
if not fund_code:
|
||||||
return jsonify({"error": "Fund code is required"}), 400
|
return jsonify({"error": "Fund code is required"}), 400
|
||||||
|
|
||||||
detail_data = fund_api.get_fund_detail(fund_code)
|
fund_data = fund_api.get_fund_data(fund_code)
|
||||||
if detail_data and 'net_worth_trend' in detail_data:
|
if fund_data and 'net_worth_trend' in fund_data:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"net_worth_trend": detail_data['net_worth_trend'],
|
"net_worth_trend": fund_data['net_worth_trend'],
|
||||||
"ac_worth_trend": detail_data.get('ac_worth_trend', [])
|
"accumulated_net_worth": fund_data.get('accumulated_net_worth', [])
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({"error": "Fund trend data not found"}), 404
|
return jsonify({"error": "Fund trend data not found"}), 404
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ PROJECT_ROOT = BACKEND_DIR.parent
|
|||||||
# 数据库路径:PROJECT_ROOT / Data / funds.db
|
# 数据库路径:PROJECT_ROOT / Data / funds.db
|
||||||
DATABASE_PATH = 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()}"
|
DATABASE_URL = f"sqlite:///{DATABASE_PATH.as_posix()}"
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
|||||||
@@ -2,107 +2,315 @@ import requests
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
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:
|
class FundAPI:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.headers = {
|
self.headers = {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
}
|
}
|
||||||
|
self.cleaner = FundDataCleaner()
|
||||||
def get_fund_basic_info(self, fund_code):
|
|
||||||
|
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:
|
try:
|
||||||
# 天天基金实时估值接口
|
return self.cleaner.clean_all_data(raw_data)
|
||||||
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)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"获取实时估值信息失败: {e}")
|
print(f"Error cleaning data for {fund_code}: {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)
|
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching detail for {fund_code}: {e}")
|
def search_funds(self, keyword: str) -> List[Dict[str, Any]]:
|
||||||
return None
|
|
||||||
|
|
||||||
def search_funds(self, keyword):
|
|
||||||
"""
|
"""
|
||||||
搜索基金
|
搜索基金(返回列表)
|
||||||
"""
|
"""
|
||||||
url = "https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx"
|
url = "https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx"
|
||||||
params = {
|
params = {
|
||||||
@@ -122,158 +330,89 @@ class FundAPI:
|
|||||||
print(f"Search error: {e}")
|
print(f"Search error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _parse_fund_js(self, js_content, fund_code):
|
def _fetch_raw_data(self, fund_code: str) -> Union[Dict[str, Any], None]:
|
||||||
"""解析基金详细数据的JS文件"""
|
"""
|
||||||
data = {
|
获取原始基金数据(字典形式),包含所有JS变量。
|
||||||
'fund_code': fund_code,
|
"""
|
||||||
'net_worth_trend': [],
|
data = {}
|
||||||
'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()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# 1. 抓取 pingzhongdata 详细数据
|
||||||
|
url = f"https://fund.eastmoney.com/pingzhongdata/{fund_code}.js"
|
||||||
try:
|
try:
|
||||||
# 解析单位净值走势Data_netWorthTrend
|
response = requests.get(url, headers=self.headers, timeout=10)
|
||||||
net_worth_match = re.search(r'var Data_netWorthTrend\s*=\s*(\[.*?\]);', js_content, re.DOTALL)
|
if response.status_code == 200:
|
||||||
if net_worth_match:
|
js_content = response.text
|
||||||
try:
|
|
||||||
data['net_worth_trend'] = json.loads(net_worth_match.group(1))
|
# 提取所有 var xxx = ...; 也就是 JS 变量
|
||||||
except:
|
# 兼容值可能是数字、字符串、数组[]、对象{}
|
||||||
pass
|
# 正则解析:var (变量名) = (值);
|
||||||
|
# 值可能跨多行,非贪婪匹配
|
||||||
# 解析累计净值走势Data_ACWorthTrend
|
var_matches = re.findall(r'var\s+(\w+)\s*=\s*(.*?);', js_content, re.DOTALL)
|
||||||
ac_worth_match = re.search(r'var Data_ACWorthTrend\s*=\s*(\[.*?\]);', js_content, re.DOTALL)
|
|
||||||
if ac_worth_match:
|
for var_name, var_value in var_matches:
|
||||||
try:
|
var_name = var_name.strip()
|
||||||
data['ac_worth_trend'] = json.loads(ac_worth_match.group(1))
|
var_value = var_value.strip()
|
||||||
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)
|
|
||||||
|
|
||||||
|
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:
|
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
|
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 class="card-body">
|
||||||
<div v-if="hasData" class="allocation-content">
|
<div v-if="hasData" class="allocation-content">
|
||||||
<div ref="chartEl" class="allocation-chart"></div>
|
<div ref="chartEl" class="allocation-chart"></div>
|
||||||
<div class="allocation-table">
|
<div class="legend-info">
|
||||||
<table>
|
<div v-for="(serie, index) in displaySeries" :key="index" class="legend-item">
|
||||||
<thead>
|
<span class="legend-dot" :style="{ background: getColor(index) }"></span>
|
||||||
<tr>
|
<span class="legend-name">{{ serie.name }}</span>
|
||||||
<th style="min-width: 100px;">时间</th>
|
<span class="legend-value">{{ formatValue(serie.data[serie.data.length - 1], serie.name) }}</span>
|
||||||
<th v-for="(serie, index) in series" :key="index" style="text-align: center; min-width: 100px;">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="no-data">
|
<div v-else class="no-data">
|
||||||
@@ -180,10 +164,14 @@ export default {
|
|||||||
})
|
})
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 用于显示的系列(排除净资产)
|
||||||
|
const displaySeries = computed(() => series.value.filter(s => s.name !== '净资产'))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chartEl,
|
chartEl,
|
||||||
categories,
|
categories,
|
||||||
series,
|
series,
|
||||||
|
displaySeries,
|
||||||
hasData,
|
hasData,
|
||||||
getColor,
|
getColor,
|
||||||
formatValue
|
formatValue
|
||||||
@@ -194,89 +182,81 @@ export default {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.asset-allocation-card {
|
.asset-allocation-card {
|
||||||
background: white;
|
height: 100%;
|
||||||
border-radius: 12px;
|
display: flex;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 16px 20px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid #e8e8e8;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header h3 {
|
.card-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 24px;
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.allocation-content {
|
.allocation-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.allocation-chart {
|
.allocation-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.allocation-table {
|
.legend-info {
|
||||||
overflow-x: auto;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.allocation-table table {
|
.legend-item {
|
||||||
width: 100%;
|
display: flex;
|
||||||
border-collapse: collapse;
|
align-items: center;
|
||||||
font-size: 14px;
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.allocation-table th,
|
.legend-dot {
|
||||||
.allocation-table td {
|
width: 10px;
|
||||||
padding: 12px;
|
height: 10px;
|
||||||
text-align: left;
|
border-radius: 50%;
|
||||||
border-bottom: 1px solid #e8e8e8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.allocation-table th {
|
.legend-name {
|
||||||
background: #f5f5f5;
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-value {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-cell {
|
.no-data {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
}
|
height: 100%;
|
||||||
|
|
||||||
.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;
|
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data p {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -51,11 +51,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<div class="metric-label">现费率</div>
|
<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>
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<div class="metric-label">最小申购</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,6 +72,11 @@ export default {
|
|||||||
fundCode: {
|
fundCode: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
// 新增:接收父组件传递的基金数据,避免重复请求
|
||||||
|
fundData: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -81,25 +86,57 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
// 监听父组件传递的数据
|
||||||
|
fundData: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newData) {
|
||||||
|
if (newData) {
|
||||||
|
this.processFundData(newData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
fundCode: {
|
fundCode: {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
handler(newCode) {
|
handler(newCode) {
|
||||||
if (newCode) {
|
// 只有在没有父组件传递数据时才自己请求
|
||||||
|
if (newCode && !this.fundData) {
|
||||||
this.fetchFundInfo()
|
this.fetchFundInfo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
async fetchFundInfo() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
try {
|
try {
|
||||||
const response = await fundAPI.getFundDetail(this.fundCode)
|
const response = await fundAPI.getFundDetail(this.fundCode)
|
||||||
// 合并 basic_info 到主对象
|
const data = response.data
|
||||||
this.fundInfo = {
|
this.processFundData(data)
|
||||||
...response.data,
|
|
||||||
...response.data.basic_info
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取基金信息失败:', error)
|
console.error('获取基金信息失败:', error)
|
||||||
this.fundInfo = null
|
this.fundInfo = null
|
||||||
@@ -119,6 +156,24 @@ export default {
|
|||||||
formatTime(timeStr) {
|
formatTime(timeStr) {
|
||||||
if (!timeStr) return '--'
|
if (!timeStr) return '--'
|
||||||
return timeStr
|
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>
|
||||||
|
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<div ref="chartEl" style="width: 100%; height: 350px;"></div>
|
<div ref="chartEl" class="chart-el"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="time-ranges">
|
<div class="time-ranges">
|
||||||
@@ -153,6 +153,13 @@ export default {
|
|||||||
startDate = new Date(now.setFullYear(now.getFullYear() - 3))
|
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())
|
return data.filter(item => item[0] >= startDate.getTime())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,15 +461,11 @@ export default {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fund-chart-card {
|
.fund-chart-card {
|
||||||
background: white;
|
height: 100%;
|
||||||
border-radius: 12px;
|
display: flex;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
flex-direction: column;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-bottom: 10px;
|
|
||||||
position: relative;
|
|
||||||
min-height: 400px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-tabs {
|
.top-tabs {
|
||||||
@@ -561,7 +564,15 @@ export default {
|
|||||||
.text-green { color: #52c41a; }
|
.text-green { color: #52c41a; }
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-el {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-ranges {
|
.time-ranges {
|
||||||
|
|||||||
@@ -1,38 +1,98 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fund-detail">
|
<div class="fund-detail">
|
||||||
<!-- 基金基础信息组件 -->
|
<!-- 基金基础信息组件 -->
|
||||||
<FundBasicInfo :fundCode="currentFundCode" />
|
<FundBasicInfo :fundCode="currentFundCode" :fundData="fundDetail" />
|
||||||
|
|
||||||
<!-- 主要内容区域 -->
|
<!-- 主要内容区域 - Dashboard 布局 -->
|
||||||
<div v-if="fundDetail" class="detail-content">
|
<div v-if="fundDetail" class="dashboard">
|
||||||
|
|
||||||
<!-- 中心区域:图表展示 -->
|
<!-- 左侧主区域 -->
|
||||||
<div class="charts-section">
|
<div class="main-area">
|
||||||
<!-- 净值走势图 (含收益对比、回撤修复) -->
|
<!-- 净值走势图 -->
|
||||||
<FundChart
|
<div class="card card-chart">
|
||||||
:netWorthTrend="processedNetWorthTrend"
|
<FundChart
|
||||||
:acWorthTrend="processedAcWorthTrend"
|
:netWorthTrend="processedNetWorthTrend"
|
||||||
:grandTotal="fundDetail.grand_total"
|
:acWorthTrend="processedAcWorthTrend"
|
||||||
/>
|
:grandTotal="fundDetail.total_return_trend"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 同类排名走势 -->
|
<!-- 中间两列区域 -->
|
||||||
<FundRankingTrend
|
<div class="grid-2">
|
||||||
:rateInSimilarType="fundDetail.rate_in_similar_type"
|
<div class="card card-md clickable" @click="openModal('ranking')">
|
||||||
:rateInSimilarPercent="fundDetail.rate_in_similar_percent"
|
<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>
|
||||||
|
|
||||||
<!-- 详细信息区域 -->
|
<!-- 右侧边栏 -->
|
||||||
<div class="detail-sections">
|
<div class="sidebar">
|
||||||
<!-- 资产配置 -->
|
<div class="card card-sidebar clickable" @click="openModal('portfolio')">
|
||||||
<FundAssetAllocation
|
<FundPortfolio
|
||||||
:assetAllocation="fundDetail.asset_allocation"
|
:portfolio="fundDetail.portfolio"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<!-- 基金规模变动 -->
|
<div class="card card-sidebar clickable" @click="openModal('manager')">
|
||||||
<FundScaleChange
|
<FundManagerInfo
|
||||||
:fluctuationScale="fundDetail.fluctuation_scale"
|
: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,6 +124,9 @@ import FundChart from './FundChart.vue'
|
|||||||
import FundRankingTrend from './FundRankingTrend.vue'
|
import FundRankingTrend from './FundRankingTrend.vue'
|
||||||
import FundAssetAllocation from './FundAssetAllocation.vue'
|
import FundAssetAllocation from './FundAssetAllocation.vue'
|
||||||
import FundScaleChange from './FundScaleChange.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'
|
import { fundAPI } from '../services/api'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -73,7 +136,10 @@ export default {
|
|||||||
FundChart,
|
FundChart,
|
||||||
FundRankingTrend,
|
FundRankingTrend,
|
||||||
FundAssetAllocation,
|
FundAssetAllocation,
|
||||||
FundScaleChange
|
FundScaleChange,
|
||||||
|
FundManagerInfo,
|
||||||
|
FundHolderStructure,
|
||||||
|
FundPortfolio
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
fundCode: {
|
fundCode: {
|
||||||
@@ -86,6 +152,22 @@ export default {
|
|||||||
const fundDetail = ref(null)
|
const fundDetail = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
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(() => {
|
const processedNetWorthTrend = computed(() => {
|
||||||
@@ -95,14 +177,21 @@ export default {
|
|||||||
// 处理不同的数据格式
|
// 处理不同的数据格式
|
||||||
const trend = fundDetail.value.net_worth_trend
|
const trend = fundDetail.value.net_worth_trend
|
||||||
if (Array.isArray(trend) && trend.length > 0) {
|
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) {
|
if (trend[0].x && trend[0].y) {
|
||||||
return trend.map(item => ({
|
return trend.map(item => ({
|
||||||
x: item.x,
|
x: item.x,
|
||||||
y: parseFloat(item.y) || 0
|
y: parseFloat(item.y) || 0
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
// 格式2: [timestamp, value]
|
// 旧格式2: [timestamp, value]
|
||||||
else if (Array.isArray(trend[0]) && trend[0].length >= 2) {
|
else if (Array.isArray(trend[0]) && trend[0].length >= 2) {
|
||||||
return trend.map(item => ({
|
return trend.map(item => ({
|
||||||
x: item[0],
|
x: item[0],
|
||||||
@@ -119,11 +208,19 @@ export default {
|
|||||||
|
|
||||||
// 处理累计净值走势数据
|
// 处理累计净值走势数据
|
||||||
const processedAcWorthTrend = computed(() => {
|
const processedAcWorthTrend = computed(() => {
|
||||||
if (!fundDetail.value?.ac_worth_trend) return []
|
if (!fundDetail.value?.accumulated_net_worth) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trend = fundDetail.value.ac_worth_trend
|
const trend = fundDetail.value.accumulated_net_worth
|
||||||
if (Array.isArray(trend) && trend.length > 0) {
|
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 => {
|
return trend.map(item => {
|
||||||
if (Array.isArray(item) && item.length >= 2) {
|
if (Array.isArray(item) && item.length >= 2) {
|
||||||
return [item[0], parseFloat(item[1]) || 0]
|
return [item[0], parseFloat(item[1]) || 0]
|
||||||
@@ -186,7 +283,11 @@ export default {
|
|||||||
error,
|
error,
|
||||||
processedNetWorthTrend,
|
processedNetWorthTrend,
|
||||||
processedAcWorthTrend,
|
processedAcWorthTrend,
|
||||||
retry
|
retry,
|
||||||
|
modalVisible,
|
||||||
|
modalType,
|
||||||
|
openModal,
|
||||||
|
closeModal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,30 +297,70 @@ export default {
|
|||||||
.fund-detail {
|
.fund-detail {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0;
|
padding: 0 16px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-content {
|
/* Dashboard 主布局 */
|
||||||
display: flex;
|
.dashboard {
|
||||||
flex-direction: column;
|
display: grid;
|
||||||
gap: 0;
|
grid-template-columns: 1fr 380px;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图表区域 */
|
/* 左侧主区域 */
|
||||||
.charts-section {
|
.main-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 16px;
|
||||||
margin-bottom: 0;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 加载状态 */
|
/* 加载状态 */
|
||||||
@@ -312,35 +453,145 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1400px) {
|
||||||
.fund-detail {
|
.dashboard {
|
||||||
padding: 0;
|
grid-template-columns: 1fr 340px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.dashboard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-sections {
|
.sidebar {
|
||||||
padding: 16px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.fund-detail {
|
.fund-detail {
|
||||||
padding: 0;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-sections {
|
.dashboard {
|
||||||
padding: 12px;
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.main-area, .sidebar {
|
||||||
padding: 60px 20px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.grid-2 {
|
||||||
font-size: 64px;
|
gap: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 可点击卡片样式 */
|
||||||
|
.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>
|
</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="fund-ranking-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>🏆 同类排名走势</h3>
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div v-if="hasRankingData" class="ranking-content">
|
<div v-if="hasRankingData" class="ranking-content">
|
||||||
@@ -12,17 +21,17 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>日期</th>
|
<th>日期</th>
|
||||||
<th>排名</th>
|
<th>排名</th>
|
||||||
<th>同类基金总数</th>
|
<th>同类总数</th>
|
||||||
<th>击败同类</th>
|
<th>击败同类</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(item, index) in recentRankings" :key="index">
|
<tr v-for="(item, index) in recentRankings" :key="index">
|
||||||
<td>{{ formatDate(item.x) }}</td>
|
<td>{{ item.dateFormatted }}</td>
|
||||||
<td class="rank-value">{{ item.y }}/{{ item.sc }}</td>
|
<td class="rank-value">{{ item.rank }}/{{ item.total_funds }}</td>
|
||||||
<td>{{ item.sc }}</td>
|
<td>{{ item.total_funds }}</td>
|
||||||
<td :class="getPercentClass((1 - item.y / item.sc) * 100)">
|
<td :class="getPercentClass((1 - item.rank / item.total_funds) * 100)">
|
||||||
{{ ((1 - item.y / item.sc) * 100).toFixed(2) }}%
|
{{ ((1 - item.rank / item.total_funds) * 100).toFixed(2) }}%
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -55,6 +64,14 @@ export default {
|
|||||||
setup(props) {
|
setup(props) {
|
||||||
const rankingChartEl = ref(null)
|
const rankingChartEl = ref(null)
|
||||||
let rankingChartInstance = 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(() =>
|
const hasRankingData = computed(() =>
|
||||||
props.rateInSimilarType && props.rateInSimilarType.length > 0
|
props.rateInSimilarType && props.rateInSimilarType.length > 0
|
||||||
@@ -66,27 +83,73 @@ export default {
|
|||||||
|
|
||||||
const percentMap = new Map()
|
const percentMap = new Map()
|
||||||
props.rateInSimilarPercent?.forEach(item => {
|
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 => ({
|
return props.rateInSimilarType.map(item => {
|
||||||
...item,
|
// 新格式: {date, rank, total_funds}
|
||||||
percent: percentMap.get(item.x) || 0
|
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(() => {
|
const filteredData = computed(() => {
|
||||||
return combinedData.value.slice(-10).reverse()
|
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) => {
|
const formatDate = (timestamp) => {
|
||||||
return new Date(timestamp).toLocaleDateString('zh-CN')
|
return new Date(timestamp).toLocaleDateString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPercentClass = (defeatPercent) => {
|
const getPercentClass = (defeatPercent) => {
|
||||||
// defeatPercent is 100 - rankPercent
|
|
||||||
// higher is better
|
|
||||||
if (defeatPercent >= 80) return 'excellent'
|
if (defeatPercent >= 80) return 'excellent'
|
||||||
if (defeatPercent >= 50) return 'good'
|
if (defeatPercent >= 50) return 'good'
|
||||||
return 'normal'
|
return 'normal'
|
||||||
@@ -101,8 +164,8 @@ export default {
|
|||||||
|
|
||||||
rankingChartInstance = echarts.init(rankingChartEl.value)
|
rankingChartInstance = echarts.init(rankingChartEl.value)
|
||||||
|
|
||||||
// 准备排名百分比数据(Y轴反转,越小越好)
|
// 准备排名百分比数据(Y轴反转,越小越好)- 使用过滤后的数据
|
||||||
const percentData = combinedData.value.map(item => [item.x, item.percent])
|
const percentData = filteredData.value.map(item => [item.x, item.percent])
|
||||||
|
|
||||||
const option = {
|
const option = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
@@ -110,20 +173,20 @@ export default {
|
|||||||
formatter: (params) => {
|
formatter: (params) => {
|
||||||
const dataIndex = params[0].dataIndex
|
const dataIndex = params[0].dataIndex
|
||||||
const item = combinedData.value[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 `
|
return `
|
||||||
<div style="font-weight: bold; margin-bottom: 8px;">${formatDate(item.x)}</div>
|
<div style="font-weight: bold; margin-bottom: 8px;">${item.dateFormatted}</div>
|
||||||
<div>排名: <strong>${item.y}/${item.sc}</strong></div>
|
<div>排名: <strong>${item.rank}/${item.total_funds}</strong></div>
|
||||||
<div>击败同类: <strong>${defeated}%</strong></div>
|
<div>击败同类: <strong>${defeated}%</strong></div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
left: '3%',
|
left: '11%',
|
||||||
right: '4%',
|
right: '12%',
|
||||||
bottom: '10%',
|
bottom: '12%',
|
||||||
top: '10%',
|
top: '4%',
|
||||||
containLabel: true
|
containLabel: false
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'time',
|
type: 'time',
|
||||||
@@ -131,7 +194,7 @@ export default {
|
|||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: '排名百分比(%)',
|
name: '前百分之',
|
||||||
inverse: true, // 反转Y轴,越小越好
|
inverse: true, // 反转Y轴,越小越好
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
formatter: '{value}%'
|
formatter: '{value}%'
|
||||||
@@ -194,7 +257,10 @@ export default {
|
|||||||
hasRankingData,
|
hasRankingData,
|
||||||
recentRankings,
|
recentRankings,
|
||||||
formatDate,
|
formatDate,
|
||||||
getPercentClass
|
getPercentClass,
|
||||||
|
timeRanges,
|
||||||
|
selectedRange,
|
||||||
|
setTimeRange
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,53 +268,87 @@ export default {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fund-ranking-card {
|
.fund-ranking-card {
|
||||||
background: white;
|
height: 100%;
|
||||||
border-radius: 12px;
|
display: flex;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 16px 20px;
|
padding: 10px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header h3 {
|
.card-header h3 {
|
||||||
margin: 0;
|
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;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 24px;
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranking-content {
|
.ranking-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranking-chart {
|
.ranking-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 400px;
|
height: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranking-table {
|
.ranking-table {
|
||||||
overflow-x: auto;
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranking-table table {
|
.ranking-table table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranking-table th,
|
.ranking-table th,
|
||||||
.ranking-table td {
|
.ranking-table td {
|
||||||
padding: 12px;
|
padding: 6px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 1px solid #e8e8e8;
|
border-bottom: 1px solid #e8e8e8;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ export default {
|
|||||||
grid: {
|
grid: {
|
||||||
left: '3%',
|
left: '3%',
|
||||||
right: '4%',
|
right: '4%',
|
||||||
bottom: '10%',
|
bottom: '15%',
|
||||||
top: '10%',
|
top: '18%',
|
||||||
containLabel: true
|
containLabel: true
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
@@ -151,53 +151,60 @@ export default {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fund-scale-card {
|
.fund-scale-card {
|
||||||
background: white;
|
height: 100%;
|
||||||
border-radius: 12px;
|
display: flex;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 16px 20px;
|
padding: 12px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header h3 {
|
.card-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 24px;
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-content {
|
.scale-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-chart {
|
.scale-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-table {
|
.scale-table {
|
||||||
overflow-x: auto;
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-table table {
|
.scale-table table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-table th,
|
.scale-table th,
|
||||||
.scale-table td {
|
.scale-table td {
|
||||||
padding: 12px;
|
padding: 6px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 1px solid #e8e8e8;
|
border-bottom: 1px solid #e8e8e8;
|
||||||
}
|
}
|
||||||
@@ -206,6 +213,8 @@ export default {
|
|||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale-value {
|
.scale-value {
|
||||||
@@ -226,12 +235,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.no-data {
|
.no-data {
|
||||||
text-align: center;
|
display: flex;
|
||||||
padding: 60px 20px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data p {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
</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