增强可视化表达能力

This commit is contained in:
Sebastian
2026-01-16 16:58:12 +08:00
parent b24015618a
commit 811e49e872
20 changed files with 2081 additions and 911 deletions

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

@@ -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 + '元'
} }
} }
} }

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

@@ -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` - 易方达消费行业股票
---
## ⚠️ 免责声明
本系统仅用于学习和研究目的,所展示的数据和分析结果仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。
---
## 📞 反馈与支持
如遇到问题或有改进建议,欢迎反馈!

File diff suppressed because one or more lines are too long

View File

@@ -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. 部分基金可能因数据不完整导致某些模块无数据显示
---
## 🔜 未来计划
- [ ] 添加基金对比功能
- [ ] 添加自选基金列表
- [ ] 添加数据导出功能
- [ ] 添加更多技术指标
- [ ] 优化移动端体验
---
## 👨‍💻 开发者
如有问题或建议,欢迎提出反馈!