Compare commits
246 Commits
feature_bu
...
bea4c7fe81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bea4c7fe81 | ||
|
|
d3f4a8e02c | ||
|
|
90e2a48d66 | ||
|
|
298ac5a335 | ||
|
|
672e746a26 | ||
|
|
88da7ad1a5 | ||
|
|
8c9cc9845d | ||
|
|
11544909d3 | ||
|
|
08842b9097 | ||
|
|
0ad0287f7b | ||
|
|
d394c25d7e | ||
| 7fd1dc34f4 | |||
|
|
6776e1d557 | ||
|
|
6eec7c6402 | ||
|
|
27b0e9375a | ||
|
|
e71f42b608 | ||
|
|
2c1acb41b4 | ||
|
|
23788bbebf | ||
|
|
2cc16be585 | ||
|
|
11ca0e7a99 | ||
|
|
ff951972ee | ||
|
|
41da6fa372 | ||
|
|
54cce55c29 | ||
|
|
0e29f1aff4 | ||
|
|
7b58f83490 | ||
|
|
22062a6556 | ||
|
|
94854fee3e | ||
|
|
852d5fd188 | ||
|
|
4e71623477 | ||
|
|
ce4da40ef6 | ||
|
|
bff440ff8a | ||
|
|
9ef206a9e7 | ||
|
|
92019ca92d | ||
|
|
010ed9b5bf | ||
|
|
afc6d16119 | ||
|
|
61e159f29b | ||
|
|
82290e8a63 | ||
|
|
029a61e42c | ||
|
|
958222e75f | ||
|
|
5b7534f6a5 | ||
|
|
1730a59ca2 | ||
|
|
986ec05eb1 | ||
|
|
02cc3eadd9 | ||
|
|
51721ce9bf | ||
| 25b2c2af49 | |||
| c7033481ee | |||
| d65376739b | |||
| 52858006b7 | |||
| 7727fcfe15 | |||
| 20ad62d229 | |||
| 0bb47e1710 | |||
| 1fa85639f4 | |||
| 4ac9b30bfb | |||
| 64fdb6e580 | |||
|
|
c979e775a5 | ||
|
|
2720946ccf | ||
|
|
5331bc64b4 | ||
|
|
3953efc2ed | ||
|
|
50d59fd2ad | ||
|
|
eaa65b2328 | ||
|
|
79572fcc98 | ||
|
|
997724e0b1 | ||
|
|
ec2270ca8e | ||
|
|
44ba2e24e8 | ||
|
|
8e679b56f4 | ||
|
|
ae397ac904 | ||
|
|
a5bc1e1ce3 | ||
|
|
2ce74b4331 | ||
|
|
7931abe89b | ||
|
|
9b8983869c | ||
|
|
4b3588e8de | ||
| 42091bc7e5 | |||
| d25c77353a | |||
| f36e210fe8 | |||
| 63ac4271b7 | |||
| 87ddc79252 | |||
| 26548c7036 | |||
| 028869aa0c | |||
| 9623b08183 | |||
| 3199e6764d | |||
| 852438b17e | |||
| c589e629b0 | |||
| a2f224d118 | |||
| 6cb2742cf6 | |||
| 8acae9c93c | |||
| 983d2575b2 | |||
| 0214052965 | |||
| 3adff89995 | |||
| 0d150f7b26 | |||
| 067b720263 | |||
| 318a83434a | |||
| c393e31eec | |||
| 854aadcbc7 | |||
| 7b5ac2ef15 | |||
| 7054124eaf | |||
| 4eb8310038 | |||
| 9b8d7d1d96 | |||
| 2d5d3b3342 | |||
|
|
480d446217 | ||
|
|
e02cbcd9b7 | ||
| dbd4cb39ec | |||
| 88db9158d6 | |||
| 542e1c6225 | |||
| 697c366e88 | |||
| 8def7f355b | |||
| c1fcf6714e | |||
| 4bf42004b7 | |||
|
|
cb662c8a37 | ||
|
|
9bb9eab922 | ||
|
|
3d7b0045b7 | ||
|
|
a3a82794ca | ||
|
|
ada9f6e778 | ||
|
|
07aebbece5 | ||
|
|
7a11800cba | ||
|
|
3b352be1a8 | ||
|
|
c49dee72eb | ||
|
|
7159e510a6 | ||
|
|
385d452f5a | ||
|
|
bdc823e122 | ||
|
|
c83d239219 | ||
|
|
c4900bd280 | ||
|
|
7736212235 | ||
|
|
348d8a0ec3 | ||
|
|
5a0d6e1569 | ||
|
|
bc2b6ae41c | ||
|
|
ac7e627b2d | ||
|
|
21e83ac1bc | ||
|
|
e2dd9e2648 | ||
|
|
f2463922f3 | ||
|
|
9aaad00f87 | ||
|
|
024126025d | ||
|
|
e2f9f3278f | ||
|
|
2d03c88f43 | ||
|
|
515b538c84 | ||
|
|
b52b54347d | ||
|
|
4954373b5b | ||
|
|
66cd6c3a29 | ||
|
|
ba99f55b16 | ||
|
|
2f69f83d16 | ||
|
|
3bd48e1ddd | ||
|
|
84914b3cca | ||
|
|
da455946a3 | ||
|
|
e734319ec4 | ||
|
|
faf2446203 | ||
|
|
83b24b6d54 | ||
|
|
ab7164681a | ||
|
|
bc6d370f55 | ||
|
|
42215b2d59 | ||
|
|
c34aa37731 | ||
|
|
2eb2a22495 | ||
|
|
6a4c475d3a | ||
|
|
e08b9d2104 | ||
|
|
3f1f438440 | ||
|
|
24720dbba0 | ||
|
|
7877c41e9c | ||
|
|
b25d48e167 | ||
|
|
804de885e1 | ||
|
|
6738a09e3a | ||
|
|
67340e9b82 | ||
|
|
00f2937a34 | ||
|
|
91ed649220 | ||
|
|
391955f88c | ||
|
|
59f4b1cdb9 | ||
|
|
3d6d01964d | ||
|
|
3f3e13bddd | ||
|
|
d27cf5b7d8 | ||
|
|
03bc2d681b | ||
|
|
1022fa4077 | ||
|
|
406b951e53 | ||
|
|
7f392619e7 | ||
|
|
09ca7265d7 | ||
|
|
276b280cb9 | ||
|
|
adfc0bd478 | ||
|
|
85a857dc19 | ||
|
|
b89837d22e | ||
|
|
942dd16800 | ||
|
|
35e3b66684 | ||
|
|
b9ea08e601 | ||
|
|
d9106bf9f7 | ||
|
|
fb42ef566b | ||
|
|
a424b3338d | ||
|
|
9e6e3ae322 | ||
|
|
e92cc09e06 | ||
|
|
23112db115 | ||
|
|
7c7c70c4d9 | ||
|
|
e049429b09 | ||
|
|
b8cd520014 | ||
|
|
96fe919164 | ||
|
|
4672a24353 | ||
|
|
26bc5fece0 | ||
|
|
1c35ea24cd | ||
|
|
d76b0d32d6 | ||
|
|
eb093a5189 | ||
|
|
2c0b06e6a0 | ||
|
|
b3fb472c66 | ||
|
|
6797f54b6c | ||
|
|
a47e0feed8 | ||
|
|
13fa91a998 | ||
|
|
fba7a7ee96 | ||
|
|
32a73efb55 | ||
|
|
7819b4f8a2 | ||
|
|
6f74c1c1de | ||
|
|
3fed9d2d65 | ||
|
|
514917c0eb | ||
|
|
6ce913d79b | ||
|
|
6d5594556b | ||
|
|
c32091e83e | ||
|
|
2994de98c2 | ||
|
|
c237a4dc0c | ||
|
|
395dc27fe2 | ||
|
|
3abee6b907 | ||
|
|
d86cef9f79 | ||
|
|
9aaf4400c1 | ||
|
|
1cd8a2d7e9 | ||
|
|
af3cdc24b1 | ||
|
|
bfb6ef63d0 | ||
|
|
722d038b56 | ||
|
|
5f6e4387e5 | ||
|
|
38076534b1 | ||
|
|
a7ab87f7c4 | ||
|
|
9a77bb6f0b | ||
|
|
bf8847698b | ||
|
|
7c83ffe008 | ||
|
|
8786fa7b06 | ||
|
|
0997cd9992 | ||
|
|
c8d704363d | ||
|
|
0de4a1f7af | ||
|
|
3382dd1036 | ||
|
|
9423094af2 | ||
|
|
4f38505a80 | ||
|
|
4274341ed5 | ||
|
|
40f6eaced6 | ||
|
|
2dd7dd755a | ||
|
|
04ce16df56 | ||
|
|
d7759b1da3 | ||
|
|
701f96855e | ||
|
|
cd1a5b743f | ||
|
|
18c83237e2 | ||
|
|
c1e10e6205 | ||
|
|
4954c58525 | ||
|
|
91bd581a5e | ||
|
|
258708fca0 | ||
|
|
90391729bb | ||
|
|
2148d319ad | ||
|
|
c61d58b0e3 | ||
|
|
ed1c7b9fa9 |
703
app.py
703
app.py
@@ -43,6 +43,7 @@ else:
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import threading
|
||||
import time
|
||||
import urllib
|
||||
import uuid
|
||||
@@ -216,14 +217,39 @@ def get_target_and_prev_trading_day(event_datetime):
|
||||
# 应用启动时加载交易日数据
|
||||
load_trading_days()
|
||||
|
||||
|
||||
def is_trading_hours():
|
||||
"""
|
||||
判断当前是否在交易时间段内
|
||||
交易时间:交易日的 9:00-15:00(含午休时间,因为事件可能在午休发布)
|
||||
|
||||
Returns:
|
||||
bool: True 表示在交易时间段,False 表示非交易时间
|
||||
"""
|
||||
now = datetime.now()
|
||||
today = now.date()
|
||||
current_time = now.time()
|
||||
|
||||
# 判断今天是否为交易日
|
||||
if today not in trading_days_set:
|
||||
return False
|
||||
|
||||
# 判断是否在 9:00-15:00 之间
|
||||
market_open = dt_time(9, 0)
|
||||
market_close = dt_time(15, 0)
|
||||
|
||||
return market_open <= current_time <= market_close
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
"mysql+pymysql://root:Zzl33818!@127.0.0.1:3306/stock?charset=utf8mb4",
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
pool_recycle=3600,
|
||||
pool_pre_ping=True,
|
||||
pool_timeout=30,
|
||||
max_overflow=20
|
||||
pool_size=50, # 每个 worker 常驻连接数
|
||||
pool_recycle=1800, # 连接回收时间 30 分钟(原 1 小时)
|
||||
pool_pre_ping=True, # 使用前检测连接是否有效
|
||||
pool_timeout=20, # 获取连接超时时间(秒)
|
||||
max_overflow=100 # 每个 worker 临时溢出连接数
|
||||
# 每个 worker 最多 150 个连接,32 workers 总共最多 4800 个连接
|
||||
)
|
||||
|
||||
# Elasticsearch 客户端初始化
|
||||
@@ -298,6 +324,105 @@ def delete_verification_code(key):
|
||||
|
||||
print(f"📦 验证码存储: Redis, 过期时间: {VERIFICATION_CODE_EXPIRE}秒")
|
||||
|
||||
# ============ 事件列表 Redis 缓存(智能 TTL 策略) ============
|
||||
EVENTS_CACHE_PREFIX = "events:cache:"
|
||||
EVENTS_CACHE_TTL_TRADING = 20 # 交易时间缓存 TTL(秒)
|
||||
EVENTS_CACHE_TTL_NON_TRADING = 600 # 非交易时间缓存 TTL(秒,10分钟)
|
||||
|
||||
|
||||
def generate_events_cache_key(args_dict):
|
||||
"""
|
||||
根据请求参数生成缓存 Key
|
||||
使用 MD5 哈希保证 key 长度固定且唯一
|
||||
|
||||
Args:
|
||||
args_dict: 请求参数字典
|
||||
|
||||
Returns:
|
||||
str: 缓存 key,格式为 events:cache:{md5_hash}
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
# 过滤掉空值,并排序保证顺序一致
|
||||
filtered_params = {k: v for k, v in sorted(args_dict.items())
|
||||
if v is not None and v != '' and v != 'all'}
|
||||
|
||||
# 生成参数字符串并计算 MD5
|
||||
params_str = json.dumps(filtered_params, sort_keys=True)
|
||||
params_hash = hashlib.md5(params_str.encode()).hexdigest()
|
||||
|
||||
return f"{EVENTS_CACHE_PREFIX}{params_hash}"
|
||||
|
||||
|
||||
def get_events_cache(cache_key):
|
||||
"""
|
||||
从 Redis 获取事件列表缓存
|
||||
|
||||
Args:
|
||||
cache_key: 缓存 key
|
||||
|
||||
Returns:
|
||||
dict or None: 缓存的响应数据,如果不存在或出错返回 None
|
||||
"""
|
||||
try:
|
||||
cached = redis_client.get(cache_key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ Redis 获取事件缓存失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def set_events_cache(cache_key, data):
|
||||
"""
|
||||
将事件列表数据存入 Redis 缓存
|
||||
根据是否在交易时间自动选择 TTL
|
||||
|
||||
Args:
|
||||
cache_key: 缓存 key
|
||||
data: 要缓存的响应数据
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
try:
|
||||
# 根据交易时间选择 TTL
|
||||
ttl = EVENTS_CACHE_TTL_TRADING if is_trading_hours() else EVENTS_CACHE_TTL_NON_TRADING
|
||||
|
||||
redis_client.setex(cache_key, ttl, json.dumps(data, ensure_ascii=False))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Redis 存储事件缓存失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def clear_events_cache():
|
||||
"""
|
||||
清除所有事件列表缓存
|
||||
用于事件数据更新后主动刷新缓存
|
||||
"""
|
||||
try:
|
||||
# 使用 SCAN 命令迭代删除,避免 KEYS 命令阻塞
|
||||
cursor = 0
|
||||
deleted_count = 0
|
||||
while True:
|
||||
cursor, keys = redis_client.scan(cursor, match=f"{EVENTS_CACHE_PREFIX}*", count=100)
|
||||
if keys:
|
||||
redis_client.delete(*keys)
|
||||
deleted_count += len(keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
if deleted_count > 0:
|
||||
print(f"🗑️ 已清除 {deleted_count} 个事件缓存")
|
||||
return deleted_count
|
||||
except Exception as e:
|
||||
print(f"❌ 清除事件缓存失败: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
print(f"📦 事件列表缓存: 交易时间 {EVENTS_CACHE_TTL_TRADING}s / 非交易时间 {EVENTS_CACHE_TTL_NON_TRADING}s")
|
||||
|
||||
# ============ 微信登录 Session 管理(Redis 存储,支持多进程) ============
|
||||
WECHAT_SESSION_EXPIRE = 300 # Session 过期时间(5分钟)
|
||||
WECHAT_SESSION_PREFIX = "wechat_session:"
|
||||
@@ -371,6 +496,197 @@ def wechat_session_exists(state):
|
||||
print(f"❌ Redis 检查 wechat session 失败: {e}")
|
||||
return False
|
||||
# ============ 微信登录 Session 管理结束 ============
|
||||
|
||||
# ============ 股票数据 Redis 缓存(股票名称 + 前收盘价) ============
|
||||
STOCK_NAME_PREFIX = "vf:stock:name:" # 股票名称缓存前缀
|
||||
STOCK_NAME_EXPIRE = 86400 # 股票名称缓存24小时
|
||||
PREV_CLOSE_PREFIX = "vf:stock:prev_close:" # 前收盘价缓存前缀
|
||||
PREV_CLOSE_EXPIRE = 86400 # 前收盘价缓存24小时(当日有效)
|
||||
|
||||
|
||||
def get_cached_stock_names(base_codes):
|
||||
"""
|
||||
批量获取股票名称(优先从 Redis 缓存读取)
|
||||
:param base_codes: 股票代码列表(不带后缀,如 ['600000', '000001'])
|
||||
:return: dict {code: name}
|
||||
"""
|
||||
if not base_codes:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
missing_codes = []
|
||||
|
||||
try:
|
||||
# 批量从 Redis 获取
|
||||
pipe = redis_client.pipeline()
|
||||
for code in base_codes:
|
||||
pipe.get(f"{STOCK_NAME_PREFIX}{code}")
|
||||
cached_values = pipe.execute()
|
||||
|
||||
for code, cached_name in zip(base_codes, cached_values):
|
||||
if cached_name:
|
||||
result[code] = cached_name
|
||||
else:
|
||||
missing_codes.append(code)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Redis 批量获取股票名称失败: {e},降级为数据库查询")
|
||||
missing_codes = base_codes
|
||||
|
||||
# 从数据库查询缺失的股票名称
|
||||
if missing_codes:
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
placeholders = ','.join([f':code{i}' for i in range(len(missing_codes))])
|
||||
params = {f'code{i}': code for i, code in enumerate(missing_codes)}
|
||||
db_result = conn.execute(text(
|
||||
f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})"
|
||||
), params).fetchall()
|
||||
|
||||
# 写入 Redis 缓存
|
||||
pipe = redis_client.pipeline()
|
||||
for row in db_result:
|
||||
code, name = row[0], row[1]
|
||||
result[code] = name
|
||||
pipe.setex(f"{STOCK_NAME_PREFIX}{code}", STOCK_NAME_EXPIRE, name)
|
||||
|
||||
try:
|
||||
pipe.execute()
|
||||
except Exception as e:
|
||||
print(f"⚠️ Redis 缓存股票名称失败: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ 数据库查询股票名称失败: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_cached_prev_close(base_codes, trade_date_str):
|
||||
"""
|
||||
批量获取前收盘价(优先从 Redis 缓存读取)
|
||||
:param base_codes: 股票代码列表(不带后缀,如 ['600000', '000001'])
|
||||
:param trade_date_str: 交易日期字符串(格式 YYYYMMDD)
|
||||
:return: dict {code: close_price}
|
||||
"""
|
||||
if not base_codes or not trade_date_str:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
missing_codes = []
|
||||
|
||||
try:
|
||||
# 批量从 Redis 获取(缓存键包含日期,确保不会跨日混用)
|
||||
pipe = redis_client.pipeline()
|
||||
for code in base_codes:
|
||||
pipe.get(f"{PREV_CLOSE_PREFIX}{trade_date_str}:{code}")
|
||||
cached_values = pipe.execute()
|
||||
|
||||
for code, cached_price in zip(base_codes, cached_values):
|
||||
if cached_price:
|
||||
result[code] = float(cached_price)
|
||||
else:
|
||||
missing_codes.append(code)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Redis 批量获取前收盘价失败: {e},降级为数据库查询")
|
||||
missing_codes = base_codes
|
||||
|
||||
# 从数据库查询缺失的前收盘价
|
||||
if missing_codes:
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
placeholders = ','.join([f':code{i}' for i in range(len(missing_codes))])
|
||||
params = {f'code{i}': code for i, code in enumerate(missing_codes)}
|
||||
params['trade_date'] = trade_date_str
|
||||
db_result = conn.execute(text(f"""
|
||||
SELECT SECCODE, F007N as close_price
|
||||
FROM ea_trade
|
||||
WHERE SECCODE IN ({placeholders})
|
||||
AND TRADEDATE = :trade_date
|
||||
AND F007N > 0
|
||||
"""), params).fetchall()
|
||||
|
||||
# 写入 Redis 缓存
|
||||
pipe = redis_client.pipeline()
|
||||
for row in db_result:
|
||||
code, close_price = row[0], float(row[1]) if row[1] else None
|
||||
if close_price:
|
||||
result[code] = close_price
|
||||
pipe.setex(f"{PREV_CLOSE_PREFIX}{trade_date_str}:{code}", PREV_CLOSE_EXPIRE, str(close_price))
|
||||
|
||||
try:
|
||||
pipe.execute()
|
||||
except Exception as e:
|
||||
print(f"⚠️ Redis 缓存前收盘价失败: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ 数据库查询前收盘价失败: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def preload_stock_cache():
|
||||
"""
|
||||
预热股票缓存(定时任务,每天 9:25 执行)
|
||||
- 批量加载所有股票名称
|
||||
- 批量加载前一交易日收盘价
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
print(f"[缓存预热] 开始预热股票缓存... {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
try:
|
||||
# 1. 预热股票名称(全量加载)
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT SECCODE, SECNAME FROM ea_stocklist")).fetchall()
|
||||
pipe = redis_client.pipeline()
|
||||
count = 0
|
||||
for row in result:
|
||||
code, name = row[0], row[1]
|
||||
if code and name:
|
||||
pipe.setex(f"{STOCK_NAME_PREFIX}{code}", STOCK_NAME_EXPIRE, name)
|
||||
count += 1
|
||||
pipe.execute()
|
||||
print(f"[缓存预热] 股票名称: {count} 条已加载到 Redis")
|
||||
|
||||
# 2. 预热前收盘价(获取前一交易日)
|
||||
today = datetime.now().date()
|
||||
today_str = today.strftime('%Y-%m-%d')
|
||||
|
||||
prev_trading_day = None
|
||||
if 'trading_days' in globals() and trading_days:
|
||||
for td in reversed(trading_days):
|
||||
if td < today_str:
|
||||
prev_trading_day = td
|
||||
break
|
||||
|
||||
if prev_trading_day:
|
||||
prev_date_str = prev_trading_day.replace('-', '') # YYYYMMDD 格式
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("""
|
||||
SELECT SECCODE, F007N as close_price
|
||||
FROM ea_trade
|
||||
WHERE TRADEDATE = :trade_date AND F007N > 0
|
||||
"""), {'trade_date': prev_date_str}).fetchall()
|
||||
|
||||
pipe = redis_client.pipeline()
|
||||
count = 0
|
||||
for row in result:
|
||||
code, close_price = row[0], row[1]
|
||||
if code and close_price:
|
||||
pipe.setex(f"{PREV_CLOSE_PREFIX}{prev_date_str}:{code}", PREV_CLOSE_EXPIRE, str(close_price))
|
||||
count += 1
|
||||
pipe.execute()
|
||||
print(f"[缓存预热] 前收盘价({prev_trading_day}): {count} 条已加载到 Redis")
|
||||
else:
|
||||
print(f"[缓存预热] 未找到前一交易日,跳过前收盘价预热")
|
||||
|
||||
print(f"[缓存预热] 预热完成 ✅ {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[缓存预热] 预热失败 ❌: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
print(f"📦 股票缓存: Redis, 名称过期 {STOCK_NAME_EXPIRE}秒, 收盘价过期 {PREV_CLOSE_EXPIRE}秒")
|
||||
# ============ 股票数据 Redis 缓存结束 ============
|
||||
|
||||
# 腾讯云短信配置
|
||||
SMS_SECRET_ID = 'AKID2we9TacdTAhCjCSYTErHVimeJo9Yr00s'
|
||||
SMS_SECRET_KEY = 'pMlBWijlkgT9fz5ziEXdWEnAPTJzRfkf'
|
||||
@@ -517,11 +833,12 @@ app.config['COMPRESS_MIMETYPES'] = [
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl33818!@127.0.0.1:3306/stock?charset=utf8mb4'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
|
||||
'pool_size': 10,
|
||||
'pool_recycle': 3600,
|
||||
'pool_pre_ping': True,
|
||||
'pool_timeout': 30,
|
||||
'max_overflow': 20
|
||||
'pool_size': 50, # 每个 worker 常驻连接数
|
||||
'pool_recycle': 1800, # 连接回收时间 30 分钟(原 1 小时)
|
||||
'pool_pre_ping': True, # 使用前检测连接是否有效
|
||||
'pool_timeout': 20, # 获取连接超时时间(秒)
|
||||
'max_overflow': 100 # 每个 worker 临时溢出连接数
|
||||
# 每个 worker 最多 150 个连接,32 workers 总共最多 4800 个连接
|
||||
}
|
||||
# Cache directory setup
|
||||
CACHE_DIR = Path('cache')
|
||||
@@ -6465,50 +6782,14 @@ class RelatedData(db.Model):
|
||||
|
||||
|
||||
class RelatedConcepts(db.Model):
|
||||
"""关联数据模型"""
|
||||
"""相关概念模型(AI分析结果)"""
|
||||
__tablename__ = 'related_concepts'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
|
||||
concept_code = db.Column(db.String(20)) # 数据标题
|
||||
concept = db.Column(db.String(100)) # 数据类型
|
||||
reason = db.Column(db.Text) # 数据描述
|
||||
image_paths = db.Column(db.JSON) # 数据内容(JSON格式)
|
||||
concept = db.Column(db.String(255)) # 概念名称
|
||||
reason = db.Column(db.Text) # 关联原因(AI分析)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
@property
|
||||
def image_paths_list(self):
|
||||
"""返回解析后的图片路径列表"""
|
||||
if not self.image_paths:
|
||||
return []
|
||||
|
||||
try:
|
||||
# 如果是字符串,先解析成JSON
|
||||
if isinstance(self.image_paths, str):
|
||||
paths = json.loads(self.image_paths)
|
||||
else:
|
||||
paths = self.image_paths
|
||||
|
||||
# 确保paths是列表
|
||||
if not isinstance(paths, list):
|
||||
paths = [paths]
|
||||
|
||||
# 从每个对象中提取path字段
|
||||
return [item['path'] if isinstance(item, dict) and 'path' in item
|
||||
else item for item in paths]
|
||||
except Exception as e:
|
||||
print(f"Error processing image paths: {e}")
|
||||
return []
|
||||
|
||||
def get_first_image_path(self):
|
||||
"""获取第一张图片的完整路径"""
|
||||
paths = self.image_paths_list
|
||||
if not paths:
|
||||
return None
|
||||
|
||||
# 获取第一个路径
|
||||
first_path = paths[0]
|
||||
# 返回完整路径
|
||||
return first_path
|
||||
|
||||
|
||||
class EventHotHistory(db.Model):
|
||||
"""事件热度历史记录"""
|
||||
@@ -6981,23 +7262,21 @@ def get_events_by_stocks():
|
||||
|
||||
@app.route('/api/events/<int:event_id>/concepts', methods=['GET'])
|
||||
def get_related_concepts(event_id):
|
||||
"""获取相关概念列表"""
|
||||
"""获取相关概念列表(AI分析结果)"""
|
||||
try:
|
||||
# 订阅控制:相关概念需要 Pro 及以上
|
||||
if not _has_required_level('pro'):
|
||||
return jsonify({'success': False, 'error': '需要Pro订阅', 'required_level': 'pro'}), 403
|
||||
event = Event.query.get_or_404(event_id)
|
||||
concepts = event.related_concepts.all()
|
||||
|
||||
# 直接查询 related_concepts 表
|
||||
concepts = RelatedConcepts.query.filter_by(event_id=event_id).all()
|
||||
|
||||
concepts_data = []
|
||||
for concept in concepts:
|
||||
concepts_data.append({
|
||||
'id': concept.id,
|
||||
'concept_code': concept.concept_code,
|
||||
'concept': concept.concept,
|
||||
'reason': concept.reason,
|
||||
'image_paths': concept.image_paths_list,
|
||||
'first_image_path': concept.get_first_image_path(),
|
||||
'created_at': concept.created_at.isoformat() if concept.created_at else None
|
||||
})
|
||||
|
||||
@@ -7310,21 +7589,9 @@ def get_stock_quotes():
|
||||
|
||||
current_time = datetime.now()
|
||||
|
||||
# ==================== 查询股票名称(直接查 MySQL) ====================
|
||||
stock_names = {}
|
||||
# ==================== 查询股票名称(使用 Redis 缓存) ====================
|
||||
base_codes = list(set([code.split('.')[0] for code in codes]))
|
||||
|
||||
if base_codes:
|
||||
with engine.connect() as conn:
|
||||
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
|
||||
params = {f'code{i}': code for i, code in enumerate(base_codes)}
|
||||
result = conn.execute(text(
|
||||
f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})"
|
||||
), params).fetchall()
|
||||
|
||||
for row in result:
|
||||
base_code, name = row[0], row[1]
|
||||
stock_names[base_code] = name
|
||||
stock_names = get_cached_stock_names(base_codes)
|
||||
|
||||
# 构建完整的名称映射
|
||||
full_stock_names = {}
|
||||
@@ -7355,34 +7622,17 @@ def get_stock_quotes():
|
||||
# 初始化 ClickHouse 客户端
|
||||
client = get_clickhouse_client()
|
||||
|
||||
# ==================== 查询前一交易日收盘价(直接查 MySQL) ====================
|
||||
# ==================== 查询前一交易日收盘价(使用 Redis 缓存) ====================
|
||||
try:
|
||||
prev_close_map = {}
|
||||
if prev_trading_day:
|
||||
# ea_trade 表的 TRADEDATE 格式是 YYYYMMDD(无连字符)
|
||||
prev_day_str = prev_trading_day.strftime('%Y%m%d') if hasattr(prev_trading_day, 'strftime') else str(prev_trading_day).replace('-', '')
|
||||
base_codes = list(set([code.split('.')[0] for code in codes]))
|
||||
base_close_map = {}
|
||||
|
||||
# 直接从 MySQL 批量查询
|
||||
with engine.connect() as conn:
|
||||
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
|
||||
params = {f'code{i}': code for i, code in enumerate(base_codes)}
|
||||
params['trade_date'] = prev_day_str
|
||||
|
||||
prev_close_result = conn.execute(text(f"""
|
||||
SELECT SECCODE, F007N as close_price
|
||||
FROM ea_trade
|
||||
WHERE SECCODE IN ({placeholders})
|
||||
AND TRADEDATE = :trade_date
|
||||
"""), params).fetchall()
|
||||
|
||||
for row in prev_close_result:
|
||||
base_code, close_price = row[0], row[1]
|
||||
close_val = float(close_price) if close_price else None
|
||||
base_close_map[base_code] = close_val
|
||||
|
||||
print(f"前一交易日({prev_day_str})收盘价: 查询到 {len(prev_close_result)} 条")
|
||||
# 使用 Redis 缓存获取前收盘价
|
||||
base_close_map = get_cached_prev_close(base_codes, prev_day_str)
|
||||
print(f"前一交易日({prev_day_str})收盘价: 获取到 {len(base_close_map)} 条(Redis缓存)")
|
||||
|
||||
# 为每个标准化代码分配收盘价
|
||||
for norm_code in normalized_codes:
|
||||
@@ -7391,20 +7641,16 @@ def get_stock_quotes():
|
||||
prev_close_map[norm_code] = base_close_map[base_code]
|
||||
|
||||
# 批量查询当前价格数据(从 ClickHouse)
|
||||
# 使用 argMax 函数获取最新价格,比窗口函数效率高很多
|
||||
batch_price_query = """
|
||||
WITH last_prices AS (
|
||||
SELECT
|
||||
code,
|
||||
close as last_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn
|
||||
FROM stock_minute
|
||||
WHERE code IN %(codes)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(end)s
|
||||
)
|
||||
SELECT code, last_price
|
||||
FROM last_prices
|
||||
WHERE rn = 1
|
||||
SELECT
|
||||
code,
|
||||
argMax(close, timestamp) as last_price
|
||||
FROM stock_minute
|
||||
WHERE code IN %(codes)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(end)s
|
||||
GROUP BY code
|
||||
"""
|
||||
|
||||
batch_data = client.execute(batch_price_query, {
|
||||
@@ -7500,14 +7746,25 @@ def get_stock_quotes():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== ClickHouse 连接池(单例模式) ====================
|
||||
_clickhouse_client = None
|
||||
_clickhouse_client_lock = threading.Lock()
|
||||
|
||||
def get_clickhouse_client():
|
||||
return Cclient(
|
||||
host='127.0.0.1',
|
||||
port=9000,
|
||||
user='default',
|
||||
password='Zzl33818!',
|
||||
database='stock'
|
||||
)
|
||||
"""获取 ClickHouse 客户端(单例模式,避免重复创建连接)"""
|
||||
global _clickhouse_client
|
||||
if _clickhouse_client is None:
|
||||
with _clickhouse_client_lock:
|
||||
if _clickhouse_client is None:
|
||||
_clickhouse_client = Cclient(
|
||||
host='127.0.0.1',
|
||||
port=9000,
|
||||
user='default',
|
||||
password='Zzl33818!',
|
||||
database='stock'
|
||||
)
|
||||
print("[ClickHouse] 创建新连接(单例)")
|
||||
return _clickhouse_client
|
||||
|
||||
|
||||
@app.route('/api/account/calendar/events', methods=['GET', 'POST'])
|
||||
@@ -8142,18 +8399,9 @@ def get_batch_kline_data():
|
||||
|
||||
client = get_clickhouse_client()
|
||||
|
||||
# 批量获取股票名称
|
||||
stock_names = {}
|
||||
with engine.connect() as conn:
|
||||
base_codes = list(set([code.split('.')[0] for code in codes]))
|
||||
if base_codes:
|
||||
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
|
||||
params = {f'code{i}': code for i, code in enumerate(base_codes)}
|
||||
result = conn.execute(text(
|
||||
f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})"
|
||||
), params).fetchall()
|
||||
for row in result:
|
||||
stock_names[row[0]] = row[1]
|
||||
# 批量获取股票名称(使用 Redis 缓存)
|
||||
base_codes = list(set([code.split('.')[0] for code in codes]))
|
||||
stock_names = get_cached_stock_names(base_codes)
|
||||
|
||||
# 确定目标交易日和涨跌幅基准日(处理跨周末场景)
|
||||
# - 周五15:00后到周一15:00前,分时图显示周一行情,涨跌幅基于周五收盘价
|
||||
@@ -8172,24 +8420,14 @@ def get_batch_kline_data():
|
||||
results = {}
|
||||
|
||||
if chart_type == 'timeline':
|
||||
# 批量获取前收盘价(从 MySQL ea_trade 表)
|
||||
# 批量获取前收盘价(使用 Redis 缓存)
|
||||
# 使用 prev_trading_day 作为基准日期(处理跨周末场景)
|
||||
prev_close_map = {}
|
||||
if prev_trading_day:
|
||||
prev_date_str = prev_trading_day.strftime('%Y%m%d')
|
||||
with engine.connect() as conn:
|
||||
base_codes = list(set([code.split('.')[0] for code in codes]))
|
||||
if base_codes:
|
||||
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
|
||||
params = {f'code{i}': code for i, code in enumerate(base_codes)}
|
||||
params['trade_date'] = prev_date_str
|
||||
result = conn.execute(text(f"""
|
||||
SELECT SECCODE, F007N FROM ea_trade
|
||||
WHERE SECCODE IN ({placeholders}) AND TRADEDATE = :trade_date AND F007N > 0
|
||||
"""), params).fetchall()
|
||||
for row in result:
|
||||
prev_close_map[row[0]] = float(row[1])
|
||||
print(f"分时图基准日期: {prev_trading_day}, 查询到 {len(prev_close_map)} 条前收盘价")
|
||||
base_codes = list(set([code.split('.')[0] for code in codes]))
|
||||
prev_close_map = get_cached_prev_close(base_codes, prev_date_str)
|
||||
print(f"分时图基准日期: {prev_trading_day}, 获取到 {len(prev_close_map)} 条前收盘价(Redis缓存)")
|
||||
|
||||
# 批量查询分时数据(使用标准化代码查询 ClickHouse)
|
||||
batch_data = client.execute("""
|
||||
@@ -8650,6 +8888,144 @@ def get_stock_basic_info(stock_code):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/stock/<stock_code>/quote-detail', methods=['GET'])
|
||||
def get_stock_quote_detail(stock_code):
|
||||
"""获取股票完整行情数据 - 供 StockQuoteCard 使用
|
||||
|
||||
返回数据包括:
|
||||
- 基础信息:名称、代码、行业分类
|
||||
- 价格信息:现价、涨跌幅、开盘、收盘、最高、最低
|
||||
- 关键指标:市盈率、市净率、流通市值、52周高低
|
||||
- 主力动态:主力净流入、机构持仓(如有)
|
||||
"""
|
||||
try:
|
||||
# 标准化股票代码(去除后缀)
|
||||
base_code = stock_code.split('.')[0] if '.' in stock_code else stock_code
|
||||
|
||||
result_data = {
|
||||
'code': stock_code,
|
||||
'name': '',
|
||||
'industry': '',
|
||||
'industry_l1': '',
|
||||
'sw_industry_l1': '',
|
||||
'sw_industry_l2': '',
|
||||
|
||||
# 价格信息
|
||||
'current_price': None,
|
||||
'change_percent': None,
|
||||
'today_open': None,
|
||||
'yesterday_close': None,
|
||||
'today_high': None,
|
||||
'today_low': None,
|
||||
|
||||
# 关键指标
|
||||
'pe': None,
|
||||
'pb': None,
|
||||
'eps': None,
|
||||
'market_cap': None,
|
||||
'circ_mv': None,
|
||||
'turnover_rate': None,
|
||||
'week52_high': None,
|
||||
'week52_low': None,
|
||||
|
||||
# 主力动态(预留字段)
|
||||
'main_net_inflow': None,
|
||||
'institution_holding': None,
|
||||
'buy_ratio': None,
|
||||
'sell_ratio': None,
|
||||
|
||||
'update_time': None
|
||||
}
|
||||
|
||||
with engine.connect() as conn:
|
||||
# 1. 获取最新交易数据(来自 ea_trade)
|
||||
trade_query = text("""
|
||||
SELECT
|
||||
t.SECCODE,
|
||||
t.SECNAME,
|
||||
t.TRADEDATE,
|
||||
t.F002N as pre_close,
|
||||
t.F003N as open_price,
|
||||
t.F004N as volume,
|
||||
t.F005N as high,
|
||||
t.F006N as low,
|
||||
t.F007N as close_price,
|
||||
t.F010N as change_pct,
|
||||
t.F011N as amount,
|
||||
t.F012N as turnover_rate,
|
||||
t.F020N as total_shares,
|
||||
t.F021N as float_shares,
|
||||
t.F026N as pe_ratio,
|
||||
b.F034V as sw_industry_l1,
|
||||
b.F036V as sw_industry_l2,
|
||||
b.F030V as industry_l1
|
||||
FROM ea_trade t
|
||||
LEFT JOIN ea_baseinfo b ON t.SECCODE = b.SECCODE
|
||||
WHERE t.SECCODE = :stock_code
|
||||
ORDER BY t.TRADEDATE DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
trade_result = conn.execute(trade_query, {'stock_code': base_code}).fetchone()
|
||||
|
||||
if trade_result:
|
||||
row = row_to_dict(trade_result)
|
||||
result_data['name'] = row.get('SECNAME') or ''
|
||||
result_data['current_price'] = float(row.get('close_price') or 0)
|
||||
result_data['change_percent'] = float(row.get('change_pct') or 0)
|
||||
result_data['today_open'] = float(row.get('open_price') or 0)
|
||||
result_data['yesterday_close'] = float(row.get('pre_close') or 0)
|
||||
result_data['today_high'] = float(row.get('high') or 0)
|
||||
result_data['today_low'] = float(row.get('low') or 0)
|
||||
result_data['pe'] = float(row.get('pe_ratio') or 0) if row.get('pe_ratio') else None
|
||||
result_data['turnover_rate'] = float(row.get('turnover_rate') or 0)
|
||||
result_data['sw_industry_l1'] = row.get('sw_industry_l1') or ''
|
||||
result_data['sw_industry_l2'] = row.get('sw_industry_l2') or ''
|
||||
result_data['industry_l1'] = row.get('industry_l1') or ''
|
||||
result_data['industry'] = row.get('sw_industry_l2') or row.get('sw_industry_l1') or ''
|
||||
|
||||
# 计算流通市值(亿元)
|
||||
float_shares = float(row.get('float_shares') or 0)
|
||||
close_price = float(row.get('close_price') or 0)
|
||||
if float_shares > 0 and close_price > 0:
|
||||
circ_mv = (float_shares * close_price) / 100000000 # 转为亿
|
||||
result_data['circ_mv'] = round(circ_mv, 2)
|
||||
result_data['market_cap'] = f"{round(circ_mv, 2)}亿"
|
||||
|
||||
trade_date = row.get('TRADEDATE')
|
||||
if trade_date:
|
||||
if hasattr(trade_date, 'strftime'):
|
||||
result_data['update_time'] = trade_date.strftime('%Y-%m-%d')
|
||||
else:
|
||||
result_data['update_time'] = str(trade_date)
|
||||
|
||||
# 2. 获取52周高低价
|
||||
week52_query = text("""
|
||||
SELECT
|
||||
MAX(F005N) as week52_high,
|
||||
MIN(F006N) as week52_low
|
||||
FROM ea_trade
|
||||
WHERE SECCODE = :stock_code
|
||||
AND TRADEDATE >= DATE_SUB(CURDATE(), INTERVAL 52 WEEK)
|
||||
AND F005N > 0 AND F006N > 0
|
||||
""")
|
||||
|
||||
week52_result = conn.execute(week52_query, {'stock_code': base_code}).fetchone()
|
||||
if week52_result:
|
||||
w52 = row_to_dict(week52_result)
|
||||
result_data['week52_high'] = float(w52.get('week52_high') or 0)
|
||||
result_data['week52_low'] = float(w52.get('week52_low') or 0)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': result_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error getting stock quote detail: {e}", exc_info=True)
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/stock/<stock_code>/announcements', methods=['GET'])
|
||||
def get_stock_announcements(stock_code):
|
||||
"""获取股票公告列表"""
|
||||
@@ -10264,8 +10640,26 @@ def get_stock_list():
|
||||
def api_get_events():
|
||||
"""
|
||||
获取事件列表API - 支持筛选、排序、分页,兼容前端调用
|
||||
|
||||
Redis 缓存策略:
|
||||
- 交易时间(交易日 9:00-15:00):缓存 20 秒
|
||||
- 非交易时间:缓存 10 分钟
|
||||
"""
|
||||
try:
|
||||
# ==================== Redis 缓存检查 ====================
|
||||
# 获取所有请求参数用于生成缓存 key
|
||||
cache_params = dict(request.args)
|
||||
cache_key = generate_events_cache_key(cache_params)
|
||||
|
||||
# 尝试从缓存获取
|
||||
cached_response = get_events_cache(cache_key)
|
||||
if cached_response:
|
||||
# 添加缓存命中标记(可选,用于调试)
|
||||
cached_response['_cached'] = True
|
||||
cached_response['_cache_ttl'] = EVENTS_CACHE_TTL_TRADING if is_trading_hours() else EVENTS_CACHE_TTL_NON_TRADING
|
||||
return jsonify(cached_response)
|
||||
|
||||
# ==================== 缓存未命中,执行数据库查询 ====================
|
||||
# 分页参数
|
||||
page = max(1, request.args.get('page', 1, type=int))
|
||||
per_page = min(100, max(1, request.args.get('per_page', 10, type=int)))
|
||||
@@ -10319,7 +10713,10 @@ def api_get_events():
|
||||
include_related_data = request.args.get('include_related_data', 'false').lower() == 'true'
|
||||
|
||||
# ==================== 构建查询 ====================
|
||||
query = Event.query
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# 使用 joinedload 预加载 creator,解决 N+1 查询问题
|
||||
query = Event.query.options(joinedload(Event.creator))
|
||||
|
||||
# 只返回有关联股票的事件(没有关联股票的事件不计入列表)
|
||||
from sqlalchemy import exists
|
||||
@@ -10520,7 +10917,9 @@ def api_get_events():
|
||||
if search_query:
|
||||
applied_filters['search_query'] = search_query
|
||||
applied_filters['search_type'] = search_type
|
||||
return jsonify({
|
||||
|
||||
# 构建响应数据
|
||||
response_data = {
|
||||
'success': True,
|
||||
'data': {
|
||||
'events': events_data,
|
||||
@@ -10537,7 +10936,12 @@ def api_get_events():
|
||||
'total_count': paginated.total
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# ==================== 存入 Redis 缓存 ====================
|
||||
set_events_cache(cache_key, response_data)
|
||||
|
||||
return jsonify(response_data)
|
||||
except Exception as e:
|
||||
app.logger.error(f"获取事件列表出错: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
@@ -11725,6 +12129,18 @@ def initialize_event_polling():
|
||||
name='检查新事件并推送',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# 每天 9:25 预热股票缓存(开盘前 5 分钟)
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
scheduler.add_job(
|
||||
func=preload_stock_cache,
|
||||
trigger=CronTrigger(hour=9, minute=25),
|
||||
id='preload_stock_cache',
|
||||
name='预热股票缓存(股票名称+前收盘价)',
|
||||
replace_existing=True
|
||||
)
|
||||
print(f'[缓存] 已添加定时任务: 每天 9:25 预热股票缓存')
|
||||
|
||||
scheduler.start()
|
||||
print(f'[轮询] APScheduler 调度器已启动 (PID: {os.getpid()}),每 30 秒检查一次新事件')
|
||||
|
||||
@@ -18550,5 +18966,12 @@ if __name__ == '__main__':
|
||||
# 初始化事件轮询机制(WebSocket 推送)
|
||||
initialize_event_polling()
|
||||
|
||||
# 启动时预热股票缓存(股票名称 + 前收盘价)
|
||||
print("[启动] 正在预热股票缓存...")
|
||||
try:
|
||||
preload_stock_cache()
|
||||
except Exception as e:
|
||||
print(f"[启动] 预热缓存失败(不影响服务启动): {e}")
|
||||
|
||||
# 使用 socketio.run 替代 app.run 以支持 WebSocket
|
||||
socketio.run(app, host='0.0.0.0', port=5001, debug=False, allow_unsafe_werkzeug=True)
|
||||
491
get_related_chg.py
Normal file
491
get_related_chg.py
Normal file
@@ -0,0 +1,491 @@
|
||||
from clickhouse_driver import Client as Cclient
|
||||
from sqlalchemy import create_engine, text
|
||||
from datetime import datetime, time as dt_time, timedelta
|
||||
import time
|
||||
import pandas as pd
|
||||
import os
|
||||
|
||||
# 读取交易日数据
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
TRADING_DAYS_FILE = os.path.join(script_dir, 'tdays.csv')
|
||||
trading_days_df = pd.read_csv(TRADING_DAYS_FILE)
|
||||
trading_days_df['DateTime'] = pd.to_datetime(trading_days_df['DateTime']).dt.date
|
||||
TRADING_DAYS = sorted(trading_days_df['DateTime'].tolist()) # 排序后的交易日列表
|
||||
|
||||
|
||||
def get_clickhouse_client():
|
||||
return Cclient(
|
||||
host='127.0.0.1',
|
||||
port=9000,
|
||||
user='default',
|
||||
password='Zzl33818!',
|
||||
database='stock'
|
||||
)
|
||||
|
||||
|
||||
def get_mysql_engine():
|
||||
return create_engine(
|
||||
"mysql+pymysql://root:Zzl33818!@127.0.0.1:3306/stock",
|
||||
echo=False
|
||||
)
|
||||
|
||||
|
||||
def is_trading_time(check_datetime=None):
|
||||
"""判断是否在交易时间内
|
||||
|
||||
Args:
|
||||
check_datetime: 要检查的时间,默认为当前时间
|
||||
|
||||
Returns:
|
||||
bool: True表示在交易时间内
|
||||
"""
|
||||
if check_datetime is None:
|
||||
check_datetime = datetime.now()
|
||||
|
||||
# 检查是否是交易日
|
||||
check_date = check_datetime.date()
|
||||
if check_date not in TRADING_DAYS:
|
||||
return False
|
||||
|
||||
# 检查是否在交易时段内
|
||||
check_time = check_datetime.time()
|
||||
|
||||
# 上午时段: 9:30 - 11:30
|
||||
morning_start = dt_time(9, 30)
|
||||
morning_end = dt_time(11, 30)
|
||||
|
||||
# 下午时段: 13:00 - 15:00
|
||||
afternoon_start = dt_time(13, 0)
|
||||
afternoon_end = dt_time(15, 0)
|
||||
|
||||
is_morning = morning_start <= check_time <= morning_end
|
||||
is_afternoon = afternoon_start <= check_time <= afternoon_end
|
||||
|
||||
return is_morning or is_afternoon
|
||||
|
||||
|
||||
def get_next_trading_time():
|
||||
"""获取下一个交易时段的开始时间"""
|
||||
now = datetime.now()
|
||||
current_date = now.date()
|
||||
current_time = now.time()
|
||||
|
||||
# 如果今天是交易日
|
||||
if current_date in TRADING_DAYS:
|
||||
morning_start = dt_time(9, 30)
|
||||
afternoon_start = dt_time(13, 0)
|
||||
|
||||
# 如果还没到上午开盘
|
||||
if current_time < morning_start:
|
||||
return datetime.combine(current_date, morning_start)
|
||||
# 如果在上午休市后,下午还没开盘
|
||||
elif dt_time(11, 30) < current_time < afternoon_start:
|
||||
return datetime.combine(current_date, afternoon_start)
|
||||
|
||||
# 否则找下一个交易日的上午开盘时间
|
||||
for td in TRADING_DAYS:
|
||||
if td > current_date:
|
||||
return datetime.combine(td, dt_time(9, 30))
|
||||
|
||||
# 如果没有找到未来交易日,返回明天上午9:30(可能需要更新交易日数据)
|
||||
return datetime.combine(current_date + timedelta(days=1), dt_time(9, 30))
|
||||
|
||||
|
||||
def get_next_trading_day(date):
|
||||
"""获取下一个交易日"""
|
||||
for td in TRADING_DAYS:
|
||||
if td > date:
|
||||
return td
|
||||
return None
|
||||
|
||||
|
||||
def get_nth_trading_day_after(start_date, n=7):
|
||||
"""获取start_date之后的第n个交易日"""
|
||||
try:
|
||||
start_idx = TRADING_DAYS.index(start_date)
|
||||
target_idx = start_idx + n
|
||||
if target_idx < len(TRADING_DAYS):
|
||||
return TRADING_DAYS[target_idx]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# 如果start_date不在交易日列表中,找到它之后的交易日
|
||||
future_days = [d for d in TRADING_DAYS if d > start_date]
|
||||
if len(future_days) >= n:
|
||||
return future_days[n - 1]
|
||||
elif future_days:
|
||||
return future_days[-1] # 返回最后一个可用的交易日
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_trading_day_info(event_datetime):
|
||||
"""获取事件对应的交易日信息"""
|
||||
event_date = event_datetime.date()
|
||||
market_close = dt_time(15, 0)
|
||||
|
||||
# 如果是交易日且在收盘前,使用当天
|
||||
if event_date in TRADING_DAYS and event_datetime.time() <= market_close:
|
||||
return event_date
|
||||
|
||||
# 否则使用下一个交易日
|
||||
return get_next_trading_day(event_date)
|
||||
|
||||
|
||||
def calculate_stock_changes(stock_codes, event_datetime, ch_client, debug=False):
|
||||
"""批量计算一个事件关联的所有股票涨跌幅"""
|
||||
|
||||
if not stock_codes:
|
||||
return None, None, None
|
||||
|
||||
event_date = event_datetime.date()
|
||||
event_time = event_datetime.time()
|
||||
market_open = dt_time(9, 30)
|
||||
market_close = dt_time(15, 0)
|
||||
|
||||
# 确定起始时间点(事件发生后的第一个有效价格点)
|
||||
if event_date in TRADING_DAYS and market_open <= event_time <= market_close:
|
||||
# 事件在交易时间内发生 → 用事件发生时的价格作为起点
|
||||
start_datetime = event_datetime
|
||||
trading_date = event_date
|
||||
end_datetime = datetime.combine(trading_date, market_close)
|
||||
if debug:
|
||||
print(f" 事件在交易时间内: {event_datetime} -> 起点={start_datetime}")
|
||||
else:
|
||||
# 事件在交易时间外发生 → 用下一个交易日开盘价作为起点
|
||||
trading_date = get_trading_day_info(event_datetime)
|
||||
if not trading_date:
|
||||
if debug:
|
||||
print(f" 找不到交易日: {event_datetime}")
|
||||
return None, None, None
|
||||
start_datetime = datetime.combine(trading_date, market_open)
|
||||
end_datetime = datetime.combine(trading_date, market_close)
|
||||
if debug:
|
||||
print(f" 事件在非交易时间: {event_datetime} -> 下一交易日={trading_date}, 起点={start_datetime}")
|
||||
|
||||
# 获取7个交易日后的日期
|
||||
week_trading_date = get_nth_trading_day_after(trading_date, 7)
|
||||
if not week_trading_date:
|
||||
# 降级:如果没有足够的未来交易日,就用当前能找到的最远日期
|
||||
week_trading_date = trading_date + timedelta(days=10)
|
||||
|
||||
week_end_datetime = datetime.combine(week_trading_date, market_close)
|
||||
|
||||
if debug:
|
||||
print(f" 查询范围: {start_datetime} -> 当日={end_datetime}, 周末={week_end_datetime}")
|
||||
print(f" 股票代码: {stock_codes}")
|
||||
|
||||
# 一次性查询所有股票的价格数据
|
||||
results = ch_client.execute("""
|
||||
SELECT code,
|
||||
-- 起始价格:事件发生时或之后的第一个价格
|
||||
argMin(close, timestamp) as start_price,
|
||||
-- 当日收盘价:当日交易结束时的最后一个价格
|
||||
argMax(
|
||||
close, if(timestamp <= %(end)s, timestamp, toDateTime('1970-01-01'))
|
||||
) as day_close_price,
|
||||
-- 周后收盘价:7个交易日后的收盘价
|
||||
argMax(
|
||||
close, if(timestamp <= %(week_end)s, timestamp, toDateTime('1970-01-01'))
|
||||
) as week_close_price
|
||||
FROM stock_minute
|
||||
WHERE code IN %(codes)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(week_end)s
|
||||
GROUP BY code
|
||||
HAVING start_price > 0
|
||||
""", {
|
||||
'codes': tuple(stock_codes),
|
||||
'start': start_datetime,
|
||||
'end': end_datetime,
|
||||
'week_end': week_end_datetime
|
||||
})
|
||||
|
||||
if debug:
|
||||
print(f" 查询到 {len(results)} 只股票的数据")
|
||||
|
||||
if not results:
|
||||
return None, None, None
|
||||
|
||||
# 计算涨跌幅
|
||||
day_changes = []
|
||||
week_changes = []
|
||||
|
||||
for code, start_price, day_close, week_close in results:
|
||||
if start_price and start_price > 0:
|
||||
# 当日涨跌幅(从事件发生到当日收盘)
|
||||
if day_close and day_close > 0:
|
||||
day_change = (day_close - start_price) / start_price * 100
|
||||
day_changes.append(day_change)
|
||||
|
||||
# 周度涨跌幅(从事件发生到第7个交易日收盘)
|
||||
if week_close and week_close > 0:
|
||||
week_change = (week_close - start_price) / start_price * 100
|
||||
week_changes.append(week_change)
|
||||
|
||||
# 计算统计值
|
||||
avg_change = sum(day_changes) / len(day_changes) if day_changes else None
|
||||
max_change = max(day_changes) if day_changes else None
|
||||
avg_week_change = sum(week_changes) / len(week_changes) if week_changes else None
|
||||
|
||||
if debug:
|
||||
print(
|
||||
f" 结果: 日均={avg_change:.2f}% 日最大={max_change:.2f}% 周均={avg_week_change:.2f}%" if avg_change else " 结果: 无有效数据")
|
||||
|
||||
return avg_change, max_change, avg_week_change
|
||||
|
||||
|
||||
def update_event_statistics(start_date=None, end_date=None, force_update=False, debug_mode=False):
|
||||
"""更新事件统计数据
|
||||
|
||||
Args:
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
force_update: 是否强制更新(忽略已有数据)
|
||||
debug_mode: 是否开启调试模式
|
||||
"""
|
||||
try:
|
||||
print("[DEBUG] 开始 update_event_statistics")
|
||||
print(f"[DEBUG] 参数: start_date={start_date}, end_date={end_date}, force_update={force_update}")
|
||||
|
||||
mysql_engine = get_mysql_engine()
|
||||
print("[DEBUG] MySQL 引擎创建成功")
|
||||
|
||||
ch_client = get_clickhouse_client()
|
||||
print("[DEBUG] ClickHouse 客户端创建成功")
|
||||
|
||||
with mysql_engine.connect() as mysql_conn:
|
||||
print("[DEBUG] MySQL 连接已建立")
|
||||
# 构建SQL查询
|
||||
query = """
|
||||
SELECT e.id, \
|
||||
e.created_at, \
|
||||
GROUP_CONCAT(rs.stock_code) as stock_codes,
|
||||
e.related_avg_chg, \
|
||||
e.related_max_chg, \
|
||||
e.related_week_chg
|
||||
FROM event e
|
||||
JOIN related_stock rs ON e.id = rs.event_id \
|
||||
"""
|
||||
|
||||
conditions = []
|
||||
params = {}
|
||||
|
||||
if start_date:
|
||||
conditions.append("e.created_at >= :start_date")
|
||||
params["start_date"] = start_date
|
||||
|
||||
if end_date:
|
||||
conditions.append("e.created_at <= :end_date")
|
||||
params["end_date"] = end_date
|
||||
|
||||
if not force_update:
|
||||
# 只更新没有数据的记录
|
||||
conditions.append("(e.related_avg_chg IS NULL OR e.related_max_chg IS NULL)")
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
query += """
|
||||
GROUP BY e.id, e.created_at, e.related_avg_chg, e.related_max_chg, e.related_week_chg
|
||||
ORDER BY e.created_at DESC
|
||||
"""
|
||||
|
||||
print(f"[DEBUG] 执行查询SQL:\n{query}")
|
||||
print(f"[DEBUG] 查询参数: {params}")
|
||||
|
||||
events = mysql_conn.execute(text(query), params).fetchall()
|
||||
|
||||
print(f"[DEBUG] 查询返回 {len(events)} 条事件记录")
|
||||
print(f"Found {len(events)} events to update (force_update={force_update})")
|
||||
if debug_mode and len(events) > 0:
|
||||
print(f"Date range: {events[-1][1]} to {events[0][1]}")
|
||||
|
||||
# 准备批量更新数据
|
||||
update_data = []
|
||||
|
||||
for idx, event in enumerate(events, 1):
|
||||
try:
|
||||
event_id = event[0]
|
||||
created_at = event[1]
|
||||
stock_codes = event[2].split(',') if event[2] else []
|
||||
existing_avg = event[3]
|
||||
existing_max = event[4]
|
||||
existing_week = event[5]
|
||||
|
||||
if not stock_codes:
|
||||
continue
|
||||
|
||||
if debug_mode and idx <= 3: # 只调试前3个事件
|
||||
print(f"\n[Event {event_id}] created_at={created_at}")
|
||||
if not force_update and existing_avg is not None:
|
||||
print(
|
||||
f" 已有数据: avg={existing_avg:.2f}% max={existing_max:.2f}% week={existing_week:.2f}%")
|
||||
|
||||
# 批量计算该事件所有股票的涨跌幅
|
||||
avg_change, max_change, week_change = calculate_stock_changes(
|
||||
stock_codes, created_at, ch_client, debug=(debug_mode and idx <= 3)
|
||||
)
|
||||
|
||||
# 收集更新数据
|
||||
if any(x is not None for x in (avg_change, max_change, week_change)):
|
||||
update_data.append({
|
||||
"avg_chg": avg_change,
|
||||
"max_chg": max_change,
|
||||
"week_chg": week_change,
|
||||
"event_id": event_id
|
||||
})
|
||||
if idx <= 5: # 前5条显示详情
|
||||
print(f"[DEBUG] 事件 {event_id}: avg={avg_change}, max={max_change}, week={week_change}")
|
||||
else:
|
||||
if idx <= 5:
|
||||
print(f"[DEBUG] 事件 {event_id}: 计算结果全为None,跳过")
|
||||
|
||||
# 每处理10个事件打印一次进度
|
||||
if idx % 10 == 0:
|
||||
print(f"Processed {idx}/{len(events)} events...")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing event {event[0]}: {str(e)}")
|
||||
if debug_mode:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
# 批量更新MySQL
|
||||
print(f"\n[DEBUG] ====== 准备写入数据库 ======")
|
||||
print(f"[DEBUG] update_data 长度: {len(update_data)}")
|
||||
if update_data:
|
||||
print(f"[DEBUG] 前3条待更新数据: {update_data[:3]}")
|
||||
print(f"[DEBUG] 执行 UPDATE 语句...")
|
||||
|
||||
result = mysql_conn.execute(text("""
|
||||
UPDATE event
|
||||
SET related_avg_chg = :avg_chg,
|
||||
related_max_chg = :max_chg,
|
||||
related_week_chg = :week_chg
|
||||
WHERE id = :event_id
|
||||
"""), update_data)
|
||||
print(f"[DEBUG] UPDATE 执行完成, rowcount={result.rowcount}")
|
||||
|
||||
# 关键:显式提交事务!SQLAlchemy 2.0 需要手动 commit
|
||||
print("[DEBUG] 准备提交事务 (commit)...")
|
||||
mysql_conn.commit()
|
||||
print("[DEBUG] 事务已提交!")
|
||||
|
||||
print(f"Successfully updated {len(update_data)} events")
|
||||
else:
|
||||
print("[DEBUG] update_data 为空,没有数据需要更新!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in update_event_statistics: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def run_monitor():
|
||||
"""运行监控循环 - 仅在交易时间段内每2分钟强制更新最近7天数据"""
|
||||
print("=" * 60)
|
||||
print("启动交易时段监控模式")
|
||||
print("运行规则: 仅在交易日的9:30-11:30和13:00-15:00运行")
|
||||
print("更新频率: 每2分钟一次")
|
||||
print("更新模式: 强制更新(force_update=True)")
|
||||
print("更新范围: 最近7天的事件数据")
|
||||
print("=" * 60)
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now()
|
||||
|
||||
# 检查是否在交易时间内
|
||||
if is_trading_time(now):
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] 交易时段 - 开始更新...")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
update_event_statistics(
|
||||
start_date=seven_days_ago,
|
||||
force_update=True, # 强制更新所有数据
|
||||
debug_mode=False
|
||||
)
|
||||
|
||||
print(f"\n[{now.strftime('%Y-%m-%d %H:%M:%S')}] 更新完成")
|
||||
print(f"等待2分钟后执行下次更新...\n")
|
||||
time.sleep(120) # 2分钟
|
||||
|
||||
else:
|
||||
# 不在交易时间,计算下次交易时间
|
||||
next_trading_time = get_next_trading_time()
|
||||
wait_seconds = (next_trading_time - now).total_seconds()
|
||||
wait_minutes = int(wait_seconds / 60)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] 非交易时段")
|
||||
print(f"下次交易时间: {next_trading_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"等待时长: {wait_minutes} 分钟")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
# 等待到下一个交易时段(每5分钟检查一次,避免程序僵死)
|
||||
check_interval = 300 # 5分钟检查一次
|
||||
while not is_trading_time():
|
||||
time.sleep(min(check_interval, max(1, wait_seconds)))
|
||||
wait_seconds = (get_next_trading_time() - datetime.now()).total_seconds()
|
||||
if wait_seconds <= 0:
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n程序被用户中断")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error in monitor loop: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print("等待1分钟后重试...")
|
||||
time.sleep(60) # 发生错误等待1分钟后重试
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# 支持命令行参数
|
||||
# python get_related_chg.py --test # 测试模式:只更新昨天和今天,开启调试
|
||||
# python get_related_chg.py --once # 单次强制更新最近7天
|
||||
# python get_related_chg.py # 正常运行:交易时段每2分钟强制更新
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == '--test':
|
||||
# 测试模式:更新昨天和今天的数据,开启调试
|
||||
print("=" * 60)
|
||||
print("测试模式:更新昨天和今天的数据")
|
||||
print("=" * 60)
|
||||
yesterday = (datetime.now() - timedelta(days=2)).replace(hour=15, minute=0, second=0)
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
update_event_statistics(
|
||||
start_date=yesterday,
|
||||
end_date=tomorrow,
|
||||
force_update=True,
|
||||
debug_mode=True
|
||||
)
|
||||
print("\n测试完成!")
|
||||
|
||||
elif sys.argv[1] == '--once':
|
||||
# 单次强制更新模式
|
||||
print("=" * 60)
|
||||
print("单次强制更新模式:重新计算最近7天所有数据")
|
||||
print("=" * 60)
|
||||
seven_days_ago = datetime.now() - timedelta(days=7)
|
||||
update_event_statistics(
|
||||
start_date=seven_days_ago,
|
||||
force_update=True,
|
||||
debug_mode=False
|
||||
)
|
||||
print("\n强制更新完成!")
|
||||
else:
|
||||
print("未知参数。支持的参数:")
|
||||
print(" --test : 测试模式(更新昨天和今天,开启调试)")
|
||||
print(" --once : 单次强制更新最近7天")
|
||||
print(" (无参数): 交易时段监控模式(每2分钟强制更新)")
|
||||
else:
|
||||
# 正常监控模式:仅在交易时间段运行
|
||||
run_monitor()
|
||||
@@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Gunicorn 配置文件 - Eventlet 极限高并发配置(110.42.32.207 专用)
|
||||
Gunicorn 配置文件 - Eventlet 高并发配置(48核128GB 专用)
|
||||
|
||||
服务器配置: 48核心 128GB 内存
|
||||
目标并发: 160,000+ 并发连接
|
||||
目标并发: 5,000-10,000 实际并发(理论 320,000 连接)
|
||||
|
||||
使用方式:
|
||||
# 设置环境变量后启动
|
||||
@@ -14,10 +14,12 @@ Gunicorn 配置文件 - Eventlet 极限高并发配置(110.42.32.207 专用)
|
||||
REDIS_HOST=127.0.0.1 gunicorn -c gunicorn_eventlet_config.py app:app
|
||||
|
||||
架构说明:
|
||||
- 16 个 Eventlet Worker(每个占用 1 核心,预留 32 核给系统/Redis/MySQL)
|
||||
- 32 个 Eventlet Worker(每个占用 1 核心,预留 16 核给系统/Redis/MySQL)
|
||||
- 每个 Worker 处理 10000+ 并发连接(协程异步 I/O)
|
||||
- 数据库连接池: 32 workers × 150 = 4800 连接(实际瓶颈)
|
||||
- Redis 消息队列同步跨 Worker 的 WebSocket 消息
|
||||
- 总并发能力: 16 × 10000 = 160,000+ 连接
|
||||
- 理论并发能力: 32 × 10000 = 320,000 连接
|
||||
- 实际并发能力: 5,000-10,000(受数据库连接限制)
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -32,9 +34,9 @@ os.environ.setdefault('REDIS_HOST', '127.0.0.1')
|
||||
bind = '0.0.0.0:5001'
|
||||
|
||||
# Worker 进程数
|
||||
# 48 核心机器: 16 Workers(预留资源给 Redis/MySQL/系统)
|
||||
# 48 核心机器: 32 Workers(目标 5000-10000 并发)
|
||||
# 每个 Eventlet Worker 是单线程但支持协程并发
|
||||
workers = 16
|
||||
workers = 32
|
||||
|
||||
# Worker 类型 - eventlet 异步模式
|
||||
worker_class = 'eventlet'
|
||||
@@ -97,14 +99,17 @@ def on_starting(server):
|
||||
workers = server.app.cfg.workers
|
||||
connections = server.app.cfg.worker_connections
|
||||
total = workers * connections
|
||||
db_pool = workers * 150 # pool_size=50 + max_overflow=100
|
||||
|
||||
print("=" * 70)
|
||||
print("🚀 Gunicorn + Eventlet 极限高并发服务器正在启动...")
|
||||
print("🚀 Gunicorn + Eventlet 高并发服务器正在启动...")
|
||||
print("=" * 70)
|
||||
print(f" 服务器配置: 48核心 128GB 内存")
|
||||
print(f" Workers: {workers} 个 Eventlet 协程进程")
|
||||
print(f" 每 Worker 连接数: {connections:,}")
|
||||
print(f" 总并发能力: {total:,} 连接")
|
||||
print(f" 理论并发能力: {total:,} 连接")
|
||||
print(f" 数据库连接池: {db_pool:,} 连接(实际瓶颈)")
|
||||
print(f" 目标实际并发: 5,000-10,000")
|
||||
print("-" * 70)
|
||||
print(f" Bind: {server.app.cfg.bind}")
|
||||
print(f" Max Requests: {server.app.cfg.max_requests:,}")
|
||||
@@ -122,18 +127,21 @@ def when_ready(server):
|
||||
workers = server.app.cfg.workers
|
||||
connections = server.app.cfg.worker_connections
|
||||
total = workers * connections
|
||||
db_pool = workers * 150
|
||||
|
||||
print("=" * 70)
|
||||
print(f"✅ Gunicorn + Eventlet 服务准备就绪!")
|
||||
print(f" {workers} 个 Worker 已启动")
|
||||
print(f" 总并发能力: {total:,} 连接")
|
||||
print(f" 理论并发能力: {total:,} 连接")
|
||||
print(f" 数据库连接池: {db_pool:,} 连接")
|
||||
print(f" 目标实际并发: 5,000-10,000")
|
||||
print(f" WebSocket + HTTP API 混合高并发已启用")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
def post_worker_init(worker):
|
||||
"""Worker 初始化完成后调用"""
|
||||
print(f"✅ Eventlet Worker {worker.pid} 已初始化 (10,000 并发连接就绪)")
|
||||
print(f"✅ Eventlet Worker {worker.pid} 已初始化 (10,000 并发连接 + 150 数据库连接就绪)")
|
||||
|
||||
# 触发事件轮询初始化(使用 Redis 锁确保只有一个 Worker 启动调度器)
|
||||
try:
|
||||
|
||||
40
src/components/Charts/ECharts.tsx
Normal file
40
src/components/Charts/ECharts.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* ECharts 包装组件
|
||||
*
|
||||
* 基于 echarts-for-react,使用按需引入的 echarts 实例
|
||||
* 减少打包体积约 500KB
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import ECharts from '@components/Charts/ECharts';
|
||||
*
|
||||
* <ECharts option={chartOption} style={{ height: 300 }} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import ReactEChartsCore from 'echarts-for-react/lib/core';
|
||||
import { echarts } from '@lib/echarts';
|
||||
|
||||
// Re-export ReactEChartsCore props type
|
||||
import type { EChartsReactProps } from 'echarts-for-react';
|
||||
|
||||
export type EChartsProps = Omit<EChartsReactProps, 'echarts'>;
|
||||
|
||||
/**
|
||||
* ECharts 图表组件
|
||||
* 自动使用按需引入的 echarts 实例
|
||||
*/
|
||||
const ECharts = forwardRef<ReactEChartsCore, EChartsProps>((props, ref) => {
|
||||
return (
|
||||
<ReactEChartsCore
|
||||
ref={ref}
|
||||
echarts={echarts}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ECharts.displayName = 'ECharts';
|
||||
|
||||
export default ECharts;
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/Charts/Stock/MiniTimelineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box, useColorModeValue } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
|
||||
/**
|
||||
* ECharts 图表渲染组件
|
||||
|
||||
@@ -198,10 +198,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
}
|
||||
}, [sectionState.stocks, stocks.length, refreshQuotes]);
|
||||
|
||||
// 相关概念 - 展开/收起(无需加载)
|
||||
const handleConceptsToggle = useCallback(() => {
|
||||
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
|
||||
}, []);
|
||||
|
||||
// 历史事件对比 - 数据已预加载,只需切换展开状态
|
||||
const handleHistoricalToggle = useCallback(() => {
|
||||
@@ -350,13 +346,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 相关概念(可折叠) - 需要 PRO 权限 */}
|
||||
{/* 相关概念(手风琴样式) - 需要 PRO 权限 */}
|
||||
<RelatedConceptsSection
|
||||
eventId={event.id}
|
||||
eventTitle={event.title}
|
||||
effectiveTradingDate={event.trading_date || event.created_at}
|
||||
eventTime={event.created_at}
|
||||
isOpen={sectionState.concepts.isOpen}
|
||||
onToggle={handleConceptsToggle}
|
||||
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessConcepts}
|
||||
onLockedClick={() => handleLockedClick('相关概念', 'pro')}
|
||||
|
||||
@@ -19,8 +19,9 @@ import ConceptStockItem from './ConceptStockItem';
|
||||
/**
|
||||
* 详细概念卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.concept - 概念对象(兼容 v1/v2 API)
|
||||
* @param {Object} props.concept - 概念对象(兼容 v1/v2 API 和 related_concepts 表数据)
|
||||
* - concept: 概念名称
|
||||
* - reason: 关联原因(来自 related_concepts 表)
|
||||
* - stock_count: 相关股票数量
|
||||
* - score: 相关度(0-1)
|
||||
* - price_info.avg_change_pct: 平均涨跌幅
|
||||
@@ -34,6 +35,8 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const reasonBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const reasonColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 计算相关度百分比
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
@@ -43,6 +46,9 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
|
||||
const changeSymbol = changePct > 0 ? '+' : '';
|
||||
|
||||
// 判断是否来自数据库(有 reason 字段)
|
||||
const isFromDatabase = !!concept.reason;
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
@@ -67,17 +73,27 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
相关度: {relevanceScore}%
|
||||
</Badge>
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
{concept.stock_count} 只股票
|
||||
</Badge>
|
||||
{/* 数据库数据显示"AI分析"标签,搜索数据显示相关度 */}
|
||||
{isFromDatabase ? (
|
||||
<Badge colorScheme="green" fontSize="xs">
|
||||
AI 分析
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
相关度: {relevanceScore}%
|
||||
</Badge>
|
||||
)}
|
||||
{/* 只有搜索数据才显示股票数量 */}
|
||||
{!isFromDatabase && concept.stock_count > 0 && (
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
{concept.stock_count} 只股票
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅 */}
|
||||
{concept.price_info?.avg_change_pct && (
|
||||
{/* 右侧:涨跌幅(仅搜索数据有) */}
|
||||
{!isFromDatabase && concept.price_info?.avg_change_pct && (
|
||||
<Box textAlign="right">
|
||||
<Text fontSize="xs" color={stockCountColor} mb={1}>
|
||||
平均涨跌幅
|
||||
@@ -97,8 +113,30 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 概念描述 */}
|
||||
{concept.description && (
|
||||
{/* 关联原因(来自数据库,突出显示) */}
|
||||
{concept.reason && (
|
||||
<Box
|
||||
bg={reasonBg}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="blue.400"
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="bold" color="blue.500" mb={1}>
|
||||
关联原因
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={reasonColor}
|
||||
lineHeight="1.8"
|
||||
>
|
||||
{concept.reason}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 概念描述(仅搜索数据有,且没有 reason 时显示) */}
|
||||
{!concept.reason && concept.description && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={stockCountColor}
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
/**
|
||||
* 简单概念卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.concept - 概念对象
|
||||
* - name: 概念名称
|
||||
* @param {Object} props.concept - 概念对象(兼容搜索数据和数据库数据)
|
||||
* - concept: 概念名称
|
||||
* - reason: 关联原因(来自数据库)
|
||||
* - stock_count: 相关股票数量
|
||||
* - relevance: 相关度(0-100)
|
||||
* - score: 相关度(0-1)
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Function} props.getRelevanceColor - 获取相关度颜色的函数
|
||||
*/
|
||||
@@ -34,13 +35,16 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
const changeColor = changePct !== null ? (changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray') : null;
|
||||
const changeSymbol = changePct !== null && changePct > 0 ? '+' : '';
|
||||
|
||||
// 判断是否来自数据库(有 reason 字段)
|
||||
const isFromDatabase = !!concept.reason;
|
||||
|
||||
return (
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={1}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderColor={isFromDatabase ? 'green.300' : borderColor}
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={1}
|
||||
@@ -61,30 +65,39 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
wordBreak="break-word"
|
||||
lineHeight="1.4"
|
||||
>
|
||||
{concept.concept}{' '}
|
||||
<Text as="span" color="gray.500">
|
||||
({concept.stock_count})
|
||||
</Text>
|
||||
{concept.concept}
|
||||
{/* 只有搜索数据才显示股票数量 */}
|
||||
{!isFromDatabase && concept.stock_count > 0 && (
|
||||
<Text as="span" color="gray.500">
|
||||
{' '}({concept.stock_count})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* 第二行:相关度 + 涨跌幅 */}
|
||||
{/* 第二行:标签 */}
|
||||
<Flex justify="space-between" align="center" gap={1} flexWrap="wrap">
|
||||
{/* 相关度标签 */}
|
||||
<Box
|
||||
bg={relevanceColors.bg}
|
||||
color={relevanceColors.color}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
borderRadius="sm"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap">
|
||||
相关度: {relevanceScore}%
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 数据库数据显示"AI分析",搜索数据显示相关度 */}
|
||||
{isFromDatabase ? (
|
||||
<Badge colorScheme="green" fontSize="10px" px={1.5} py={0.5}>
|
||||
AI 分析
|
||||
</Badge>
|
||||
) : (
|
||||
<Box
|
||||
bg={relevanceColors.bg}
|
||||
color={relevanceColors.color}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
borderRadius="sm"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap">
|
||||
相关度: {relevanceScore}%
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 涨跌幅数据 */}
|
||||
{changePct !== null && (
|
||||
{/* 涨跌幅数据(仅搜索数据有) */}
|
||||
{!isFromDatabase && changePct !== null && (
|
||||
<Badge
|
||||
colorScheme={changeColor}
|
||||
fontSize="10px"
|
||||
|
||||
@@ -1,83 +1,140 @@
|
||||
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
|
||||
// 相关概念区组件(主组件)
|
||||
// 相关概念区组件 - 折叠手风琴样式
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
Button,
|
||||
Collapse,
|
||||
Heading,
|
||||
Center,
|
||||
Spinner,
|
||||
Text,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Icon,
|
||||
Collapse,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import SimpleConceptCard from './SimpleConceptCard';
|
||||
import DetailedConceptCard from './DetailedConceptCard';
|
||||
import TradingDateInfo from './TradingDateInfo';
|
||||
import { logger } from '@utils/logger';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
/**
|
||||
* 单个概念项组件(手风琴项)
|
||||
*/
|
||||
const ConceptItem = ({ concept, isExpanded, onToggle, onNavigate }) => {
|
||||
const itemBg = useColorModeValue('white', 'gray.700');
|
||||
const itemHoverBg = useColorModeValue('gray.50', 'gray.650');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const conceptColor = useColorModeValue('blue.600', 'blue.300');
|
||||
const reasonBg = useColorModeValue('blue.50', 'gray.800');
|
||||
const reasonColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const iconColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
bg={itemBg}
|
||||
>
|
||||
{/* 概念标题行 - 可点击展开 */}
|
||||
<Flex
|
||||
px={3}
|
||||
py={2.5}
|
||||
cursor="pointer"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
_hover={{ bg: itemHoverBg }}
|
||||
onClick={onToggle}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Icon
|
||||
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
|
||||
color={iconColor}
|
||||
boxSize={4}
|
||||
transition="transform 0.2s"
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color={conceptColor}
|
||||
cursor="pointer"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(concept);
|
||||
}}
|
||||
>
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<Badge colorScheme="green" fontSize="xs" flexShrink={0}>
|
||||
AI 分析
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 关联原因 - 可折叠 */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
<Box
|
||||
px={4}
|
||||
py={3}
|
||||
bg={reasonBg}
|
||||
borderTop="1px solid"
|
||||
borderTopColor={borderColor}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={reasonColor}
|
||||
lineHeight="1.8"
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{concept.reason || '暂无关联原因说明'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 相关概念区组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.eventTitle - 事件标题(用于搜索概念)
|
||||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||||
* @param {string|Object} props.eventTime - 事件发生时间
|
||||
* @param {number} props.eventId - 事件ID(用于获取 related_concepts 表数据)
|
||||
* @param {string} props.eventTitle - 事件标题(备用)
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选)
|
||||
* @param {boolean} props.isLocked - 是否锁定详细模式(需要付费)
|
||||
* @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗)
|
||||
* @param {boolean} props.isLocked - 是否锁定(需要付费)
|
||||
* @param {Function} props.onLockedClick - 锁定时的点击回调
|
||||
*/
|
||||
const RelatedConceptsSection = ({
|
||||
eventId,
|
||||
eventTitle,
|
||||
effectiveTradingDate,
|
||||
eventTime,
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null,
|
||||
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
|
||||
onToggle = undefined // 新增:受控模式(外部控制展开回调)
|
||||
}) => {
|
||||
// 使用外部 isOpen,如果没有则使用内部 useState
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isExpanded = onToggle !== undefined ? isOpen : internalExpanded;
|
||||
const [concepts, setConcepts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
// 记录每个概念的展开状态
|
||||
const [expandedItems, setExpandedItems] = useState({});
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 颜色配置
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const countBadgeBg = useColorModeValue('blue.100', 'blue.800');
|
||||
const countBadgeColor = useColorModeValue('blue.700', 'blue.200');
|
||||
|
||||
console.log('[RelatedConceptsSection] 组件渲染', {
|
||||
eventTitle,
|
||||
effectiveTradingDate,
|
||||
eventTime,
|
||||
loading,
|
||||
conceptsCount: concepts?.length || 0,
|
||||
error
|
||||
});
|
||||
|
||||
// 搜索相关概念
|
||||
// 获取相关概念
|
||||
useEffect(() => {
|
||||
const searchConcepts = async () => {
|
||||
console.log('[RelatedConceptsSection] useEffect 触发', {
|
||||
eventTitle,
|
||||
effectiveTradingDate
|
||||
});
|
||||
|
||||
if (!eventTitle || !effectiveTradingDate) {
|
||||
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
|
||||
hasEventTitle: !!eventTitle,
|
||||
hasEffectiveTradingDate: !!effectiveTradingDate
|
||||
});
|
||||
const fetchConcepts = async () => {
|
||||
if (!eventId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -86,178 +143,103 @@ const RelatedConceptsSection = ({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 格式化交易日期 - 统一使用 moment 处理
|
||||
let formattedTradeDate;
|
||||
try {
|
||||
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
|
||||
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
|
||||
// 验证日期是否有效
|
||||
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
query: eventTitle,
|
||||
size: 5,
|
||||
page: 1,
|
||||
sort_by: "_score",
|
||||
trade_date: formattedTradeDate
|
||||
};
|
||||
|
||||
const apiUrl = `${getApiBase()}/concept-api/search`;
|
||||
console.log('[RelatedConceptsSection] 发送请求', {
|
||||
url: apiUrl,
|
||||
requestBody
|
||||
});
|
||||
logger.debug('RelatedConceptsSection', '搜索概念', requestBody);
|
||||
|
||||
const apiUrl = `${getApiBase()}/api/events/${eventId}/concepts`;
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
console.log('[RelatedConceptsSection] 响应状态', {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
setConcepts([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[RelatedConceptsSection] 响应数据', {
|
||||
hasResults: !!data.results,
|
||||
resultsCount: data.results?.length || 0,
|
||||
hasDataConcepts: !!(data.data && data.data.concepts),
|
||||
data: data
|
||||
});
|
||||
logger.debug('RelatedConceptsSection', '概念搜索响应', {
|
||||
hasResults: !!data.results,
|
||||
resultsCount: data.results?.length || 0
|
||||
});
|
||||
|
||||
// 设置概念数据
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
console.log('[RelatedConceptsSection] 设置概念数据 (results)', data.results);
|
||||
setConcepts(data.results);
|
||||
} else if (data.data && data.data.concepts) {
|
||||
// 向后兼容
|
||||
console.log('[RelatedConceptsSection] 设置概念数据 (data.concepts)', data.data.concepts);
|
||||
setConcepts(data.data.concepts);
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setConcepts(data.data);
|
||||
// 默认展开第一个
|
||||
if (data.data.length > 0) {
|
||||
setExpandedItems({ 0: true });
|
||||
}
|
||||
} else {
|
||||
console.log('[RelatedConceptsSection] 没有找到概念数据,设置为空数组');
|
||||
setConcepts([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RelatedConceptsSection] 搜索概念失败', err);
|
||||
logger.error('RelatedConceptsSection', 'searchConcepts', err);
|
||||
console.error('[RelatedConceptsSection] 获取概念失败', err);
|
||||
logger.error('RelatedConceptsSection', 'fetchConcepts', err);
|
||||
setError('加载概念数据失败');
|
||||
setConcepts([]);
|
||||
} finally {
|
||||
console.log('[RelatedConceptsSection] 加载完成');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
searchConcepts();
|
||||
}, [eventTitle, effectiveTradingDate]);
|
||||
fetchConcepts();
|
||||
}, [eventId]);
|
||||
|
||||
// 切换某个概念的展开状态
|
||||
const toggleItem = (index) => {
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
return;
|
||||
}
|
||||
setExpandedItems(prev => ({
|
||||
...prev,
|
||||
[index]: !prev[index]
|
||||
}));
|
||||
};
|
||||
|
||||
// 跳转到概念中心
|
||||
const handleNavigate = (concept) => {
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
};
|
||||
|
||||
// 加载中状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" mr={2} />
|
||||
<Spinner size="sm" color="blue.500" mr={2} />
|
||||
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 判断是否有数据
|
||||
const hasNoConcepts = !concepts || concepts.length === 0;
|
||||
|
||||
/**
|
||||
* 根据相关度获取颜色(浅色背景 + 深色文字)
|
||||
* @param {number} relevance - 相关度(0-100)
|
||||
* @returns {Object} 包含背景色和文字色
|
||||
*/
|
||||
const getRelevanceColor = (relevance) => {
|
||||
if (relevance >= 90) {
|
||||
return { bg: 'purple.50', color: 'purple.800' }; // 极高相关
|
||||
} else if (relevance >= 80) {
|
||||
return { bg: 'pink.50', color: 'pink.800' }; // 高相关
|
||||
} else if (relevance >= 70) {
|
||||
return { bg: 'orange.50', color: 'orange.800' }; // 中等相关
|
||||
} else {
|
||||
return { bg: 'gray.100', color: 'gray.700' }; // 低相关
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理概念点击
|
||||
* @param {Object} concept - 概念对象
|
||||
*/
|
||||
const handleConceptClick = (concept) => {
|
||||
// 跳转到概念中心,并搜索该概念
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
{/* 标题栏 - 两行布局 */}
|
||||
<Box mb={3}>
|
||||
{/* 第一行:标题 + Badge + 按钮 */}
|
||||
<Flex justify="space-between" align="center" mb={2}>
|
||||
<Flex align="center" gap={2}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
相关概念
|
||||
</Heading>
|
||||
{/* 订阅徽章 */}
|
||||
{subscriptionBadge}
|
||||
</Flex>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
onClick={() => {
|
||||
// 如果被锁定且有回调函数,触发付费弹窗
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
} else if (onToggle !== undefined) {
|
||||
// 受控模式:调用外部回调
|
||||
onToggle();
|
||||
} else {
|
||||
// 非受控模式:使用内部状态
|
||||
setInternalExpanded(!internalExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '收起' : '查看详细'}
|
||||
</Button>
|
||||
</Flex>
|
||||
{/* 第二行:交易日期信息 */}
|
||||
<TradingDateInfo
|
||||
effectiveTradingDate={effectiveTradingDate}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
{/* 标题栏 */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<HStack spacing={2}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
相关概念
|
||||
</Heading>
|
||||
{!hasNoConcepts && (
|
||||
<Badge
|
||||
bg={countBadgeBg}
|
||||
color={countBadgeColor}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
>
|
||||
{concepts.length}
|
||||
</Badge>
|
||||
)}
|
||||
{subscriptionBadge}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 简单模式:横向卡片列表(总是显示) */}
|
||||
{/* 概念列表 - 手风琴样式 */}
|
||||
{hasNoConcepts ? (
|
||||
<Box mb={isExpanded ? 3 : 0}>
|
||||
<Box py={2}>
|
||||
{error ? (
|
||||
<Text color="red.500" fontSize="sm">{error}</Text>
|
||||
) : (
|
||||
@@ -265,41 +247,18 @@ const RelatedConceptsSection = ({
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{concepts.map((concept, index) => (
|
||||
<SimpleConceptCard
|
||||
key={index}
|
||||
<ConceptItem
|
||||
key={concept.id || index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
getRelevanceColor={getRelevanceColor}
|
||||
isExpanded={!!expandedItems[index]}
|
||||
onToggle={() => toggleItem(index)}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 详细模式:卡片网格(可折叠) */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
{hasNoConcepts ? (
|
||||
<Box py={4}>
|
||||
{error ? (
|
||||
<Text color="red.500" fontSize="sm" textAlign="center">{error}</Text>
|
||||
) : (
|
||||
<Text color={textColor} fontSize="sm" textAlign="center">暂无详细数据</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
/* 详细概念卡片网格 */
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{concepts.map((concept, index) => (
|
||||
<DetailedConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
81
src/components/FUI/AmbientGlow.tsx
Normal file
81
src/components/FUI/AmbientGlow.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 环境光效果组件
|
||||
* James Turrell 风格的背景光晕效果
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, BoxProps } from '@chakra-ui/react';
|
||||
|
||||
export interface AmbientGlowProps extends Omit<BoxProps, 'bg'> {
|
||||
/** 预设主题 */
|
||||
variant?: 'default' | 'gold' | 'blue' | 'purple' | 'warm';
|
||||
/** 自定义渐变(覆盖 variant) */
|
||||
customGradient?: string;
|
||||
}
|
||||
|
||||
// 预设光效配置
|
||||
const GLOW_VARIANTS = {
|
||||
default: `
|
||||
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(212, 175, 55, 0.08), transparent 50%),
|
||||
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(100, 200, 255, 0.04), transparent 40%),
|
||||
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(255, 200, 100, 0.04), transparent 40%)
|
||||
`,
|
||||
gold: `
|
||||
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(212, 175, 55, 0.12), transparent 50%),
|
||||
radial-gradient(ellipse 80% 60% at 20% 80%, rgba(212, 175, 55, 0.06), transparent 40%),
|
||||
radial-gradient(ellipse 80% 60% at 80% 80%, rgba(255, 200, 100, 0.05), transparent 40%)
|
||||
`,
|
||||
blue: `
|
||||
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(100, 200, 255, 0.1), transparent 50%),
|
||||
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(60, 160, 255, 0.06), transparent 40%),
|
||||
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(140, 220, 255, 0.05), transparent 40%)
|
||||
`,
|
||||
purple: `
|
||||
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(160, 100, 255, 0.1), transparent 50%),
|
||||
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(200, 150, 255, 0.05), transparent 40%),
|
||||
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(120, 80, 255, 0.05), transparent 40%)
|
||||
`,
|
||||
warm: `
|
||||
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(255, 150, 100, 0.1), transparent 50%),
|
||||
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(255, 200, 150, 0.05), transparent 40%),
|
||||
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(255, 180, 120, 0.05), transparent 40%)
|
||||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* 环境光效果组件
|
||||
* 创建 James Turrell 风格的微妙背景光晕
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Box position="relative">
|
||||
* <AmbientGlow variant="gold" />
|
||||
* {children}
|
||||
* </Box>
|
||||
* ```
|
||||
*/
|
||||
const AmbientGlow = memo<AmbientGlowProps>(({
|
||||
variant = 'default',
|
||||
customGradient,
|
||||
...boxProps
|
||||
}) => {
|
||||
const gradient = customGradient || GLOW_VARIANTS[variant];
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
pointerEvents="none"
|
||||
zIndex={0}
|
||||
bg={gradient}
|
||||
{...boxProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
AmbientGlow.displayName = 'AmbientGlow';
|
||||
|
||||
export default AmbientGlow;
|
||||
140
src/components/FUI/CardGlow.tsx
Normal file
140
src/components/FUI/CardGlow.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* CardGlow - 卡片级装饰光效组件
|
||||
*
|
||||
* 为卡片提供 FUI 风格的装饰元素:
|
||||
* - 顶部光条(Ash Thorp 风格)
|
||||
* - 角落发光效果(James Turrell 风格)
|
||||
* - 可选背景网格
|
||||
*
|
||||
* 与 AmbientGlow 的区别:
|
||||
* - AmbientGlow: 页面级环境光,position: fixed
|
||||
* - CardGlow: 卡片级装饰光,相对于父容器定位
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Box position="relative" overflow="hidden">
|
||||
* <CardGlow variant="gold" />
|
||||
* {children}
|
||||
* </Box>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
export interface CardGlowProps {
|
||||
/** 预设主题 */
|
||||
variant?: 'gold' | 'cyan' | 'purple' | 'default';
|
||||
/** 是否显示背景网格 */
|
||||
showGrid?: boolean;
|
||||
/** 自定义主色(覆盖 variant) */
|
||||
primaryColor?: string;
|
||||
/** 自定义次色(覆盖 variant) */
|
||||
secondaryColor?: string;
|
||||
}
|
||||
|
||||
// 预设颜色配置
|
||||
const COLOR_PRESETS = {
|
||||
gold: {
|
||||
primary: 'rgba(212, 175, 55, 1)',
|
||||
secondary: 'rgba(0, 212, 255, 0.1)',
|
||||
grid: 'rgba(212, 175, 55, 0.03)',
|
||||
},
|
||||
cyan: {
|
||||
primary: 'rgba(0, 212, 255, 1)',
|
||||
secondary: 'rgba(212, 175, 55, 0.1)',
|
||||
grid: 'rgba(0, 212, 255, 0.03)',
|
||||
},
|
||||
purple: {
|
||||
primary: 'rgba(168, 85, 247, 1)',
|
||||
secondary: 'rgba(0, 212, 255, 0.1)',
|
||||
grid: 'rgba(168, 85, 247, 0.03)',
|
||||
},
|
||||
default: {
|
||||
primary: 'rgba(255, 255, 255, 0.6)',
|
||||
secondary: 'rgba(255, 255, 255, 0.1)',
|
||||
grid: 'rgba(255, 255, 255, 0.02)',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 卡片装饰光效组件
|
||||
*
|
||||
* 纯展示组件,需要父容器设置 position: relative 和 overflow: hidden
|
||||
*/
|
||||
const CardGlow = memo<CardGlowProps>(({
|
||||
variant = 'gold',
|
||||
showGrid = true,
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
}) => {
|
||||
const preset = COLOR_PRESETS[variant];
|
||||
const primary = primaryColor || preset.primary;
|
||||
const secondary = secondaryColor || preset.secondary;
|
||||
const gridColor = preset.grid;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 顶部光条 - Ash Thorp 风格数据终端效果 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
width="60%"
|
||||
height="1px"
|
||||
background={`linear-gradient(90deg, transparent, ${primary}, transparent)`}
|
||||
opacity={0.6}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* 左上角光晕 - James Turrell 风格光影效果 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-40px"
|
||||
left="-40px"
|
||||
width="80px"
|
||||
height="80px"
|
||||
borderRadius="50%"
|
||||
background={`radial-gradient(circle, ${primary.replace('1)', '0.15)')} 0%, transparent 70%)`}
|
||||
filter="blur(20px)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* 右下角光晕 - 补充色,增加层次感 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-40px"
|
||||
right="-40px"
|
||||
width="80px"
|
||||
height="80px"
|
||||
borderRadius="50%"
|
||||
background={`radial-gradient(circle, ${secondary} 0%, transparent 70%)`}
|
||||
filter="blur(20px)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* 背景网格 - 微妙的科技感纹理 */}
|
||||
{showGrid && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
backgroundImage={`
|
||||
linear-gradient(${gridColor} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${gridColor} 1px, transparent 1px)
|
||||
`}
|
||||
backgroundSize="40px 40px"
|
||||
pointerEvents="none"
|
||||
opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
CardGlow.displayName = 'CardGlow';
|
||||
|
||||
export default CardGlow;
|
||||
93
src/components/FUI/FuiContainer.tsx
Normal file
93
src/components/FUI/FuiContainer.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* FUI 毛玻璃容器组件
|
||||
* 科幻风格的 Glassmorphism 容器,带角落装饰
|
||||
*/
|
||||
|
||||
import React, { memo, ReactNode } from 'react';
|
||||
import { Box, BoxProps } from '@chakra-ui/react';
|
||||
import FuiCorners, { FuiCornersProps } from './FuiCorners';
|
||||
|
||||
export interface FuiContainerProps extends Omit<BoxProps, 'children'> {
|
||||
children: ReactNode;
|
||||
/** 是否显示角落装饰 */
|
||||
showCorners?: boolean;
|
||||
/** 角落装饰配置 */
|
||||
cornersProps?: FuiCornersProps;
|
||||
/** 预设主题 */
|
||||
variant?: 'default' | 'gold' | 'blue' | 'dark';
|
||||
}
|
||||
|
||||
// 预设主题配置
|
||||
const VARIANTS = {
|
||||
default: {
|
||||
bg: 'linear-gradient(145deg, rgba(26, 26, 46, 0.95) 0%, rgba(15, 15, 26, 0.98) 100%)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
|
||||
cornerColor: 'rgba(212, 175, 55, 0.4)',
|
||||
},
|
||||
gold: {
|
||||
bg: 'linear-gradient(145deg, rgba(26, 26, 46, 0.95) 0%, rgba(15, 15, 26, 0.98) 100%)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(212, 175, 55, 0.1)',
|
||||
cornerColor: 'rgba(212, 175, 55, 0.5)',
|
||||
},
|
||||
blue: {
|
||||
bg: 'linear-gradient(145deg, rgba(20, 30, 48, 0.95) 0%, rgba(10, 15, 26, 0.98) 100%)',
|
||||
borderColor: 'rgba(100, 200, 255, 0.15)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(100, 200, 255, 0.05)',
|
||||
cornerColor: 'rgba(100, 200, 255, 0.4)',
|
||||
},
|
||||
dark: {
|
||||
bg: 'linear-gradient(145deg, rgba(18, 18, 28, 0.98) 0%, rgba(8, 8, 16, 0.99) 100%)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03)',
|
||||
cornerColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* FUI 毛玻璃容器组件
|
||||
* 带有科幻风格角落装饰的 Glassmorphism 容器
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FuiContainer variant="gold">
|
||||
* <YourContent />
|
||||
* </FuiContainer>
|
||||
* ```
|
||||
*/
|
||||
const FuiContainer = memo<FuiContainerProps>(({
|
||||
children,
|
||||
showCorners = true,
|
||||
cornersProps,
|
||||
variant = 'default',
|
||||
...boxProps
|
||||
}) => {
|
||||
const theme = VARIANTS[variant];
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
bg={theme.bg}
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor={theme.borderColor}
|
||||
overflow="hidden"
|
||||
backdropFilter="blur(16px)"
|
||||
boxShadow={theme.boxShadow}
|
||||
{...boxProps}
|
||||
>
|
||||
{showCorners && (
|
||||
<FuiCorners
|
||||
borderColor={theme.cornerColor}
|
||||
{...cornersProps}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
FuiContainer.displayName = 'FuiContainer';
|
||||
|
||||
export default FuiContainer;
|
||||
126
src/components/FUI/FuiCorners.tsx
Normal file
126
src/components/FUI/FuiCorners.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* FUI 角落装饰组件
|
||||
* Ash Thorp 风格的科幻 UI 角落装饰
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, BoxProps } from '@chakra-ui/react';
|
||||
|
||||
export interface FuiCornersProps {
|
||||
/** 装饰框大小 */
|
||||
size?: number;
|
||||
/** 边框宽度 */
|
||||
borderWidth?: number;
|
||||
/** 边框颜色 */
|
||||
borderColor?: string;
|
||||
/** 透明度 */
|
||||
opacity?: number;
|
||||
/** 距离容器边缘的距离 */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
interface CornerBoxProps {
|
||||
corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
size: number;
|
||||
borderWidth: number;
|
||||
borderColor: string;
|
||||
opacity: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
const CornerBox = memo<CornerBoxProps>(({
|
||||
corner,
|
||||
size,
|
||||
borderWidth,
|
||||
borderColor,
|
||||
opacity,
|
||||
offset,
|
||||
}) => {
|
||||
const cornerStyles: Record<string, BoxProps> = {
|
||||
'top-left': {
|
||||
top: `${offset}px`,
|
||||
left: `${offset}px`,
|
||||
borderTop: `${borderWidth}px solid`,
|
||||
borderLeft: `${borderWidth}px solid`,
|
||||
},
|
||||
'top-right': {
|
||||
top: `${offset}px`,
|
||||
right: `${offset}px`,
|
||||
borderTop: `${borderWidth}px solid`,
|
||||
borderRight: `${borderWidth}px solid`,
|
||||
},
|
||||
'bottom-left': {
|
||||
bottom: `${offset}px`,
|
||||
left: `${offset}px`,
|
||||
borderBottom: `${borderWidth}px solid`,
|
||||
borderLeft: `${borderWidth}px solid`,
|
||||
},
|
||||
'bottom-right': {
|
||||
bottom: `${offset}px`,
|
||||
right: `${offset}px`,
|
||||
borderBottom: `${borderWidth}px solid`,
|
||||
borderRight: `${borderWidth}px solid`,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
w={`${size}px`}
|
||||
h={`${size}px`}
|
||||
borderColor={borderColor}
|
||||
opacity={opacity}
|
||||
pointerEvents="none"
|
||||
{...cornerStyles[corner]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CornerBox.displayName = 'CornerBox';
|
||||
|
||||
/**
|
||||
* FUI 角落装饰组件
|
||||
* 在容器四角添加科幻风格的装饰边框
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Box position="relative">
|
||||
* <FuiCorners />
|
||||
* {children}
|
||||
* </Box>
|
||||
* ```
|
||||
*/
|
||||
const FuiCorners = memo<FuiCornersProps>(({
|
||||
size = 16,
|
||||
borderWidth = 2,
|
||||
borderColor = 'rgba(212, 175, 55, 0.4)',
|
||||
opacity = 0.6,
|
||||
offset = 12,
|
||||
}) => {
|
||||
const corners: CornerBoxProps['corner'][] = [
|
||||
'top-left',
|
||||
'top-right',
|
||||
'bottom-left',
|
||||
'bottom-right',
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{corners.map((corner) => (
|
||||
<CornerBox
|
||||
key={corner}
|
||||
corner={corner}
|
||||
size={size}
|
||||
borderWidth={borderWidth}
|
||||
borderColor={borderColor}
|
||||
opacity={opacity}
|
||||
offset={offset}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
FuiCorners.displayName = 'FuiCorners';
|
||||
|
||||
export default FuiCorners;
|
||||
20
src/components/FUI/index.ts
Normal file
20
src/components/FUI/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* FUI (Futuristic UI) 组件集合
|
||||
* Ash Thorp 风格的科幻 UI 组件
|
||||
*
|
||||
* 组件说明:
|
||||
* - FuiCorners: 科幻角落装饰
|
||||
* - FuiContainer: FUI 风格容器
|
||||
* - AmbientGlow: 页面级环境光效果(position: fixed)
|
||||
* - CardGlow: 卡片级装饰光效(相对定位,用于卡片内部)
|
||||
*/
|
||||
|
||||
export { default as FuiCorners } from './FuiCorners';
|
||||
export { default as FuiContainer } from './FuiContainer';
|
||||
export { default as AmbientGlow } from './AmbientGlow';
|
||||
export { default as CardGlow } from './CardGlow';
|
||||
|
||||
export type { FuiCornersProps } from './FuiCorners';
|
||||
export type { FuiContainerProps } from './FuiContainer';
|
||||
export type { AmbientGlowProps } from './AmbientGlow';
|
||||
export type { CardGlowProps } from './CardGlow';
|
||||
84
src/components/FavoriteButton/index.tsx
Normal file
84
src/components/FavoriteButton/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* FavoriteButton - 通用关注/收藏按钮组件(图标按钮)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { IconButton, Tooltip, Spinner } from '@chakra-ui/react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
export interface FavoriteButtonProps {
|
||||
/** 是否已关注 */
|
||||
isFavorite: boolean;
|
||||
/** 加载状态 */
|
||||
isLoading?: boolean;
|
||||
/** 点击回调 */
|
||||
onClick: () => void;
|
||||
/** 按钮大小 */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** 颜色主题 */
|
||||
colorScheme?: 'gold' | 'default';
|
||||
/** 是否显示 tooltip */
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
// 颜色配置
|
||||
const COLORS = {
|
||||
gold: {
|
||||
active: '#F4D03F', // 已关注 - 亮金色
|
||||
inactive: '#C9A961', // 未关注 - 暗金色
|
||||
hoverBg: 'whiteAlpha.100',
|
||||
},
|
||||
default: {
|
||||
active: 'yellow.400',
|
||||
inactive: 'gray.400',
|
||||
hoverBg: 'gray.100',
|
||||
},
|
||||
};
|
||||
|
||||
const FavoriteButton: React.FC<FavoriteButtonProps> = ({
|
||||
isFavorite,
|
||||
isLoading = false,
|
||||
onClick,
|
||||
size = 'sm',
|
||||
colorScheme = 'gold',
|
||||
showTooltip = true,
|
||||
}) => {
|
||||
const colors = COLORS[colorScheme];
|
||||
const currentColor = isFavorite ? colors.active : colors.inactive;
|
||||
const label = isFavorite ? '取消关注' : '加入自选';
|
||||
|
||||
const iconButton = (
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
icon={
|
||||
isLoading ? (
|
||||
<Spinner size="sm" color={currentColor} />
|
||||
) : (
|
||||
<Star
|
||||
size={size === 'sm' ? 18 : size === 'md' ? 20 : 24}
|
||||
fill={isFavorite ? currentColor : 'none'}
|
||||
stroke={currentColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
variant="ghost"
|
||||
color={currentColor}
|
||||
size={size}
|
||||
onClick={onClick}
|
||||
isDisabled={isLoading}
|
||||
_hover={{ bg: colors.hoverBg }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (showTooltip) {
|
||||
return (
|
||||
<Tooltip label={label} placement="top">
|
||||
{iconButton}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return iconButton;
|
||||
};
|
||||
|
||||
export default FavoriteButton;
|
||||
@@ -545,19 +545,13 @@ const InvestmentCalendar = () => {
|
||||
render: (concepts) => (
|
||||
<Space wrap>
|
||||
{concepts && concepts.length > 0 ? (
|
||||
concepts.slice(0, 3).map((concept, index) => {
|
||||
// 兼容多种数据格式:字符串、数组、对象
|
||||
const conceptName = typeof concept === 'string'
|
||||
? concept
|
||||
: Array.isArray(concept)
|
||||
? concept[0]
|
||||
: concept?.concept || concept?.name || '';
|
||||
return (
|
||||
<Tag key={index} icon={<TagsOutlined />}>
|
||||
{conceptName}
|
||||
</Tag>
|
||||
);
|
||||
})
|
||||
concepts.slice(0, 3).map((concept, index) => (
|
||||
<Tag key={index} icon={<TagsOutlined />}>
|
||||
{typeof concept === 'string'
|
||||
? concept
|
||||
: (concept?.concept || concept?.name || '未知')}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">无</Text>
|
||||
)}
|
||||
@@ -949,7 +943,7 @@ const InvestmentCalendar = () => {
|
||||
<Table
|
||||
dataSource={selectedStocks}
|
||||
columns={stockColumns}
|
||||
rowKey={(record) => record[0]}
|
||||
rowKey={(record) => record.code}
|
||||
size="middle"
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
@@ -1,45 +1,187 @@
|
||||
import React from "react";
|
||||
// src/components/Navbars/SearchBar/SearchBar.js
|
||||
// 全局股票搜索栏 - 模糊搜索 + 下拉选择
|
||||
|
||||
import React, { useRef, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
IconButton,
|
||||
Box,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
useColorModeValue,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Tag,
|
||||
Center,
|
||||
List,
|
||||
ListItem,
|
||||
Flex,
|
||||
} from "@chakra-ui/react";
|
||||
import { SearchIcon } from "@chakra-ui/icons";
|
||||
import { SearchIcon, CloseIcon } from "@chakra-ui/icons";
|
||||
import { useStockSearch } from "@hooks/useStockSearch";
|
||||
|
||||
export function SearchBar(props) {
|
||||
// Pass the computed styles into the `__css` prop
|
||||
const { variant, children, ...rest } = props;
|
||||
// Chakra Color Mode
|
||||
const searchIconColor = useColorModeValue("gray.700", "gray.200");
|
||||
const inputBg = useColorModeValue("white", "navy.800");
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// 颜色配置 - 固定使用深色主题
|
||||
const searchIconColor = "gray.400";
|
||||
const inputBg = "whiteAlpha.100";
|
||||
const dropdownBg = "#1a1a2e";
|
||||
const borderColor = "rgba(212, 175, 55, 0.3)";
|
||||
const hoverBg = "whiteAlpha.100";
|
||||
const textColor = "white";
|
||||
const subTextColor = "whiteAlpha.600";
|
||||
const accentColor = "#D4AF37";
|
||||
|
||||
// 使用搜索 Hook
|
||||
const {
|
||||
searchQuery,
|
||||
searchResults,
|
||||
isSearching,
|
||||
showResults,
|
||||
handleSearch,
|
||||
clearSearch,
|
||||
setShowResults,
|
||||
} = useStockSearch({ limit: 10, debounceMs: 300 });
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||||
setShowResults(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [setShowResults]);
|
||||
|
||||
// 选择股票 - 跳转到详情页
|
||||
const handleSelectStock = useCallback((stock) => {
|
||||
clearSearch();
|
||||
// 跳转到股票详情页
|
||||
navigate(`/company/${stock.stock_code}`);
|
||||
}, [navigate, clearSearch]);
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === "Enter" && searchResults.length > 0) {
|
||||
handleSelectStock(searchResults[0]);
|
||||
} else if (e.key === "Escape") {
|
||||
setShowResults(false);
|
||||
}
|
||||
}, [searchResults, handleSelectStock, setShowResults]);
|
||||
|
||||
return (
|
||||
<InputGroup borderRadius='8px' w='200px' {...rest}>
|
||||
<InputLeftElement
|
||||
children={
|
||||
<IconButton
|
||||
bg='inherit'
|
||||
borderRadius='inherit'
|
||||
_hover={{}}
|
||||
_active={{
|
||||
bg: "inherit",
|
||||
transform: "none",
|
||||
borderColor: "transparent",
|
||||
}}
|
||||
_focus={{
|
||||
boxShadow: "none",
|
||||
}}
|
||||
icon={
|
||||
<SearchIcon color={searchIconColor} w='15px' h='15px' />
|
||||
}></IconButton>
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
variant='search'
|
||||
fontSize='xs'
|
||||
bg={inputBg}
|
||||
placeholder='Type here...'
|
||||
/>
|
||||
</InputGroup>
|
||||
<Box ref={containerRef} position="relative" {...rest}>
|
||||
<InputGroup borderRadius="8px" w="220px">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<SearchIcon color={searchIconColor} w="15px" h="15px" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
variant="search"
|
||||
fontSize="sm"
|
||||
bg={inputBg}
|
||||
placeholder="搜索股票..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => searchQuery && searchResults.length > 0 && setShowResults(true)}
|
||||
borderColor={borderColor}
|
||||
_hover={{ borderColor: accentColor }}
|
||||
_focus={{ borderColor: accentColor, boxShadow: `0 0 0 1px ${accentColor}` }}
|
||||
/>
|
||||
{(searchQuery || isSearching) && (
|
||||
<InputRightElement>
|
||||
{isSearching ? (
|
||||
<Spinner size="sm" color={accentColor} />
|
||||
) : (
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon={<CloseIcon w="10px" h="10px" />}
|
||||
onClick={clearSearch}
|
||||
aria-label="清除搜索"
|
||||
_hover={{ bg: "transparent" }}
|
||||
/>
|
||||
)}
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
{/* 搜索结果下拉 */}
|
||||
{showResults && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="100%"
|
||||
left={0}
|
||||
mt={2}
|
||||
w="320px"
|
||||
bg={dropdownBg}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
maxH="400px"
|
||||
overflowY="auto"
|
||||
zIndex={9999}
|
||||
>
|
||||
{searchResults.length > 0 ? (
|
||||
<List spacing={0}>
|
||||
{searchResults.map((stock, index) => (
|
||||
<ListItem
|
||||
key={stock.stock_code}
|
||||
px={4}
|
||||
py={3}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: hoverBg }}
|
||||
onClick={() => handleSelectStock(stock)}
|
||||
borderBottomWidth={index < searchResults.length - 1 ? "1px" : "0"}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<Text fontWeight="bold" color={textColor} fontSize="sm">
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="xs" color={subTextColor}>
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
{stock.pinyin_abbr && (
|
||||
<Text fontSize="xs" color={subTextColor}>
|
||||
({stock.pinyin_abbr.toUpperCase()})
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
{stock.exchange && (
|
||||
<Tag
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
>
|
||||
{stock.exchange}
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Center p={4}>
|
||||
<Text color={subTextColor} fontSize="sm">
|
||||
{searchQuery ? "未找到相关股票" : "输入股票代码或名称搜索"}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import type { ECharts, EChartsOption } from '@lib/echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
@@ -295,7 +296,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
}
|
||||
|
||||
// 图表配置(H5 响应式)
|
||||
const option: echarts.EChartsOption = {
|
||||
const option: EChartsOption = {
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: {
|
||||
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Button, Spin, Typography } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts, type ECharts, type EChartsOption } from '@lib/echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import { klineDataCache, getCacheKey, fetchKlineData } from '@utils/stock/klineDataCache';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
@@ -186,7 +186,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
}
|
||||
|
||||
// 图表配置(H5 响应式)
|
||||
const option: echarts.EChartsOption = {
|
||||
const option: EChartsOption = {
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: {
|
||||
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
|
||||
|
||||
403
src/components/SubTabContainer/index.tsx
Normal file
403
src/components/SubTabContainer/index.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* SubTabContainer - 二级导航容器组件
|
||||
*
|
||||
* 深空 FUI 设计风格(Glassmorphism + Ash Thorp + James Turrell)
|
||||
* - 玻璃态导航栏,漂浮感
|
||||
* - 选中态发光效果,科幻数据终端感
|
||||
* - 流畅的过渡动画
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SubTabContainer
|
||||
* tabs={[
|
||||
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1 },
|
||||
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2 },
|
||||
* ]}
|
||||
* componentProps={{ stockCode: '000001' }}
|
||||
* onTabChange={(index, key) => console.log('切换到', key)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, memo, Suspense } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Icon,
|
||||
HStack,
|
||||
Text,
|
||||
Center,
|
||||
Spinner,
|
||||
} from '@chakra-ui/react';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
|
||||
/**
|
||||
* Tab 配置项
|
||||
*/
|
||||
export interface SubTabConfig {
|
||||
key: string;
|
||||
name: string;
|
||||
icon?: IconType | ComponentType;
|
||||
component?: ComponentType<any>;
|
||||
/** 自定义 Suspense fallback(如骨架屏) */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深空 FUI 主题配置
|
||||
*/
|
||||
const DEEP_SPACE = {
|
||||
// 背景
|
||||
bgGlass: 'rgba(12, 14, 28, 0.6)',
|
||||
bgGlassHover: 'rgba(18, 22, 42, 0.7)',
|
||||
|
||||
// 边框
|
||||
borderGold: 'rgba(212, 175, 55, 0.2)',
|
||||
borderGoldHover: 'rgba(212, 175, 55, 0.5)',
|
||||
borderGlass: 'rgba(255, 255, 255, 0.06)',
|
||||
|
||||
// 发光
|
||||
glowGold: '0 0 30px rgba(212, 175, 55, 0.25), 0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||
innerGlow: 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
|
||||
|
||||
// 文字
|
||||
textWhite: 'rgba(255, 255, 255, 0.95)',
|
||||
textMuted: 'rgba(255, 255, 255, 0.6)',
|
||||
textGold: '#F4D03F',
|
||||
textDark: '#0A0A14',
|
||||
|
||||
// 选中态
|
||||
selectedBg: 'linear-gradient(135deg, rgba(212, 175, 55, 0.95) 0%, rgba(184, 150, 12, 0.95) 100%)',
|
||||
|
||||
// 圆角
|
||||
radius: '12px',
|
||||
radiusLG: '16px',
|
||||
|
||||
// 动画
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题配置
|
||||
*/
|
||||
export interface SubTabTheme {
|
||||
bg: string;
|
||||
borderColor: string;
|
||||
tabSelectedBg: string;
|
||||
tabSelectedColor: string;
|
||||
tabUnselectedColor: string;
|
||||
tabHoverBg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尺寸配置
|
||||
*/
|
||||
const SIZE_CONFIG = {
|
||||
sm: { fontSize: '13px', px: 4, py: 2, gap: 1.5, iconSize: 3.5 },
|
||||
md: { fontSize: '15px', px: 6, py: 3, gap: 2, iconSize: 4 },
|
||||
} as const;
|
||||
|
||||
export type SubTabSize = keyof typeof SIZE_CONFIG;
|
||||
|
||||
/**
|
||||
* 预设主题 - 深空 FUI 风格
|
||||
*/
|
||||
const THEME_PRESETS: Record<string, SubTabTheme> = {
|
||||
blackGold: {
|
||||
bg: DEEP_SPACE.bgGlass,
|
||||
borderColor: DEEP_SPACE.borderGold,
|
||||
tabSelectedBg: DEEP_SPACE.selectedBg,
|
||||
tabSelectedColor: DEEP_SPACE.textDark,
|
||||
tabUnselectedColor: DEEP_SPACE.textWhite,
|
||||
tabHoverBg: DEEP_SPACE.bgGlassHover,
|
||||
},
|
||||
default: {
|
||||
bg: 'white',
|
||||
borderColor: 'gray.200',
|
||||
tabSelectedBg: 'blue.500',
|
||||
tabSelectedColor: 'white',
|
||||
tabUnselectedColor: 'gray.600',
|
||||
tabHoverBg: 'gray.100',
|
||||
},
|
||||
};
|
||||
|
||||
export interface SubTabContainerProps {
|
||||
/** Tab 配置数组 */
|
||||
tabs: SubTabConfig[];
|
||||
/** 传递给 Tab 内容组件的 props */
|
||||
componentProps?: Record<string, any>;
|
||||
/** 默认选中的 Tab 索引 */
|
||||
defaultIndex?: number;
|
||||
/** 受控模式下的当前索引 */
|
||||
index?: number;
|
||||
/** Tab 变更回调 */
|
||||
onTabChange?: (index: number, tabKey: string) => void;
|
||||
/** 主题预设 */
|
||||
themePreset?: 'blackGold' | 'default';
|
||||
/** 自定义主题(优先级高于预设) */
|
||||
theme?: Partial<SubTabTheme>;
|
||||
/** 内容区内边距 */
|
||||
contentPadding?: number;
|
||||
/** 是否懒加载 */
|
||||
isLazy?: boolean;
|
||||
/** TabList 右侧自定义内容 */
|
||||
rightElement?: React.ReactNode;
|
||||
/** 紧凑模式 - 移除 TabList 的外边距 */
|
||||
compact?: boolean;
|
||||
/** Tab 尺寸: sm=小号(二级导航), md=正常(一级导航) */
|
||||
size?: SubTabSize;
|
||||
}
|
||||
|
||||
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
tabs,
|
||||
componentProps = {},
|
||||
defaultIndex = 0,
|
||||
index: controlledIndex,
|
||||
onTabChange,
|
||||
themePreset = 'blackGold',
|
||||
theme: customTheme,
|
||||
contentPadding = 4,
|
||||
isLazy = true,
|
||||
rightElement,
|
||||
compact = false,
|
||||
size = 'md',
|
||||
}) => {
|
||||
// 获取尺寸配置
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
// 内部状态(非受控模式)
|
||||
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
||||
|
||||
// 当前索引
|
||||
const currentIndex = controlledIndex ?? internalIndex;
|
||||
|
||||
// 记录已访问的 Tab 索引(用于真正的懒加载)
|
||||
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
|
||||
() => new Set([controlledIndex ?? defaultIndex])
|
||||
);
|
||||
|
||||
// 记录每个 Tab 的激活次数(用于支持特定 Tab 切换时重新请求)
|
||||
const [activationCounts, setActivationCounts] = useState<Record<number, number>>(
|
||||
() => ({ [controlledIndex ?? defaultIndex]: 1 })
|
||||
);
|
||||
|
||||
// 合并主题
|
||||
const theme: SubTabTheme = {
|
||||
...THEME_PRESETS[themePreset],
|
||||
...customTheme,
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Tab 切换
|
||||
*/
|
||||
const handleTabChange = useCallback(
|
||||
(newIndex: number) => {
|
||||
// 保存当前滚动位置,防止 Tab 切换时页面跳转
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
const tabKey = tabs[newIndex]?.key || '';
|
||||
onTabChange?.(newIndex, tabKey);
|
||||
|
||||
// 记录已访问的 Tab(用于懒加载)
|
||||
setVisitedTabs(prev => {
|
||||
if (prev.has(newIndex)) return prev;
|
||||
return new Set(prev).add(newIndex);
|
||||
});
|
||||
|
||||
// 更新激活计数(用于触发特定 Tab 的数据刷新)
|
||||
setActivationCounts(prev => ({
|
||||
...prev,
|
||||
[newIndex]: (prev[newIndex] || 0) + 1,
|
||||
}));
|
||||
|
||||
if (controlledIndex === undefined) {
|
||||
setInternalIndex(newIndex);
|
||||
}
|
||||
|
||||
// 恢复滚动位置,阻止浏览器自动滚动
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, scrollY);
|
||||
});
|
||||
},
|
||||
[tabs, onTabChange, controlledIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs
|
||||
isLazy={isLazy}
|
||||
lazyBehavior="keepMounted"
|
||||
variant="unstyled"
|
||||
index={currentIndex}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
{/* 导航栏容器:左侧 Tab 可滚动,右侧元素固定 */}
|
||||
<Flex
|
||||
bg={theme.bg}
|
||||
backdropFilter="blur(20px)"
|
||||
borderBottom="1px solid"
|
||||
borderColor={theme.borderColor}
|
||||
borderRadius={compact ? 0 : DEEP_SPACE.radiusLG}
|
||||
mx={compact ? 0 : 2}
|
||||
mb={compact ? 0 : 2}
|
||||
position="relative"
|
||||
boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow}
|
||||
alignItems="center"
|
||||
>
|
||||
{/* 顶部金色光条 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
width="50%"
|
||||
height="1px"
|
||||
background={`linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)`}
|
||||
/>
|
||||
|
||||
{/* 左侧:可滚动的 Tab 区域 */}
|
||||
<Box
|
||||
flex="1"
|
||||
minW={0}
|
||||
overflowX="auto"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
<TabList
|
||||
border="none"
|
||||
px={3}
|
||||
py={compact ? 2 : sizeConfig.py}
|
||||
flexWrap="nowrap"
|
||||
gap={sizeConfig.gap}
|
||||
>
|
||||
{tabs.map((tab, idx) => {
|
||||
const isSelected = idx === currentIndex;
|
||||
|
||||
return (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
color={theme.tabUnselectedColor}
|
||||
borderRadius={DEEP_SPACE.radius}
|
||||
px={sizeConfig.px}
|
||||
py={sizeConfig.py}
|
||||
fontSize={sizeConfig.fontSize}
|
||||
fontWeight="500"
|
||||
whiteSpace="nowrap"
|
||||
flexShrink={0}
|
||||
border="1px solid transparent"
|
||||
position="relative"
|
||||
letterSpacing="0.03em"
|
||||
transition={DEEP_SPACE.transition}
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: '-1px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: isSelected ? '70%' : '0%',
|
||||
height: '2px',
|
||||
bg: '#D4AF37',
|
||||
borderRadius: 'full',
|
||||
transition: 'width 0.3s ease',
|
||||
boxShadow: isSelected ? '0 0 10px rgba(212, 175, 55, 0.5)' : 'none',
|
||||
}}
|
||||
_selected={{
|
||||
bg: theme.tabSelectedBg,
|
||||
color: theme.tabSelectedColor,
|
||||
fontWeight: '700',
|
||||
boxShadow: DEEP_SPACE.glowGold,
|
||||
border: `1px solid ${DEEP_SPACE.borderGoldHover}`,
|
||||
transform: 'translateY(-2px)',
|
||||
}}
|
||||
_hover={{
|
||||
bg: isSelected ? undefined : theme.tabHoverBg,
|
||||
border: isSelected ? undefined : `1px solid ${DEEP_SPACE.borderGold}`,
|
||||
transform: 'translateY(-1px)',
|
||||
}}
|
||||
_active={{
|
||||
transform: 'translateY(0)',
|
||||
}}
|
||||
>
|
||||
<HStack spacing={size === 'sm' ? 1.5 : 2}>
|
||||
{tab.icon && (
|
||||
<Icon
|
||||
as={tab.icon}
|
||||
boxSize={sizeConfig.iconSize}
|
||||
opacity={isSelected ? 1 : 0.7}
|
||||
transition="opacity 0.2s"
|
||||
/>
|
||||
)}
|
||||
<Text>{tab.name}</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</TabList>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:固定的自定义元素(如期数选择器) */}
|
||||
{rightElement && (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
pr={3}
|
||||
pl={2}
|
||||
py={compact ? 2 : sizeConfig.py}
|
||||
borderLeft="1px solid"
|
||||
borderColor={DEEP_SPACE.borderGold}
|
||||
>
|
||||
{rightElement}
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<TabPanels p={contentPadding}>
|
||||
{tabs.map((tab, idx) => {
|
||||
const Component = tab.component;
|
||||
// 懒加载:只渲染已访问过的 Tab
|
||||
const shouldRender = !isLazy || visitedTabs.has(idx);
|
||||
// 判断是否为当前激活的 Tab(用于控制数据加载)
|
||||
const isActive = idx === currentIndex;
|
||||
|
||||
return (
|
||||
<TabPanel key={tab.key} p={0}>
|
||||
{shouldRender && Component ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
tab.fallback || (
|
||||
<Center py={20}>
|
||||
<Spinner
|
||||
size="lg"
|
||||
color={DEEP_SPACE.textGold}
|
||||
thickness="3px"
|
||||
speed="0.8s"
|
||||
/>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Component
|
||||
{...componentProps}
|
||||
isActive={isActive}
|
||||
activationKey={activationCounts[idx] || 0}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</TabPanel>
|
||||
);
|
||||
})}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SubTabContainer.displayName = 'SubTabContainer';
|
||||
|
||||
export default SubTabContainer;
|
||||
@@ -1632,14 +1632,17 @@ export default function SubscriptionContentNew() {
|
||||
<Text fontSize="sm" color="rgba(255, 255, 255, 0.7)">
|
||||
我已阅读并同意
|
||||
<ChakraLink
|
||||
href={AGREEMENT_URLS[(selectedPlan as any)?.name?.toLowerCase()] || AGREEMENT_URLS.pro}
|
||||
href={(() => {
|
||||
const planName = (selectedPlan as { name?: string } | null)?.name?.toLowerCase();
|
||||
return planName === 'pro' || planName === 'max' ? AGREEMENT_URLS[planName] : AGREEMENT_URLS.pro;
|
||||
})()}
|
||||
isExternal
|
||||
color="#3182CE"
|
||||
textDecoration="underline"
|
||||
mx={1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
《{(selectedPlan as any)?.name?.toLowerCase() === 'max' ? 'MAX' : 'PRO'}会员服务协议》
|
||||
《{(selectedPlan as { name?: string } | null)?.name?.toLowerCase() === 'max' ? 'MAX' : 'PRO'}会员服务协议》
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</Checkbox>
|
||||
|
||||
56
src/components/TabContainer/TabNavigation.tsx
Normal file
56
src/components/TabContainer/TabNavigation.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* TabNavigation 通用导航组件
|
||||
*
|
||||
* 渲染 Tab 按钮列表,支持图标 + 文字
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TabList, Tab, HStack, Icon, Text } from '@chakra-ui/react';
|
||||
import type { TabNavigationProps } from './types';
|
||||
|
||||
const TabNavigation: React.FC<TabNavigationProps> = ({
|
||||
tabs,
|
||||
themeColors,
|
||||
borderRadius = 'lg',
|
||||
}) => {
|
||||
return (
|
||||
<TabList
|
||||
bg={themeColors.bg}
|
||||
borderBottom="1px solid"
|
||||
borderColor={themeColors.dividerColor}
|
||||
borderTopLeftRadius={borderRadius}
|
||||
borderTopRightRadius={borderRadius}
|
||||
pl={0}
|
||||
pr={4}
|
||||
py={2}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
color={themeColors.unselectedText}
|
||||
borderRadius="full"
|
||||
px={4}
|
||||
py={2}
|
||||
fontSize="sm"
|
||||
_selected={{
|
||||
bg: themeColors.selectedBg,
|
||||
color: themeColors.selectedText,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.100',
|
||||
}}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
{tab.icon && <Icon as={tab.icon} boxSize={4} />}
|
||||
<Text>{tab.name}</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabNavigation;
|
||||
55
src/components/TabContainer/constants.ts
Normal file
55
src/components/TabContainer/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* TabContainer 常量和主题预设
|
||||
*/
|
||||
|
||||
import type { ThemeColors, ThemePreset } from './types';
|
||||
|
||||
/**
|
||||
* 主题预设配置
|
||||
*/
|
||||
export const THEME_PRESETS: Record<ThemePreset, Required<ThemeColors>> = {
|
||||
// 黑金主题(原 Company 模块风格)
|
||||
blackGold: {
|
||||
bg: '#1A202C',
|
||||
selectedBg: '#C9A961',
|
||||
selectedText: '#FFFFFF',
|
||||
unselectedText: '#D4AF37',
|
||||
dividerColor: 'gray.600',
|
||||
},
|
||||
// 默认主题(Chakra 风格)
|
||||
default: {
|
||||
bg: 'white',
|
||||
selectedBg: 'blue.500',
|
||||
selectedText: 'white',
|
||||
unselectedText: 'gray.600',
|
||||
dividerColor: 'gray.200',
|
||||
},
|
||||
// 深色主题
|
||||
dark: {
|
||||
bg: 'gray.800',
|
||||
selectedBg: 'blue.400',
|
||||
selectedText: 'white',
|
||||
unselectedText: 'gray.300',
|
||||
dividerColor: 'gray.600',
|
||||
},
|
||||
// 浅色主题
|
||||
light: {
|
||||
bg: 'gray.50',
|
||||
selectedBg: 'blue.500',
|
||||
selectedText: 'white',
|
||||
unselectedText: 'gray.700',
|
||||
dividerColor: 'gray.300',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
export const DEFAULT_CONFIG = {
|
||||
themePreset: 'blackGold' as ThemePreset,
|
||||
isLazy: true,
|
||||
size: 'lg' as const,
|
||||
borderRadius: 'lg',
|
||||
shadow: 'lg',
|
||||
panelPadding: 0,
|
||||
};
|
||||
134
src/components/TabContainer/index.tsx
Normal file
134
src/components/TabContainer/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* TabContainer 通用 Tab 容器组件
|
||||
*
|
||||
* 功能:
|
||||
* - 管理 Tab 切换状态(支持受控/非受控模式)
|
||||
* - 动态渲染 Tab 导航和内容
|
||||
* - 支持多种主题预设(黑金、默认、深色、浅色)
|
||||
* - 支持自定义主题颜色
|
||||
* - 支持懒加载
|
||||
*
|
||||
* @example
|
||||
* // 基础用法(传入 components)
|
||||
* <TabContainer
|
||||
* tabs={[
|
||||
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1Content },
|
||||
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2Content },
|
||||
* ]}
|
||||
* componentProps={{ userId: '123' }}
|
||||
* onTabChange={(index, key) => console.log('切换到', key)}
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // 自定义渲染用法(使用 children)
|
||||
* <TabContainer tabs={tabs} themePreset="dark">
|
||||
* <TabPanel>自定义内容 1</TabPanel>
|
||||
* <TabPanel>自定义内容 2</TabPanel>
|
||||
* </TabContainer>
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
Tabs,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import TabNavigation from './TabNavigation';
|
||||
import { THEME_PRESETS, DEFAULT_CONFIG } from './constants';
|
||||
import type { TabContainerProps, ThemeColors } from './types';
|
||||
|
||||
// 导出类型和常量
|
||||
export type { TabConfig, ThemeColors, ThemePreset, TabContainerProps } from './types';
|
||||
export { THEME_PRESETS } from './constants';
|
||||
|
||||
const TabContainer: React.FC<TabContainerProps> = ({
|
||||
tabs,
|
||||
componentProps = {},
|
||||
onTabChange,
|
||||
defaultIndex = 0,
|
||||
index: controlledIndex,
|
||||
themePreset = DEFAULT_CONFIG.themePreset,
|
||||
themeColors: customThemeColors,
|
||||
isLazy = DEFAULT_CONFIG.isLazy,
|
||||
size = DEFAULT_CONFIG.size,
|
||||
borderRadius = DEFAULT_CONFIG.borderRadius,
|
||||
shadow = DEFAULT_CONFIG.shadow,
|
||||
panelPadding = DEFAULT_CONFIG.panelPadding,
|
||||
children,
|
||||
}) => {
|
||||
// 内部状态(非受控模式)
|
||||
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
||||
|
||||
// 当前索引(支持受控/非受控)
|
||||
const currentIndex = controlledIndex ?? internalIndex;
|
||||
|
||||
// 合并主题颜色(自定义颜色优先)
|
||||
const themeColors: Required<ThemeColors> = useMemo(() => ({
|
||||
...THEME_PRESETS[themePreset],
|
||||
...customThemeColors,
|
||||
}), [themePreset, customThemeColors]);
|
||||
|
||||
/**
|
||||
* 处理 Tab 切换
|
||||
*/
|
||||
const handleTabChange = useCallback((newIndex: number) => {
|
||||
const tabKey = tabs[newIndex]?.key || '';
|
||||
|
||||
// 触发回调
|
||||
onTabChange?.(newIndex, tabKey, currentIndex);
|
||||
|
||||
// 非受控模式下更新内部状态
|
||||
if (controlledIndex === undefined) {
|
||||
setInternalIndex(newIndex);
|
||||
}
|
||||
}, [tabs, onTabChange, currentIndex, controlledIndex]);
|
||||
|
||||
/**
|
||||
* 渲染 Tab 内容
|
||||
*/
|
||||
const renderTabPanels = () => {
|
||||
// 如果传入了 children,直接渲染 children
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// 否则根据 tabs 配置渲染
|
||||
return tabs.map((tab) => {
|
||||
const Component = tab.component;
|
||||
return (
|
||||
<TabPanel key={tab.key} px={panelPadding} py={panelPadding}>
|
||||
{Component ? <Component {...componentProps} /> : null}
|
||||
</TabPanel>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card shadow={shadow} bg={themeColors.bg} borderRadius={borderRadius}>
|
||||
<CardBody p={0}>
|
||||
<Tabs
|
||||
isLazy={isLazy}
|
||||
variant="unstyled"
|
||||
size={size}
|
||||
index={currentIndex}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
{/* Tab 导航 */}
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
themeColors={themeColors}
|
||||
borderRadius={borderRadius}
|
||||
/>
|
||||
|
||||
{/* Tab 内容面板 */}
|
||||
<TabPanels>{renderTabPanels()}</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabContainer;
|
||||
85
src/components/TabContainer/types.ts
Normal file
85
src/components/TabContainer/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* TabContainer 通用 Tab 容器组件类型定义
|
||||
*/
|
||||
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
|
||||
/**
|
||||
* Tab 配置项
|
||||
*/
|
||||
export interface TabConfig {
|
||||
/** Tab 唯一标识 */
|
||||
key: string;
|
||||
/** Tab 显示名称 */
|
||||
name: string;
|
||||
/** Tab 图标(可选) */
|
||||
icon?: IconType | ComponentType;
|
||||
/** Tab 内容组件(可选,如果不传则使用 children 渲染) */
|
||||
component?: ComponentType<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题颜色配置
|
||||
*/
|
||||
export interface ThemeColors {
|
||||
/** 容器背景色 */
|
||||
bg?: string;
|
||||
/** 选中 Tab 背景色 */
|
||||
selectedBg?: string;
|
||||
/** 选中 Tab 文字颜色 */
|
||||
selectedText?: string;
|
||||
/** 未选中 Tab 文字颜色 */
|
||||
unselectedText?: string;
|
||||
/** 分割线颜色 */
|
||||
dividerColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设主题类型
|
||||
*/
|
||||
export type ThemePreset = 'blackGold' | 'default' | 'dark' | 'light';
|
||||
|
||||
/**
|
||||
* TabContainer 组件 Props
|
||||
*/
|
||||
export interface TabContainerProps {
|
||||
/** Tab 配置数组 */
|
||||
tabs: TabConfig[];
|
||||
/** 传递给 Tab 内容组件的通用 props */
|
||||
componentProps?: Record<string, any>;
|
||||
/** Tab 变更回调 */
|
||||
onTabChange?: (index: number, tabKey: string, prevIndex: number) => void;
|
||||
/** 默认选中的 Tab 索引 */
|
||||
defaultIndex?: number;
|
||||
/** 受控模式下的当前索引 */
|
||||
index?: number;
|
||||
/** 主题预设 */
|
||||
themePreset?: ThemePreset;
|
||||
/** 自定义主题颜色(优先级高于预设) */
|
||||
themeColors?: ThemeColors;
|
||||
/** 是否启用懒加载 */
|
||||
isLazy?: boolean;
|
||||
/** Tab 尺寸 */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** 容器圆角 */
|
||||
borderRadius?: string;
|
||||
/** 容器阴影 */
|
||||
shadow?: string;
|
||||
/** 自定义 Tab 面板内边距 */
|
||||
panelPadding?: number | string;
|
||||
/** 子元素(用于自定义渲染 Tab 内容) */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* TabNavigation 组件 Props
|
||||
*/
|
||||
export interface TabNavigationProps {
|
||||
/** Tab 配置数组 */
|
||||
tabs: TabConfig[];
|
||||
/** 主题颜色 */
|
||||
themeColors: Required<ThemeColors>;
|
||||
/** 容器圆角 */
|
||||
borderRadius?: string;
|
||||
}
|
||||
107
src/components/TabPanelContainer/index.tsx
Normal file
107
src/components/TabPanelContainer/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* TabPanelContainer - Tab 面板通用容器组件
|
||||
*
|
||||
* 提供统一的:
|
||||
* - Loading 状态处理
|
||||
* - VStack 布局
|
||||
* - 免责声明(可选)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <TabPanelContainer loading={loading} showDisclaimer>
|
||||
* <YourContent />
|
||||
* </TabPanelContainer>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { VStack, Center, Spinner, Text, Box } from '@chakra-ui/react';
|
||||
|
||||
// 默认免责声明文案
|
||||
const DEFAULT_DISCLAIMER =
|
||||
'免责声明:本内容由AI模型基于新闻、公告、研报等公开信息自动分析和生成,未经许可严禁转载。所有内容仅供参考,不构成任何投资建议,请投资者注意风险,独立审慎决策。';
|
||||
|
||||
export interface TabPanelContainerProps {
|
||||
/** 是否处于加载状态 */
|
||||
loading?: boolean;
|
||||
/** 加载状态显示的文案 */
|
||||
loadingMessage?: string;
|
||||
/** 加载状态高度 */
|
||||
loadingHeight?: string;
|
||||
/** 自定义骨架屏组件,优先于默认 Spinner */
|
||||
skeleton?: React.ReactNode;
|
||||
/** 子组件间距,默认 6 */
|
||||
spacing?: number;
|
||||
/** 内边距,默认 4 */
|
||||
padding?: number;
|
||||
/** 是否显示免责声明,默认 false */
|
||||
showDisclaimer?: boolean;
|
||||
/** 自定义免责声明文案 */
|
||||
disclaimerText?: string;
|
||||
/** 子组件 */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载状态组件
|
||||
*/
|
||||
const LoadingState: React.FC<{ message: string; height: string }> = ({
|
||||
message,
|
||||
height,
|
||||
}) => (
|
||||
<Center h={height}>
|
||||
<VStack spacing={3}>
|
||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{message}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
|
||||
/**
|
||||
* 免责声明组件
|
||||
*/
|
||||
const DisclaimerText: React.FC<{ text: string }> = ({ text }) => (
|
||||
<Text mt={4} color="gray.500" fontSize="12px" lineHeight="1.5">
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
|
||||
/**
|
||||
* Tab 面板通用容器
|
||||
*/
|
||||
const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
|
||||
({
|
||||
loading = false,
|
||||
loadingMessage = '加载中...',
|
||||
loadingHeight = '200px',
|
||||
skeleton,
|
||||
spacing = 6,
|
||||
padding = 4,
|
||||
showDisclaimer = false,
|
||||
disclaimerText = DEFAULT_DISCLAIMER,
|
||||
children,
|
||||
}) => {
|
||||
if (loading) {
|
||||
// 如果提供了自定义骨架屏,使用骨架屏;否则使用默认 Spinner
|
||||
if (skeleton) {
|
||||
return <>{skeleton}</>;
|
||||
}
|
||||
return <LoadingState message={loadingMessage} height={loadingHeight} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={padding}>
|
||||
<VStack spacing={spacing} align="stretch">
|
||||
{children}
|
||||
</VStack>
|
||||
{showDisclaimer && <DisclaimerText text={disclaimerText} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TabPanelContainer.displayName = 'TabPanelContainer';
|
||||
|
||||
export default TabPanelContainer;
|
||||
@@ -661,6 +661,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
|
||||
useEffect(() => {
|
||||
// ⚡ Mock 模式下跳过 Socket 连接(避免连接生产服务器失败的错误)
|
||||
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
logger.debug('NotificationContext', 'Mock 模式,跳过 Socket 连接');
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚡ 防止 React Strict Mode 导致的重复初始化
|
||||
if (socketInitialized) {
|
||||
logger.debug('NotificationContext', 'Socket 已初始化,跳过重复执行(Strict Mode 保护)');
|
||||
|
||||
80
src/hooks/useDocumentTitle.ts
Normal file
80
src/hooks/useDocumentTitle.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 动态设置网页标题的 Hook
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UseDocumentTitleOptions {
|
||||
/** 基础标题(默认:价值前沿) */
|
||||
baseTitle?: string;
|
||||
/** 是否在组件卸载时恢复基础标题 */
|
||||
restoreOnUnmount?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态设置网页标题
|
||||
*
|
||||
* @param title - 要显示的标题(会与 baseTitle 组合)
|
||||
* @param options - 配置选项
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 基础用法
|
||||
* useDocumentTitle('我的页面');
|
||||
* // 结果: "我的页面 - 价值前沿"
|
||||
*
|
||||
* // 股票页面
|
||||
* useDocumentTitle(stockName ? `${stockName}(${stockCode})` : stockCode);
|
||||
* // 结果: "平安银行(000001) - 价值前沿"
|
||||
*
|
||||
* // 自定义基础标题
|
||||
* useDocumentTitle('Dashboard', { baseTitle: 'My App' });
|
||||
* // 结果: "Dashboard - My App"
|
||||
* ```
|
||||
*/
|
||||
export const useDocumentTitle = (
|
||||
title?: string | null,
|
||||
options: UseDocumentTitleOptions = {}
|
||||
): void => {
|
||||
const { baseTitle = '价值前沿', restoreOnUnmount = true } = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (title) {
|
||||
document.title = `${title} - ${baseTitle}`;
|
||||
} else {
|
||||
document.title = baseTitle;
|
||||
}
|
||||
|
||||
// 组件卸载时恢复默认标题
|
||||
if (restoreOnUnmount) {
|
||||
return () => {
|
||||
document.title = baseTitle;
|
||||
};
|
||||
}
|
||||
}, [title, baseTitle, restoreOnUnmount]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 股票页面专用的标题 Hook
|
||||
*
|
||||
* @param stockCode - 股票代码
|
||||
* @param stockName - 股票名称(可选)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useStockDocumentTitle('000001', '平安银行');
|
||||
* // 结果: "平安银行(000001) - 价值前沿"
|
||||
* ```
|
||||
*/
|
||||
export const useStockDocumentTitle = (
|
||||
stockCode: string,
|
||||
stockName?: string | null
|
||||
): void => {
|
||||
const title = stockName
|
||||
? `${stockName}(${stockCode})`
|
||||
: stockCode || null;
|
||||
|
||||
useDocumentTitle(title);
|
||||
};
|
||||
|
||||
export default useDocumentTitle;
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
/**
|
||||
* 股票搜索 Hook
|
||||
@@ -37,7 +38,7 @@ export const useStockSearch = (options = {}) => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/stocks/search?q=${encodeURIComponent(query.trim())}&limit=${limit}`
|
||||
`${getApiBase()}/api/stocks/search?q=${encodeURIComponent(query.trim())}&limit=${limit}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
11
src/index.js
11
src/index.js
@@ -5,6 +5,17 @@ import { BrowserRouter as Router } from 'react-router-dom';
|
||||
|
||||
// ⚡ 性能监控:在应用启动时尽早标记
|
||||
import { performanceMonitor } from './utils/performanceMonitor';
|
||||
|
||||
// T0: HTML 加载完成时间点
|
||||
if (document.readyState === 'complete') {
|
||||
performanceMonitor.mark('html-loaded');
|
||||
} else {
|
||||
window.addEventListener('load', () => {
|
||||
performanceMonitor.mark('html-loaded');
|
||||
});
|
||||
}
|
||||
|
||||
// T1: React 开始初始化
|
||||
performanceMonitor.mark('app-start');
|
||||
|
||||
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
||||
|
||||
124
src/lib/echarts.ts
Normal file
124
src/lib/echarts.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* ECharts 按需导入配置
|
||||
*
|
||||
* 使用方式:
|
||||
* import { echarts } from '@lib/echarts';
|
||||
*
|
||||
* 优势:
|
||||
* - 减小打包体积(从 ~800KB 降至 ~200-300KB)
|
||||
* - Tree-shaking 支持
|
||||
* - 统一管理图表类型和组件
|
||||
*/
|
||||
|
||||
// 核心模块
|
||||
import * as echarts from 'echarts/core';
|
||||
|
||||
// 图表类型 - 按需导入
|
||||
import {
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
CandlestickChart,
|
||||
ScatterChart,
|
||||
} from 'echarts/charts';
|
||||
|
||||
// 组件 - 按需导入
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
DataZoomComponent,
|
||||
ToolboxComponent,
|
||||
MarkLineComponent,
|
||||
MarkPointComponent,
|
||||
MarkAreaComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
} from 'echarts/components';
|
||||
|
||||
// 渲染器
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
// 类型导出
|
||||
import type {
|
||||
ECharts,
|
||||
EChartsCoreOption,
|
||||
SetOptionOpts,
|
||||
ComposeOption,
|
||||
} from 'echarts/core';
|
||||
|
||||
import type {
|
||||
LineSeriesOption,
|
||||
BarSeriesOption,
|
||||
PieSeriesOption,
|
||||
CandlestickSeriesOption,
|
||||
ScatterSeriesOption,
|
||||
} from 'echarts/charts';
|
||||
|
||||
import type {
|
||||
TitleComponentOption,
|
||||
TooltipComponentOption,
|
||||
LegendComponentOption,
|
||||
GridComponentOption,
|
||||
DataZoomComponentOption,
|
||||
ToolboxComponentOption,
|
||||
MarkLineComponentOption,
|
||||
MarkPointComponentOption,
|
||||
MarkAreaComponentOption,
|
||||
DatasetComponentOption,
|
||||
} from 'echarts/components';
|
||||
|
||||
// 注册必需的组件
|
||||
echarts.use([
|
||||
// 图表类型
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
CandlestickChart,
|
||||
ScatterChart,
|
||||
// 组件
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
DataZoomComponent,
|
||||
ToolboxComponent,
|
||||
MarkLineComponent,
|
||||
MarkPointComponent,
|
||||
MarkAreaComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
// 渲染器
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
// 组合类型定义(用于 TypeScript 类型推断)
|
||||
export type ECOption = ComposeOption<
|
||||
| LineSeriesOption
|
||||
| BarSeriesOption
|
||||
| PieSeriesOption
|
||||
| CandlestickSeriesOption
|
||||
| ScatterSeriesOption
|
||||
| TitleComponentOption
|
||||
| TooltipComponentOption
|
||||
| LegendComponentOption
|
||||
| GridComponentOption
|
||||
| DataZoomComponentOption
|
||||
| ToolboxComponentOption
|
||||
| MarkLineComponentOption
|
||||
| MarkPointComponentOption
|
||||
| MarkAreaComponentOption
|
||||
| DatasetComponentOption
|
||||
>;
|
||||
|
||||
// 导出
|
||||
export { echarts };
|
||||
|
||||
// EChartsOption 类型别名(兼容旧代码)
|
||||
export type EChartsOption = EChartsCoreOption;
|
||||
|
||||
export type { ECharts, SetOptionOpts };
|
||||
|
||||
// 默认导出(兼容 import * as echarts from 'echarts' 的用法)
|
||||
export default echarts;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -874,8 +874,20 @@ export function generateMockEvents(params = {}) {
|
||||
e.title.toLowerCase().includes(query) ||
|
||||
e.description.toLowerCase().includes(query) ||
|
||||
// keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
|
||||
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
|
||||
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query)) ||
|
||||
// 搜索 related_stocks 中的股票名称和代码
|
||||
(e.related_stocks && e.related_stocks.some(stock =>
|
||||
(stock.stock_name && stock.stock_name.toLowerCase().includes(query)) ||
|
||||
(stock.stock_code && stock.stock_code.toLowerCase().includes(query))
|
||||
)) ||
|
||||
// 搜索行业
|
||||
(e.industry && e.industry.toLowerCase().includes(query))
|
||||
);
|
||||
|
||||
// 如果搜索结果为空,返回所有事件(宽松模式)
|
||||
if (filteredEvents.length === 0) {
|
||||
filteredEvents = allEvents;
|
||||
}
|
||||
}
|
||||
|
||||
// 行业筛选
|
||||
@@ -1042,7 +1054,7 @@ function generateTransmissionChain(industry, index) {
|
||||
|
||||
let nodeName;
|
||||
if (nodeType === 'company' && industryStock) {
|
||||
nodeName = industryStock.name;
|
||||
nodeName = industryStock.stock_name;
|
||||
} else if (nodeType === 'industry') {
|
||||
nodeName = `${industry}产业`;
|
||||
} else if (nodeType === 'policy') {
|
||||
@@ -1133,7 +1145,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
const stock = industryStocks[j % industryStocks.length];
|
||||
relatedStocks.push({
|
||||
stock_code: stock.stock_code,
|
||||
stock_name: stock.name,
|
||||
stock_name: stock.stock_name,
|
||||
relation_desc: relationDescriptions[j % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
@@ -1145,7 +1157,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
|
||||
relatedStocks.push({
|
||||
stock_code: randomStock.stock_code,
|
||||
stock_name: randomStock.name,
|
||||
stock_name: randomStock.stock_name,
|
||||
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,80 +3,344 @@
|
||||
|
||||
// 生成财务数据
|
||||
export const generateFinancialData = (stockCode) => {
|
||||
const periods = ['2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31'];
|
||||
// 12 期数据 - 用于财务指标表格(7个指标Tab)
|
||||
const metricsPeriods = [
|
||||
'2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31',
|
||||
'2023-09-30', '2023-06-30', '2023-03-31', '2022-12-31',
|
||||
'2022-09-30', '2022-06-30', '2022-03-31', '2021-12-31',
|
||||
];
|
||||
|
||||
// 8 期数据 - 用于财务报表(3个报表Tab)
|
||||
const statementPeriods = [
|
||||
'2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31',
|
||||
'2023-09-30', '2023-06-30', '2023-03-31', '2022-12-31',
|
||||
];
|
||||
|
||||
// 兼容旧代码
|
||||
const periods = statementPeriods.slice(0, 4);
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
|
||||
// 股票基本信息
|
||||
stockInfo: {
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
stock_code: stockCode,
|
||||
stock_name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
industry: stockCode === '000001' ? '银行' : '制造业',
|
||||
list_date: '1991-04-03',
|
||||
market: 'SZ'
|
||||
market: 'SZ',
|
||||
// 关键指标
|
||||
key_metrics: {
|
||||
eps: 2.72,
|
||||
roe: 16.23,
|
||||
gross_margin: 71.92,
|
||||
net_margin: 32.56,
|
||||
roa: 1.05
|
||||
},
|
||||
// 增长率
|
||||
growth_rates: {
|
||||
revenue_growth: 8.2,
|
||||
profit_growth: 12.5,
|
||||
asset_growth: 5.6,
|
||||
equity_growth: 6.8
|
||||
},
|
||||
// 财务概要
|
||||
financial_summary: {
|
||||
revenue: 162350,
|
||||
net_profit: 52860,
|
||||
total_assets: 5024560,
|
||||
total_liabilities: 4698880
|
||||
},
|
||||
// 最新业绩预告
|
||||
latest_forecast: {
|
||||
forecast_type: '预增',
|
||||
content: '预计全年净利润同比增长10%-17%'
|
||||
}
|
||||
},
|
||||
|
||||
// 资产负债表
|
||||
balanceSheet: periods.map((period, i) => ({
|
||||
// 资产负债表 - 嵌套结构(8期数据)
|
||||
balanceSheet: statementPeriods.map((period, i) => ({
|
||||
period,
|
||||
total_assets: 5024560 - i * 50000, // 百万元
|
||||
total_liabilities: 4698880 - i * 48000,
|
||||
shareholders_equity: 325680 - i * 2000,
|
||||
current_assets: 2512300 - i * 25000,
|
||||
non_current_assets: 2512260 - i * 25000,
|
||||
current_liabilities: 3456780 - i * 35000,
|
||||
non_current_liabilities: 1242100 - i * 13000
|
||||
assets: {
|
||||
current_assets: {
|
||||
cash: 856780 - i * 10000,
|
||||
trading_financial_assets: 234560 - i * 5000,
|
||||
notes_receivable: 12340 - i * 200,
|
||||
accounts_receivable: 45670 - i * 1000,
|
||||
prepayments: 8900 - i * 100,
|
||||
other_receivables: 23450 - i * 500,
|
||||
inventory: 156780 - i * 3000,
|
||||
contract_assets: 34560 - i * 800,
|
||||
other_current_assets: 67890 - i * 1500,
|
||||
total: 2512300 - i * 25000
|
||||
},
|
||||
non_current_assets: {
|
||||
long_term_equity_investments: 234560 - i * 5000,
|
||||
investment_property: 45670 - i * 1000,
|
||||
fixed_assets: 678900 - i * 15000,
|
||||
construction_in_progress: 123450 - i * 3000,
|
||||
right_of_use_assets: 34560 - i * 800,
|
||||
intangible_assets: 89012 - i * 2000,
|
||||
goodwill: 45670 - i * 1000,
|
||||
deferred_tax_assets: 12340 - i * 300,
|
||||
other_non_current_assets: 67890 - i * 1500,
|
||||
total: 2512260 - i * 25000
|
||||
},
|
||||
total: 5024560 - i * 50000
|
||||
},
|
||||
liabilities: {
|
||||
current_liabilities: {
|
||||
short_term_borrowings: 456780 - i * 10000,
|
||||
notes_payable: 23450 - i * 500,
|
||||
accounts_payable: 234560 - i * 5000,
|
||||
advance_receipts: 12340 - i * 300,
|
||||
contract_liabilities: 34560 - i * 800,
|
||||
employee_compensation_payable: 45670 - i * 1000,
|
||||
taxes_payable: 23450 - i * 500,
|
||||
other_payables: 78900 - i * 1500,
|
||||
non_current_liabilities_due_within_one_year: 89012 - i * 2000,
|
||||
total: 3456780 - i * 35000
|
||||
},
|
||||
non_current_liabilities: {
|
||||
long_term_borrowings: 678900 - i * 15000,
|
||||
bonds_payable: 234560 - i * 5000,
|
||||
lease_liabilities: 45670 - i * 1000,
|
||||
deferred_tax_liabilities: 12340 - i * 300,
|
||||
other_non_current_liabilities: 89012 - i * 2000,
|
||||
total: 1242100 - i * 13000
|
||||
},
|
||||
total: 4698880 - i * 48000
|
||||
},
|
||||
equity: {
|
||||
share_capital: 19405,
|
||||
capital_reserve: 89012 - i * 2000,
|
||||
surplus_reserve: 45670 - i * 1000,
|
||||
undistributed_profit: 156780 - i * 3000,
|
||||
treasury_stock: 0,
|
||||
other_comprehensive_income: 12340 - i * 300,
|
||||
parent_company_equity: 315680 - i * 1800,
|
||||
minority_interests: 10000 - i * 200,
|
||||
total: 325680 - i * 2000
|
||||
}
|
||||
})),
|
||||
|
||||
// 利润表
|
||||
incomeStatement: periods.map((period, i) => ({
|
||||
// 利润表 - 嵌套结构(8期数据)
|
||||
incomeStatement: statementPeriods.map((period, i) => ({
|
||||
period,
|
||||
revenue: 162350 - i * 4000, // 百万元
|
||||
operating_cost: 45620 - i * 1200,
|
||||
gross_profit: 116730 - i * 2800,
|
||||
operating_profit: 68450 - i * 1500,
|
||||
net_profit: 52860 - i * 1200,
|
||||
eps: 2.72 - i * 0.06
|
||||
revenue: {
|
||||
total_operating_revenue: 162350 - i * 4000,
|
||||
operating_revenue: 158900 - i * 3900,
|
||||
other_income: 3450 - i * 100
|
||||
},
|
||||
costs: {
|
||||
total_operating_cost: 93900 - i * 2500,
|
||||
operating_cost: 45620 - i * 1200,
|
||||
taxes_and_surcharges: 4560 - i * 100,
|
||||
selling_expenses: 12340 - i * 300,
|
||||
admin_expenses: 15670 - i * 400,
|
||||
rd_expenses: 8900 - i * 200,
|
||||
financial_expenses: 6810 - i * 300,
|
||||
interest_expense: 8900 - i * 200,
|
||||
interest_income: 2090 - i * 50,
|
||||
three_expenses_total: 34820 - i * 1000,
|
||||
four_expenses_total: 43720 - i * 1200,
|
||||
asset_impairment_loss: 1200 - i * 50,
|
||||
credit_impairment_loss: 2340 - i * 100
|
||||
},
|
||||
other_gains: {
|
||||
fair_value_change: 1230 - i * 50,
|
||||
investment_income: 3450 - i * 100,
|
||||
investment_income_from_associates: 890 - i * 20,
|
||||
exchange_income: 560 - i * 10,
|
||||
asset_disposal_income: 340 - i * 10
|
||||
},
|
||||
profit: {
|
||||
operating_profit: 68450 - i * 1500,
|
||||
total_profit: 69500 - i * 1500,
|
||||
income_tax_expense: 16640 - i * 300,
|
||||
net_profit: 52860 - i * 1200,
|
||||
parent_net_profit: 51200 - i * 1150,
|
||||
minority_profit: 1660 - i * 50,
|
||||
continuing_operations_net_profit: 52860 - i * 1200,
|
||||
discontinued_operations_net_profit: 0
|
||||
},
|
||||
non_operating: {
|
||||
non_operating_income: 1050 - i * 20,
|
||||
non_operating_expenses: 450 - i * 10
|
||||
},
|
||||
per_share: {
|
||||
basic_eps: 2.72 - i * 0.06,
|
||||
diluted_eps: 2.70 - i * 0.06
|
||||
},
|
||||
comprehensive_income: {
|
||||
other_comprehensive_income: 890 - i * 20,
|
||||
total_comprehensive_income: 53750 - i * 1220,
|
||||
parent_comprehensive_income: 52050 - i * 1170,
|
||||
minority_comprehensive_income: 1700 - i * 50
|
||||
}
|
||||
})),
|
||||
|
||||
// 现金流量表
|
||||
cashflow: periods.map((period, i) => ({
|
||||
// 现金流量表 - 嵌套结构(8期数据)
|
||||
cashflow: statementPeriods.map((period, i) => ({
|
||||
period,
|
||||
operating_cashflow: 125600 - i * 3000, // 百万元
|
||||
investing_cashflow: -45300 - i * 1000,
|
||||
financing_cashflow: -38200 + i * 500,
|
||||
net_cashflow: 42100 - i * 1500,
|
||||
cash_ending: 456780 - i * 10000
|
||||
operating_activities: {
|
||||
inflow: {
|
||||
cash_from_sales: 178500 - i * 4500
|
||||
},
|
||||
outflow: {
|
||||
cash_for_goods: 52900 - i * 1500
|
||||
},
|
||||
net_flow: 125600 - i * 3000
|
||||
},
|
||||
investment_activities: {
|
||||
net_flow: -45300 - i * 1000
|
||||
},
|
||||
financing_activities: {
|
||||
net_flow: -38200 + i * 500
|
||||
},
|
||||
cash_changes: {
|
||||
net_increase: 42100 - i * 1500,
|
||||
ending_balance: 456780 - i * 10000
|
||||
},
|
||||
key_metrics: {
|
||||
free_cash_flow: 80300 - i * 2000
|
||||
}
|
||||
})),
|
||||
|
||||
// 财务指标
|
||||
financialMetrics: periods.map((period, i) => ({
|
||||
// 财务指标 - 嵌套结构(12期数据)
|
||||
financialMetrics: metricsPeriods.map((period, i) => ({
|
||||
period,
|
||||
roe: 16.23 - i * 0.3, // %
|
||||
roa: 1.05 - i * 0.02,
|
||||
gross_margin: 71.92 - i * 0.5,
|
||||
net_margin: 32.56 - i * 0.3,
|
||||
current_ratio: 0.73 + i * 0.01,
|
||||
quick_ratio: 0.71 + i * 0.01,
|
||||
debt_ratio: 93.52 + i * 0.05,
|
||||
asset_turnover: 0.41 - i * 0.01,
|
||||
inventory_turnover: 0, // 银行无库存
|
||||
receivable_turnover: 0 // 银行特殊
|
||||
profitability: {
|
||||
roe: 16.23 - i * 0.3,
|
||||
roe_deducted: 15.89 - i * 0.3,
|
||||
roe_weighted: 16.45 - i * 0.3,
|
||||
roa: 1.05 - i * 0.02,
|
||||
gross_margin: 71.92 - i * 0.5,
|
||||
net_profit_margin: 32.56 - i * 0.3,
|
||||
operating_profit_margin: 42.16 - i * 0.4,
|
||||
cost_profit_ratio: 115.8 - i * 1.2,
|
||||
ebit: 86140 - i * 1800
|
||||
},
|
||||
per_share_metrics: {
|
||||
eps: 2.72 - i * 0.06,
|
||||
basic_eps: 2.72 - i * 0.06,
|
||||
diluted_eps: 2.70 - i * 0.06,
|
||||
deducted_eps: 2.65 - i * 0.06,
|
||||
bvps: 16.78 - i * 0.1,
|
||||
operating_cash_flow_ps: 6.47 - i * 0.15,
|
||||
capital_reserve_ps: 4.59 - i * 0.1,
|
||||
undistributed_profit_ps: 8.08 - i * 0.15
|
||||
},
|
||||
growth: {
|
||||
revenue_growth: 8.2 - i * 0.5,
|
||||
net_profit_growth: 12.5 - i * 0.8,
|
||||
deducted_profit_growth: 11.8 - i * 0.7,
|
||||
parent_profit_growth: 12.3 - i * 0.75,
|
||||
operating_cash_flow_growth: 15.6 - i * 1.0,
|
||||
total_asset_growth: 5.6 - i * 0.3,
|
||||
equity_growth: 6.8 - i * 0.4,
|
||||
fixed_asset_growth: 4.2 - i * 0.2
|
||||
},
|
||||
operational_efficiency: {
|
||||
total_asset_turnover: 0.41 - i * 0.01,
|
||||
fixed_asset_turnover: 2.35 - i * 0.05,
|
||||
current_asset_turnover: 0.82 - i * 0.02,
|
||||
receivable_turnover: 12.5 - i * 0.3,
|
||||
receivable_days: 29.2 + i * 0.7,
|
||||
inventory_turnover: 0, // 银行无库存
|
||||
inventory_days: 0,
|
||||
working_capital_turnover: 1.68 - i * 0.04
|
||||
},
|
||||
solvency: {
|
||||
current_ratio: 0.73 + i * 0.01,
|
||||
quick_ratio: 0.71 + i * 0.01,
|
||||
cash_ratio: 0.25 + i * 0.005,
|
||||
conservative_quick_ratio: 0.68 + i * 0.01,
|
||||
asset_liability_ratio: 93.52 + i * 0.05,
|
||||
interest_coverage: 8.56 - i * 0.2,
|
||||
cash_to_maturity_debt_ratio: 0.45 - i * 0.01,
|
||||
tangible_asset_debt_ratio: 94.12 + i * 0.05
|
||||
},
|
||||
expense_ratios: {
|
||||
selling_expense_ratio: 7.60 + i * 0.1,
|
||||
admin_expense_ratio: 9.65 + i * 0.1,
|
||||
financial_expense_ratio: 4.19 + i * 0.1,
|
||||
rd_expense_ratio: 5.48 + i * 0.1,
|
||||
three_expense_ratio: 21.44 + i * 0.3,
|
||||
four_expense_ratio: 26.92 + i * 0.4,
|
||||
cost_ratio: 28.10 + i * 0.2
|
||||
}
|
||||
})),
|
||||
|
||||
// 主营业务
|
||||
// 主营业务 - 按产品/业务分类
|
||||
mainBusiness: {
|
||||
by_product: [
|
||||
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
|
||||
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
|
||||
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
|
||||
product_classification: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
report_type: '2024年三季报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 81320000000, gross_margin: 68.5, profit_margin: 42.3, profit: 34398160000 },
|
||||
{ content: '对公金融业务', revenue: 68540000000, gross_margin: 62.8, profit_margin: 38.6, profit: 26456440000 },
|
||||
{ content: '金融市场业务', revenue: 12490000000, gross_margin: 75.2, profit_margin: 52.1, profit: 6507290000 },
|
||||
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2024-06-30',
|
||||
report_type: '2024年中报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 78650000000, gross_margin: 67.8, profit_margin: 41.5, profit: 32639750000 },
|
||||
{ content: '对公金融业务', revenue: 66280000000, gross_margin: 61.9, profit_margin: 37.8, profit: 25053840000 },
|
||||
{ content: '金融市场业务', revenue: 11870000000, gross_margin: 74.5, profit_margin: 51.2, profit: 6077440000 },
|
||||
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2024-03-31',
|
||||
report_type: '2024年一季报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 38920000000, gross_margin: 67.2, profit_margin: 40.8, profit: 15879360000 },
|
||||
{ content: '对公金融业务', revenue: 32650000000, gross_margin: 61.2, profit_margin: 37.1, profit: 12113150000 },
|
||||
{ content: '金融市场业务', revenue: 5830000000, gross_margin: 73.8, profit_margin: 50.5, profit: 2944150000 },
|
||||
{ content: '合计', revenue: 77400000000, gross_margin: 66.1, profit_margin: 39.8, profit: 30805200000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2023-12-31',
|
||||
report_type: '2023年年报',
|
||||
products: [
|
||||
{ content: '零售金融业务', revenue: 152680000000, gross_margin: 66.5, profit_margin: 40.2, profit: 61377360000 },
|
||||
{ content: '对公金融业务', revenue: 128450000000, gross_margin: 60.5, profit_margin: 36.5, profit: 46884250000 },
|
||||
{ content: '金融市场业务', revenue: 22870000000, gross_margin: 73.2, profit_margin: 49.8, profit: 11389260000 },
|
||||
{ content: '合计', revenue: 304000000000, gross_margin: 65.2, profit_margin: 39.2, profit: 119168000000 },
|
||||
]
|
||||
},
|
||||
],
|
||||
by_region: [
|
||||
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
|
||||
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
|
||||
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
|
||||
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
|
||||
industry_classification: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
report_type: '2024年三季报',
|
||||
industries: [
|
||||
{ content: '华南地区', revenue: 56817500000, gross_margin: 69.2, profit_margin: 43.5, profit: 24715612500 },
|
||||
{ content: '华东地区', revenue: 48705000000, gross_margin: 67.8, profit_margin: 41.2, profit: 20066460000 },
|
||||
{ content: '华北地区', revenue: 32470000000, gross_margin: 65.5, profit_margin: 38.8, profit: 12598360000 },
|
||||
{ content: '西南地区', revenue: 16235000000, gross_margin: 64.2, profit_margin: 37.5, profit: 6088125000 },
|
||||
{ content: '其他地区', revenue: 8122500000, gross_margin: 62.8, profit_margin: 35.2, profit: 2859120000 },
|
||||
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
period: '2024-06-30',
|
||||
report_type: '2024年中报',
|
||||
industries: [
|
||||
{ content: '华南地区', revenue: 54880000000, gross_margin: 68.5, profit_margin: 42.8, profit: 23488640000 },
|
||||
{ content: '华东地区', revenue: 47040000000, gross_margin: 67.1, profit_margin: 40.5, profit: 19051200000 },
|
||||
{ content: '华北地区', revenue: 31360000000, gross_margin: 64.8, profit_margin: 38.1, profit: 11948160000 },
|
||||
{ content: '西南地区', revenue: 15680000000, gross_margin: 63.5, profit_margin: 36.8, profit: 5770240000 },
|
||||
{ content: '其他地区', revenue: 7840000000, gross_margin: 62.1, profit_margin: 34.5, profit: 2704800000 },
|
||||
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -92,48 +356,74 @@ export const generateFinancialData = (stockCode) => {
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
|
||||
// 行业排名
|
||||
industryRank: {
|
||||
industry: '银行',
|
||||
total_companies: 42,
|
||||
rankings: [
|
||||
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
|
||||
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
|
||||
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
|
||||
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
|
||||
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
|
||||
]
|
||||
},
|
||||
// 行业排名(数组格式,符合 IndustryRankingView 组件要求)
|
||||
industryRank: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
report_type: '三季报',
|
||||
rankings: [
|
||||
{
|
||||
industry_name: stockCode === '000001' ? '银行' : '制造业',
|
||||
level_description: '一级行业',
|
||||
metrics: {
|
||||
eps: { value: 2.72, rank: 8, industry_avg: 1.85 },
|
||||
bvps: { value: 15.23, rank: 12, industry_avg: 12.50 },
|
||||
roe: { value: 16.23, rank: 10, industry_avg: 12.00 },
|
||||
revenue_growth: { value: 8.2, rank: 15, industry_avg: 5.50 },
|
||||
profit_growth: { value: 12.5, rank: 9, industry_avg: 8.00 },
|
||||
operating_margin: { value: 32.56, rank: 6, industry_avg: 25.00 },
|
||||
debt_ratio: { value: 92.5, rank: 35, industry_avg: 88.00 },
|
||||
receivable_turnover: { value: 5.2, rank: 18, industry_avg: 4.80 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// 期间对比
|
||||
periodComparison: {
|
||||
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
|
||||
metrics: [
|
||||
{
|
||||
name: '营业收入',
|
||||
unit: '百万元',
|
||||
values: [41500, 40800, 40200, 40850],
|
||||
yoy: [8.2, 7.8, 8.5, 9.2]
|
||||
},
|
||||
{
|
||||
name: '净利润',
|
||||
unit: '百万元',
|
||||
values: [13420, 13180, 13050, 13210],
|
||||
yoy: [12.5, 11.2, 10.8, 12.3]
|
||||
},
|
||||
{
|
||||
name: 'ROE',
|
||||
unit: '%',
|
||||
values: [16.23, 15.98, 15.75, 16.02],
|
||||
yoy: [1.2, 0.8, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
name: 'EPS',
|
||||
unit: '元',
|
||||
values: [0.69, 0.68, 0.67, 0.68],
|
||||
yoy: [12.3, 11.5, 10.5, 12.0]
|
||||
// 期间对比 - 营收与利润趋势数据
|
||||
periodComparison: [
|
||||
{
|
||||
period: '2024-09-30',
|
||||
performance: {
|
||||
revenue: 41500000000, // 415亿
|
||||
net_profit: 13420000000 // 134.2亿
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2024-06-30',
|
||||
performance: {
|
||||
revenue: 40800000000, // 408亿
|
||||
net_profit: 13180000000 // 131.8亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2024-03-31',
|
||||
performance: {
|
||||
revenue: 40200000000, // 402亿
|
||||
net_profit: 13050000000 // 130.5亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2023-12-31',
|
||||
performance: {
|
||||
revenue: 40850000000, // 408.5亿
|
||||
net_profit: 13210000000 // 132.1亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2023-09-30',
|
||||
performance: {
|
||||
revenue: 38500000000, // 385亿
|
||||
net_profit: 11920000000 // 119.2亿
|
||||
}
|
||||
},
|
||||
{
|
||||
period: '2023-06-30',
|
||||
performance: {
|
||||
revenue: 37800000000, // 378亿
|
||||
net_profit: 11850000000 // 118.5亿
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
// src/mocks/data/market.js
|
||||
// 市场行情相关的 Mock 数据
|
||||
|
||||
// 股票名称映射
|
||||
const STOCK_NAME_MAP = {
|
||||
'000001': { name: '平安银行', basePrice: 13.50 },
|
||||
'600000': { name: '浦发银行', basePrice: 8.20 },
|
||||
'600519': { name: '贵州茅台', basePrice: 1650.00 },
|
||||
'000858': { name: '五粮液', basePrice: 165.00 },
|
||||
'601318': { name: '中国平安', basePrice: 45.00 },
|
||||
'600036': { name: '招商银行', basePrice: 32.00 },
|
||||
'300750': { name: '宁德时代', basePrice: 180.00 },
|
||||
'002594': { name: '比亚迪', basePrice: 260.00 },
|
||||
};
|
||||
|
||||
// 生成市场数据
|
||||
export const generateMarketData = (stockCode) => {
|
||||
const basePrice = 13.50; // 基准价格(平安银行约13.5元)
|
||||
const stockInfo = STOCK_NAME_MAP[stockCode] || { name: `股票${stockCode}`, basePrice: 20.00 };
|
||||
const basePrice = stockInfo.basePrice;
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
@@ -24,8 +37,9 @@ export const generateMarketData = (stockCode) => {
|
||||
low: parseFloat(low.toFixed(2)),
|
||||
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
|
||||
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
|
||||
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
|
||||
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
|
||||
turnover_rate: parseFloat((Math.random() * 2 + 0.5).toFixed(2)), // 0.5-2.5%
|
||||
change_percent: parseFloat((Math.random() * 6 - 3).toFixed(2)), // -3% to +3%
|
||||
pe_ratio: parseFloat((Math.random() * 3 + 4).toFixed(2)) // 4-7
|
||||
};
|
||||
})
|
||||
},
|
||||
@@ -78,36 +92,45 @@ export const generateMarketData = (stockCode) => {
|
||||
}))
|
||||
},
|
||||
|
||||
// 股权质押
|
||||
// 股权质押 - 匹配 PledgeData[] 类型
|
||||
pledgeData: {
|
||||
success: true,
|
||||
data: {
|
||||
total_pledged: 25.6, // 质押比例%
|
||||
major_shareholders: [
|
||||
{ name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 },
|
||||
{ name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 }
|
||||
],
|
||||
update_date: '2024-09-30'
|
||||
}
|
||||
data: Array(12).fill(null).map((_, i) => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - (11 - i));
|
||||
return {
|
||||
end_date: date.toISOString().split('T')[0].slice(0, 7) + '-01',
|
||||
unrestricted_pledge: Math.floor(Math.random() * 1000000000) + 500000000,
|
||||
restricted_pledge: Math.floor(Math.random() * 200000000) + 50000000,
|
||||
total_pledge: Math.floor(Math.random() * 1200000000) + 550000000,
|
||||
total_shares: 19405918198,
|
||||
pledge_ratio: parseFloat((Math.random() * 3 + 6).toFixed(2)), // 6-9%
|
||||
pledge_count: Math.floor(Math.random() * 50) + 100 // 100-150
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
// 市场摘要
|
||||
// 市场摘要 - 匹配 MarketSummary 类型
|
||||
summaryData: {
|
||||
success: true,
|
||||
data: {
|
||||
current_price: basePrice,
|
||||
change: 0.25,
|
||||
change_pct: 1.89,
|
||||
open: 13.35,
|
||||
high: 13.68,
|
||||
low: 13.28,
|
||||
volume: 345678900,
|
||||
amount: 4678900000,
|
||||
turnover_rate: 1.78,
|
||||
pe_ratio: 4.96,
|
||||
pb_ratio: 0.72,
|
||||
total_market_cap: 262300000000,
|
||||
circulating_market_cap: 262300000000
|
||||
stock_code: stockCode,
|
||||
stock_name: stockInfo.name,
|
||||
latest_trade: {
|
||||
close: basePrice,
|
||||
change_percent: 1.89,
|
||||
volume: 345678900,
|
||||
amount: 4678900000,
|
||||
turnover_rate: 1.78,
|
||||
pe_ratio: 4.96
|
||||
},
|
||||
latest_funding: {
|
||||
financing_balance: 5823000000,
|
||||
securities_balance: 125600000
|
||||
},
|
||||
latest_pledge: {
|
||||
pledge_ratio: 8.25
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -131,26 +154,57 @@ export const generateMarketData = (stockCode) => {
|
||||
})
|
||||
},
|
||||
|
||||
// 最新分时数据
|
||||
// 最新分时数据 - 匹配 MinuteData 类型
|
||||
latestMinuteData: {
|
||||
success: true,
|
||||
data: Array(240).fill(null).map((_, i) => {
|
||||
const minute = 9 * 60 + 30 + i; // 从9:30开始
|
||||
const hour = Math.floor(minute / 60);
|
||||
const min = minute % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
||||
const randomChange = (Math.random() - 0.5) * 0.1;
|
||||
return {
|
||||
time,
|
||||
price: (basePrice + randomChange).toFixed(2),
|
||||
volume: Math.floor(Math.random() * 2000000) + 500000,
|
||||
avg_price: (basePrice + randomChange * 0.8).toFixed(2)
|
||||
};
|
||||
}),
|
||||
data: (() => {
|
||||
const minuteData = [];
|
||||
// 上午 9:30-11:30 (120分钟)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const hour = 9 + Math.floor((30 + i) / 60);
|
||||
const min = (30 + i) % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
||||
const randomChange = (Math.random() - 0.5) * 0.1;
|
||||
const open = parseFloat((basePrice + randomChange).toFixed(2));
|
||||
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
|
||||
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
|
||||
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
|
||||
minuteData.push({
|
||||
time,
|
||||
open,
|
||||
close,
|
||||
high,
|
||||
low,
|
||||
volume: Math.floor(Math.random() * 2000000) + 500000,
|
||||
amount: Math.floor(Math.random() * 30000000) + 5000000
|
||||
});
|
||||
}
|
||||
// 下午 13:00-15:00 (120分钟)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const hour = 13 + Math.floor(i / 60);
|
||||
const min = i % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
||||
const randomChange = (Math.random() - 0.5) * 0.1;
|
||||
const open = parseFloat((basePrice + randomChange).toFixed(2));
|
||||
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
|
||||
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
|
||||
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
|
||||
minuteData.push({
|
||||
time,
|
||||
open,
|
||||
close,
|
||||
high,
|
||||
low,
|
||||
volume: Math.floor(Math.random() * 1500000) + 400000,
|
||||
amount: Math.floor(Math.random() * 25000000) + 4000000
|
||||
});
|
||||
}
|
||||
return minuteData;
|
||||
})(),
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例股票',
|
||||
name: stockInfo.name,
|
||||
trade_date: new Date().toISOString().split('T')[0],
|
||||
type: 'minute'
|
||||
type: '1min'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
// src/mocks/handlers/bytedesk.js
|
||||
/**
|
||||
* Bytedesk 客服 Widget MSW Handler
|
||||
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
|
||||
* Mock 模式下返回模拟数据
|
||||
*/
|
||||
|
||||
import { http, passthrough } from 'msw';
|
||||
import { http, HttpResponse, passthrough } from 'msw';
|
||||
|
||||
export const bytedeskHandlers = [
|
||||
// Bytedesk API 请求 - 直接 passthrough
|
||||
// 匹配 /bytedesk/* 路径(通过代理访问后端)
|
||||
// 未读消息数量
|
||||
http.get('/bytedesk/visitor/api/v1/message/unread/count', () => {
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: { count: 0 },
|
||||
});
|
||||
}),
|
||||
|
||||
// 其他 Bytedesk API - 返回通用成功响应
|
||||
http.all('/bytedesk/*', () => {
|
||||
return passthrough();
|
||||
return HttpResponse.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// Bytedesk 外部 CDN/服务请求
|
||||
|
||||
@@ -43,12 +43,10 @@ export const companyHandlers = [
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 直接返回 keyFactorsTimeline 对象(包含 key_factors 和 development_timeline)
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
timeline: data.keyFactorsTimeline,
|
||||
total: data.keyFactorsTimeline.length
|
||||
}
|
||||
data: data.keyFactorsTimeline
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -69,10 +67,14 @@ export const companyHandlers = [
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
const raw = data.actualControl;
|
||||
|
||||
// 数据保持原始百分比格式(如 52.38 表示 52.38%)
|
||||
const formatted = Array.isArray(raw) ? raw : [];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.actualControl
|
||||
data: formatted
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -81,10 +83,14 @@ export const companyHandlers = [
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
const raw = data.concentration;
|
||||
|
||||
// 数据保持原始百分比格式(如 52.38 表示 52.38%)
|
||||
const formatted = Array.isArray(raw) ? raw : [];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.concentration
|
||||
data: formatted
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -120,9 +120,14 @@ export const eventHandlers = [
|
||||
try {
|
||||
const result = generateMockEvents(params);
|
||||
|
||||
// 返回格式兼容 useEventData 期望的结构
|
||||
// useEventData 期望: { success, data: { events: [], pagination: {} } }
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
data: {
|
||||
events: result.events, // 事件数组
|
||||
pagination: result.pagination // 分页信息
|
||||
},
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -136,16 +141,14 @@ export const eventHandlers = [
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件列表失败',
|
||||
data: {
|
||||
events: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0, // ← 对齐后端字段名
|
||||
has_prev: false, // ← 对齐后端
|
||||
has_next: false // ← 对齐后端
|
||||
}
|
||||
data: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
has_prev: false,
|
||||
has_next: false
|
||||
}
|
||||
},
|
||||
{ status: 500 }
|
||||
|
||||
@@ -263,15 +263,15 @@ export const stockHandlers = [
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (type === 'timeline') {
|
||||
if (type === 'timeline' || type === 'minute') {
|
||||
// timeline 和 minute 都使用分时数据
|
||||
data = generateTimelineData(indexCode);
|
||||
} else if (type === 'daily') {
|
||||
data = generateDailyData(indexCode, 30);
|
||||
} else {
|
||||
return HttpResponse.json(
|
||||
{ error: '不支持的类型' },
|
||||
{ status: 400 }
|
||||
);
|
||||
// 其他类型也降级使用 timeline 数据
|
||||
console.log('[Mock Stock] 未知类型,降级使用 timeline:', type);
|
||||
data = generateTimelineData(indexCode);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
@@ -387,6 +387,68 @@ export const stockHandlers = [
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票业绩预告
|
||||
http.get('/api/stock/:stockCode/forecast', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { stockCode } = params;
|
||||
console.log('[Mock Stock] 获取业绩预告:', { stockCode });
|
||||
|
||||
// 生成股票列表用于查找名称
|
||||
const stockList = generateStockList();
|
||||
const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, ''));
|
||||
const stockName = stockInfo?.name || `股票${stockCode}`;
|
||||
|
||||
// 业绩预告类型列表
|
||||
const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈'];
|
||||
|
||||
// 生成业绩预告数据
|
||||
const forecasts = [
|
||||
{
|
||||
forecast_type: '预增',
|
||||
report_date: '2024年年报',
|
||||
content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元,同比增长10%至17%。`,
|
||||
reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。',
|
||||
change_range: {
|
||||
lower: 10,
|
||||
upper: 17
|
||||
},
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
{
|
||||
forecast_type: '略增',
|
||||
report_date: '2024年三季报',
|
||||
content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元,同比增长5%至12%。`,
|
||||
reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。',
|
||||
change_range: {
|
||||
lower: 5,
|
||||
upper: 12
|
||||
},
|
||||
publish_date: '2024-07-12'
|
||||
},
|
||||
{
|
||||
forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)],
|
||||
report_date: '2024年中报',
|
||||
content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`,
|
||||
reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。',
|
||||
change_range: {
|
||||
lower: 3,
|
||||
upper: 8
|
||||
},
|
||||
publish_date: '2024-04-20'
|
||||
}
|
||||
];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
forecasts: forecasts
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取股票报价(批量)
|
||||
http.post('/api/stock/quotes', async ({ request }) => {
|
||||
await delay(200);
|
||||
@@ -414,6 +476,25 @@ export const stockHandlers = [
|
||||
stockMap[s.code] = s.name;
|
||||
});
|
||||
|
||||
// 行业和指数映射表
|
||||
const stockIndustryMap = {
|
||||
'000001': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证180'] },
|
||||
'600519': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300', '上证50'] },
|
||||
'300750': { industry_l1: '工业', industry: '电池', index_tags: ['创业板50'] },
|
||||
'601318': { industry_l1: '金融', industry: '保险', index_tags: ['沪深300', '上证50'] },
|
||||
'600036': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证50'] },
|
||||
'000858': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300'] },
|
||||
'002594': { industry_l1: '汽车', industry: '乘用车', index_tags: ['沪深300', '创业板指'] },
|
||||
};
|
||||
|
||||
const defaultIndustries = [
|
||||
{ industry_l1: '科技', industry: '软件' },
|
||||
{ industry_l1: '医药', industry: '化学制药' },
|
||||
{ industry_l1: '消费', industry: '食品' },
|
||||
{ industry_l1: '金融', industry: '证券' },
|
||||
{ industry_l1: '工业', industry: '机械' },
|
||||
];
|
||||
|
||||
// 为每只股票生成报价数据
|
||||
const quotesData = {};
|
||||
codes.forEach(stockCode => {
|
||||
@@ -426,6 +507,11 @@ export const stockHandlers = [
|
||||
// 昨收
|
||||
const prevClose = parseFloat((basePrice - change).toFixed(2));
|
||||
|
||||
// 获取行业和指数信息
|
||||
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
|
||||
const industryInfo = stockIndustryMap[codeWithoutSuffix] ||
|
||||
defaultIndustries[Math.floor(Math.random() * defaultIndustries.length)];
|
||||
|
||||
quotesData[stockCode] = {
|
||||
code: stockCode,
|
||||
name: stockMap[stockCode] || `股票${stockCode}`,
|
||||
@@ -439,7 +525,23 @@ export const stockHandlers = [
|
||||
volume: Math.floor(Math.random() * 100000000),
|
||||
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
|
||||
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
|
||||
update_time: new Date().toISOString()
|
||||
update_time: new Date().toISOString(),
|
||||
// 行业和指数标签
|
||||
industry_l1: industryInfo.industry_l1,
|
||||
industry: industryInfo.industry,
|
||||
index_tags: industryInfo.index_tags || [],
|
||||
// 关键指标
|
||||
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
|
||||
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
|
||||
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
|
||||
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
|
||||
week52_low: parseFloat((basePrice * 0.7).toFixed(2)),
|
||||
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
|
||||
// 主力动态
|
||||
main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)),
|
||||
institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)),
|
||||
buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)),
|
||||
sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2))
|
||||
};
|
||||
});
|
||||
|
||||
@@ -456,4 +558,133 @@ export const stockHandlers = [
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票详细行情(quote-detail)
|
||||
http.get('/api/stock/:stockCode/quote-detail', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { stockCode } = params;
|
||||
console.log('[Mock Stock] 获取股票详细行情:', { stockCode });
|
||||
|
||||
const stocks = generateStockList();
|
||||
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
|
||||
const stockInfo = stocks.find(s => s.code === codeWithoutSuffix);
|
||||
const stockName = stockInfo?.name || `股票${stockCode}`;
|
||||
|
||||
// 生成基础价格(10-200之间)
|
||||
const basePrice = parseFloat((Math.random() * 190 + 10).toFixed(2));
|
||||
// 涨跌幅(-10% 到 +10%)
|
||||
const changePercent = parseFloat((Math.random() * 20 - 10).toFixed(2));
|
||||
// 涨跌额
|
||||
const change = parseFloat((basePrice * changePercent / 100).toFixed(2));
|
||||
// 昨收
|
||||
const prevClose = parseFloat((basePrice - change).toFixed(2));
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
price: basePrice,
|
||||
change: change,
|
||||
change_percent: changePercent,
|
||||
prev_close: prevClose,
|
||||
open: parseFloat((prevClose * (1 + (Math.random() * 0.02 - 0.01))).toFixed(2)),
|
||||
high: parseFloat((basePrice * (1 + Math.random() * 0.05)).toFixed(2)),
|
||||
low: parseFloat((basePrice * (1 - Math.random() * 0.05)).toFixed(2)),
|
||||
volume: Math.floor(Math.random() * 100000000),
|
||||
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
|
||||
turnover_rate: parseFloat((Math.random() * 10).toFixed(2)),
|
||||
amplitude: parseFloat((Math.random() * 8).toFixed(2)),
|
||||
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
|
||||
update_time: new Date().toISOString(),
|
||||
// 买卖盘口
|
||||
bid1: parseFloat((basePrice * 0.998).toFixed(2)),
|
||||
bid1_volume: Math.floor(Math.random() * 10000),
|
||||
bid2: parseFloat((basePrice * 0.996).toFixed(2)),
|
||||
bid2_volume: Math.floor(Math.random() * 10000),
|
||||
bid3: parseFloat((basePrice * 0.994).toFixed(2)),
|
||||
bid3_volume: Math.floor(Math.random() * 10000),
|
||||
bid4: parseFloat((basePrice * 0.992).toFixed(2)),
|
||||
bid4_volume: Math.floor(Math.random() * 10000),
|
||||
bid5: parseFloat((basePrice * 0.990).toFixed(2)),
|
||||
bid5_volume: Math.floor(Math.random() * 10000),
|
||||
ask1: parseFloat((basePrice * 1.002).toFixed(2)),
|
||||
ask1_volume: Math.floor(Math.random() * 10000),
|
||||
ask2: parseFloat((basePrice * 1.004).toFixed(2)),
|
||||
ask2_volume: Math.floor(Math.random() * 10000),
|
||||
ask3: parseFloat((basePrice * 1.006).toFixed(2)),
|
||||
ask3_volume: Math.floor(Math.random() * 10000),
|
||||
ask4: parseFloat((basePrice * 1.008).toFixed(2)),
|
||||
ask4_volume: Math.floor(Math.random() * 10000),
|
||||
ask5: parseFloat((basePrice * 1.010).toFixed(2)),
|
||||
ask5_volume: Math.floor(Math.random() * 10000),
|
||||
// 关键指标
|
||||
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
|
||||
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
|
||||
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
|
||||
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
|
||||
circulating_market_cap: `${(Math.random() * 3000 + 50).toFixed(0)}亿`,
|
||||
total_shares: `${(Math.random() * 100 + 10).toFixed(2)}亿`,
|
||||
circulating_shares: `${(Math.random() * 80 + 5).toFixed(2)}亿`,
|
||||
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
|
||||
week52_low: parseFloat((basePrice * 0.7).toFixed(2))
|
||||
},
|
||||
message: '获取成功'
|
||||
});
|
||||
}),
|
||||
|
||||
// FlexScreen 行情数据
|
||||
http.get('/api/flex-screen/quotes', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const codes = url.searchParams.get('codes')?.split(',') || [];
|
||||
|
||||
console.log('[Mock Stock] 获取 FlexScreen 行情:', { codes });
|
||||
|
||||
// 默认主要指数
|
||||
const defaultIndices = ['000001', '399001', '399006'];
|
||||
const targetCodes = codes.length > 0 ? codes : defaultIndices;
|
||||
|
||||
const indexData = {
|
||||
'000001': { name: '上证指数', basePrice: 3200 },
|
||||
'399001': { name: '深证成指', basePrice: 10500 },
|
||||
'399006': { name: '创业板指', basePrice: 2100 },
|
||||
'000300': { name: '沪深300', basePrice: 3800 },
|
||||
'000016': { name: '上证50', basePrice: 2600 },
|
||||
'000905': { name: '中证500', basePrice: 5800 },
|
||||
};
|
||||
|
||||
const quotesData = {};
|
||||
targetCodes.forEach(code => {
|
||||
const codeWithoutSuffix = code.replace(/\.(SH|SZ)$/i, '');
|
||||
const info = indexData[codeWithoutSuffix] || { name: `指数${code}`, basePrice: 3000 };
|
||||
|
||||
const changePercent = parseFloat((Math.random() * 4 - 2).toFixed(2));
|
||||
const price = parseFloat((info.basePrice * (1 + changePercent / 100)).toFixed(2));
|
||||
const change = parseFloat((price - info.basePrice).toFixed(2));
|
||||
|
||||
quotesData[code] = {
|
||||
code: code,
|
||||
name: info.name,
|
||||
price: price,
|
||||
change: change,
|
||||
change_percent: changePercent,
|
||||
prev_close: info.basePrice,
|
||||
open: parseFloat((info.basePrice * (1 + (Math.random() * 0.01 - 0.005))).toFixed(2)),
|
||||
high: parseFloat((price * (1 + Math.random() * 0.01)).toFixed(2)),
|
||||
low: parseFloat((price * (1 - Math.random() * 0.01)).toFixed(2)),
|
||||
volume: parseFloat((Math.random() * 5000 + 2000).toFixed(2)), // 亿手
|
||||
amount: parseFloat((Math.random() * 8000 + 3000).toFixed(2)), // 亿元
|
||||
update_time: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: quotesData,
|
||||
message: '获取成功'
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -35,9 +35,9 @@ export const lazyComponents = {
|
||||
|
||||
// 公司相关模块
|
||||
CompanyIndex: React.lazy(() => import('@views/Company')),
|
||||
ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
|
||||
FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
|
||||
MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
|
||||
ForecastReport: React.lazy(() => import('@views/Company/components/ForecastReport')),
|
||||
FinancialPanorama: React.lazy(() => import('@views/Company/components/FinancialPanorama')),
|
||||
MarketDataView: React.lazy(() => import('@views/Company/components/MarketDataView')),
|
||||
|
||||
// Agent模块
|
||||
AgentChat: React.lazy(() => import('@views/AgentChat')),
|
||||
|
||||
49
src/services/financialService.d.ts
vendored
Normal file
49
src/services/financialService.d.ts
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
// financialService 类型声明
|
||||
|
||||
export interface RequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface FinancialService {
|
||||
getStockInfo(seccode: string, options?: RequestOptions): Promise<ApiResponse<any>>;
|
||||
getBalanceSheet(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
|
||||
getIncomeStatement(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
|
||||
getCashflow(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
|
||||
getFinancialMetrics(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
|
||||
getMainBusiness(seccode: string, periods?: number, options?: RequestOptions): Promise<ApiResponse<any>>;
|
||||
getForecast(seccode: string, options?: RequestOptions): Promise<ApiResponse<any>>;
|
||||
getIndustryRank(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
|
||||
getPeriodComparison(seccode: string, periods?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
|
||||
}
|
||||
|
||||
export const financialService: FinancialService;
|
||||
|
||||
export interface FormatUtils {
|
||||
formatLargeNumber(num: number, decimal?: number): string;
|
||||
formatPercent(num: number, decimal?: number): string;
|
||||
formatDate(dateStr: string): string;
|
||||
getReportType(dateStr: string): string;
|
||||
getGrowthColor(value: number): string;
|
||||
getTrendIcon(current: number, previous: number): 'up' | 'down' | 'stable';
|
||||
calculateYoY(current: number, yearAgo: number): number | null;
|
||||
calculateQoQ(current: number, previous: number): number | null;
|
||||
getFinancialHealthScore(metrics: any): { score: number; level: string; color: string } | null;
|
||||
getTableColumns(type: string): any[];
|
||||
}
|
||||
|
||||
export const formatUtils: FormatUtils;
|
||||
|
||||
export interface ChartUtils {
|
||||
prepareTrendData(data: any[], metrics: any[]): any[];
|
||||
preparePieData(data: any[], valueKey: string, nameKey: string): any[];
|
||||
prepareComparisonData(data: any[], periods: any[], metrics: any[]): any[];
|
||||
getChartColors(theme?: string): string[];
|
||||
}
|
||||
|
||||
export const chartUtils: ChartUtils;
|
||||
@@ -1,133 +1,137 @@
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
// src/services/financialService.js
|
||||
/**
|
||||
* 完整的财务数据服务层
|
||||
* 对应Flask后端的所有财务API接口
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = getApiBase();
|
||||
import axios from '@utils/axiosConfig';
|
||||
|
||||
/**
|
||||
* 统一的 API 请求函数
|
||||
* axios 拦截器已自动处理日志记录
|
||||
* @param {string} url - 请求 URL
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号,用于取消请求
|
||||
*/
|
||||
const apiRequest = async (url, options = {}) => {
|
||||
try {
|
||||
logger.debug('financialService', 'API请求', {
|
||||
url: `${API_BASE_URL}${url}`,
|
||||
method: options.method || 'GET'
|
||||
});
|
||||
const { method = 'GET', body, signal, ...rest } = options;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
credentials: 'include', // 包含 cookies,以便后端识别登录状态
|
||||
});
|
||||
const config = {
|
||||
method,
|
||||
url,
|
||||
signal,
|
||||
...rest,
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('financialService', 'apiRequest', new Error(`HTTP ${response.status}`), {
|
||||
url,
|
||||
status: response.status,
|
||||
errorText: errorText.substring(0, 200)
|
||||
});
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
// 如果有 body,根据方法设置 data 或 params
|
||||
if (body) {
|
||||
if (method === 'GET') {
|
||||
config.params = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
} else {
|
||||
config.data = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
logger.debug('financialService', 'API响应', {
|
||||
url,
|
||||
success: data.success,
|
||||
hasData: !!data.data
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('financialService', 'apiRequest', error, { url });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response = await axios(config);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const financialService = {
|
||||
/**
|
||||
* 获取股票基本信息和最新财务摘要
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getStockInfo: async (seccode) => {
|
||||
return await apiRequest(`/api/financial/stock-info/${seccode}`);
|
||||
getStockInfo: async (seccode, options = {}) => {
|
||||
return await apiRequest(`/api/financial/stock-info/${seccode}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取完整的资产负债表数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getBalanceSheet: async (seccode, limit = 12) => {
|
||||
return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`);
|
||||
getBalanceSheet: async (seccode, limit = 12, options = {}) => {
|
||||
return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取完整的利润表数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getIncomeStatement: async (seccode, limit = 12) => {
|
||||
return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`);
|
||||
getIncomeStatement: async (seccode, limit = 12, options = {}) => {
|
||||
return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取完整的现金流量表数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getCashflow: async (seccode, limit = 12) => {
|
||||
return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`);
|
||||
getCashflow: async (seccode, limit = 12, options = {}) => {
|
||||
return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取完整的财务指标数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getFinancialMetrics: async (seccode, limit = 12) => {
|
||||
return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`);
|
||||
getFinancialMetrics: async (seccode, limit = 12, options = {}) => {
|
||||
return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取主营业务构成数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} periods - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getMainBusiness: async (seccode, periods = 4) => {
|
||||
return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`);
|
||||
getMainBusiness: async (seccode, periods = 4, options = {}) => {
|
||||
return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取业绩预告和预披露时间
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getForecast: async (seccode) => {
|
||||
return await apiRequest(`/api/financial/forecast/${seccode}`);
|
||||
getForecast: async (seccode, options = {}) => {
|
||||
return await apiRequest(`/api/financial/forecast/${seccode}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取行业排名数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getIndustryRank: async (seccode, limit = 4) => {
|
||||
return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`);
|
||||
getIndustryRank: async (seccode, limit = 4, options = {}) => {
|
||||
return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取不同报告期的对比数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} periods - 对比的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getPeriodComparison: async (seccode, periods = 8) => {
|
||||
return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`);
|
||||
getPeriodComparison: async (seccode, periods = 8, options = {}) => {
|
||||
return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`, options);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,53 +1,36 @@
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
// src/services/marketService.js
|
||||
/**
|
||||
* 完整的市场行情数据服务层
|
||||
* 对应Flask后端的所有市场API接口
|
||||
*/
|
||||
|
||||
import axios from '@utils/axiosConfig';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
/**
|
||||
* 统一的 API 请求函数
|
||||
* axios 拦截器已自动处理日志记录
|
||||
*/
|
||||
const apiRequest = async (url, options = {}) => {
|
||||
try {
|
||||
logger.debug('marketService', 'API请求', {
|
||||
url: `${API_BASE_URL}${url}`,
|
||||
method: options.method || 'GET'
|
||||
});
|
||||
const { method = 'GET', body, ...rest } = options;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
credentials: 'include', // 包含 cookies,以便后端识别登录状态
|
||||
});
|
||||
const config = {
|
||||
method,
|
||||
url,
|
||||
...rest,
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('marketService', 'apiRequest', new Error(`HTTP ${response.status}`), {
|
||||
url,
|
||||
status: response.status,
|
||||
errorText: errorText.substring(0, 200)
|
||||
});
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
// 如果有 body,根据方法设置 data 或 params
|
||||
if (body) {
|
||||
if (method === 'GET') {
|
||||
config.params = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
} else {
|
||||
config.data = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
logger.debug('marketService', 'API响应', {
|
||||
url,
|
||||
success: data.success,
|
||||
hasData: !!data.data
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('marketService', 'apiRequest', error, { url });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response = await axios(config);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const marketService = {
|
||||
|
||||
@@ -92,9 +92,18 @@ class SocketService {
|
||||
// 监听连接错误
|
||||
this.socket.on('connect_error', (error) => {
|
||||
this.reconnectAttempts++;
|
||||
logger.error('socketService', 'connect_error', error, {
|
||||
attempts: this.reconnectAttempts,
|
||||
});
|
||||
|
||||
// 首次连接失败使用 warn 级别,后续使用 debug 级别减少日志噪音
|
||||
if (this.reconnectAttempts === 1) {
|
||||
logger.warn('socketService', `Socket 连接失败,将在后台重试`, {
|
||||
url: API_BASE_URL,
|
||||
error: error.message,
|
||||
});
|
||||
} else {
|
||||
logger.debug('socketService', `Socket 重连尝试 #${this.reconnectAttempts}`, {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 使用指数退避策略安排下次重连
|
||||
this.scheduleReconnection();
|
||||
|
||||
@@ -4,6 +4,56 @@ import { eventService, stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
|
||||
// ==================== Watchlist 缓存配置 ====================
|
||||
const WATCHLIST_CACHE_KEY = 'watchlist_cache';
|
||||
const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取自选股缓存
|
||||
*/
|
||||
const loadWatchlistFromCache = () => {
|
||||
try {
|
||||
const cached = localStorage.getItem(WATCHLIST_CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
|
||||
const { data, timestamp } = JSON.parse(cached);
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存是否过期(7天)
|
||||
if (now - timestamp > WATCHLIST_CACHE_DURATION) {
|
||||
localStorage.removeItem(WATCHLIST_CACHE_KEY);
|
||||
logger.debug('stockSlice', '自选股缓存已过期');
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('stockSlice', '自选股 localStorage 缓存命中', {
|
||||
count: data?.length || 0,
|
||||
age: Math.round((now - timestamp) / 1000 / 60) + '分钟前'
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('stockSlice', 'loadWatchlistFromCache', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存自选股到 localStorage
|
||||
*/
|
||||
const saveWatchlistToCache = (data) => {
|
||||
try {
|
||||
localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
logger.debug('stockSlice', '自选股已缓存到 localStorage', {
|
||||
count: data?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('stockSlice', 'saveWatchlistToCache', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Async Thunks ====================
|
||||
|
||||
/**
|
||||
@@ -153,13 +203,28 @@ export const fetchExpectationScore = createAsyncThunk(
|
||||
|
||||
/**
|
||||
* 加载用户自选股列表(包含完整信息)
|
||||
* 缓存策略:Redux 内存缓存 → localStorage 持久缓存(7天) → API 请求
|
||||
*/
|
||||
export const loadWatchlist = createAsyncThunk(
|
||||
'stock/loadWatchlist',
|
||||
async () => {
|
||||
async (_, { getState }) => {
|
||||
logger.debug('stockSlice', 'loadWatchlist');
|
||||
|
||||
try {
|
||||
// 1. 先检查 Redux 内存缓存
|
||||
const reduxCached = getState().stock.watchlist;
|
||||
if (reduxCached && reduxCached.length > 0) {
|
||||
logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length });
|
||||
return reduxCached;
|
||||
}
|
||||
|
||||
// 2. 再检查 localStorage 持久缓存(7天有效期)
|
||||
const localCached = loadWatchlistFromCache();
|
||||
if (localCached && localCached.length > 0) {
|
||||
return localCached;
|
||||
}
|
||||
|
||||
// 3. 缓存无效,调用 API
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/api/account/watchlist`, {
|
||||
credentials: 'include'
|
||||
@@ -172,6 +237,10 @@ export const loadWatchlist = createAsyncThunk(
|
||||
stock_code: item.stock_code,
|
||||
stock_name: item.stock_name,
|
||||
}));
|
||||
|
||||
// 保存到 localStorage 缓存
|
||||
saveWatchlistToCache(watchlistData);
|
||||
|
||||
logger.debug('stockSlice', '自选股列表加载成功', {
|
||||
count: watchlistData.length
|
||||
});
|
||||
@@ -340,6 +409,26 @@ const stockSlice = createSlice({
|
||||
delete state.historicalEventsCache[eventId];
|
||||
delete state.chainAnalysisCache[eventId];
|
||||
delete state.expectationScores[eventId];
|
||||
},
|
||||
|
||||
/**
|
||||
* 乐观更新:添加自选股(同步)
|
||||
*/
|
||||
optimisticAddWatchlist: (state, action) => {
|
||||
const { stockCode, stockName } = action.payload;
|
||||
// 避免重复添加
|
||||
const exists = state.watchlist.some(item => item.stock_code === stockCode);
|
||||
if (!exists) {
|
||||
state.watchlist.push({ stock_code: stockCode, stock_name: stockName || '' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 乐观更新:移除自选股(同步)
|
||||
*/
|
||||
optimisticRemoveWatchlist: (state, action) => {
|
||||
const { stockCode } = action.payload;
|
||||
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
@@ -470,9 +559,10 @@ const stockSlice = createSlice({
|
||||
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
|
||||
}
|
||||
})
|
||||
// fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作
|
||||
.addCase(toggleWatchlist.fulfilled, () => {
|
||||
// 状态已在 pending 时更新
|
||||
// fulfilled: 同步更新 localStorage 缓存
|
||||
.addCase(toggleWatchlist.fulfilled, (state) => {
|
||||
// 状态已在 pending 时更新,这里同步到 localStorage
|
||||
saveWatchlistToCache(state.watchlist);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -481,7 +571,9 @@ export const {
|
||||
updateQuote,
|
||||
updateQuotes,
|
||||
clearQuotes,
|
||||
clearEventCache
|
||||
clearEventCache,
|
||||
optimisticAddWatchlist,
|
||||
optimisticRemoveWatchlist
|
||||
} = stockSlice.actions;
|
||||
|
||||
export default stockSlice.reducer;
|
||||
|
||||
@@ -87,3 +87,55 @@ select::-webkit-scrollbar-thumb {
|
||||
select::-webkit-scrollbar-thumb:hover {
|
||||
background: #FFC107;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ant Design AutoComplete 下拉框样式 (FUI 主题)
|
||||
*/
|
||||
.fui-autocomplete-dropdown {
|
||||
background-color: #1a1a2e !important;
|
||||
border: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||
border-radius: 10px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.fui-autocomplete-dropdown .ant-select-item {
|
||||
color: #ffffff !important;
|
||||
padding: 10px 12px !important;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.fui-autocomplete-dropdown .ant-select-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.fui-autocomplete-dropdown .ant-select-item-option-active,
|
||||
.fui-autocomplete-dropdown .ant-select-item:hover {
|
||||
background-color: rgba(212, 175, 55, 0.15) !important;
|
||||
}
|
||||
|
||||
.fui-autocomplete-dropdown .ant-select-item-option-selected {
|
||||
background-color: rgba(212, 175, 55, 0.25) !important;
|
||||
}
|
||||
|
||||
.fui-autocomplete-dropdown .ant-select-item-empty {
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
}
|
||||
|
||||
/* AutoComplete 下拉框滚动条 */
|
||||
.fui-autocomplete-dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.fui-autocomplete-dropdown::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.fui-autocomplete-dropdown::-webkit-scrollbar-thumb {
|
||||
background: rgba(212, 175, 55, 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.fui-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(212, 175, 55, 0.6);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ axios.interceptors.response.use(
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// 忽略取消请求的错误(组件卸载时正常行为)
|
||||
if (error.name === 'CanceledError' || axios.isCancel(error)) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const method = error.config?.method?.toUpperCase() || 'UNKNOWN';
|
||||
const url = error.config?.url || 'UNKNOWN';
|
||||
const requestData = error.config?.data || error.config?.params || null;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 性能监控工具 - 统计白屏时间和性能指标
|
||||
|
||||
import { logger } from './logger';
|
||||
import { reportPerformanceMetrics } from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* 性能指标接口
|
||||
@@ -208,6 +209,9 @@ class PerformanceMonitor {
|
||||
// 性能分析建议
|
||||
this.analyzePerformance();
|
||||
|
||||
// 上报性能指标到 PostHog
|
||||
reportPerformanceMetrics(this.metrics);
|
||||
|
||||
return this.metrics;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,3 +103,71 @@ export const PriceArrow = ({ value }) => {
|
||||
|
||||
return <Icon color={color} boxSize="16px" />;
|
||||
};
|
||||
|
||||
// ==================== 货币/数值格式化 ====================
|
||||
|
||||
/**
|
||||
* 格式化货币金额(自动选择单位:亿元/万元/元)
|
||||
* @param {number|null|undefined} value - 金额(单位:元)
|
||||
* @returns {string} 格式化后的金额字符串
|
||||
*/
|
||||
export const formatCurrency = (value) => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue >= 100000000) {
|
||||
return (value / 100000000).toFixed(2) + '亿元';
|
||||
} else if (absValue >= 10000) {
|
||||
return (value / 10000).toFixed(2) + '万元';
|
||||
}
|
||||
return value.toFixed(2) + '元';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化业务营收(支持指定单位)
|
||||
* @param {number|null|undefined} value - 营收金额
|
||||
* @param {string} [unit] - 原始单位(元/万元/亿元)
|
||||
* @returns {string} 格式化后的营收字符串
|
||||
*/
|
||||
export const formatBusinessRevenue = (value, unit) => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (unit) {
|
||||
if (unit === '元') {
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue >= 100000000) {
|
||||
return (value / 100000000).toFixed(2) + '亿元';
|
||||
} else if (absValue >= 10000) {
|
||||
return (value / 10000).toFixed(2) + '万元';
|
||||
}
|
||||
return value.toFixed(0) + '元';
|
||||
} else if (unit === '万元') {
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue >= 10000) {
|
||||
return (value / 10000).toFixed(2) + '亿元';
|
||||
}
|
||||
return value.toFixed(2) + '万元';
|
||||
} else if (unit === '亿元') {
|
||||
return value.toFixed(2) + '亿元';
|
||||
} else {
|
||||
return value.toFixed(2) + unit;
|
||||
}
|
||||
}
|
||||
// 无单位时,假设为元
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue >= 100000000) {
|
||||
return (value / 100000000).toFixed(2) + '亿元';
|
||||
} else if (absValue >= 10000) {
|
||||
return (value / 10000).toFixed(2) + '万元';
|
||||
}
|
||||
return value.toFixed(2) + '元';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
* @param {number|null|undefined} value - 百分比值
|
||||
* @param {number} [decimals=2] - 小数位数
|
||||
* @returns {string} 格式化后的百分比字符串
|
||||
*/
|
||||
export const formatPercentage = (value, decimals = 2) => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
return value.toFixed(decimals) + '%';
|
||||
};
|
||||
|
||||
@@ -52,18 +52,21 @@ export const useEventData = (filters, pageSize = 10) => {
|
||||
total: response.data?.pagination?.total
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setEvents(response.data.events);
|
||||
if (response.success && response.data) {
|
||||
const events = response.data.events || [];
|
||||
const paginationData = response.data.pagination || {};
|
||||
|
||||
setEvents(events);
|
||||
setPagination({
|
||||
current: response.data.pagination.page,
|
||||
pageSize: response.data.pagination.per_page,
|
||||
total: response.data.pagination.total
|
||||
current: paginationData.page || page,
|
||||
pageSize: paginationData.per_page || pagination.pageSize,
|
||||
total: paginationData.total || 0
|
||||
});
|
||||
setLastUpdateTime(new Date());
|
||||
|
||||
logger.debug('useEventData', 'loadEvents 成功', {
|
||||
count: response.data.events.length,
|
||||
total: response.data.pagination.total
|
||||
count: events.length,
|
||||
total: paginationData.total || 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,161 +1,31 @@
|
||||
// 简易版公司盈利预测报表视图
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Flex, Input, Button, SimpleGrid, HStack, Text, Skeleton, VStack } from '@chakra-ui/react';
|
||||
import { Card, CardHeader, CardBody, Heading, Table, Thead, Tr, Th, Tbody, Td, Tag } from '@chakra-ui/react';
|
||||
import { RepeatIcon } from '@chakra-ui/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import React from 'react';
|
||||
import { Box, Text, VStack, Icon } from '@chakra-ui/react';
|
||||
import { FaChartLine } from 'react-icons/fa';
|
||||
|
||||
const ForecastReport = ({ stockCode: propStockCode }) => {
|
||||
const [code, setCode] = useState(propStockCode || '600000');
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (!code) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await stockService.getForecastReport(code);
|
||||
if (resp && resp.success) setData(resp.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听props中的stockCode变化
|
||||
useEffect(() => {
|
||||
if (propStockCode && propStockCode !== code) {
|
||||
setCode(propStockCode);
|
||||
}
|
||||
}, [propStockCode, code]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
load();
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
const years = data?.detail_table?.years || [];
|
||||
|
||||
const colors = ['#805AD5', '#38B2AC', '#F6AD55', '#63B3ED', '#E53E3E', '#10B981'];
|
||||
|
||||
const incomeProfitOption = data ? {
|
||||
color: [colors[0], colors[4]],
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['营业总收入(百万元)', '归母净利润(百万元)'] },
|
||||
grid: { left: 40, right: 20, bottom: 40, top: 30 },
|
||||
xAxis: { type: 'category', data: data.income_profit_trend.years, axisLabel: { rotate: 30 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '收入(百万元)' },
|
||||
{ type: 'value', name: '利润(百万元)' }
|
||||
],
|
||||
series: [
|
||||
{ name: '营业总收入(百万元)', type: 'line', data: data.income_profit_trend.income, smooth: true, lineStyle: { width: 2 }, areaStyle: { opacity: 0.08 } },
|
||||
{ name: '归母净利润(百万元)', type: 'line', yAxisIndex: 1, data: data.income_profit_trend.profit, smooth: true, lineStyle: { width: 2 } }
|
||||
]
|
||||
} : {};
|
||||
|
||||
const growthOption = data ? {
|
||||
color: [colors[2]],
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, bottom: 40, top: 30 },
|
||||
xAxis: { type: 'category', data: data.growth_bars.years, axisLabel: { rotate: 30 } },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: '{value}%' } },
|
||||
series: [ {
|
||||
name: '营收增长率(%)',
|
||||
type: 'bar',
|
||||
data: data.growth_bars.revenue_growth_pct,
|
||||
itemStyle: { color: (params) => params.value >= 0 ? '#E53E3E' : '#10B981' }
|
||||
} ]
|
||||
} : {};
|
||||
|
||||
const epsOption = data ? {
|
||||
color: [colors[3]],
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, bottom: 40, top: 30 },
|
||||
xAxis: { type: 'category', data: data.eps_trend.years, axisLabel: { rotate: 30 } },
|
||||
yAxis: { type: 'value', name: '元/股' },
|
||||
series: [ { name: 'EPS(稀释)', type: 'line', data: data.eps_trend.eps, smooth: true, areaStyle: { opacity: 0.1 }, lineStyle: { width: 2 } } ]
|
||||
} : {};
|
||||
|
||||
const pePegOption = data ? {
|
||||
color: [colors[0], colors[1]],
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['PE', 'PEG'] },
|
||||
grid: { left: 40, right: 40, bottom: 40, top: 30 },
|
||||
xAxis: { type: 'category', data: data.pe_peg_axes.years, axisLabel: { rotate: 30 } },
|
||||
yAxis: [ { type: 'value', name: 'PE(倍)' }, { type: 'value', name: 'PEG' } ],
|
||||
series: [
|
||||
{ name: 'PE', type: 'line', data: data.pe_peg_axes.pe, smooth: true },
|
||||
{ name: 'PEG', type: 'line', yAxisIndex: 1, data: data.pe_peg_axes.peg, smooth: true }
|
||||
]
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<Box p={4}>
|
||||
<HStack align="center" justify="space-between" mb={4}>
|
||||
<Heading size="md">盈利预测报表</Heading>
|
||||
<Button
|
||||
leftIcon={<RepeatIcon />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={load}
|
||||
isLoading={loading}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{loading && !data && (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{[1,2,3,4].map(i => (
|
||||
<Card key={i}>
|
||||
<CardHeader><Skeleton height="18px" width="140px" /></CardHeader>
|
||||
<CardBody>
|
||||
<Skeleton height="320px" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<Card><CardHeader><Heading size="sm">营业收入与净利润趋势</Heading></CardHeader><CardBody><ReactECharts option={incomeProfitOption} style={{ height: 320 }} /></CardBody></Card>
|
||||
<Card><CardHeader><Heading size="sm">增长率分析</Heading></CardHeader><CardBody><ReactECharts option={growthOption} style={{ height: 320 }} /></CardBody></Card>
|
||||
<Card><CardHeader><Heading size="sm">EPS 趋势</Heading></CardHeader><CardBody><ReactECharts option={epsOption} style={{ height: 320 }} /></CardBody></Card>
|
||||
<Card><CardHeader><Heading size="sm">PE 与 PEG 分析</Heading></CardHeader><CardBody><ReactECharts option={pePegOption} style={{ height: 320 }} /></CardBody></Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<Card mt={4}>
|
||||
<CardHeader><Heading size="sm">详细数据表格</Heading></CardHeader>
|
||||
<CardBody>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>关键指标</Th>
|
||||
{years.map(y => <Th key={y}>{y}</Th>)}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{data.detail_table.rows.map((row, idx) => (
|
||||
<Tr key={idx}>
|
||||
<Td><Tag>{row['指标']}</Tag></Td>
|
||||
{years.map(y => <Td key={y}>{row[y] ?? '-'}</Td>)}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
/**
|
||||
* 预测报告组件 - 占位符
|
||||
* TODO: 实现完整功能
|
||||
*/
|
||||
const ForecastReport = ({ stockCode }) => {
|
||||
return (
|
||||
<Box
|
||||
p={8}
|
||||
borderRadius="lg"
|
||||
bg="gray.50"
|
||||
_dark={{ bg: 'gray.800' }}
|
||||
textAlign="center"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Icon as={FaChartLine} boxSize={12} color="gray.400" />
|
||||
<Text fontSize="lg" fontWeight="medium" color="gray.600" _dark={{ color: 'gray.400' }}>
|
||||
预测报告功能开发中
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
股票代码: {stockCode || '未选择'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastReport;
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1253
src/views/Company/STRUCTURE.md
Normal file
1253
src/views/Company/STRUCTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
41
src/views/Company/components/CompanyHeader/README.md
Normal file
41
src/views/Company/components/CompanyHeader/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# CompanyHeader 组件
|
||||
|
||||
Company 页面顶部搜索栏组件,采用 FUI 科幻风格。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
CompanyHeader/
|
||||
├── index.tsx # 主组件入口
|
||||
├── constants.ts # 样式常量配置
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 功能说明
|
||||
|
||||
- 股票代码/名称搜索(AutoComplete)
|
||||
- 搜索结果下拉展示
|
||||
- 支持拼音缩写搜索
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
CompanyHeader
|
||||
└── SearchBox # 搜索框子组件
|
||||
└── AutoComplete (antd) # 自动完成输入
|
||||
└── Input # 搜索输入框
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```tsx
|
||||
import CompanyHeader from '@views/Company/components/CompanyHeader';
|
||||
|
||||
<CompanyHeader onStockChange={(code) => handleStockChange(code)} />
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| 属性 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `onStockChange` | `(code: string) => void` | 是 | 股票切换回调 |
|
||||
70
src/views/Company/components/CompanyHeader/constants.ts
Normal file
70
src/views/Company/components/CompanyHeader/constants.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* CompanyHeader 组件常量
|
||||
*/
|
||||
|
||||
import { FUI_COLORS, FUI_GLOW, FUI_ANIMATION } from '../../theme/fui';
|
||||
|
||||
/** 下拉菜单样式 */
|
||||
export const DROPDOWN_STYLE: React.CSSProperties = {
|
||||
backgroundColor: FUI_COLORS.bg.elevated,
|
||||
borderRadius: '6px',
|
||||
border: `1px solid ${FUI_COLORS.gold[400]}`,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
||||
};
|
||||
|
||||
/** 搜索图标样式 */
|
||||
export const SEARCH_ICON_STYLE: React.CSSProperties = {
|
||||
color: FUI_COLORS.gold[400],
|
||||
fontSize: 16,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
/** 输入框样式 */
|
||||
export const INPUT_STYLE: React.CSSProperties = {
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: FUI_COLORS.gold[400],
|
||||
borderRadius: 6,
|
||||
height: 44,
|
||||
color: FUI_COLORS.gold[400],
|
||||
};
|
||||
|
||||
/** AutoComplete 宽度样式 */
|
||||
export const AUTOCOMPLETE_STYLE: React.CSSProperties = {
|
||||
width: 320,
|
||||
};
|
||||
|
||||
/** 搜索框容器样式 */
|
||||
export const SEARCH_BOX_SX = {
|
||||
'.ant-select': {
|
||||
width: '320px !important',
|
||||
},
|
||||
'.ant-input-affix-wrapper': {
|
||||
backgroundColor: 'transparent !important',
|
||||
borderColor: `${FUI_COLORS.gold[400]} !important`,
|
||||
borderWidth: '1px !important',
|
||||
borderRadius: '6px !important',
|
||||
height: '44px !important',
|
||||
padding: '0 12px !important',
|
||||
transition: `all ${FUI_ANIMATION.duration.fast} ${FUI_ANIMATION.easing.default}`,
|
||||
'&:hover': {
|
||||
borderColor: `${FUI_COLORS.gold[300]} !important`,
|
||||
boxShadow: FUI_GLOW.gold.sm,
|
||||
},
|
||||
'&:focus-within, &.ant-input-affix-wrapper-focused': {
|
||||
borderColor: `${FUI_COLORS.gold[300]} !important`,
|
||||
boxShadow: `${FUI_GLOW.gold.md} !important`,
|
||||
},
|
||||
},
|
||||
'.ant-input': {
|
||||
backgroundColor: 'transparent !important',
|
||||
color: `${FUI_COLORS.gold[400]} !important`,
|
||||
fontSize: '14px !important',
|
||||
'&::placeholder': {
|
||||
color: `${FUI_COLORS.gold[400]} !important`,
|
||||
opacity: '0.7 !important',
|
||||
},
|
||||
},
|
||||
'.ant-input-prefix': {
|
||||
marginRight: '8px !important',
|
||||
},
|
||||
} as const;
|
||||
153
src/views/Company/components/CompanyHeader/index.tsx
Normal file
153
src/views/Company/components/CompanyHeader/index.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Company 页面顶部搜索栏组件 - FUI 科幻风格
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Flex, HStack, VStack, Text } from '@chakra-ui/react';
|
||||
import { AutoComplete, Input, Spin } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { useStockSearch } from '@hooks/useStockSearch';
|
||||
import { THEME } from '../../config';
|
||||
import { FUI_COLORS, FUI_GLOW } from '../../theme/fui';
|
||||
import type { CompanyHeaderProps, StockSearchResult } from '../../types';
|
||||
import {
|
||||
DROPDOWN_STYLE,
|
||||
SEARCH_ICON_STYLE,
|
||||
INPUT_STYLE,
|
||||
AUTOCOMPLETE_STYLE,
|
||||
SEARCH_BOX_SX,
|
||||
} from './constants';
|
||||
|
||||
// ============================================
|
||||
// SearchBox 子组件
|
||||
// ============================================
|
||||
|
||||
const SearchBox = memo<{
|
||||
onStockChange: (value: string) => void;
|
||||
}>(({ onStockChange }) => {
|
||||
const [inputCode, setInputCode] = useState('');
|
||||
|
||||
const {
|
||||
searchResults,
|
||||
isSearching,
|
||||
handleSearch: doSearch,
|
||||
clearSearch,
|
||||
} = useStockSearch({
|
||||
limit: 10,
|
||||
debounceMs: 300,
|
||||
onSearch: () => {},
|
||||
}) as {
|
||||
searchResults: StockSearchResult[];
|
||||
isSearching: boolean;
|
||||
handleSearch: (query: string) => void;
|
||||
clearSearch: () => void;
|
||||
};
|
||||
|
||||
const stockOptions = useMemo(() => (
|
||||
searchResults.map((stock: StockSearchResult) => ({
|
||||
value: stock.stock_code,
|
||||
label: (
|
||||
<Flex justify="space-between" align="center" py={1}>
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="bold" color={THEME.gold}>{stock.stock_code}</Text>
|
||||
<Text color={THEME.textPrimary}>{stock.stock_name}</Text>
|
||||
</HStack>
|
||||
{stock.pinyin_abbr && (
|
||||
<Text fontSize="xs" color={THEME.textMuted}>
|
||||
{stock.pinyin_abbr.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
}))
|
||||
), [searchResults]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (inputCode) {
|
||||
onStockChange(inputCode);
|
||||
}
|
||||
}, [inputCode, onStockChange]);
|
||||
|
||||
const handleSelect = useCallback((value: string) => {
|
||||
clearSearch();
|
||||
setInputCode(value);
|
||||
onStockChange(value);
|
||||
}, [clearSearch, onStockChange]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}, [handleSearch]);
|
||||
|
||||
const searchIcon = useMemo(() => (
|
||||
<SearchOutlined style={SEARCH_ICON_STYLE} onClick={handleSearch} />
|
||||
), [handleSearch]);
|
||||
|
||||
return (
|
||||
<Box sx={SEARCH_BOX_SX}>
|
||||
<AutoComplete
|
||||
classNames={{ popup: { root: 'fui-autocomplete-dropdown' } }}
|
||||
styles={{ popup: { root: DROPDOWN_STYLE } }}
|
||||
value={inputCode}
|
||||
options={stockOptions}
|
||||
onSearch={doSearch}
|
||||
onSelect={handleSelect}
|
||||
onChange={setInputCode}
|
||||
style={AUTOCOMPLETE_STYLE}
|
||||
notFoundContent={isSearching ? <Spin size="small" /> : null}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入股票代码或名称"
|
||||
prefix={searchIcon}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
</AutoComplete>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SearchBox.displayName = 'SearchBox';
|
||||
|
||||
// ============================================
|
||||
// CompanyHeader 主组件
|
||||
// ============================================
|
||||
|
||||
const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({ onStockChange }) => (
|
||||
<Box
|
||||
position="relative"
|
||||
bg={FUI_COLORS.bg.primary}
|
||||
borderBottom="1px solid"
|
||||
borderColor={FUI_COLORS.line.default}
|
||||
px={6}
|
||||
py={4}
|
||||
>
|
||||
<Flex
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
maxW="container.xl"
|
||||
mx="auto"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
color={FUI_COLORS.gold[400]}
|
||||
letterSpacing="wider"
|
||||
textShadow={FUI_GLOW.text.gold}
|
||||
>
|
||||
个股详情
|
||||
</Text>
|
||||
<Text fontSize="sm" color={FUI_COLORS.text.muted} letterSpacing="wide">
|
||||
查看股票实时行情、财务数据和盈利预测
|
||||
</Text>
|
||||
</VStack>
|
||||
<SearchBox onStockChange={onStockChange} />
|
||||
</Flex>
|
||||
</Box>
|
||||
));
|
||||
|
||||
CompanyHeader.displayName = 'CompanyHeader';
|
||||
|
||||
export default CompanyHeader;
|
||||
@@ -0,0 +1,161 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx
|
||||
// 公司公告 Tab Panel
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
IconButton,
|
||||
Button,
|
||||
Tag,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { ExternalLinkIcon } from "@chakra-ui/icons";
|
||||
|
||||
import { useAnnouncementsData } from "../../hooks/useAnnouncementsData";
|
||||
import { THEME } from "../config";
|
||||
import { formatDate } from "../utils";
|
||||
import LoadingState from "./LoadingState";
|
||||
|
||||
interface AnnouncementsPanelProps {
|
||||
stockCode: string;
|
||||
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||
isActive?: boolean;
|
||||
/** 激活次数,变化时触发重新请求 */
|
||||
activationKey?: number;
|
||||
}
|
||||
|
||||
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode, isActive = true, activationKey }) => {
|
||||
const { announcements, loading } = useAnnouncementsData({ stockCode, enabled: isActive, refreshKey: activationKey });
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);
|
||||
|
||||
const handleAnnouncementClick = (announcement: any) => {
|
||||
setSelectedAnnouncement(announcement);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="加载公告数据..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 最新公告 */}
|
||||
<Box>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{announcements.map((announcement: any, idx: number) => (
|
||||
<Card
|
||||
key={idx}
|
||||
bg={THEME.tableBg}
|
||||
border="1px solid"
|
||||
borderColor={THEME.border}
|
||||
size="sm"
|
||||
cursor="pointer"
|
||||
onClick={() => handleAnnouncementClick(announcement)}
|
||||
_hover={{ bg: THEME.tableHoverBg }}
|
||||
>
|
||||
<CardBody p={3}>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Badge size="sm" bg={THEME.gold} color="gray.900">
|
||||
{announcement.info_type || "公告"}
|
||||
</Badge>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||
{formatDate(announcement.announce_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" fontWeight="medium" noOfLines={1} color={THEME.textPrimary}>
|
||||
{announcement.title}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack>
|
||||
{announcement.format && (
|
||||
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
|
||||
{announcement.format}
|
||||
</Tag>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={<ExternalLinkIcon />}
|
||||
variant="ghost"
|
||||
color={THEME.goldLight}
|
||||
aria-label="查看原文"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(announcement.url, "_blank");
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* 公告详情模态框 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={THEME.cardBg}>
|
||||
<ModalHeader color={THEME.textPrimary}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text>{selectedAnnouncement?.title}</Text>
|
||||
<HStack>
|
||||
<Badge bg={THEME.gold} color="gray.900">
|
||||
{selectedAnnouncement?.info_type || "公告"}
|
||||
</Badge>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
{formatDate(selectedAnnouncement?.announce_date)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color={THEME.textPrimary} />
|
||||
<ModalBody>
|
||||
<VStack align="start" spacing={3}>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
文件格式:{selectedAnnouncement?.format || "-"}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
文件大小:{selectedAnnouncement?.file_size || "-"} KB
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
bg={THEME.gold}
|
||||
color="gray.900"
|
||||
mr={3}
|
||||
_hover={{ bg: THEME.goldLight }}
|
||||
onClick={() => window.open(selectedAnnouncement?.url, "_blank")}
|
||||
>
|
||||
查看原文
|
||||
</Button>
|
||||
<Button variant="ghost" color={THEME.textSecondary} onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsPanel;
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* BasicInfoTab 骨架屏组件
|
||||
* 用于各个 Tab 面板的加载状态显示
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
SkeletonCircle,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
// 黑金主题骨架屏样式
|
||||
const skeletonStyles = {
|
||||
startColor: 'rgba(212, 175, 55, 0.1)',
|
||||
endColor: 'rgba(212, 175, 55, 0.2)',
|
||||
};
|
||||
|
||||
// 卡片骨架屏样式
|
||||
const cardStyle = {
|
||||
bg: 'linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))',
|
||||
border: '1px solid',
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
borderRadius: '12px',
|
||||
p: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* 分支机构骨架屏
|
||||
*/
|
||||
export const BranchesSkeleton: React.FC = () => (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Box key={i} sx={cardStyle}>
|
||||
{/* 顶部金色装饰线 */}
|
||||
<Box
|
||||
h="2px"
|
||||
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.3), transparent)"
|
||||
mb={4}
|
||||
/>
|
||||
<VStack align="start" spacing={4}>
|
||||
{/* 标题行 */}
|
||||
<HStack justify="space-between" w="full">
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Skeleton
|
||||
{...skeletonStyles}
|
||||
height="28px"
|
||||
width="28px"
|
||||
borderRadius="md"
|
||||
/>
|
||||
<Skeleton
|
||||
{...skeletonStyles}
|
||||
height="16px"
|
||||
width="60%"
|
||||
/>
|
||||
</HStack>
|
||||
<Skeleton
|
||||
{...skeletonStyles}
|
||||
height="22px"
|
||||
width="60px"
|
||||
borderRadius="full"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box
|
||||
w="full"
|
||||
h="1px"
|
||||
bgGradient="linear(to-r, rgba(212, 175, 55, 0.2), transparent)"
|
||||
/>
|
||||
|
||||
{/* 信息网格 */}
|
||||
<SimpleGrid columns={2} spacing={3} w="full">
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<VStack key={j} align="start" spacing={1}>
|
||||
<Skeleton {...skeletonStyles} height="12px" width="50px" />
|
||||
<Skeleton {...skeletonStyles} height="14px" width="80px" />
|
||||
</VStack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
/**
|
||||
* 工商信息骨架屏
|
||||
*/
|
||||
export const BusinessInfoSkeleton: React.FC = () => (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 上半部分:工商信息 + 服务机构 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
|
||||
{/* 工商信息卡片 */}
|
||||
<Box sx={cardStyle}>
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Skeleton {...skeletonStyles} height="16px" width="16px" />
|
||||
<Skeleton {...skeletonStyles} height="16px" width="80px" />
|
||||
</HStack>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<HStack key={i} spacing={3} p={2}>
|
||||
<Skeleton {...skeletonStyles} height="14px" width="14px" />
|
||||
<Skeleton {...skeletonStyles} height="14px" width="60px" />
|
||||
<Skeleton {...skeletonStyles} height="14px" flex={1} />
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 服务机构卡片 */}
|
||||
<Box sx={cardStyle}>
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Skeleton {...skeletonStyles} height="16px" width="16px" />
|
||||
<Skeleton {...skeletonStyles} height="16px" width="80px" />
|
||||
</HStack>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{[1, 2].map((i) => (
|
||||
<Box key={i} p={4} borderRadius="10px" bg="rgba(255,255,255,0.02)">
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Skeleton {...skeletonStyles} height="14px" width="14px" />
|
||||
<Skeleton {...skeletonStyles} height="12px" width="80px" />
|
||||
</HStack>
|
||||
<Skeleton {...skeletonStyles} height="14px" width="70%" />
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 下半部分:主营业务 + 经营范围 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
|
||||
{[1, 2].map((i) => (
|
||||
<Box key={i} sx={cardStyle}>
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Skeleton {...skeletonStyles} height="16px" width="16px" />
|
||||
<Skeleton {...skeletonStyles} height="16px" width="80px" />
|
||||
</HStack>
|
||||
<SkeletonText
|
||||
{...skeletonStyles}
|
||||
noOfLines={4}
|
||||
spacing={3}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
/**
|
||||
* 股权结构骨架屏
|
||||
*/
|
||||
export const ShareholderSkeleton: React.FC = () => (
|
||||
<Box p={4}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 实际控制人 + 股权集中度 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
||||
{[1, 2].map((i) => (
|
||||
<Box key={i} sx={cardStyle}>
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Skeleton {...skeletonStyles} height="18px" width="18px" />
|
||||
<Skeleton {...skeletonStyles} height="18px" width="100px" />
|
||||
</HStack>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{[1, 2, 3].map((j) => (
|
||||
<HStack key={j} justify="space-between">
|
||||
<Skeleton {...skeletonStyles} height="14px" width="80px" />
|
||||
<Skeleton {...skeletonStyles} height="14px" width="60px" />
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 十大股东表格 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
||||
{[1, 2].map((i) => (
|
||||
<Box key={i} sx={cardStyle}>
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Skeleton {...skeletonStyles} height="18px" width="18px" />
|
||||
<Skeleton {...skeletonStyles} height="18px" width="100px" />
|
||||
</HStack>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{/* 表头 */}
|
||||
<HStack spacing={4} pb={2} borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.1)">
|
||||
<Skeleton {...skeletonStyles} height="12px" width="30px" />
|
||||
<Skeleton {...skeletonStyles} height="12px" flex={1} />
|
||||
<Skeleton {...skeletonStyles} height="12px" width="60px" />
|
||||
<Skeleton {...skeletonStyles} height="12px" width="60px" />
|
||||
</HStack>
|
||||
{/* 表格行 */}
|
||||
{[1, 2, 3, 4, 5].map((j) => (
|
||||
<HStack key={j} spacing={4} py={2}>
|
||||
<SkeletonCircle {...skeletonStyles} size="6" />
|
||||
<Skeleton {...skeletonStyles} height="14px" flex={1} />
|
||||
<Skeleton {...skeletonStyles} height="14px" width="60px" />
|
||||
<Skeleton {...skeletonStyles} height="14px" width="60px" />
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* 管理团队骨架屏
|
||||
*/
|
||||
export const ManagementSkeleton: React.FC = () => (
|
||||
<Box p={4}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 每个分类 */}
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Box key={i}>
|
||||
{/* 分类标题 */}
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Skeleton {...skeletonStyles} height="20px" width="20px" />
|
||||
<Skeleton {...skeletonStyles} height="18px" width="80px" />
|
||||
<Skeleton
|
||||
{...skeletonStyles}
|
||||
height="20px"
|
||||
width="30px"
|
||||
borderRadius="full"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 人员卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<Box key={j} sx={cardStyle}>
|
||||
<VStack spacing={3}>
|
||||
<SkeletonCircle {...skeletonStyles} size="12" />
|
||||
<Skeleton {...skeletonStyles} height="16px" width="60px" />
|
||||
<Skeleton {...skeletonStyles} height="12px" width="80px" />
|
||||
<HStack spacing={2}>
|
||||
<Skeleton {...skeletonStyles} height="10px" width="40px" />
|
||||
<Skeleton {...skeletonStyles} height="10px" width="40px" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* 通用内容骨架屏
|
||||
*/
|
||||
export const ContentSkeleton: React.FC = () => (
|
||||
<Box p={4}>
|
||||
<SkeletonText {...skeletonStyles} noOfLines={6} spacing={4} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default {
|
||||
BranchesSkeleton,
|
||||
BusinessInfoSkeleton,
|
||||
ShareholderSkeleton,
|
||||
ManagementSkeleton,
|
||||
ContentSkeleton,
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx
|
||||
// 分支机构 Tab Panel - 黑金风格
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
Center,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaSitemap, FaBuilding, FaCheckCircle, FaTimesCircle } from "react-icons/fa";
|
||||
|
||||
import { useBranchesData } from "../../hooks/useBranchesData";
|
||||
import { THEME } from "../config";
|
||||
import { formatDate } from "../utils";
|
||||
import { BranchesSkeleton } from "./BasicInfoTabSkeleton";
|
||||
|
||||
interface BranchesPanelProps {
|
||||
stockCode: string;
|
||||
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// 黑金卡片样式
|
||||
const cardStyles = {
|
||||
bg: "linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))",
|
||||
border: "1px solid",
|
||||
borderColor: "rgba(212, 175, 55, 0.3)",
|
||||
borderRadius: "12px",
|
||||
overflow: "hidden",
|
||||
transition: "all 0.3s ease",
|
||||
_hover: {
|
||||
borderColor: "rgba(212, 175, 55, 0.6)",
|
||||
boxShadow: "0 4px 20px rgba(212, 175, 55, 0.15), inset 0 1px 0 rgba(212, 175, 55, 0.1)",
|
||||
transform: "translateY(-2px)",
|
||||
},
|
||||
};
|
||||
|
||||
// 状态徽章样式
|
||||
const getStatusBadgeStyles = (isActive: boolean) => ({
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: "full",
|
||||
fontSize: "xs",
|
||||
fontWeight: "medium",
|
||||
bg: isActive ? "rgba(212, 175, 55, 0.15)" : "rgba(255, 100, 100, 0.15)",
|
||||
color: isActive ? THEME.gold : "#ff6b6b",
|
||||
border: "1px solid",
|
||||
borderColor: isActive ? "rgba(212, 175, 55, 0.3)" : "rgba(255, 100, 100, 0.3)",
|
||||
});
|
||||
|
||||
// 信息项组件
|
||||
const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
|
||||
<VStack align="start" spacing={0.5}>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} letterSpacing="0.5px">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="semibold" color={THEME.textPrimary}>
|
||||
{value || "-"}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode, isActive = true }) => {
|
||||
const { branches, loading } = useBranchesData({ stockCode, enabled: isActive });
|
||||
|
||||
if (loading) {
|
||||
return <BranchesSkeleton />;
|
||||
}
|
||||
|
||||
if (branches.length === 0) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<VStack spacing={3}>
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="full"
|
||||
bg="rgba(212, 175, 55, 0.1)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.2)"
|
||||
>
|
||||
<Icon as={FaSitemap} boxSize={10} color={THEME.gold} opacity={0.6} />
|
||||
</Box>
|
||||
<Text color={THEME.textSecondary} fontSize="sm">
|
||||
暂无分支机构信息
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{branches.map((branch: any, idx: number) => {
|
||||
const isActive = branch.business_status === "存续";
|
||||
|
||||
return (
|
||||
<Box key={idx} sx={cardStyles}>
|
||||
{/* 顶部金色装饰线 */}
|
||||
<Box
|
||||
h="2px"
|
||||
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.6), transparent)"
|
||||
/>
|
||||
|
||||
<Box p={4}>
|
||||
<VStack align="start" spacing={4}>
|
||||
{/* 标题行 */}
|
||||
<HStack justify="space-between" w="full" align="flex-start">
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Box
|
||||
p={1.5}
|
||||
borderRadius="md"
|
||||
bg="rgba(212, 175, 55, 0.1)"
|
||||
>
|
||||
<Icon as={FaBuilding} boxSize={3.5} color={THEME.gold} />
|
||||
</Box>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={THEME.textPrimary}
|
||||
fontSize="sm"
|
||||
noOfLines={2}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{branch.branch_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 状态徽章 */}
|
||||
<Box sx={getStatusBadgeStyles(isActive)}>
|
||||
<Icon
|
||||
as={isActive ? FaCheckCircle : FaTimesCircle}
|
||||
boxSize={3}
|
||||
/>
|
||||
<Text>{branch.business_status}</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box
|
||||
w="full"
|
||||
h="1px"
|
||||
bgGradient="linear(to-r, rgba(212, 175, 55, 0.3), transparent)"
|
||||
/>
|
||||
|
||||
{/* 信息网格 */}
|
||||
<SimpleGrid columns={2} spacing={3} w="full">
|
||||
<InfoItem label="注册资本" value={branch.register_capital} />
|
||||
<InfoItem label="法人代表" value={branch.legal_person} />
|
||||
<InfoItem label="成立日期" value={formatDate(branch.register_date)} />
|
||||
<InfoItem
|
||||
label="关联企业"
|
||||
value={`${branch.related_company_count || 0} 家`}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
export default BranchesPanel;
|
||||
@@ -0,0 +1,239 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx
|
||||
// 工商信息 Tab Panel - FUI 风格
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Center,
|
||||
Icon,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
FaBuilding,
|
||||
FaMapMarkerAlt,
|
||||
FaIdCard,
|
||||
FaUsers,
|
||||
FaBalanceScale,
|
||||
FaCalculator,
|
||||
FaBriefcase,
|
||||
FaFileAlt,
|
||||
} from "react-icons/fa";
|
||||
|
||||
// 使用统一主题
|
||||
import { COLORS, GLASS, glassCardStyle } from "@views/Company/theme";
|
||||
import { THEME } from "../config";
|
||||
import { useBasicInfo } from "../../hooks/useBasicInfo";
|
||||
import { BusinessInfoSkeleton } from "./BasicInfoTabSkeleton";
|
||||
|
||||
interface BusinessInfoPanelProps {
|
||||
stockCode: string;
|
||||
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// 区块标题组件
|
||||
const SectionTitle: React.FC<{ icon: React.ElementType; title: string }> = ({
|
||||
icon,
|
||||
title,
|
||||
}) => (
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Icon as={icon} color={COLORS.gold} boxSize={4} />
|
||||
<Text
|
||||
fontSize="14px"
|
||||
fontWeight="700"
|
||||
color={COLORS.gold}
|
||||
textTransform="uppercase"
|
||||
letterSpacing="0.05em"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Box flex={1} h="1px" bg={`linear-gradient(90deg, ${COLORS.border}, transparent)`} />
|
||||
</HStack>
|
||||
);
|
||||
|
||||
// 信息行组件
|
||||
const InfoRow: React.FC<{
|
||||
icon?: React.ElementType;
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
isCode?: boolean;
|
||||
isMultiline?: boolean;
|
||||
}> = ({ icon, label, value, isCode, isMultiline }) => (
|
||||
<HStack
|
||||
w="full"
|
||||
align={isMultiline ? "start" : "center"}
|
||||
spacing={3}
|
||||
py={2}
|
||||
px={3}
|
||||
borderRadius="8px"
|
||||
bg={GLASS.bgDark}
|
||||
_hover={{ bg: GLASS.bgGold }}
|
||||
transition="all 0.15s ease"
|
||||
>
|
||||
{icon && <Icon as={icon} color={COLORS.goldLight} boxSize={3.5} opacity={0.8} />}
|
||||
<Text fontSize="13px" color={COLORS.textSecondary} minW="70px" flexShrink={0}>
|
||||
{label}
|
||||
</Text>
|
||||
{isCode ? (
|
||||
<Text
|
||||
fontSize="12px"
|
||||
fontFamily="mono"
|
||||
bg="rgba(212, 175, 55, 0.15)"
|
||||
color={COLORS.goldLight}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="4px"
|
||||
letterSpacing="0.05em"
|
||||
>
|
||||
{value || "-"}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
fontSize="13px"
|
||||
color={COLORS.textPrimary}
|
||||
fontWeight="500"
|
||||
noOfLines={isMultiline ? 2 : 1}
|
||||
flex={1}
|
||||
>
|
||||
{value || "-"}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
// 服务机构卡片
|
||||
const ServiceCard: React.FC<{
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}> = ({ icon, label, value }) => (
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="10px"
|
||||
bg={GLASS.bgDark}
|
||||
border={`1px solid ${COLORS.borderSubtle}`}
|
||||
_hover={{ borderColor: COLORS.border }}
|
||||
transition="all 0.15s ease"
|
||||
>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={icon} color={COLORS.goldLight} boxSize={3.5} />
|
||||
<Text fontSize="12px" color={COLORS.textSecondary}>
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="13px" color={COLORS.textPrimary} fontWeight="500" noOfLines={2}>
|
||||
{value || "-"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// 文本区块组件
|
||||
const TextSection: React.FC<{
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
content: string | undefined;
|
||||
}> = ({ icon, title, content }) => (
|
||||
<Box {...glassCardStyle} p={4}>
|
||||
<SectionTitle icon={icon} title={title} />
|
||||
<Text
|
||||
fontSize="13px"
|
||||
lineHeight="1.8"
|
||||
color={COLORS.textSecondary}
|
||||
sx={{
|
||||
textIndent: "2em",
|
||||
textAlign: "justify",
|
||||
}}
|
||||
>
|
||||
{content || "暂无信息"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode, isActive = true }) => {
|
||||
const { basicInfo, loading } = useBasicInfo({ stockCode, enabled: isActive });
|
||||
|
||||
if (loading) {
|
||||
return <BusinessInfoSkeleton />;
|
||||
}
|
||||
|
||||
if (!basicInfo) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<Text color={THEME.textSecondary}>暂无工商信息</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 上半部分:工商信息 + 服务机构 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
|
||||
{/* 工商信息卡片 */}
|
||||
<Box {...glassCardStyle} p={4}>
|
||||
<SectionTitle icon={FaBuilding} title="工商信息" />
|
||||
<VStack spacing={2} align="stretch">
|
||||
<InfoRow
|
||||
icon={FaIdCard}
|
||||
label="信用代码"
|
||||
value={basicInfo.credit_code}
|
||||
isCode
|
||||
/>
|
||||
<InfoRow
|
||||
icon={FaUsers}
|
||||
label="公司规模"
|
||||
value={basicInfo.company_size}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={FaMapMarkerAlt}
|
||||
label="注册地址"
|
||||
value={basicInfo.reg_address}
|
||||
isMultiline
|
||||
/>
|
||||
<InfoRow
|
||||
icon={FaMapMarkerAlt}
|
||||
label="办公地址"
|
||||
value={basicInfo.office_address}
|
||||
isMultiline
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 服务机构卡片 */}
|
||||
<Box {...glassCardStyle} p={4}>
|
||||
<SectionTitle icon={FaBalanceScale} title="服务机构" />
|
||||
<VStack spacing={3} align="stretch">
|
||||
<ServiceCard
|
||||
icon={FaCalculator}
|
||||
label="会计师事务所"
|
||||
value={basicInfo.accounting_firm}
|
||||
/>
|
||||
<ServiceCard
|
||||
icon={FaBalanceScale}
|
||||
label="律师事务所"
|
||||
value={basicInfo.law_firm}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 下半部分:主营业务 + 经营范围 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
|
||||
<TextSection
|
||||
icon={FaBriefcase}
|
||||
title="主营业务"
|
||||
content={basicInfo.main_business}
|
||||
/>
|
||||
<TextSection
|
||||
icon={FaFileAlt}
|
||||
title="经营范围"
|
||||
content={basicInfo.business_scope}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessInfoPanel;
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 公司概览 - 导航骨架屏组件
|
||||
*
|
||||
* 用于懒加载时显示,让二级导航立即可见
|
||||
* 导航使用真实 UI,内容区域显示骨架屏
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Skeleton,
|
||||
VStack,
|
||||
Card,
|
||||
CardBody,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaShareAlt,
|
||||
FaUserTie,
|
||||
FaSitemap,
|
||||
FaInfoCircle,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
// 深空 FUI 主题配置(与 SubTabContainer 保持一致)
|
||||
const DEEP_SPACE = {
|
||||
bgGlass: 'rgba(12, 14, 28, 0.6)',
|
||||
borderGold: 'rgba(212, 175, 55, 0.2)',
|
||||
borderGoldHover: 'rgba(212, 175, 55, 0.5)',
|
||||
glowGold: '0 0 30px rgba(212, 175, 55, 0.25), 0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||
innerGlow: 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
|
||||
textWhite: 'rgba(255, 255, 255, 0.95)',
|
||||
textDark: '#0A0A14',
|
||||
selectedBg: 'linear-gradient(135deg, rgba(212, 175, 55, 0.95) 0%, rgba(184, 150, 12, 0.95) 100%)',
|
||||
radius: '12px',
|
||||
radiusLG: '16px',
|
||||
};
|
||||
|
||||
// 导航配置(与主组件 config.ts 保持同步)
|
||||
const OVERVIEW_TABS = [
|
||||
{ key: 'shareholder', name: '股权结构', icon: FaShareAlt },
|
||||
{ key: 'management', name: '管理团队', icon: FaUserTie },
|
||||
{ key: 'branches', name: '分支机构', icon: FaSitemap },
|
||||
{ key: 'business', name: '工商信息', icon: FaInfoCircle },
|
||||
];
|
||||
|
||||
/**
|
||||
* 股权结构内容骨架屏
|
||||
*/
|
||||
const ShareholderContentSkeleton: React.FC = () => (
|
||||
<Box p={4}>
|
||||
{/* 表格骨架屏 */}
|
||||
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<CardBody p={0}>
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
|
||||
<Skeleton height="14px" width="60px" startColor="gray.700" endColor="gray.600" />
|
||||
</Th>
|
||||
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
|
||||
<Skeleton height="14px" width="80px" startColor="gray.700" endColor="gray.600" />
|
||||
</Th>
|
||||
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
|
||||
<Skeleton height="14px" width="60px" startColor="gray.700" endColor="gray.600" />
|
||||
</Th>
|
||||
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
|
||||
<Skeleton height="14px" width="70px" startColor="gray.700" endColor="gray.600" />
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Tr key={i}>
|
||||
<Td borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<Skeleton height="14px" width="120px" startColor="gray.700" endColor="gray.600" />
|
||||
</Td>
|
||||
<Td borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<Skeleton height="14px" width="80px" startColor="gray.700" endColor="gray.600" />
|
||||
</Td>
|
||||
<Td borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<Skeleton height="14px" width="60px" startColor="gray.700" endColor="gray.600" />
|
||||
</Td>
|
||||
<Td borderColor="rgba(212, 175, 55, 0.2)">
|
||||
<Skeleton height="14px" width="80px" startColor="gray.700" endColor="gray.600" />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* CompanyOverview 导航骨架屏
|
||||
*
|
||||
* 显示真实的导航 Tab(默认选中第一个),内容区域显示骨架屏
|
||||
*/
|
||||
const CompanyOverviewNavSkeleton: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
{/* 导航栏容器 - compact 模式(无外边距) */}
|
||||
<Flex
|
||||
bg={DEEP_SPACE.bgGlass}
|
||||
backdropFilter="blur(20px)"
|
||||
borderBottom="1px solid"
|
||||
borderColor={DEEP_SPACE.borderGold}
|
||||
borderRadius={0}
|
||||
mx={0}
|
||||
mb={0}
|
||||
position="relative"
|
||||
boxShadow="none"
|
||||
alignItems="center"
|
||||
>
|
||||
{/* 顶部金色光条 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
width="50%"
|
||||
height="1px"
|
||||
background="linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)"
|
||||
/>
|
||||
|
||||
{/* Tab 列表 */}
|
||||
<Box
|
||||
flex="1"
|
||||
minW={0}
|
||||
overflowX="auto"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
border="none"
|
||||
px={3}
|
||||
py={2}
|
||||
flexWrap="nowrap"
|
||||
gap={1.5}
|
||||
>
|
||||
{OVERVIEW_TABS.map((tab, idx) => {
|
||||
const isSelected = idx === 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={tab.key}
|
||||
color={isSelected ? DEEP_SPACE.textDark : DEEP_SPACE.textWhite}
|
||||
borderRadius={DEEP_SPACE.radius}
|
||||
px={4}
|
||||
py={2}
|
||||
fontSize="13px"
|
||||
fontWeight={isSelected ? '700' : '500'}
|
||||
whiteSpace="nowrap"
|
||||
flexShrink={0}
|
||||
border="1px solid"
|
||||
borderColor={isSelected ? DEEP_SPACE.borderGoldHover : 'transparent'}
|
||||
position="relative"
|
||||
letterSpacing="0.03em"
|
||||
bg={isSelected ? DEEP_SPACE.selectedBg : 'transparent'}
|
||||
boxShadow={isSelected ? DEEP_SPACE.glowGold : 'none'}
|
||||
transform={isSelected ? 'translateY(-2px)' : 'none'}
|
||||
cursor="default"
|
||||
>
|
||||
<HStack spacing={1.5}>
|
||||
<Icon
|
||||
as={tab.icon}
|
||||
boxSize={3.5}
|
||||
opacity={isSelected ? 1 : 0.7}
|
||||
/>
|
||||
<Text>{tab.name}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 内容区域骨架屏 */}
|
||||
<ShareholderContentSkeleton />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyOverviewNavSkeleton;
|
||||
@@ -0,0 +1,78 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx
|
||||
// 财报披露日程 Tab Panel
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { useDisclosureData } from "../../hooks/useDisclosureData";
|
||||
import { THEME } from "../config";
|
||||
import { formatDate } from "../utils";
|
||||
import LoadingState from "./LoadingState";
|
||||
|
||||
interface DisclosureSchedulePanelProps {
|
||||
stockCode: string;
|
||||
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode, isActive = true }) => {
|
||||
const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive });
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="加载披露日程..." />;
|
||||
}
|
||||
|
||||
if (disclosureSchedule.length === 0) {
|
||||
return (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color={THEME.textSecondary}>暂无披露日程数据</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
|
||||
{disclosureSchedule.map((schedule: any, idx: number) => (
|
||||
<Card
|
||||
key={idx}
|
||||
bg={schedule.is_disclosed ? "green.900" : "orange.900"}
|
||||
border="1px solid"
|
||||
borderColor={schedule.is_disclosed ? "green.600" : "orange.600"}
|
||||
size="sm"
|
||||
>
|
||||
<CardBody p={3}>
|
||||
<VStack spacing={1}>
|
||||
<Badge colorScheme={schedule.is_disclosed ? "green" : "orange"}>
|
||||
{schedule.report_name}
|
||||
</Badge>
|
||||
<Text fontSize="sm" fontWeight="bold" color={THEME.textPrimary}>
|
||||
{schedule.is_disclosed ? "已披露" : "预计"}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||
{formatDate(
|
||||
schedule.is_disclosed
|
||||
? schedule.actual_date
|
||||
: schedule.latest_scheduled_date
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisclosureSchedulePanel;
|
||||
@@ -0,0 +1,32 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
|
||||
// 复用的加载状态组件
|
||||
|
||||
import React from "react";
|
||||
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
|
||||
import { THEME } from "../config";
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载状态组件(黑金主题)
|
||||
*/
|
||||
const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
message = "加载中...",
|
||||
height = "200px",
|
||||
}) => {
|
||||
return (
|
||||
<Center h={height}>
|
||||
<VStack>
|
||||
<Spinner size="lg" color={THEME.gold} thickness="3px" />
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>
|
||||
{message}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingState;
|
||||
@@ -0,0 +1,63 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx
|
||||
// 股权结构 Tab Panel - 使用拆分后的子组件
|
||||
|
||||
import React from "react";
|
||||
import { SimpleGrid, Box } from "@chakra-ui/react";
|
||||
|
||||
import { useShareholderData } from "../../hooks/useShareholderData";
|
||||
import {
|
||||
ActualControlCard,
|
||||
ConcentrationCard,
|
||||
ShareholdersTable,
|
||||
} from "../../components/shareholder";
|
||||
import TabPanelContainer from "@components/TabPanelContainer";
|
||||
import { ShareholderSkeleton } from "./BasicInfoTabSkeleton";
|
||||
|
||||
interface ShareholderPanelProps {
|
||||
stockCode: string;
|
||||
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股权结构面板
|
||||
* 使用拆分后的子组件:
|
||||
* - ActualControlCard: 实际控制人卡片
|
||||
* - ConcentrationCard: 股权集中度卡片
|
||||
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
|
||||
*/
|
||||
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode, isActive = true }) => {
|
||||
const {
|
||||
actualControl,
|
||||
concentration,
|
||||
topShareholders,
|
||||
topCirculationShareholders,
|
||||
loading,
|
||||
} = useShareholderData({ stockCode, enabled: isActive });
|
||||
|
||||
return (
|
||||
<TabPanelContainer loading={loading} skeleton={<ShareholderSkeleton />}>
|
||||
{/* 实际控制人 + 股权集中度 左右分布 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<ActualControlCard actualControl={actualControl} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ConcentrationCard concentration={concentration} />
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 十大股东 + 十大流通股东 左右分布 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<ShareholdersTable type="top" shareholders={topShareholders} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ShareholdersTable type="circulation" shareholders={topCirculationShareholders} />
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</TabPanelContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareholderPanel;
|
||||
@@ -0,0 +1,14 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts
|
||||
// 组件导出
|
||||
|
||||
export { default as LoadingState } from "./LoadingState";
|
||||
// TabPanelContainer 已提升为通用组件,从 @components/TabPanelContainer 导入
|
||||
export { default as TabPanelContainer } from "@components/TabPanelContainer";
|
||||
export { default as ShareholderPanel } from "./ShareholderPanel";
|
||||
export { ManagementPanel } from "./management";
|
||||
export { default as AnnouncementsPanel } from "./AnnouncementsPanel";
|
||||
export { default as BranchesPanel } from "./BranchesPanel";
|
||||
export { default as BusinessInfoPanel } from "./BusinessInfoPanel";
|
||||
|
||||
// 骨架屏组件
|
||||
export * from "./BasicInfoTabSkeleton";
|
||||
@@ -0,0 +1,63 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/CategorySection.tsx
|
||||
// 管理层分类区块组件
|
||||
|
||||
import React, { memo } from "react";
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
import type { IconType } from "react-icons";
|
||||
|
||||
import { THEME } from "../../config";
|
||||
import ManagementCard from "./ManagementCard";
|
||||
import type { ManagementPerson, ManagementCategory } from "./types";
|
||||
|
||||
interface CategorySectionProps {
|
||||
category: ManagementCategory;
|
||||
people: ManagementPerson[];
|
||||
icon: IconType;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const CategorySection: React.FC<CategorySectionProps> = ({
|
||||
category,
|
||||
people,
|
||||
icon,
|
||||
color,
|
||||
}) => {
|
||||
if (people.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 分类标题 */}
|
||||
<HStack mb={4}>
|
||||
<Icon as={icon} color={color} boxSize={5} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>
|
||||
{category}
|
||||
</Heading>
|
||||
<Badge bg={THEME.gold} color="gray.900">
|
||||
{people.length}人
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* 人员卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{people.map((person, idx) => (
|
||||
<ManagementCard
|
||||
key={`${person.name}-${idx}`}
|
||||
person={person}
|
||||
categoryColor={color}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CategorySection);
|
||||
@@ -0,0 +1,100 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementCard.tsx
|
||||
// 管理人员卡片组件
|
||||
|
||||
import React, { memo } from "react";
|
||||
import {
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
Avatar,
|
||||
Tag,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
FaVenusMars,
|
||||
FaGraduationCap,
|
||||
FaPassport,
|
||||
} from "react-icons/fa";
|
||||
|
||||
import { THEME } from "../../config";
|
||||
import { formatDate } from "../../utils";
|
||||
import type { ManagementPerson } from "./types";
|
||||
|
||||
interface ManagementCardProps {
|
||||
person: ManagementPerson;
|
||||
categoryColor: string;
|
||||
}
|
||||
|
||||
const ManagementCard: React.FC<ManagementCardProps> = ({ person, categoryColor }) => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const age = person.birth_year ? currentYear - parseInt(person.birth_year, 10) : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={THEME.tableBg}
|
||||
border="1px solid"
|
||||
borderColor={THEME.border}
|
||||
size="sm"
|
||||
>
|
||||
<CardBody>
|
||||
<HStack spacing={3} align="start">
|
||||
<Avatar
|
||||
name={person.name}
|
||||
size="md"
|
||||
bg={categoryColor}
|
||||
/>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
{/* 姓名和性别 */}
|
||||
<HStack>
|
||||
<Text fontWeight="bold" color={THEME.textPrimary}>
|
||||
{person.name}
|
||||
</Text>
|
||||
{person.gender && (
|
||||
<Icon
|
||||
as={FaVenusMars}
|
||||
color={person.gender === "男" ? "blue.400" : "pink.400"}
|
||||
boxSize={3}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 职位 */}
|
||||
<Text fontSize="sm" color={THEME.goldLight}>
|
||||
{person.position_name}
|
||||
</Text>
|
||||
|
||||
{/* 标签:学历、年龄、国籍 */}
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{person.education && (
|
||||
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
|
||||
<Icon as={FaGraduationCap} mr={1} boxSize={3} />
|
||||
{person.education}
|
||||
</Tag>
|
||||
)}
|
||||
{age && (
|
||||
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
|
||||
{age}岁
|
||||
</Tag>
|
||||
)}
|
||||
{person.nationality && person.nationality !== "中国" && (
|
||||
<Tag size="sm" bg="orange.600" color="white">
|
||||
<Icon as={FaPassport} mr={1} boxSize={3} />
|
||||
{person.nationality}
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 任职日期 */}
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||
任职日期:{formatDate(person.start_date)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ManagementCard);
|
||||
@@ -0,0 +1,103 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx
|
||||
// 管理团队 Tab Panel(重构版)
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
FaUserTie,
|
||||
FaCrown,
|
||||
FaEye,
|
||||
FaUsers,
|
||||
} from "react-icons/fa";
|
||||
|
||||
import { useManagementData } from "../../../hooks/useManagementData";
|
||||
import { THEME } from "../../config";
|
||||
import TabPanelContainer from "@components/TabPanelContainer";
|
||||
import CategorySection from "./CategorySection";
|
||||
import { ManagementSkeleton } from "../BasicInfoTabSkeleton";
|
||||
import type {
|
||||
ManagementPerson,
|
||||
ManagementCategory,
|
||||
CategorizedManagement,
|
||||
CategoryConfig,
|
||||
} from "./types";
|
||||
|
||||
interface ManagementPanelProps {
|
||||
stockCode: string;
|
||||
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类配置映射
|
||||
*/
|
||||
const CATEGORY_CONFIG: Record<ManagementCategory, CategoryConfig> = {
|
||||
高管: { icon: FaUserTie, color: THEME.gold },
|
||||
董事: { icon: FaCrown, color: THEME.goldLight },
|
||||
监事: { icon: FaEye, color: "green.400" },
|
||||
其他: { icon: FaUsers, color: THEME.textSecondary },
|
||||
};
|
||||
|
||||
/**
|
||||
* 分类顺序
|
||||
*/
|
||||
const CATEGORY_ORDER: ManagementCategory[] = ["高管", "董事", "监事", "其他"];
|
||||
|
||||
/**
|
||||
* 根据职位信息对管理人员进行分类
|
||||
*/
|
||||
const categorizeManagement = (management: ManagementPerson[]): CategorizedManagement => {
|
||||
const categories: CategorizedManagement = {
|
||||
高管: [],
|
||||
董事: [],
|
||||
监事: [],
|
||||
其他: [],
|
||||
};
|
||||
|
||||
management.forEach((person) => {
|
||||
const positionCategory = person.position_category;
|
||||
const positionName = person.position_name || "";
|
||||
|
||||
if (positionCategory === "高管" || positionName.includes("总")) {
|
||||
categories["高管"].push(person);
|
||||
} else if (positionCategory === "董事" || positionName.includes("董事")) {
|
||||
categories["董事"].push(person);
|
||||
} else if (positionCategory === "监事" || positionName.includes("监事")) {
|
||||
categories["监事"].push(person);
|
||||
} else {
|
||||
categories["其他"].push(person);
|
||||
}
|
||||
});
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode, isActive = true }) => {
|
||||
const { management, loading } = useManagementData({ stockCode, enabled: isActive });
|
||||
|
||||
// 使用 useMemo 缓存分类计算结果
|
||||
const categorizedManagement = useMemo(
|
||||
() => categorizeManagement(management as ManagementPerson[]),
|
||||
[management]
|
||||
);
|
||||
|
||||
return (
|
||||
<TabPanelContainer loading={loading} skeleton={<ManagementSkeleton />}>
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const config = CATEGORY_CONFIG[category];
|
||||
const people = categorizedManagement[category];
|
||||
|
||||
return (
|
||||
<CategorySection
|
||||
key={category}
|
||||
category={category}
|
||||
people={people}
|
||||
icon={config.icon}
|
||||
color={config.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabPanelContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagementPanel;
|
||||
@@ -0,0 +1,7 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/index.ts
|
||||
// 管理团队组件导出
|
||||
|
||||
export { default as ManagementPanel } from "./ManagementPanel";
|
||||
export { default as ManagementCard } from "./ManagementCard";
|
||||
export { default as CategorySection } from "./CategorySection";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,36 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/types.ts
|
||||
// 管理团队相关类型定义
|
||||
|
||||
import type { IconType } from "react-icons";
|
||||
|
||||
/**
|
||||
* 管理人员信息
|
||||
*/
|
||||
export interface ManagementPerson {
|
||||
name: string;
|
||||
position_name?: string;
|
||||
position_category?: string;
|
||||
gender?: "男" | "女";
|
||||
education?: string;
|
||||
birth_year?: string;
|
||||
nationality?: string;
|
||||
start_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理层分类
|
||||
*/
|
||||
export type ManagementCategory = "高管" | "董事" | "监事" | "其他";
|
||||
|
||||
/**
|
||||
* 分类后的管理层数据
|
||||
*/
|
||||
export type CategorizedManagement = Record<ManagementCategory, ManagementPerson[]>;
|
||||
|
||||
/**
|
||||
* 分类配置项
|
||||
*/
|
||||
export interface CategoryConfig {
|
||||
icon: IconType;
|
||||
color: string;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/config.ts
|
||||
// Tab 配置 + 黑金主题配置
|
||||
|
||||
import { IconType } from "react-icons";
|
||||
import {
|
||||
FaShareAlt,
|
||||
FaUserTie,
|
||||
FaSitemap,
|
||||
FaInfoCircle,
|
||||
} from "react-icons/fa";
|
||||
|
||||
// 主题类型定义
|
||||
export interface Theme {
|
||||
bg: string;
|
||||
cardBg: string;
|
||||
tableBg: string;
|
||||
tableHoverBg: string;
|
||||
gold: string;
|
||||
goldLight: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
border: string;
|
||||
tabSelected: {
|
||||
bg: string;
|
||||
color: string;
|
||||
};
|
||||
tabUnselected: {
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 黑金主题配置
|
||||
// 注:文字颜色使用更亮的金色(#F4D03F)以提高对比度
|
||||
export const THEME: Theme = {
|
||||
bg: "gray.900",
|
||||
cardBg: "gray.800",
|
||||
tableBg: "gray.700",
|
||||
tableHoverBg: "gray.600",
|
||||
gold: "#F4D03F", // 亮黄金色(用于文字,对比度更好)
|
||||
goldLight: "#F0D78C", // 浅金色(用于次要文字)
|
||||
textPrimary: "white",
|
||||
textSecondary: "gray.400",
|
||||
border: "rgba(212, 175, 55, 0.3)", // 边框保持原色
|
||||
tabSelected: {
|
||||
bg: "#D4AF37", // 选中背景保持深金色
|
||||
color: "gray.900",
|
||||
},
|
||||
tabUnselected: {
|
||||
color: "#F4D03F", // 未选中使用亮金色
|
||||
},
|
||||
};
|
||||
|
||||
// Tab 配置类型
|
||||
export interface TabConfig {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: IconType;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Tab 配置
|
||||
export const TAB_CONFIG: TabConfig[] = [
|
||||
{
|
||||
key: "shareholder",
|
||||
name: "股权结构",
|
||||
icon: FaShareAlt,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: "management",
|
||||
name: "管理团队",
|
||||
icon: FaUserTie,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: "branches",
|
||||
name: "分支机构",
|
||||
icon: FaSitemap,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
name: "工商信息",
|
||||
icon: FaInfoCircle,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 获取启用的 Tab 列表
|
||||
export const getEnabledTabs = (enabledKeys?: string[]): TabConfig[] => {
|
||||
if (!enabledKeys || enabledKeys.length === 0) {
|
||||
return TAB_CONFIG.filter((tab) => tab.enabled);
|
||||
}
|
||||
return TAB_CONFIG.filter(
|
||||
(tab) => tab.enabled && enabledKeys.includes(tab.key)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx
|
||||
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Card, CardBody } from "@chakra-ui/react";
|
||||
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
|
||||
|
||||
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
|
||||
import {
|
||||
ShareholderPanel,
|
||||
ManagementPanel,
|
||||
AnnouncementsPanel,
|
||||
BranchesPanel,
|
||||
BusinessInfoPanel,
|
||||
} from "./components";
|
||||
|
||||
// Props 类型定义
|
||||
export interface BasicInfoTabProps {
|
||||
stockCode: string;
|
||||
|
||||
// 可配置项
|
||||
enabledTabs?: string[]; // 指定显示哪些 Tab(通过 key)
|
||||
defaultTabIndex?: number; // 默认选中 Tab
|
||||
onTabChange?: (index: number, tabKey: string) => void;
|
||||
}
|
||||
|
||||
// Tab 组件映射
|
||||
const TAB_COMPONENTS: Record<string, React.FC<any>> = {
|
||||
shareholder: ShareholderPanel,
|
||||
management: ManagementPanel,
|
||||
announcements: AnnouncementsPanel,
|
||||
branches: BranchesPanel,
|
||||
business: BusinessInfoPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建 SubTabContainer 所需的 tabs 配置
|
||||
*/
|
||||
const buildTabsConfig = (enabledKeys?: string[]): SubTabConfig[] => {
|
||||
const enabledTabs = getEnabledTabs(enabledKeys);
|
||||
return enabledTabs.map((tab) => ({
|
||||
key: tab.key,
|
||||
name: tab.name,
|
||||
icon: tab.icon,
|
||||
component: TAB_COMPONENTS[tab.key],
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 基本信息 Tab 组件
|
||||
*
|
||||
* 特性:
|
||||
* - 使用 SubTabContainer 通用组件
|
||||
* - 可配置显示哪些 Tab(enabledTabs)
|
||||
* - 黑金主题
|
||||
* - 懒加载
|
||||
* - 支持 Tab 变更回调
|
||||
*/
|
||||
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
||||
stockCode,
|
||||
enabledTabs,
|
||||
defaultTabIndex = 0,
|
||||
onTabChange,
|
||||
}) => {
|
||||
// 构建 tabs 配置(缓存避免重复计算)
|
||||
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
|
||||
|
||||
return (
|
||||
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
tabs={tabs}
|
||||
componentProps={{ stockCode }}
|
||||
defaultIndex={defaultTabIndex}
|
||||
onTabChange={onTabChange}
|
||||
themePreset="blackGold"
|
||||
size="sm"
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInfoTab;
|
||||
|
||||
// 导出配置和工具,供外部使用
|
||||
export { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
|
||||
export * from "./utils";
|
||||
@@ -0,0 +1,52 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/utils.ts
|
||||
// 格式化工具函数
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
*/
|
||||
export const formatPercentage = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化数字(自动转换亿/万)
|
||||
*/
|
||||
export const formatNumber = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (value >= 100000000) {
|
||||
return `${(value / 100000000).toFixed(2)}亿`;
|
||||
} else if (value >= 10000) {
|
||||
return `${(value / 10000).toFixed(2)}万`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化股数(自动转换亿股/万股)
|
||||
*/
|
||||
export const formatShares = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (value >= 100000000) {
|
||||
return `${(value / 100000000).toFixed(2)}亿股`;
|
||||
} else if (value >= 10000) {
|
||||
return `${(value / 10000).toFixed(2)}万股`;
|
||||
}
|
||||
return `${value.toLocaleString()}股`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期(去掉时间部分)
|
||||
*/
|
||||
export const formatDate = (dateStr: string | null | undefined): string => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr.split("T")[0];
|
||||
};
|
||||
|
||||
// 导出工具对象(兼容旧代码)
|
||||
export const formatUtils = {
|
||||
formatPercentage,
|
||||
formatNumber,
|
||||
formatShares,
|
||||
formatDate,
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 业务结构树形项组件
|
||||
*
|
||||
* 递归显示业务结构层级
|
||||
* 使用位置:业务结构分析卡片
|
||||
* 黑金主题风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, HStack, VStack, Text, Badge, Tag, TagLabel } from '@chakra-ui/react';
|
||||
import { formatPercentage, formatBusinessRevenue } from '@utils/priceFormatters';
|
||||
import type { BusinessTreeItemProps } from '../types';
|
||||
|
||||
// 黑金主题配置(使用更亮的金色提高对比度)
|
||||
const THEME = {
|
||||
bg: 'gray.700',
|
||||
gold: '#F4D03F', // 亮金色
|
||||
goldLight: '#F0D78C',
|
||||
textPrimary: '#F4D03F', // 亮金色(提高对比度)
|
||||
textSecondary: 'gray.400',
|
||||
border: 'rgba(212, 175, 55, 0.5)',
|
||||
};
|
||||
|
||||
const BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0 }) => {
|
||||
// 获取营收显示
|
||||
const getRevenueDisplay = (): string => {
|
||||
const revenue = business.revenue || business.financial_metrics?.revenue;
|
||||
const unit = business.revenue_unit;
|
||||
if (revenue !== undefined && revenue !== null) {
|
||||
return formatBusinessRevenue(revenue, unit);
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ml={depth * 6}
|
||||
p={3}
|
||||
bg={THEME.bg}
|
||||
borderLeft={depth > 0 ? '4px solid' : 'none'}
|
||||
borderLeftColor={THEME.gold}
|
||||
borderRadius="md"
|
||||
mb={2}
|
||||
_hover={{ shadow: 'md', bg: 'gray.600' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize={depth === 0 ? 'md' : 'sm'} color={THEME.textPrimary}>
|
||||
{business.business_name}
|
||||
</Text>
|
||||
{business.financial_metrics?.revenue_ratio &&
|
||||
business.financial_metrics.revenue_ratio > 30 && (
|
||||
<Badge bg={THEME.gold} color="gray.900" size="sm">
|
||||
核心业务
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={4} flexWrap="wrap">
|
||||
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
|
||||
营收占比: {formatPercentage(business.financial_metrics?.revenue_ratio)}
|
||||
</Tag>
|
||||
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
|
||||
毛利率: {formatPercentage(business.financial_metrics?.gross_margin)}
|
||||
</Tag>
|
||||
{business.growth_metrics?.revenue_growth !== undefined && (
|
||||
<Tag
|
||||
size="sm"
|
||||
bg={business.growth_metrics.revenue_growth > 0 ? 'red.600' : 'green.600'}
|
||||
color="white"
|
||||
>
|
||||
<TagLabel>
|
||||
增长: {business.growth_metrics.revenue_growth > 0 ? '+' : ''}
|
||||
{formatPercentage(business.growth_metrics.revenue_growth)}
|
||||
</TagLabel>
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
<VStack align="end" spacing={0}>
|
||||
<Text fontSize="lg" fontWeight="bold" color={THEME.gold}>
|
||||
{getRevenueDisplay()}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={THEME.textSecondary}>
|
||||
营业收入
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessTreeItem;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 免责声明组件
|
||||
*
|
||||
* 显示 AI 分析内容的免责声明提示
|
||||
* 使用位置:深度分析各 Card 底部(共 6 处)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from '@chakra-ui/react';
|
||||
|
||||
const DisclaimerBox: React.FC = () => {
|
||||
return (
|
||||
<Text
|
||||
mb={4}
|
||||
color="gray.500"
|
||||
fontSize="12px"
|
||||
lineHeight="1.5"
|
||||
>
|
||||
免责声明:本内容由AI模型基于新闻、公告、研报等公开信息自动分析和生成,未经许可严禁转载。所有内容仅供参考,不构成任何投资建议,请投资者注意风险,独立审慎决策。
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisclaimerBox;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 关键因素卡片组件
|
||||
*
|
||||
* 显示单个关键因素的详细信息
|
||||
* 使用位置:关键因素 Accordion 内
|
||||
* 黑金主题设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Tag,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
||||
import type { KeyFactorCardProps, ImpactDirection } from '../types';
|
||||
|
||||
// 黑金主题样式常量
|
||||
const THEME = {
|
||||
cardBg: '#252D3A',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 获取影响方向对应的颜色
|
||||
*/
|
||||
const getImpactColor = (direction?: ImpactDirection): string => {
|
||||
const colorMap: Record<ImpactDirection, string> = {
|
||||
positive: 'red',
|
||||
negative: 'green',
|
||||
neutral: 'gray',
|
||||
mixed: 'yellow',
|
||||
};
|
||||
return colorMap[direction || 'neutral'] || 'gray';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取影响方向的中文标签
|
||||
*/
|
||||
const getImpactLabel = (direction?: ImpactDirection): string => {
|
||||
const labelMap: Record<ImpactDirection, string> = {
|
||||
positive: '正面',
|
||||
negative: '负面',
|
||||
neutral: '中性',
|
||||
mixed: '混合',
|
||||
};
|
||||
return labelMap[direction || 'neutral'] || '中性';
|
||||
};
|
||||
|
||||
const KeyFactorCard: React.FC<KeyFactorCardProps> = ({ factor }) => {
|
||||
const impactColor = getImpactColor(factor.impact_direction);
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={THEME.cardBg}
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.100"
|
||||
size="sm"
|
||||
>
|
||||
<CardBody p={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="medium" fontSize="sm" color={THEME.textColor}>
|
||||
{factor.factor_name}
|
||||
</Text>
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor={`${impactColor}.400`}
|
||||
color={`${impactColor}.400`}
|
||||
size="sm"
|
||||
>
|
||||
{getImpactLabel(factor.impact_direction)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="lg" fontWeight="bold" color={`${impactColor}.400`}>
|
||||
{factor.factor_value}
|
||||
{factor.factor_unit && ` ${factor.factor_unit}`}
|
||||
</Text>
|
||||
{factor.year_on_year !== undefined && (
|
||||
<Tag
|
||||
size="sm"
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
|
||||
color={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
|
||||
>
|
||||
<Icon
|
||||
as={factor.year_on_year > 0 ? FaArrowUp : FaArrowDown}
|
||||
mr={1}
|
||||
boxSize={3}
|
||||
/>
|
||||
{Math.abs(factor.year_on_year)}%
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{factor.factor_desc && (
|
||||
<Text fontSize="xs" color={THEME.subtextColor} noOfLines={2}>
|
||||
{factor.factor_desc}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="xs" color={THEME.subtextColor}>
|
||||
影响权重: {factor.impact_weight}
|
||||
</Text>
|
||||
{factor.report_period && (
|
||||
<Text fontSize="xs" color={THEME.subtextColor}>
|
||||
{factor.report_period}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyFactorCard;
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 产业链流程式导航组件
|
||||
*
|
||||
* 显示上游 → 核心 → 下游的流程式导航
|
||||
* 带图标箭头连接符
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { HStack, VStack, Box, Text, Icon, Badge } from '@chakra-ui/react';
|
||||
import { FaArrowRight } from 'react-icons/fa';
|
||||
|
||||
// 黑金主题配置(使用更亮的金色提高对比度)
|
||||
const THEME = {
|
||||
gold: '#F4D03F',
|
||||
textSecondary: 'gray.400',
|
||||
upstream: {
|
||||
active: 'orange.500',
|
||||
activeBg: 'orange.900',
|
||||
inactive: 'white',
|
||||
inactiveBg: 'gray.700',
|
||||
},
|
||||
core: {
|
||||
active: 'blue.500',
|
||||
activeBg: 'blue.900',
|
||||
inactive: 'white',
|
||||
inactiveBg: 'gray.700',
|
||||
},
|
||||
downstream: {
|
||||
active: 'green.500',
|
||||
activeBg: 'green.900',
|
||||
inactive: 'white',
|
||||
inactiveBg: 'gray.700',
|
||||
},
|
||||
};
|
||||
|
||||
export type TabType = 'upstream' | 'core' | 'downstream';
|
||||
|
||||
interface ProcessNavigationProps {
|
||||
activeTab: TabType;
|
||||
onTabChange: (tab: TabType) => void;
|
||||
upstreamCount: number;
|
||||
coreCount: number;
|
||||
downstreamCount: number;
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
label: string;
|
||||
subtitle: string;
|
||||
count: number;
|
||||
isActive: boolean;
|
||||
colorKey: 'upstream' | 'core' | 'downstream';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const NavItem: React.FC<NavItemProps> = memo(({
|
||||
label,
|
||||
subtitle,
|
||||
count,
|
||||
isActive,
|
||||
colorKey,
|
||||
onClick,
|
||||
}) => {
|
||||
const colors = THEME[colorKey];
|
||||
|
||||
return (
|
||||
<Box
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
bg={isActive ? colors.activeBg : colors.inactiveBg}
|
||||
borderWidth={2}
|
||||
borderColor={isActive ? colors.active : 'gray.600'}
|
||||
onClick={onClick}
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
borderColor: colors.active,
|
||||
transform: 'translateY(-2px)',
|
||||
}}
|
||||
>
|
||||
<VStack spacing={1} align="center">
|
||||
<HStack spacing={2}>
|
||||
<Text
|
||||
fontWeight={isActive ? 'bold' : 'medium'}
|
||||
color={isActive ? colors.active : colors.inactive}
|
||||
fontSize="sm"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Badge
|
||||
bg={isActive ? colors.active : 'gray.600'}
|
||||
color="white"
|
||||
borderRadius="full"
|
||||
px={2}
|
||||
fontSize="xs"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={THEME.textSecondary}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
NavItem.displayName = 'NavItem';
|
||||
|
||||
const ProcessNavigation: React.FC<ProcessNavigationProps> = memo(({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
upstreamCount,
|
||||
coreCount,
|
||||
downstreamCount,
|
||||
}) => {
|
||||
return (
|
||||
<HStack
|
||||
spacing={2}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
<NavItem
|
||||
label="上游供应链"
|
||||
subtitle="原材料与供应商"
|
||||
count={upstreamCount}
|
||||
isActive={activeTab === 'upstream'}
|
||||
colorKey="upstream"
|
||||
onClick={() => onTabChange('upstream')}
|
||||
/>
|
||||
|
||||
<Icon
|
||||
as={FaArrowRight}
|
||||
color={THEME.textSecondary}
|
||||
boxSize={4}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
label="核心企业"
|
||||
subtitle="公司主体与产品"
|
||||
count={coreCount}
|
||||
isActive={activeTab === 'core'}
|
||||
colorKey="core"
|
||||
onClick={() => onTabChange('core')}
|
||||
/>
|
||||
|
||||
<Icon
|
||||
as={FaArrowRight}
|
||||
color={THEME.textSecondary}
|
||||
boxSize={4}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
label="下游客户"
|
||||
subtitle="客户与终端市场"
|
||||
count={downstreamCount}
|
||||
isActive={activeTab === 'downstream'}
|
||||
colorKey="downstream"
|
||||
onClick={() => onTabChange('downstream')}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
ProcessNavigation.displayName = 'ProcessNavigation';
|
||||
|
||||
export default ProcessNavigation;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 评分进度条组件
|
||||
*
|
||||
* 显示带图标的评分进度条
|
||||
* 使用位置:竞争力分析区域(共 8 处)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, HStack, Text, Badge, Progress, Icon } from '@chakra-ui/react';
|
||||
import type { ScoreBarProps } from '../types';
|
||||
|
||||
/**
|
||||
* 根据分数百分比获取颜色方案
|
||||
*/
|
||||
const getColorScheme = (percentage: number): string => {
|
||||
if (percentage >= 80) return 'purple';
|
||||
if (percentage >= 60) return 'blue';
|
||||
if (percentage >= 40) return 'yellow';
|
||||
return 'orange';
|
||||
};
|
||||
|
||||
const ScoreBar: React.FC<ScoreBarProps> = ({ label, score, icon }) => {
|
||||
const percentage = ((score || 0) / 100) * 100;
|
||||
const colorScheme = getColorScheme(percentage);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<HStack>
|
||||
{icon && (
|
||||
<Icon as={icon} boxSize={4} color={`${colorScheme}.500`} />
|
||||
)}
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Badge colorScheme={colorScheme}>{score || 0}</Badge>
|
||||
</HStack>
|
||||
<Progress
|
||||
value={percentage}
|
||||
size="sm"
|
||||
colorScheme={colorScheme}
|
||||
borderRadius="full"
|
||||
hasStripe
|
||||
isAnimated
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScoreBar;
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 产业链筛选栏组件
|
||||
*
|
||||
* 提供类型筛选、重要度筛选和视图切换功能
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Select,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
// 黑金主题配置(使用更亮的金色提高对比度)
|
||||
const THEME = {
|
||||
gold: '#F4D03F',
|
||||
textPrimary: '#F4D03F',
|
||||
textSecondary: 'gray.400',
|
||||
inputBg: 'gray.700',
|
||||
inputBorder: 'gray.600',
|
||||
};
|
||||
|
||||
export type ViewMode = 'hierarchy' | 'flow';
|
||||
|
||||
// 节点类型选项
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'all', label: '全部类型' },
|
||||
{ value: 'company', label: '公司' },
|
||||
{ value: 'supplier', label: '供应商' },
|
||||
{ value: 'customer', label: '客户' },
|
||||
{ value: 'regulator', label: '监管机构' },
|
||||
{ value: 'product', label: '产品' },
|
||||
{ value: 'service', label: '服务' },
|
||||
{ value: 'channel', label: '渠道' },
|
||||
{ value: 'raw_material', label: '原材料' },
|
||||
{ value: 'end_user', label: '终端用户' },
|
||||
];
|
||||
|
||||
// 重要度选项
|
||||
const IMPORTANCE_OPTIONS = [
|
||||
{ value: 'all', label: '全部重要度' },
|
||||
{ value: 'high', label: '高 (≥80)' },
|
||||
{ value: 'medium', label: '中 (50-79)' },
|
||||
{ value: 'low', label: '低 (<50)' },
|
||||
];
|
||||
|
||||
interface ValueChainFilterBarProps {
|
||||
typeFilter: string;
|
||||
onTypeChange: (value: string) => void;
|
||||
importanceFilter: string;
|
||||
onImportanceChange: (value: string) => void;
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (value: ViewMode) => void;
|
||||
}
|
||||
|
||||
const ValueChainFilterBar: React.FC<ValueChainFilterBarProps> = memo(({
|
||||
typeFilter,
|
||||
onTypeChange,
|
||||
importanceFilter,
|
||||
onImportanceChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}) => {
|
||||
return (
|
||||
<HStack
|
||||
spacing={3}
|
||||
flexWrap="wrap"
|
||||
gap={3}
|
||||
>
|
||||
{/* 左侧筛选区 */}
|
||||
{/* <HStack spacing={3}>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(e) => onTypeChange(e.target.value)}
|
||||
size="sm"
|
||||
w="140px"
|
||||
bg={THEME.inputBg}
|
||||
borderColor={THEME.inputBorder}
|
||||
color={THEME.textPrimary}
|
||||
_hover={{ borderColor: THEME.gold }}
|
||||
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
|
||||
>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={importanceFilter}
|
||||
onChange={(e) => onImportanceChange(e.target.value)}
|
||||
size="sm"
|
||||
w="140px"
|
||||
bg={THEME.inputBg}
|
||||
borderColor={THEME.inputBorder}
|
||||
color={THEME.textPrimary}
|
||||
_hover={{ borderColor: THEME.gold }}
|
||||
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
|
||||
>
|
||||
{IMPORTANCE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack> */}
|
||||
|
||||
{/* 右侧视图切换 */}
|
||||
<Tabs
|
||||
index={viewMode === 'hierarchy' ? 0 : 1}
|
||||
onChange={(index) => onViewModeChange(index === 0 ? 'hierarchy' : 'flow')}
|
||||
variant="soft-rounded"
|
||||
size="sm"
|
||||
>
|
||||
<TabList>
|
||||
<Tab
|
||||
color={THEME.textSecondary}
|
||||
_selected={{
|
||||
bg: THEME.gold,
|
||||
color: 'gray.900',
|
||||
}}
|
||||
_hover={{
|
||||
bg: 'gray.600',
|
||||
}}
|
||||
>
|
||||
层级视图
|
||||
</Tab>
|
||||
<Tab
|
||||
color={THEME.textSecondary}
|
||||
_selected={{
|
||||
bg: THEME.gold,
|
||||
color: 'gray.900',
|
||||
}}
|
||||
_hover={{
|
||||
bg: 'gray.600',
|
||||
}}
|
||||
>
|
||||
流向关系
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
ValueChainFilterBar.displayName = 'ValueChainFilterBar';
|
||||
|
||||
export default ValueChainFilterBar;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 原子组件导出
|
||||
*
|
||||
* DeepAnalysisTab 内部使用的基础 UI 组件
|
||||
*/
|
||||
|
||||
export { default as DisclaimerBox } from './DisclaimerBox';
|
||||
export { default as ScoreBar } from './ScoreBar';
|
||||
export { default as BusinessTreeItem } from './BusinessTreeItem';
|
||||
export { default as KeyFactorCard } from './KeyFactorCard';
|
||||
export { default as ProcessNavigation } from './ProcessNavigation';
|
||||
export { default as ValueChainFilterBar } from './ValueChainFilterBar';
|
||||
export type { TabType } from './ProcessNavigation';
|
||||
export type { ViewMode } from './ValueChainFilterBar';
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 业务板块详情卡片
|
||||
*
|
||||
* 显示公司各业务板块的详细信息
|
||||
* 黑金主题风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaIndustry, FaExpandAlt, FaCompressAlt } from 'react-icons/fa';
|
||||
import type { BusinessSegment } from '../types';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
cardBg: 'gray.800',
|
||||
innerCardBg: 'gray.700',
|
||||
gold: '#F4D03F',
|
||||
goldLight: '#F0D78C',
|
||||
textPrimary: '#F4D03F',
|
||||
textSecondary: 'gray.400',
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
};
|
||||
|
||||
interface BusinessSegmentsCardProps {
|
||||
businessSegments: BusinessSegment[];
|
||||
expandedSegments: Record<number, boolean>;
|
||||
onToggleSegment: (index: number) => void;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
|
||||
businessSegments,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
}) => {
|
||||
if (!businessSegments || businessSegments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaIndustry} color={THEME.gold} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>业务板块详情</Heading>
|
||||
<Badge bg={THEME.gold} color="gray.900">{businessSegments.length} 个板块</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody px={2}>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{businessSegments.map((segment, idx) => {
|
||||
const isExpanded = expandedSegments[idx];
|
||||
|
||||
return (
|
||||
<Card key={idx} bg={THEME.innerCardBg}>
|
||||
<CardBody px={2}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="bold" fontSize="md" color={THEME.textPrimary}>
|
||||
{segment.segment_name}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={
|
||||
<Icon as={isExpanded ? FaCompressAlt : FaExpandAlt} />
|
||||
}
|
||||
onClick={() => onToggleSegment(idx)}
|
||||
color={THEME.gold}
|
||||
_hover={{ bg: 'gray.600' }}
|
||||
>
|
||||
{isExpanded ? '折叠' : '展开'}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
业务描述
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.textPrimary}
|
||||
noOfLines={isExpanded ? undefined : 3}
|
||||
>
|
||||
{segment.segment_description || '暂无描述'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
竞争地位
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.textPrimary}
|
||||
noOfLines={isExpanded ? undefined : 2}
|
||||
>
|
||||
{segment.competitive_position || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
未来潜力
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
noOfLines={isExpanded ? undefined : 2}
|
||||
color={THEME.goldLight}
|
||||
>
|
||||
{segment.future_potential || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{isExpanded && segment.key_products && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
主要产品
|
||||
</Text>
|
||||
<Text fontSize="sm" color="green.300">
|
||||
{segment.key_products}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isExpanded && segment.market_share !== undefined && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
市场份额
|
||||
</Text>
|
||||
<Badge bg="purple.600" color="white" fontSize="sm">
|
||||
{segment.market_share}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isExpanded && segment.revenue_contribution !== undefined && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
|
||||
营收贡献
|
||||
</Text>
|
||||
<Badge bg={THEME.gold} color="gray.900" fontSize="sm">
|
||||
{segment.revenue_contribution}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessSegmentsCard;
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 业务结构分析卡片
|
||||
*
|
||||
* 显示公司业务结构树形图
|
||||
* 黑金主题风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaChartPie } from 'react-icons/fa';
|
||||
import { BusinessTreeItem } from '../atoms';
|
||||
import type { BusinessStructure } from '../types';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
cardBg: 'gray.800',
|
||||
gold: '#F4D03F',
|
||||
textPrimary: '#F4D03F',
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
};
|
||||
|
||||
interface BusinessStructureCardProps {
|
||||
businessStructure: BusinessStructure[];
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const BusinessStructureCard: React.FC<BusinessStructureCardProps> = ({
|
||||
businessStructure,
|
||||
}) => {
|
||||
if (!businessStructure || businessStructure.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaChartPie} color={THEME.gold} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>业务结构分析</Heading>
|
||||
<Badge bg={THEME.gold} color="gray.900">{businessStructure[0]?.report_period}</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody px={0}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{businessStructure.map((business, idx) => (
|
||||
<BusinessTreeItem
|
||||
key={idx}
|
||||
business={business}
|
||||
depth={business.business_level - 1}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessStructureCard;
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 竞争地位分析卡片
|
||||
*
|
||||
* 显示竞争力评分、雷达图和竞争分析
|
||||
* 包含行业排名弹窗功能
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Grid,
|
||||
GridItem,
|
||||
Box,
|
||||
Icon,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
Button,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaTrophy,
|
||||
FaCog,
|
||||
FaStar,
|
||||
FaChartLine,
|
||||
FaDollarSign,
|
||||
FaFlask,
|
||||
FaShieldAlt,
|
||||
FaRocket,
|
||||
FaUsers,
|
||||
FaExternalLinkAlt,
|
||||
} from 'react-icons/fa';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { ScoreBar } from '../atoms';
|
||||
import { getRadarChartOption } from '../utils/chartOptions';
|
||||
import { IndustryRankingView } from '../../../FinancialPanorama/components';
|
||||
import type { ComprehensiveData, CompetitivePosition, IndustryRankData } from '../types';
|
||||
|
||||
// 黑金主题弹窗样式
|
||||
const MODAL_STYLES = {
|
||||
content: {
|
||||
bg: 'gray.900',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
borderWidth: '1px',
|
||||
maxW: '900px',
|
||||
},
|
||||
header: {
|
||||
color: 'yellow.500',
|
||||
borderBottomColor: 'rgba(212, 175, 55, 0.2)',
|
||||
borderBottomWidth: '1px',
|
||||
},
|
||||
closeButton: {
|
||||
color: 'yellow.500',
|
||||
_hover: { bg: 'rgba(212, 175, 55, 0.1)' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// 样式常量 - 避免每次渲染创建新对象
|
||||
const CARD_STYLES = {
|
||||
bg: 'transparent',
|
||||
shadow: 'md',
|
||||
} as const;
|
||||
|
||||
const CONTENT_BOX_STYLES = {
|
||||
p: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.600',
|
||||
borderRadius: 'md',
|
||||
} as const;
|
||||
|
||||
const GRID_COLSPAN = { base: 2, lg: 1 } as const;
|
||||
const CHART_STYLE = { height: '320px' } as const;
|
||||
|
||||
interface CompetitiveAnalysisCardProps {
|
||||
comprehensiveData: ComprehensiveData;
|
||||
industryRankData?: IndustryRankData[];
|
||||
}
|
||||
|
||||
// 竞争对手标签组件
|
||||
interface CompetitorTagsProps {
|
||||
competitors: string[];
|
||||
}
|
||||
|
||||
const CompetitorTags = memo<CompetitorTagsProps>(({ competitors }) => (
|
||||
<Box mb={4}>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2} color="yellow.500">
|
||||
主要竞争对手
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{competitors.map((competitor, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="md"
|
||||
variant="outline"
|
||||
borderColor="yellow.600"
|
||||
color="yellow.500"
|
||||
borderRadius="full"
|
||||
>
|
||||
<Icon as={FaUsers} mr={1} />
|
||||
<TagLabel>{competitor}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
));
|
||||
|
||||
CompetitorTags.displayName = 'CompetitorTags';
|
||||
|
||||
// 评分区域组件
|
||||
interface ScoreSectionProps {
|
||||
scores: CompetitivePosition['scores'];
|
||||
}
|
||||
|
||||
const ScoreSection = memo<ScoreSectionProps>(({ scores }) => (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<ScoreBar label="市场地位" score={scores?.market_position} icon={FaTrophy} />
|
||||
<ScoreBar label="技术实力" score={scores?.technology} icon={FaCog} />
|
||||
<ScoreBar label="品牌价值" score={scores?.brand} icon={FaStar} />
|
||||
<ScoreBar label="运营效率" score={scores?.operation} icon={FaChartLine} />
|
||||
<ScoreBar label="财务健康" score={scores?.finance} icon={FaDollarSign} />
|
||||
<ScoreBar label="创新能力" score={scores?.innovation} icon={FaFlask} />
|
||||
<ScoreBar label="风险控制" score={scores?.risk} icon={FaShieldAlt} />
|
||||
<ScoreBar label="成长潜力" score={scores?.growth} icon={FaRocket} />
|
||||
</VStack>
|
||||
));
|
||||
|
||||
ScoreSection.displayName = 'ScoreSection';
|
||||
|
||||
// 竞争优劣势组件
|
||||
interface AdvantagesSectionProps {
|
||||
advantages?: string;
|
||||
disadvantages?: string;
|
||||
}
|
||||
|
||||
const AdvantagesSection = memo<AdvantagesSectionProps>(
|
||||
({ advantages, disadvantages }) => (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<Box {...CONTENT_BOX_STYLES}>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2} color="green.400">
|
||||
竞争优势
|
||||
</Text>
|
||||
<Text fontSize="sm" color="white">
|
||||
{advantages || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box {...CONTENT_BOX_STYLES}>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2} color="red.400">
|
||||
竞争劣势
|
||||
</Text>
|
||||
<Text fontSize="sm" color="white">
|
||||
{disadvantages || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
)
|
||||
);
|
||||
|
||||
AdvantagesSection.displayName = 'AdvantagesSection';
|
||||
|
||||
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
|
||||
({ comprehensiveData, industryRankData }) => {
|
||||
const competitivePosition = comprehensiveData.competitive_position;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
if (!competitivePosition) return null;
|
||||
|
||||
// 缓存雷达图配置
|
||||
const radarOption = useMemo(
|
||||
() => getRadarChartOption(comprehensiveData),
|
||||
[comprehensiveData]
|
||||
);
|
||||
|
||||
// 缓存竞争对手列表
|
||||
const competitors = useMemo(
|
||||
() =>
|
||||
competitivePosition.analysis?.main_competitors
|
||||
?.split(',')
|
||||
.map((c) => c.trim()) || [],
|
||||
[competitivePosition.analysis?.main_competitors]
|
||||
);
|
||||
|
||||
// 判断是否有行业排名数据可展示
|
||||
const hasIndustryRankData = industryRankData && industryRankData.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaTrophy} color="yellow.500" />
|
||||
<Heading size="sm" color="yellow.500">竞争地位分析</Heading>
|
||||
{competitivePosition.ranking && (
|
||||
<Badge
|
||||
ml={2}
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="yellow.600"
|
||||
color="yellow.500"
|
||||
cursor={hasIndustryRankData ? 'pointer' : 'default'}
|
||||
onClick={hasIndustryRankData ? onOpen : undefined}
|
||||
_hover={hasIndustryRankData ? { bg: 'rgba(212, 175, 55, 0.1)' } : undefined}
|
||||
>
|
||||
行业排名 {competitivePosition.ranking.industry_rank}/
|
||||
{competitivePosition.ranking.total_companies}
|
||||
</Badge>
|
||||
)}
|
||||
{hasIndustryRankData && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="yellow.500"
|
||||
rightIcon={<Icon as={FaExternalLinkAlt} boxSize={3} />}
|
||||
onClick={onOpen}
|
||||
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* 主要竞争对手 */}
|
||||
{competitors.length > 0 && <CompetitorTags competitors={competitors} />}
|
||||
|
||||
{/* 评分和雷达图 */}
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
<GridItem colSpan={GRID_COLSPAN}>
|
||||
<ScoreSection scores={competitivePosition.scores} />
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={GRID_COLSPAN}>
|
||||
{radarOption && (
|
||||
<ReactECharts
|
||||
option={radarOption}
|
||||
style={CHART_STYLE}
|
||||
theme="dark"
|
||||
/>
|
||||
)}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
<Divider my={4} borderColor="yellow.600" />
|
||||
|
||||
{/* 竞争优势和劣势 */}
|
||||
<AdvantagesSection
|
||||
advantages={competitivePosition.analysis?.competitive_advantages}
|
||||
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 行业排名弹窗 - 黑金主题 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
|
||||
<ModalOverlay bg="blackAlpha.700" />
|
||||
<ModalContent {...MODAL_STYLES.content}>
|
||||
<ModalHeader {...MODAL_STYLES.header}>
|
||||
<HStack>
|
||||
<Icon as={FaTrophy} color="yellow.500" />
|
||||
<Text>行业排名详情</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton {...MODAL_STYLES.closeButton} />
|
||||
<ModalBody py={4}>
|
||||
{hasIndustryRankData && (
|
||||
<IndustryRankingView
|
||||
industryRank={industryRankData}
|
||||
bgColor="gray.800"
|
||||
borderColor="rgba(212, 175, 55, 0.3)"
|
||||
/>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CompetitiveAnalysisCard.displayName = 'CompetitiveAnalysisCard';
|
||||
|
||||
export default CompetitiveAnalysisCard;
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 投资亮点卡片组件
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, HStack, VStack, Icon, Text } from '@chakra-ui/react';
|
||||
import { FaUsers } from 'react-icons/fa';
|
||||
import { THEME, ICON_MAP, HIGHLIGHT_HOVER_STYLES } from '../theme';
|
||||
import type { InvestmentHighlightItem } from '../../../types';
|
||||
|
||||
interface HighlightCardProps {
|
||||
highlight: InvestmentHighlightItem;
|
||||
}
|
||||
|
||||
export const HighlightCard = memo<HighlightCardProps>(({ highlight }) => {
|
||||
const IconComponent = ICON_MAP[highlight.icon] || FaUsers;
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
bg={THEME.light.cardBg}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.100"
|
||||
{...HIGHLIGHT_HOVER_STYLES}
|
||||
transition="border-color 0.2s"
|
||||
>
|
||||
<HStack spacing={3} align="flex-start">
|
||||
<Box
|
||||
p={2}
|
||||
bg="whiteAlpha.100"
|
||||
borderRadius="md"
|
||||
color={THEME.light.titleColor}
|
||||
>
|
||||
<Icon as={IconComponent} boxSize={4} />
|
||||
</Box>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
|
||||
{highlight.title}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={THEME.light.subtextColor}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{highlight.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
HighlightCard.displayName = 'HighlightCard';
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 商业模式板块组件
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Box, VStack, HStack, Text, Tag, Divider } from '@chakra-ui/react';
|
||||
import { THEME } from '../theme';
|
||||
import type { BusinessModelSection } from '../../../types';
|
||||
|
||||
interface ModelBlockProps {
|
||||
section: BusinessModelSection;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export const ModelBlock = memo<ModelBlockProps>(({ section, isLast }) => (
|
||||
<Box>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
|
||||
{section.title}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={THEME.light.subtextColor} lineHeight="tall">
|
||||
{section.description}
|
||||
</Text>
|
||||
{section.tags && section.tags.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap" mt={1}>
|
||||
{section.tags.map((tag, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="sm"
|
||||
bg={THEME.light.tagBg}
|
||||
color={THEME.light.tagColor}
|
||||
borderRadius="full"
|
||||
px={3}
|
||||
py={1}
|
||||
fontSize="xs"
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
{!isLast && <Divider my={4} borderColor="whiteAlpha.100" />}
|
||||
</Box>
|
||||
));
|
||||
|
||||
ModelBlock.displayName = 'ModelBlock';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 区域标题组件
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { HStack, Icon, Text } from '@chakra-ui/react';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { THEME } from '../theme';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
icon: IconType;
|
||||
title: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const SectionHeader = memo<SectionHeaderProps>(
|
||||
({ icon, title, color = THEME.dark.titleColor }) => (
|
||||
<HStack spacing={2} mb={4}>
|
||||
<Icon as={icon} color={color} boxSize={4} />
|
||||
<Text fontWeight="bold" color={color} fontSize="md">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
)
|
||||
);
|
||||
|
||||
SectionHeader.displayName = 'SectionHeader';
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* CorePositioningCard 原子组件统一导出
|
||||
*/
|
||||
|
||||
export { SectionHeader } from './SectionHeader';
|
||||
export { HighlightCard } from './HighlightCard';
|
||||
export { ModelBlock } from './ModelBlock';
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 核心定位卡片
|
||||
*
|
||||
* 显示公司的核心定位、投资亮点和商业模式
|
||||
* 黑金主题设计
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
Text,
|
||||
Box,
|
||||
Grid,
|
||||
GridItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCrown, FaStar, FaBriefcase } from 'react-icons/fa';
|
||||
import type {
|
||||
QualitativeAnalysis,
|
||||
InvestmentHighlightItem,
|
||||
} from '../../types';
|
||||
import {
|
||||
THEME,
|
||||
CARD_STYLES,
|
||||
GRID_COLUMNS,
|
||||
BORDER_RIGHT_RESPONSIVE,
|
||||
} from './theme';
|
||||
import { SectionHeader, HighlightCard, ModelBlock } from './atoms';
|
||||
|
||||
// ==================== 主组件 ====================
|
||||
|
||||
interface CorePositioningCardProps {
|
||||
qualitativeAnalysis: QualitativeAnalysis;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const CorePositioningCard: React.FC<CorePositioningCardProps> = memo(
|
||||
({ qualitativeAnalysis }) => {
|
||||
const corePositioning = qualitativeAnalysis.core_positioning;
|
||||
|
||||
// 判断是否有结构化数据
|
||||
const hasStructuredData = useMemo(
|
||||
() =>
|
||||
!!(
|
||||
corePositioning?.features?.length ||
|
||||
(Array.isArray(corePositioning?.investment_highlights) &&
|
||||
corePositioning.investment_highlights.length > 0) ||
|
||||
corePositioning?.business_model_sections?.length
|
||||
),
|
||||
[corePositioning]
|
||||
);
|
||||
|
||||
// 如果没有结构化数据,使用旧的文本格式渲染
|
||||
if (!hasStructuredData) {
|
||||
return (
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<SectionHeader icon={FaCrown} title="核心定位" />
|
||||
{corePositioning?.one_line_intro && (
|
||||
<Box
|
||||
p={4}
|
||||
bg={THEME.dark.cardBg}
|
||||
borderRadius="lg"
|
||||
borderLeft="4px solid"
|
||||
borderColor={THEME.dark.border}
|
||||
>
|
||||
<Text color={THEME.dark.textColor} fontWeight="medium">
|
||||
{corePositioning.one_line_intro}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Grid templateColumns={GRID_COLUMNS.twoColumnMd} gap={4}>
|
||||
<GridItem>
|
||||
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
|
||||
<SectionHeader icon={FaStar} title="投资亮点" />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.light.subtextColor}
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{corePositioning?.investment_highlights_text ||
|
||||
(typeof corePositioning?.investment_highlights === 'string'
|
||||
? corePositioning.investment_highlights
|
||||
: '暂无数据')}
|
||||
</Text>
|
||||
</Box>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
|
||||
<SectionHeader icon={FaBriefcase} title="商业模式" />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={THEME.light.subtextColor}
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{corePositioning?.business_model_desc || '暂无数据'}
|
||||
</Text>
|
||||
</Box>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 结构化数据渲染 - 缓存数组计算
|
||||
const highlights = useMemo(
|
||||
() =>
|
||||
(Array.isArray(corePositioning?.investment_highlights)
|
||||
? corePositioning.investment_highlights
|
||||
: []) as InvestmentHighlightItem[],
|
||||
[corePositioning?.investment_highlights]
|
||||
);
|
||||
|
||||
const businessSections = useMemo(
|
||||
() => corePositioning?.business_model_sections || [],
|
||||
[corePositioning?.business_model_sections]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardBody p={0}>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{/* 核心定位区域(深色背景) */}
|
||||
<Box p={6} bg={THEME.dark.bg}>
|
||||
<SectionHeader icon={FaCrown} title="核心定位" />
|
||||
|
||||
{/* 一句话介绍 */}
|
||||
{corePositioning?.one_line_intro && (
|
||||
<Box
|
||||
p={4}
|
||||
bg={THEME.dark.cardBg}
|
||||
borderRadius="lg"
|
||||
borderLeft="4px solid"
|
||||
borderColor={THEME.dark.border}
|
||||
>
|
||||
<Text color={THEME.dark.textColor} fontWeight="medium">
|
||||
{corePositioning.one_line_intro}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 投资亮点 + 商业模式区域 */}
|
||||
<Grid templateColumns={GRID_COLUMNS.twoColumn} bg={THEME.light.bg}>
|
||||
{/* 投资亮点区域 */}
|
||||
<GridItem
|
||||
p={6}
|
||||
borderRight={BORDER_RIGHT_RESPONSIVE}
|
||||
borderColor="whiteAlpha.100"
|
||||
>
|
||||
<SectionHeader icon={FaStar} title="投资亮点" />
|
||||
<VStack spacing={3} align="stretch">
|
||||
{highlights.length > 0 ? (
|
||||
highlights.map((highlight, idx) => (
|
||||
<HighlightCard key={idx} highlight={highlight} />
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={THEME.light.subtextColor}>
|
||||
暂无数据
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</GridItem>
|
||||
|
||||
{/* 商业模式区域 */}
|
||||
<GridItem p={6}>
|
||||
<SectionHeader icon={FaBriefcase} title="商业模式" />
|
||||
<Box
|
||||
p={4}
|
||||
bg={THEME.light.cardBg}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.100"
|
||||
>
|
||||
{businessSections.length > 0 ? (
|
||||
businessSections.map((section, idx) => (
|
||||
<ModelBlock
|
||||
key={idx}
|
||||
section={section}
|
||||
isLast={idx === businessSections.length - 1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={THEME.light.subtextColor}>
|
||||
暂无数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CorePositioningCard.displayName = 'CorePositioningCard';
|
||||
|
||||
export default CorePositioningCard;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* CorePositioningCard 主题和样式常量
|
||||
*/
|
||||
|
||||
import {
|
||||
FaUniversity,
|
||||
FaFire,
|
||||
FaUsers,
|
||||
FaChartLine,
|
||||
FaMicrochip,
|
||||
FaShieldAlt,
|
||||
} from 'react-icons/fa';
|
||||
import type { IconType } from 'react-icons';
|
||||
|
||||
// ==================== 主题常量 ====================
|
||||
|
||||
export const THEME = {
|
||||
// 深色背景区域(核心定位)- 使用更亮的金色提高对比度
|
||||
dark: {
|
||||
bg: '#1A202C',
|
||||
cardBg: '#252D3A',
|
||||
border: '#E8C14D',
|
||||
borderGradient: 'linear-gradient(90deg, #E8C14D, #A08040)',
|
||||
titleColor: '#E8C14D',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
},
|
||||
// 浅色背景区域(投资亮点/商业模式)
|
||||
light: {
|
||||
bg: '#1E2530',
|
||||
cardBg: '#252D3A',
|
||||
titleColor: '#E8C14D',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
tagBg: 'rgba(232, 193, 77, 0.15)',
|
||||
tagColor: '#E8C14D',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ==================== 图标映射 ====================
|
||||
|
||||
export const ICON_MAP: Record<string, IconType> = {
|
||||
bank: FaUniversity,
|
||||
fire: FaFire,
|
||||
users: FaUsers,
|
||||
'trending-up': FaChartLine,
|
||||
cpu: FaMicrochip,
|
||||
'shield-check': FaShieldAlt,
|
||||
};
|
||||
|
||||
// ==================== 样式常量 ====================
|
||||
|
||||
// 卡片通用样式(含顶部金色边框)
|
||||
export const CARD_STYLES = {
|
||||
bg: THEME.dark.bg,
|
||||
shadow: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'whiteAlpha.100',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: THEME.dark.borderGradient,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// HighlightCard hover 样式
|
||||
export const HIGHLIGHT_HOVER_STYLES = {
|
||||
_hover: { borderColor: 'whiteAlpha.200' },
|
||||
} as const;
|
||||
|
||||
// 响应式布局常量
|
||||
export const GRID_COLUMNS = {
|
||||
twoColumn: { base: '1fr', lg: 'repeat(2, 1fr)' },
|
||||
twoColumnMd: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
} as const;
|
||||
|
||||
export const BORDER_RIGHT_RESPONSIVE = { lg: '1px solid' } as const;
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 关键因素卡片
|
||||
*
|
||||
* 显示影响公司的关键因素列表
|
||||
* 黑金主题设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaBalanceScale } from 'react-icons/fa';
|
||||
import { KeyFactorCard } from '../atoms';
|
||||
import type { KeyFactors } from '../types';
|
||||
|
||||
// 黑金主题样式常量
|
||||
const THEME = {
|
||||
bg: '#1A202C',
|
||||
cardBg: '#252D3A',
|
||||
border: '#E8C14D',
|
||||
borderGradient: 'linear-gradient(90deg, #E8C14D, #A08040)',
|
||||
titleColor: '#E8C14D',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
} as const;
|
||||
|
||||
const CARD_STYLES = {
|
||||
bg: THEME.bg,
|
||||
shadow: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'whiteAlpha.100',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: THEME.borderGradient,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface KeyFactorsCardProps {
|
||||
keyFactors: KeyFactors;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const KeyFactorsCard: React.FC<KeyFactorsCardProps> = ({ keyFactors }) => {
|
||||
return (
|
||||
<Card {...CARD_STYLES} h="full">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaBalanceScale} color="yellow.500" />
|
||||
<Heading size="sm" color={THEME.titleColor}>
|
||||
关键因素
|
||||
</Heading>
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="yellow.600"
|
||||
color="yellow.500"
|
||||
>
|
||||
{keyFactors.total_factors} 项
|
||||
</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Accordion allowMultiple>
|
||||
{keyFactors.categories.map((category, idx) => (
|
||||
<AccordionItem key={idx} border="none">
|
||||
<AccordionButton
|
||||
bg={THEME.cardBg}
|
||||
borderRadius="md"
|
||||
mb={2}
|
||||
_hover={{ bg: 'whiteAlpha.100' }}
|
||||
>
|
||||
<Box flex="1" textAlign="left">
|
||||
<HStack>
|
||||
<Text fontWeight="medium" color={THEME.textColor}>
|
||||
{category.category_name}
|
||||
</Text>
|
||||
<Badge
|
||||
bg="whiteAlpha.100"
|
||||
color={THEME.subtextColor}
|
||||
size="sm"
|
||||
>
|
||||
{category.factors.length}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
<AccordionIcon color={THEME.subtextColor} />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{category.factors.map((factor, fidx) => (
|
||||
<KeyFactorCard key={fidx} factor={factor} />
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyFactorsCard;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 战略分析卡片
|
||||
*
|
||||
* 显示公司战略方向和战略举措
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Box,
|
||||
Icon,
|
||||
Grid,
|
||||
GridItem,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaRocket, FaChartBar } from 'react-icons/fa';
|
||||
import type { Strategy } from '../types';
|
||||
|
||||
// 样式常量 - 避免每次渲染创建新对象
|
||||
const CARD_STYLES = {
|
||||
bg: 'transparent',
|
||||
shadow: 'md',
|
||||
} as const;
|
||||
|
||||
const CONTENT_BOX_STYLES = {
|
||||
p: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.600',
|
||||
borderRadius: 'md',
|
||||
} as const;
|
||||
|
||||
const EMPTY_BOX_STYLES = {
|
||||
border: '1px dashed',
|
||||
borderColor: 'yellow.600',
|
||||
borderRadius: 'md',
|
||||
py: 12,
|
||||
} as const;
|
||||
|
||||
const GRID_RESPONSIVE_COLSPAN = { base: 2, md: 1 } as const;
|
||||
|
||||
interface StrategyAnalysisCardProps {
|
||||
strategy: Strategy;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
// 空状态组件 - 独立 memo 避免重复渲染
|
||||
const EmptyState = memo(() => (
|
||||
<Box {...EMPTY_BOX_STYLES}>
|
||||
<Center>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FaChartBar} boxSize={10} color="yellow.600" />
|
||||
<Text fontWeight="medium">战略数据更新中</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
战略方向和具体举措数据将在近期更新
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
));
|
||||
|
||||
EmptyState.displayName = 'StrategyEmptyState';
|
||||
|
||||
// 内容项组件 - 复用结构
|
||||
interface ContentItemProps {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const ContentItem = memo<ContentItemProps>(({ title, content }) => (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontWeight="bold" fontSize="sm" color="yellow.500">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="white">
|
||||
{content}
|
||||
</Text>
|
||||
</VStack>
|
||||
));
|
||||
|
||||
ContentItem.displayName = 'StrategyContentItem';
|
||||
|
||||
const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
|
||||
({ strategy }) => {
|
||||
// 缓存数据检测结果
|
||||
const hasData = useMemo(
|
||||
() => !!(strategy?.strategy_description || strategy?.strategic_initiatives),
|
||||
[strategy?.strategy_description, strategy?.strategic_initiatives]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card {...CARD_STYLES}>
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaRocket} color="yellow.500" />
|
||||
<Heading size="sm" color="yellow.500">战略分析</Heading>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!hasData ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<Box {...CONTENT_BOX_STYLES}>
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
|
||||
<ContentItem
|
||||
title="战略方向"
|
||||
content={strategy.strategy_description || '暂无数据'}
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
|
||||
<ContentItem
|
||||
title="战略举措"
|
||||
content={strategy.strategic_initiatives || '暂无数据'}
|
||||
/>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
StrategyAnalysisCard.displayName = 'StrategyAnalysisCard';
|
||||
|
||||
export default StrategyAnalysisCard;
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 发展时间线卡片
|
||||
*
|
||||
* 显示公司发展历程时间线
|
||||
* 黑金主题设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaHistory } from 'react-icons/fa';
|
||||
import TimelineComponent from '../organisms/TimelineComponent';
|
||||
import type { DevelopmentTimeline } from '../types';
|
||||
|
||||
// 黑金主题样式常量
|
||||
const THEME = {
|
||||
bg: '#1A202C',
|
||||
cardBg: '#252D3A',
|
||||
border: '#E8C14D',
|
||||
borderGradient: 'linear-gradient(90deg, #E8C14D, #A08040)',
|
||||
titleColor: '#E8C14D',
|
||||
textColor: '#E2E8F0',
|
||||
subtextColor: '#A0AEC0',
|
||||
} as const;
|
||||
|
||||
const CARD_STYLES = {
|
||||
bg: THEME.bg,
|
||||
shadow: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'whiteAlpha.100',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: THEME.borderGradient,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface TimelineCardProps {
|
||||
developmentTimeline: DevelopmentTimeline;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const TimelineCard: React.FC<TimelineCardProps> = ({ developmentTimeline }) => {
|
||||
return (
|
||||
<Card {...CARD_STYLES} h="full">
|
||||
<CardHeader>
|
||||
<HStack>
|
||||
<Icon as={FaHistory} color="yellow.500" />
|
||||
<Heading size="sm" color={THEME.titleColor}>
|
||||
发展时间线
|
||||
</Heading>
|
||||
<HStack spacing={1}>
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="red.400"
|
||||
color="red.400"
|
||||
>
|
||||
正面 {developmentTimeline.statistics?.positive_events || 0}
|
||||
</Badge>
|
||||
<Badge
|
||||
bg="transparent"
|
||||
border="1px solid"
|
||||
borderColor="green.400"
|
||||
color="green.400"
|
||||
>
|
||||
负面 {developmentTimeline.statistics?.negative_events || 0}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Box maxH="600px" overflowY="auto" pr={2}>
|
||||
<TimelineComponent events={developmentTimeline.events} />
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineCard;
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 产业链分析卡片
|
||||
*
|
||||
* 显示产业链层级视图和流向关系
|
||||
* 黑金主题风格 + 流程式导航
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, memo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
Center,
|
||||
Box,
|
||||
Flex,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaNetworkWired } from 'react-icons/fa';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import {
|
||||
ProcessNavigation,
|
||||
ValueChainFilterBar,
|
||||
} from '../atoms';
|
||||
import type { TabType, ViewMode } from '../atoms';
|
||||
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
|
||||
import { getSankeyChartOption } from '../utils/chartOptions';
|
||||
import type { ValueChainData, ValueChainNode } from '../types';
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
cardBg: 'gray.800',
|
||||
gold: '#F4D03F',
|
||||
goldLight: '#F0D78C',
|
||||
textPrimary: '#F4D03F',
|
||||
textSecondary: 'gray.400',
|
||||
};
|
||||
|
||||
interface ValueChainCardProps {
|
||||
valueChainData: ValueChainData;
|
||||
companyName?: string;
|
||||
cardBg?: string;
|
||||
}
|
||||
|
||||
const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
|
||||
valueChainData,
|
||||
companyName = '目标公司',
|
||||
}) => {
|
||||
// 状态管理
|
||||
const [activeTab, setActiveTab] = useState<TabType>('upstream');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [importanceFilter, setImportanceFilter] = useState<string>('all');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('hierarchy');
|
||||
|
||||
// 解析节点数据
|
||||
const nodesByLevel = valueChainData.value_chain_structure?.nodes_by_level;
|
||||
|
||||
// 获取上游节点
|
||||
const upstreamNodes = useMemo(() => [
|
||||
...(nodesByLevel?.['level_-2'] || []),
|
||||
...(nodesByLevel?.['level_-1'] || []),
|
||||
], [nodesByLevel]);
|
||||
|
||||
// 获取核心节点
|
||||
const coreNodes = useMemo(() =>
|
||||
nodesByLevel?.['level_0'] || [],
|
||||
[nodesByLevel]);
|
||||
|
||||
// 获取下游节点
|
||||
const downstreamNodes = useMemo(() => [
|
||||
...(nodesByLevel?.['level_1'] || []),
|
||||
...(nodesByLevel?.['level_2'] || []),
|
||||
], [nodesByLevel]);
|
||||
|
||||
// 计算总节点数
|
||||
const totalNodes = valueChainData.analysis_summary?.total_nodes ||
|
||||
(upstreamNodes.length + coreNodes.length + downstreamNodes.length);
|
||||
|
||||
// 根据 activeTab 获取当前节点
|
||||
const currentNodes = useMemo(() => {
|
||||
switch (activeTab) {
|
||||
case 'upstream':
|
||||
return upstreamNodes;
|
||||
case 'core':
|
||||
return coreNodes;
|
||||
case 'downstream':
|
||||
return downstreamNodes;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [activeTab, upstreamNodes, coreNodes, downstreamNodes]);
|
||||
|
||||
// 筛选节点
|
||||
const filteredNodes = useMemo(() => {
|
||||
let nodes = [...currentNodes];
|
||||
|
||||
// 类型筛选
|
||||
if (typeFilter !== 'all') {
|
||||
nodes = nodes.filter((n: ValueChainNode) => n.node_type === typeFilter);
|
||||
}
|
||||
|
||||
// 重要度筛选
|
||||
if (importanceFilter !== 'all') {
|
||||
nodes = nodes.filter((n: ValueChainNode) => {
|
||||
const score = n.importance_score || 0;
|
||||
switch (importanceFilter) {
|
||||
case 'high':
|
||||
return score >= 80;
|
||||
case 'medium':
|
||||
return score >= 50 && score < 80;
|
||||
case 'low':
|
||||
return score < 50;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}, [currentNodes, typeFilter, importanceFilter]);
|
||||
|
||||
// Sankey 图配置
|
||||
const sankeyOption = useMemo(() =>
|
||||
getSankeyChartOption(valueChainData),
|
||||
[valueChainData]);
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md">
|
||||
{/* 头部区域 */}
|
||||
<CardHeader py={0}>
|
||||
<HStack flexWrap="wrap" gap={0}>
|
||||
<Icon as={FaNetworkWired} color={THEME.gold} />
|
||||
<Heading size="sm" color={THEME.textPrimary}>
|
||||
产业链分析
|
||||
</Heading>
|
||||
<Text color={THEME.textSecondary} fontSize="sm">
|
||||
| {companyName}供应链图谱
|
||||
</Text>
|
||||
<Badge bg={THEME.gold} color="gray.900">
|
||||
节点 {totalNodes}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody px={2}>
|
||||
{/* 工具栏:左侧流程导航 + 右侧筛选 */}
|
||||
<Flex
|
||||
borderBottom="1px solid"
|
||||
borderColor="gray.700"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{/* 左侧:流程式导航 - 仅在层级视图显示 */}
|
||||
{viewMode === 'hierarchy' && (
|
||||
<ProcessNavigation
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
upstreamCount={upstreamNodes.length}
|
||||
coreCount={coreNodes.length}
|
||||
downstreamCount={downstreamNodes.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧:筛选与视图切换 - 始终靠右 */}
|
||||
<Box ml="auto">
|
||||
<ValueChainFilterBar
|
||||
typeFilter={typeFilter}
|
||||
onTypeChange={setTypeFilter}
|
||||
importanceFilter={importanceFilter}
|
||||
onImportanceChange={setImportanceFilter}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<Box px={0} pt={4}>
|
||||
{viewMode === 'hierarchy' ? (
|
||||
filteredNodes.length > 0 ? (
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
|
||||
{filteredNodes.map((node, idx) => (
|
||||
<ValueChainNodeCard
|
||||
key={idx}
|
||||
node={node}
|
||||
isCompany={node.node_type === 'company'}
|
||||
level={node.node_level}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Center h="200px">
|
||||
<Text color={THEME.textSecondary}>暂无匹配的节点数据</Text>
|
||||
</Center>
|
||||
)
|
||||
) : sankeyOption ? (
|
||||
<ReactECharts
|
||||
option={sankeyOption}
|
||||
style={{ height: '500px' }}
|
||||
theme="dark"
|
||||
/>
|
||||
) : (
|
||||
<Center h="200px">
|
||||
<Text color={THEME.textSecondary}>暂无流向数据</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
ValueChainCard.displayName = 'ValueChainCard';
|
||||
|
||||
export default ValueChainCard;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Card 子组件导出
|
||||
*
|
||||
* DeepAnalysisTab 的各个区块组件
|
||||
*/
|
||||
|
||||
export { default as CorePositioningCard } from './CorePositioningCard';
|
||||
export { default as CompetitiveAnalysisCard } from './CompetitiveAnalysisCard';
|
||||
export { default as BusinessStructureCard } from './BusinessStructureCard';
|
||||
export { default as ValueChainCard } from './ValueChainCard';
|
||||
export { default as KeyFactorsCard } from './KeyFactorsCard';
|
||||
export { default as TimelineCard } from './TimelineCard';
|
||||
export { default as BusinessSegmentsCard } from './BusinessSegmentsCard';
|
||||
export { default as StrategyAnalysisCard } from './StrategyAnalysisCard';
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 深度分析 Tab 主组件
|
||||
*
|
||||
* 使用 SubTabContainer 二级导航组件,分为 4 个子 Tab:
|
||||
* 1. 战略分析 - 核心定位 + 战略分析 + 竞争地位
|
||||
* 2. 业务结构 - 业务结构树 + 业务板块详情
|
||||
* 3. 产业链 - 产业链分析(独立,含 Sankey 图)
|
||||
* 4. 发展历程 - 关键因素 + 时间线
|
||||
*
|
||||
* 支持懒加载:通过 activeTab 和 onTabChange 实现按需加载数据
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, CardBody } from '@chakra-ui/react';
|
||||
import { FaBrain, FaBuilding, FaLink, FaHistory } from 'react-icons/fa';
|
||||
import SubTabContainer, { type SubTabConfig } from '@components/SubTabContainer';
|
||||
import LoadingState from '../../LoadingState';
|
||||
import { StrategyTab, BusinessTab, ValueChainTab, DevelopmentTab } from './tabs';
|
||||
import type { DeepAnalysisTabProps, DeepAnalysisTabKey } from './types';
|
||||
|
||||
// 主题配置(与 BasicInfoTab 保持一致)
|
||||
const THEME = {
|
||||
cardBg: 'gray.900',
|
||||
border: 'rgba(212, 175, 55, 0.3)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Tab 配置
|
||||
*/
|
||||
const DEEP_ANALYSIS_TABS: SubTabConfig[] = [
|
||||
{ key: 'strategy', name: '战略分析', icon: FaBrain, component: StrategyTab },
|
||||
{ key: 'business', name: '业务结构', icon: FaBuilding, component: BusinessTab },
|
||||
{ key: 'valueChain', name: '产业链', icon: FaLink, component: ValueChainTab },
|
||||
{ key: 'development', name: '发展历程', icon: FaHistory, component: DevelopmentTab },
|
||||
];
|
||||
|
||||
/**
|
||||
* Tab key 到 index 的映射
|
||||
*/
|
||||
const TAB_KEY_TO_INDEX: Record<DeepAnalysisTabKey, number> = {
|
||||
strategy: 0,
|
||||
business: 1,
|
||||
valueChain: 2,
|
||||
development: 3,
|
||||
};
|
||||
|
||||
const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
||||
comprehensiveData,
|
||||
valueChainData,
|
||||
keyFactorsData,
|
||||
industryRankData,
|
||||
loading,
|
||||
cardBg,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}) => {
|
||||
// 计算当前 Tab 索引(受控模式)
|
||||
const currentIndex = useMemo(() => {
|
||||
if (activeTab) {
|
||||
return TAB_KEY_TO_INDEX[activeTab] ?? 0;
|
||||
}
|
||||
return undefined; // 非受控模式
|
||||
}, [activeTab]);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
tabs={DEEP_ANALYSIS_TABS}
|
||||
index={currentIndex}
|
||||
onTabChange={onTabChange}
|
||||
componentProps={{}}
|
||||
themePreset="blackGold"
|
||||
size="sm"
|
||||
/>
|
||||
<LoadingState message="加载数据中..." height="200px" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
tabs={DEEP_ANALYSIS_TABS}
|
||||
index={currentIndex}
|
||||
onTabChange={onTabChange}
|
||||
componentProps={{
|
||||
comprehensiveData,
|
||||
valueChainData,
|
||||
keyFactorsData,
|
||||
industryRankData,
|
||||
cardBg,
|
||||
expandedSegments,
|
||||
onToggleSegment,
|
||||
}}
|
||||
themePreset="blackGold"
|
||||
size="sm"
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeepAnalysisTab;
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 事件详情模态框组件
|
||||
*
|
||||
* 显示时间线事件的详细信息
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Box,
|
||||
Progress,
|
||||
Icon,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||
import type { TimelineEvent } from '../../types';
|
||||
|
||||
interface EventDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
event: TimelineEvent | null;
|
||||
}
|
||||
|
||||
const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
event,
|
||||
}) => {
|
||||
if (!event) return null;
|
||||
|
||||
const isPositive = event.impact_metrics?.is_positive;
|
||||
const impactScore = event.impact_metrics?.impact_score || 0;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<HStack>
|
||||
<Icon
|
||||
as={isPositive ? FaCheckCircle : FaExclamationCircle}
|
||||
color={isPositive ? 'red.500' : 'green.500'}
|
||||
boxSize={6}
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text>{event.event_title}</Text>
|
||||
<HStack>
|
||||
<Badge colorScheme={isPositive ? 'red' : 'green'}>
|
||||
{event.event_type}
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{event.event_date}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="gray.600">
|
||||
事件详情
|
||||
</Text>
|
||||
<Text fontSize="sm" lineHeight="1.6">
|
||||
{event.event_desc}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{event.related_info?.financial_impact && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="gray.600">
|
||||
财务影响
|
||||
</Text>
|
||||
<Text fontSize="sm" lineHeight="1.6" color="blue.600">
|
||||
{event.related_info.financial_impact}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="gray.600">
|
||||
影响评估
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
<VStack spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
影响度
|
||||
</Text>
|
||||
<Progress
|
||||
value={impactScore}
|
||||
size="lg"
|
||||
width="120px"
|
||||
colorScheme={impactScore > 70 ? 'red' : 'orange'}
|
||||
hasStripe
|
||||
isAnimated
|
||||
/>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{impactScore}/100
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack>
|
||||
<Badge
|
||||
size="lg"
|
||||
colorScheme={isPositive ? 'red' : 'green'}
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
{isPositive ? '正面影响' : '负面影响'}
|
||||
</Badge>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="blue" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user