Compare commits
348 Commits
feature_bu
...
a2c5c8bb47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c5c8bb47 | ||
|
|
292d3a007a | ||
|
|
a27065e613 | ||
|
|
12fc63bef9 | ||
|
|
ac76db09a2 | ||
|
|
9156da410d | ||
|
|
073fba5c57 | ||
|
|
06475f82a4 | ||
|
|
b578504591 | ||
|
|
60aa5c80a5 | ||
|
|
a332d5571a | ||
| 1eb94cc213 | |||
|
|
e0e1e7e444 | ||
|
|
f1ae48bd42 | ||
|
|
602dcf8eee | ||
|
|
d9dbf65e7d | ||
|
|
12a57f2fa2 | ||
|
|
39fb70a1eb | ||
|
|
068d59634b | ||
|
|
4cae6fe5b6 | ||
|
|
145b6575d8 | ||
|
|
7d859e18ca | ||
|
|
939b4e736c | ||
|
|
b2ade04b00 | ||
|
|
6a21a57f4c | ||
|
|
2fe535e553 | ||
|
|
d24f9c7b16 | ||
|
|
ab5b19847f | ||
|
|
0b683f4227 | ||
|
|
fd5b74ec16 | ||
|
|
92e6fb254b | ||
|
|
c325d51316 | ||
|
|
a41cd71a65 | ||
|
|
3dabddf222 | ||
|
|
89ed59640e | ||
|
|
dafef2c572 | ||
|
|
bb0506b2bb | ||
|
|
8b9e35e55c | ||
|
|
5ca19d11a4 | ||
|
|
7a079a86b1 | ||
|
|
0a9ae6507b | ||
|
|
22d731167c | ||
|
|
600d9cc846 | ||
|
|
dcba97a121 | ||
|
|
a2a15e45a4 | ||
|
|
4f6bfe0b8c | ||
|
|
bbd965a307 | ||
|
|
f557ef96cf | ||
|
|
b6ed68244e | ||
|
|
e93d5532bf | ||
|
|
429737c111 | ||
| 9750ab75ba | |||
|
|
93928f4ee7 | ||
|
|
30b831e880 | ||
| 8a9e4f018a | |||
| a626c6c872 | |||
|
|
18ba36a539 | ||
|
|
c639b418f0 | ||
|
|
712090accb | ||
|
|
bc844bb4dc | ||
|
|
10e34d911f | ||
|
|
1a55e037c9 | ||
|
|
16c30b45b9 | ||
| 317bdb1daf | |||
| 5843029b9c | |||
| 0b95953db9 | |||
| 3ef1e6ea29 | |||
| 8936118133 | |||
| 1071405aaf | |||
| 144cc256cf | |||
| 82e4fab55c | |||
| 22c5c166bf | |||
| 61a29ce5ce | |||
| 20bcf3770a | |||
| 6d878df27c | |||
| a2a233bb0f | |||
|
|
174fe32850 | ||
|
|
77ea38e5c9 | ||
|
|
9e271747da | ||
|
|
88b836e75a | ||
| 307d80c808 | |||
| 897067a94e | |||
| da02461965 | |||
| efe5f45e31 | |||
| 96c94eaec4 | |||
| 23dd573663 | |||
| 2d48e08e43 | |||
| 46c7649bf0 | |||
| ee734e719e | |||
| 453c2f8635 | |||
| d7429b94ae | |||
| fec478f361 | |||
| 79ec798abf | |||
| f09062491e | |||
| 19ca71068b | |||
| 840ed920b8 | |||
| 9baa57a15d | |||
| 54b7d9fc89 | |||
| d9b804c46c | |||
| e177de647d | |||
| b61f7a5048 | |||
|
|
d74162b7ce | ||
|
|
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 |
File diff suppressed because it is too large
Load Diff
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:
|
||||
|
||||
18
package.json
18
package.json
@@ -14,10 +14,6 @@
|
||||
"@fontsource/open-sans": "^4.5.0",
|
||||
"@fontsource/raleway": "^4.5.0",
|
||||
"@fontsource/roboto": "^4.5.0",
|
||||
"@fullcalendar/core": "^6.1.19",
|
||||
"@fullcalendar/daygrid": "^6.1.19",
|
||||
"@fullcalendar/interaction": "^6.1.19",
|
||||
"@fullcalendar/react": "^6.1.19",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
@@ -28,29 +24,20 @@
|
||||
"@visx/visx": "^3.12.0",
|
||||
"@visx/wordcloud": "^3.12.0",
|
||||
"antd": "^5.27.4",
|
||||
"apexcharts": "^3.27.3",
|
||||
"axios": "^1.10.0",
|
||||
"classnames": "^2.5.1",
|
||||
"craco-less": "^3.0.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.23.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"draft-js": "^0.11.7",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"fullcalendar": "^5.9.0",
|
||||
"globalize": "^1.7.0",
|
||||
"history": "^5.3.0",
|
||||
"klinecharts": "^10.0.0-beta1",
|
||||
"lucide-react": "^0.540.0",
|
||||
"match-sorter": "6.3.0",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.295.0",
|
||||
"react": "^19.0.0",
|
||||
"react-apexcharts": "^1.3.9",
|
||||
"react-circular-slider-svg": "^0.1.5",
|
||||
"react-custom-scrollbars-2": "^4.4.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-force-graph-3d": "^1.29.0",
|
||||
@@ -62,16 +49,12 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-scroll": "^1.8.4",
|
||||
"react-scroll-into-view": "^2.1.3",
|
||||
"react-table": "^7.7.0",
|
||||
"react-tagsinput": "3.19.0",
|
||||
"react-to-print": "^3.0.3",
|
||||
"react-tsparticles": "^2.12.2",
|
||||
"reagraph": "^4.27.0",
|
||||
"recharts": "^3.1.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sass": "^1.49.9",
|
||||
@@ -79,7 +62,6 @@
|
||||
"styled-components": "^5.3.11",
|
||||
"stylis": "^4.0.10",
|
||||
"stylis-plugin-rtl": "^2.1.1",
|
||||
"three": "^0.181.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
BIN
public/images/services/wechat-app.jpg
Normal file
BIN
public/images/services/wechat-app.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -203,44 +203,46 @@ When it's NOT activated, the fc-button classes won't even be in the DOM.
|
||||
|
||||
}
|
||||
.fc .fc-button-primary {
|
||||
color: #fff;
|
||||
color: var(--fc-button-text-color, #fff);
|
||||
background-color: #805AD5;
|
||||
background-color: var(--fc-button-bg-color, #805AD5);
|
||||
border-color: #805AD5;
|
||||
border-color: var(--fc-button-border-color, #805AD5);
|
||||
color: #0A0A14;
|
||||
color: var(--fc-button-text-color, #0A0A14);
|
||||
background-color: #D4AF37;
|
||||
background-color: var(--fc-button-bg-color, #D4AF37);
|
||||
border-color: #D4AF37;
|
||||
border-color: var(--fc-button-border-color, #D4AF37);
|
||||
font-weight: 600;
|
||||
}
|
||||
.fc .fc-button-primary:hover {
|
||||
color: #fff;
|
||||
color: var(--fc-button-text-color, #fff);
|
||||
background-color: #6B46C1;
|
||||
background-color: var(--fc-button-hover-bg-color, #6B46C1);
|
||||
border-color: #6B46C1;
|
||||
border-color: var(--fc-button-hover-border-color, #6B46C1);
|
||||
color: #0A0A14;
|
||||
color: var(--fc-button-text-color, #0A0A14);
|
||||
background-color: #B8960C;
|
||||
background-color: var(--fc-button-hover-bg-color, #B8960C);
|
||||
border-color: #B8960C;
|
||||
border-color: var(--fc-button-hover-border-color, #B8960C);
|
||||
}
|
||||
.fc .fc-button-primary:disabled { /* not DRY */
|
||||
color: #fff;
|
||||
color: var(--fc-button-text-color, #fff);
|
||||
background-color: #805AD5;
|
||||
background-color: var(--fc-button-bg-color, #805AD5);
|
||||
border-color: #805AD5;
|
||||
border-color: var(--fc-button-border-color, #805AD5); /* overrides :hover */
|
||||
color: #0A0A14;
|
||||
color: var(--fc-button-text-color, #0A0A14);
|
||||
background-color: #B8960C;
|
||||
background-color: var(--fc-button-bg-color, #B8960C);
|
||||
border-color: #B8960C;
|
||||
border-color: var(--fc-button-border-color, #B8960C); /* overrides :hover */
|
||||
opacity: 1;
|
||||
}
|
||||
.fc .fc-button-primary:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(128, 90, 213, 0.5);
|
||||
box-shadow: 0 0 0 0.2rem rgba(212, 175, 55, 0.5);
|
||||
}
|
||||
.fc .fc-button-primary:not(:disabled):active,
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active {
|
||||
color: #fff;
|
||||
color: var(--fc-button-text-color, #fff);
|
||||
background-color: #6B46C1;
|
||||
background-color: var(--fc-button-active-bg-color, #6B46C1);
|
||||
border-color: #6B46C1;
|
||||
border-color: var(--fc-button-active-border-color, #6B46C1);
|
||||
color: #0A0A14;
|
||||
color: var(--fc-button-text-color, #0A0A14);
|
||||
background-color: #B8960C;
|
||||
background-color: var(--fc-button-active-bg-color, #B8960C);
|
||||
border-color: #B8960C;
|
||||
border-color: var(--fc-button-active-border-color, #B8960C);
|
||||
}
|
||||
.fc .fc-button-primary:not(:disabled):active:focus,
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(128, 90, 213, 0.5);
|
||||
box-shadow: 0 0 0 0.2rem rgba(212, 175, 55, 0.5);
|
||||
}
|
||||
.fc {
|
||||
|
||||
|
||||
269
src/components/Calendar/BaseCalendar.tsx
Normal file
269
src/components/Calendar/BaseCalendar.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* BaseCalendar - 基础日历组件
|
||||
* 封装 Ant Design Calendar,提供统一的黑金主题和接口
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Calendar, ConfigProvider, Button } from 'antd';
|
||||
import type { CalendarProps } from 'antd';
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { Box, HStack, Text } from '@chakra-ui/react';
|
||||
import { CALENDAR_THEME, CALENDAR_COLORS, CALENDAR_STYLES } from './theme';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 单元格渲染信息
|
||||
*/
|
||||
export interface CellRenderInfo {
|
||||
type: 'date' | 'month';
|
||||
isToday: boolean;
|
||||
isCurrentMonth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BaseCalendar Props
|
||||
*/
|
||||
export interface BaseCalendarProps {
|
||||
/** 当前选中日期 */
|
||||
value?: Dayjs;
|
||||
/** 日期变化回调(月份切换等) */
|
||||
onChange?: (date: Dayjs) => void;
|
||||
/** 日期选择回调(点击日期) */
|
||||
onSelect?: (date: Dayjs) => void;
|
||||
/** 自定义单元格内容渲染 */
|
||||
cellRender?: (date: Dayjs, info: CellRenderInfo) => React.ReactNode;
|
||||
/** 日历高度 */
|
||||
height?: string | number;
|
||||
/** 是否显示工具栏 */
|
||||
showToolbar?: boolean;
|
||||
/** 工具栏标题格式 */
|
||||
titleFormat?: string;
|
||||
/** 额外的 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认工具栏组件
|
||||
*/
|
||||
const CalendarToolbar: React.FC<{
|
||||
value: Dayjs;
|
||||
onChange: (date: Dayjs) => void;
|
||||
titleFormat?: string;
|
||||
}> = ({ value, onChange, titleFormat = 'YYYY年M月' }) => {
|
||||
const handlePrev = () => onChange(value.subtract(1, 'month'));
|
||||
const handleNext = () => onChange(value.add(1, 'month'));
|
||||
const handleToday = () => onChange(dayjs());
|
||||
|
||||
return (
|
||||
<HStack justify="flex-start" mb={4} px={2} spacing={4}>
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePrev}
|
||||
style={{
|
||||
backgroundColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||
borderColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||
color: CALENDAR_STYLES.toolbar.buttonColor,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNext}
|
||||
style={{
|
||||
backgroundColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||
borderColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||
color: CALENDAR_STYLES.toolbar.buttonColor,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleToday}
|
||||
style={{
|
||||
backgroundColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||
borderColor: CALENDAR_STYLES.toolbar.buttonBg,
|
||||
color: CALENDAR_STYLES.toolbar.buttonColor,
|
||||
}}
|
||||
>
|
||||
今天
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize={{ base: 'lg', md: 'xl' }}
|
||||
fontWeight="700"
|
||||
bgGradient={`linear(135deg, ${CALENDAR_COLORS.gold.primary} 0%, #F5E6A3 100%)`}
|
||||
bgClip="text"
|
||||
>
|
||||
{value.format(titleFormat)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* BaseCalendar 组件
|
||||
*/
|
||||
export const BaseCalendar: React.FC<BaseCalendarProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
cellRender,
|
||||
height = '100%',
|
||||
showToolbar = true,
|
||||
titleFormat = 'YYYY年M月',
|
||||
className,
|
||||
}) => {
|
||||
const [currentValue, setCurrentValue] = React.useState<Dayjs>(value || dayjs());
|
||||
|
||||
// 同步外部 value
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
setCurrentValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 处理日期变化
|
||||
const handleChange = useCallback((date: Dayjs) => {
|
||||
setCurrentValue(date);
|
||||
onChange?.(date);
|
||||
}, [onChange]);
|
||||
|
||||
// 处理日期选择(只在点击日期时触发,不在切换面板时触发)
|
||||
const handleSelect: CalendarProps<Dayjs>['onSelect'] = useCallback((date: Dayjs, selectInfo) => {
|
||||
// selectInfo.source: 'date' 表示点击日期,'month' 表示切换月份面板
|
||||
// 只在点击日期时触发 onSelect
|
||||
if (selectInfo.source === 'date') {
|
||||
setCurrentValue(date);
|
||||
onSelect?.(date);
|
||||
}
|
||||
}, [onSelect]);
|
||||
|
||||
// 自定义单元格渲染
|
||||
const fullCellRender = useCallback((date: Dayjs) => {
|
||||
const isToday = date.isSame(dayjs(), 'day');
|
||||
const isCurrentMonth = date.isSame(currentValue, 'month');
|
||||
|
||||
const info: CellRenderInfo = {
|
||||
type: 'date',
|
||||
isToday,
|
||||
isCurrentMonth,
|
||||
};
|
||||
|
||||
// 基础日期单元格样式
|
||||
const cellStyle: React.CSSProperties = {
|
||||
minHeight: CALENDAR_STYLES.cell.minHeight,
|
||||
padding: CALENDAR_STYLES.cell.padding,
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
...(isToday ? {
|
||||
backgroundColor: CALENDAR_STYLES.today.bg,
|
||||
border: CALENDAR_STYLES.today.border,
|
||||
} : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={cellStyle}
|
||||
className="base-calendar-cell"
|
||||
>
|
||||
{/* 日期数字 */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
fontWeight: isToday ? 700 : 600,
|
||||
color: isToday
|
||||
? CALENDAR_COLORS.gold.primary
|
||||
: isCurrentMonth
|
||||
? '#FFFFFF' // 纯白色,更亮
|
||||
: 'rgba(255, 255, 255, 0.4)',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{date.date()}
|
||||
</div>
|
||||
{/* 自定义内容 */}
|
||||
{cellRender?.(date, info)}
|
||||
</div>
|
||||
);
|
||||
}, [currentValue, cellRender]);
|
||||
|
||||
// 隐藏默认 header
|
||||
const headerRender = useCallback((): React.ReactNode => null, []);
|
||||
|
||||
return (
|
||||
<Box height={height} className={className}>
|
||||
<ConfigProvider theme={CALENDAR_THEME} locale={zhCN}>
|
||||
{showToolbar && (
|
||||
<CalendarToolbar
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
titleFormat={titleFormat}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
height={showToolbar ? 'calc(100% - 60px)' : '100%'}
|
||||
sx={{
|
||||
// 日历整体样式
|
||||
'.ant-picker-calendar': {
|
||||
bg: 'transparent',
|
||||
},
|
||||
'.ant-picker-panel': {
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
},
|
||||
// 星期头 - 居中显示
|
||||
'.ant-picker-content thead th': {
|
||||
color: `${CALENDAR_COLORS.gold.primary} !important`,
|
||||
fontWeight: '600 !important',
|
||||
fontSize: '14px',
|
||||
padding: '8px 0',
|
||||
textAlign: 'center !important',
|
||||
},
|
||||
// 日期单元格
|
||||
'.ant-picker-cell': {
|
||||
padding: '2px',
|
||||
},
|
||||
'.ant-picker-cell-inner': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
// 非当前月份
|
||||
'.ant-picker-cell-in-view': {
|
||||
opacity: 1,
|
||||
},
|
||||
'.ant-picker-cell:not(.ant-picker-cell-in-view)': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
// hover 效果
|
||||
'.base-calendar-cell:hover': {
|
||||
bg: 'rgba(212, 175, 55, 0.1)',
|
||||
},
|
||||
// 选中状态
|
||||
'.ant-picker-cell-selected .base-calendar-cell': {
|
||||
bg: 'rgba(212, 175, 55, 0.15)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
fullscreen={true}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
onSelect={handleSelect}
|
||||
fullCellRender={fullCellRender}
|
||||
headerRender={headerRender}
|
||||
/>
|
||||
</Box>
|
||||
</ConfigProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseCalendar;
|
||||
142
src/components/Calendar/CalendarEventBlock.tsx
Normal file
142
src/components/Calendar/CalendarEventBlock.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* CalendarEventBlock - 日历事件块组件
|
||||
* 用于在日历单元格中显示事件列表,支持多种事件类型和 "更多" 折叠
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { HStack, Text, Badge, VStack, Box } from '@chakra-ui/react';
|
||||
import { CALENDAR_COLORS } from './theme';
|
||||
|
||||
/**
|
||||
* 事件类型定义
|
||||
*/
|
||||
export type EventType = 'news' | 'report' | 'plan' | 'review' | 'system' | 'priceUp' | 'priceDown';
|
||||
|
||||
/**
|
||||
* 日历事件接口
|
||||
*/
|
||||
export interface CalendarEvent {
|
||||
id: string | number;
|
||||
type: EventType;
|
||||
title: string;
|
||||
date: string;
|
||||
count?: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件块 Props
|
||||
*/
|
||||
interface CalendarEventBlockProps {
|
||||
events: CalendarEvent[];
|
||||
maxDisplay?: number;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onMoreClick?: (events: CalendarEvent[]) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件类型配置
|
||||
*/
|
||||
const EVENT_CONFIG: Record<EventType, { label: string; color: string; emoji?: string }> = {
|
||||
news: { label: '新闻', color: CALENDAR_COLORS.events.news, emoji: '📰' },
|
||||
report: { label: '研报', color: CALENDAR_COLORS.events.report, emoji: '📊' },
|
||||
plan: { label: '计划', color: CALENDAR_COLORS.events.plan },
|
||||
review: { label: '复盘', color: CALENDAR_COLORS.events.review },
|
||||
system: { label: '系统', color: CALENDAR_COLORS.events.system },
|
||||
priceUp: { label: '涨', color: CALENDAR_COLORS.events.priceUp, emoji: '🔥' },
|
||||
priceDown: { label: '跌', color: CALENDAR_COLORS.events.priceDown },
|
||||
};
|
||||
|
||||
/**
|
||||
* 单个事件行组件
|
||||
*/
|
||||
const EventLine: React.FC<{
|
||||
event: CalendarEvent;
|
||||
compact?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = ({ event, compact, onClick }) => {
|
||||
const config = EVENT_CONFIG[event.type] || { label: event.type, color: '#888' };
|
||||
|
||||
return (
|
||||
<Box
|
||||
fontSize={compact ? '9px' : '10px'}
|
||||
color={config.color}
|
||||
cursor="pointer"
|
||||
px={1}
|
||||
py={0.5}
|
||||
borderRadius="sm"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
|
||||
w="100%"
|
||||
overflow="hidden"
|
||||
textAlign="left"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
{/* 格式:计划:年末布局XX+1 */}
|
||||
<Text fontWeight="600" fontSize={compact ? '9px' : '10px'} isTruncated>
|
||||
{config.emoji ? `${config.emoji} ` : ''}{config.label}:{event.title || ''}
|
||||
{(event.count ?? 0) > 1 && <Text as="span" color={config.color}>+{(event.count ?? 1) - 1}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 日历事件块组件
|
||||
*/
|
||||
export const CalendarEventBlock: React.FC<CalendarEventBlockProps> = ({
|
||||
events,
|
||||
maxDisplay = 3,
|
||||
onEventClick,
|
||||
onMoreClick,
|
||||
compact = false,
|
||||
}) => {
|
||||
// 计算显示的事件和剩余事件
|
||||
const { displayEvents, remainingCount, remainingEvents } = useMemo(() => {
|
||||
if (events.length <= maxDisplay) {
|
||||
return { displayEvents: events, remainingCount: 0, remainingEvents: [] };
|
||||
}
|
||||
return {
|
||||
displayEvents: events.slice(0, maxDisplay),
|
||||
remainingCount: events.length - maxDisplay,
|
||||
remainingEvents: events.slice(maxDisplay),
|
||||
};
|
||||
}, [events, maxDisplay]);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
return (
|
||||
<VStack spacing={0} align="stretch" w="100%">
|
||||
{displayEvents.map((event) => (
|
||||
<EventLine
|
||||
key={event.id}
|
||||
event={event}
|
||||
compact={compact}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
/>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Box
|
||||
fontSize="9px"
|
||||
color={CALENDAR_COLORS.text.secondary}
|
||||
cursor="pointer"
|
||||
px={1}
|
||||
py={0.5}
|
||||
borderRadius="sm"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoreClick?.(remainingEvents);
|
||||
}}
|
||||
>
|
||||
<Text>+{remainingCount} 更多</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarEventBlock;
|
||||
19
src/components/Calendar/index.ts
Normal file
19
src/components/Calendar/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Calendar 公共组件库
|
||||
* 统一的日历组件,基于 Ant Design Calendar + 黑金主题
|
||||
*/
|
||||
|
||||
// 基础日历组件
|
||||
export { BaseCalendar } from './BaseCalendar';
|
||||
export type { BaseCalendarProps, CellRenderInfo } from './BaseCalendar';
|
||||
|
||||
// 事件块组件
|
||||
export { CalendarEventBlock } from './CalendarEventBlock';
|
||||
export type { CalendarEvent, EventType } from './CalendarEventBlock';
|
||||
|
||||
// 主题配置
|
||||
export {
|
||||
CALENDAR_THEME,
|
||||
CALENDAR_COLORS,
|
||||
CALENDAR_STYLES,
|
||||
} from './theme';
|
||||
111
src/components/Calendar/theme.ts
Normal file
111
src/components/Calendar/theme.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Calendar 黑金主题配置
|
||||
* 统一的 Ant Design Calendar 主题,用于所有日历组件
|
||||
*/
|
||||
|
||||
import type { ThemeConfig } from 'antd';
|
||||
|
||||
// 黑金主题色值
|
||||
export const CALENDAR_COLORS = {
|
||||
// 主色
|
||||
gold: {
|
||||
primary: '#D4AF37',
|
||||
secondary: '#B8960C',
|
||||
gradient: 'linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%)',
|
||||
},
|
||||
// 背景色
|
||||
bg: {
|
||||
deep: '#0A0A14',
|
||||
primary: '#0F0F1A',
|
||||
elevated: '#1A1A2E',
|
||||
surface: '#252540',
|
||||
},
|
||||
// 边框色
|
||||
border: {
|
||||
subtle: 'rgba(212, 175, 55, 0.1)',
|
||||
default: 'rgba(212, 175, 55, 0.2)',
|
||||
emphasis: 'rgba(212, 175, 55, 0.4)',
|
||||
},
|
||||
// 文字色
|
||||
text: {
|
||||
primary: 'rgba(255, 255, 255, 0.95)',
|
||||
secondary: 'rgba(255, 255, 255, 0.6)',
|
||||
muted: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
// 事件类型颜色
|
||||
events: {
|
||||
news: '#9F7AEA', // 紫色 - 新闻
|
||||
report: '#805AD5', // 深紫 - 研报
|
||||
plan: '#D4AF37', // 金色 - 计划
|
||||
review: '#10B981', // 绿色 - 复盘
|
||||
system: '#3B82F6', // 蓝色 - 系统事件
|
||||
priceUp: '#FC8181', // 红色 - 上涨
|
||||
priceDown: '#68D391', // 绿色 - 下跌
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Ant Design Calendar 黑金主题配置
|
||||
*/
|
||||
export const CALENDAR_THEME: ThemeConfig = {
|
||||
token: {
|
||||
// 基础色
|
||||
colorBgContainer: 'transparent',
|
||||
colorBgElevated: CALENDAR_COLORS.bg.elevated,
|
||||
colorText: CALENDAR_COLORS.text.primary,
|
||||
colorTextSecondary: CALENDAR_COLORS.text.secondary,
|
||||
colorTextTertiary: CALENDAR_COLORS.text.muted,
|
||||
colorTextHeading: CALENDAR_COLORS.gold.primary,
|
||||
|
||||
// 边框
|
||||
colorBorder: CALENDAR_COLORS.border.default,
|
||||
colorBorderSecondary: CALENDAR_COLORS.border.subtle,
|
||||
|
||||
// 主色
|
||||
colorPrimary: CALENDAR_COLORS.gold.primary,
|
||||
colorPrimaryHover: CALENDAR_COLORS.gold.secondary,
|
||||
colorPrimaryActive: CALENDAR_COLORS.gold.secondary,
|
||||
|
||||
// 链接色
|
||||
colorLink: CALENDAR_COLORS.gold.primary,
|
||||
colorLinkHover: CALENDAR_COLORS.gold.secondary,
|
||||
|
||||
// 圆角
|
||||
borderRadius: 8,
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
components: {
|
||||
Calendar: {
|
||||
// 日历整体背景
|
||||
fullBg: 'transparent',
|
||||
fullPanelBg: 'transparent',
|
||||
|
||||
// 选中项背景
|
||||
itemActiveBg: 'rgba(212, 175, 55, 0.15)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 日历样式常量(用于内联样式或 CSS-in-JS)
|
||||
*/
|
||||
export const CALENDAR_STYLES = {
|
||||
// 今天高亮
|
||||
today: {
|
||||
bg: 'rgba(212, 175, 55, 0.1)',
|
||||
border: `2px solid ${CALENDAR_COLORS.gold.primary}`,
|
||||
},
|
||||
// 日期单元格
|
||||
cell: {
|
||||
minHeight: '85px',
|
||||
padding: '4px',
|
||||
},
|
||||
// 工具栏
|
||||
toolbar: {
|
||||
buttonBg: CALENDAR_COLORS.gold.primary,
|
||||
buttonColor: CALENDAR_COLORS.bg.deep,
|
||||
buttonHoverBg: CALENDAR_COLORS.gold.secondary,
|
||||
},
|
||||
};
|
||||
|
||||
export default CALENDAR_THEME;
|
||||
@@ -1,40 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import FullCalendar from '@fullcalendar/react'; // must go before plugins
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'; // a plugin!
|
||||
import interactionPlugin from '@fullcalendar/interaction'; // needed for dayClick
|
||||
|
||||
function EventCalendar(props) {
|
||||
const { calendarData, initialDate } = props;
|
||||
|
||||
return (
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
headerToolbar={false}
|
||||
initialView='dayGridMonth'
|
||||
initialDate={initialDate}
|
||||
contentHeight='600'
|
||||
events={calendarData}
|
||||
editable={true}
|
||||
height='100%'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventCalendar;
|
||||
@@ -1,50 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
import React, { Component } from "react";
|
||||
import Chart from "react-apexcharts";
|
||||
|
||||
class BarChart extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
chartData: [],
|
||||
chartOptions: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
chartData: this.props.chartData,
|
||||
chartOptions: this.props.chartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Chart
|
||||
options={this.state.chartOptions}
|
||||
series={this.state.chartData}
|
||||
type="bar"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default BarChart;
|
||||
@@ -1,50 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
import React, { Component } from "react";
|
||||
import Chart from "react-apexcharts";
|
||||
|
||||
class BubbleChart extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
chartData: [],
|
||||
chartOptions: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
chartData: this.props.chartData,
|
||||
chartOptions: this.props.chartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Chart
|
||||
options={this.state.chartOptions}
|
||||
series={this.state.chartData}
|
||||
type="bubble"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default BubbleChart;
|
||||
@@ -1,51 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import ReactApexChart from "react-apexcharts";
|
||||
|
||||
class DonutChart extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
chartData: [],
|
||||
chartOptions: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
chartData: this.props.chartData,
|
||||
chartOptions: this.props.chartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ReactApexChart
|
||||
options={this.state.chartOptions}
|
||||
series={this.state.chartData}
|
||||
type="donut"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DonutChart;
|
||||
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,50 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
import React, { Component } from "react";
|
||||
import Chart from "react-apexcharts";
|
||||
|
||||
class LineBarChart extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
chartData: [],
|
||||
chartOptions: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
chartData: this.props.chartData,
|
||||
chartOptions: this.props.chartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Chart
|
||||
options={this.state.chartOptions}
|
||||
series={this.state.chartData}
|
||||
type="line"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LineBarChart;
|
||||
@@ -1,51 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import ReactApexChart from "react-apexcharts";
|
||||
|
||||
class LineChart extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
chartData: [],
|
||||
chartOptions: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
chartData: this.props.chartData,
|
||||
chartOptions: this.props.chartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ReactApexChart
|
||||
options={this.state.chartOptions}
|
||||
series={this.state.chartData}
|
||||
type="area"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LineChart;
|
||||
@@ -1,51 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import ReactApexChart from "react-apexcharts";
|
||||
|
||||
class PieChart extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
chartData: [],
|
||||
chartOptions: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
chartData: this.props.chartData,
|
||||
chartOptions: this.props.chartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ReactApexChart
|
||||
options={this.state.chartOptions}
|
||||
series={this.state.chartData}
|
||||
type="pie"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PieChart;
|
||||
@@ -1,51 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import ReactApexChart from "react-apexcharts";
|
||||
|
||||
class PolarChart extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
chartData: [],
|
||||
chartOptions: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
chartData: this.props.chartData,
|
||||
chartOptions: this.props.chartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ReactApexChart
|
||||
options={this.state.chartOptions}
|
||||
series={this.state.chartData}
|
||||
type="polarArea"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PolarChart;
|
||||
@@ -1,51 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import ReactApexChart from "react-apexcharts";
|
||||
|
||||
class RadarChart extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
chartData: [],
|
||||
chartOptions: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
chartData: this.props.chartData,
|
||||
chartOptions: this.props.chartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ReactApexChart
|
||||
options={this.state.chartOptions}
|
||||
series={this.state.chartData}
|
||||
type="radar"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RadarChart;
|
||||
@@ -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 图表渲染组件
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
Badge,
|
||||
IconButton,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||
|
||||
/**
|
||||
* 可折叠模块标题组件
|
||||
@@ -38,9 +38,10 @@ const CollapsibleHeader = ({
|
||||
onModeToggle = null,
|
||||
isLocked = false
|
||||
}) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
// 深色主题 - 标题区块背景稍亮
|
||||
const sectionBg = '#3D4A5C';
|
||||
const hoverBg = '#4A5568';
|
||||
const headingColor = '#F7FAFC';
|
||||
|
||||
// 获取按钮文案
|
||||
const getButtonText = () => {
|
||||
|
||||
@@ -5,10 +5,8 @@ import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import CollapsibleHeader from './CollapsibleHeader';
|
||||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||
|
||||
/**
|
||||
* 通用可折叠区块组件
|
||||
@@ -38,7 +36,8 @@ const CollapsibleSection = ({
|
||||
showModeToggle = false,
|
||||
defaultMode = 'detailed'
|
||||
}) => {
|
||||
const sectionBg = PROFESSIONAL_COLORS.background.secondary;
|
||||
// 深色主题 - 折叠区块背景稍亮
|
||||
const sectionBg = '#354259';
|
||||
|
||||
// 模式状态:'detailed' | 'simple'
|
||||
const [displayMode, setDisplayMode] = useState(defaultMode);
|
||||
|
||||
@@ -86,9 +86,10 @@ const sectionReducer = (state, action) => {
|
||||
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { user } = useAuth();
|
||||
const cardBg = PROFESSIONAL_COLORS.background.card;
|
||||
const borderColor = PROFESSIONAL_COLORS.border.default;
|
||||
const textColor = PROFESSIONAL_COLORS.text.secondary;
|
||||
// 深色主题 - 与弹窗背景一致
|
||||
const cardBg = '#2D3748';
|
||||
const borderColor = 'rgba(255, 255, 255, 0.08)';
|
||||
const textColor = '#CBD5E0';
|
||||
|
||||
// 使用 useWatchlist Hook 管理自选股
|
||||
const {
|
||||
@@ -198,10 +199,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
}
|
||||
}, [sectionState.stocks, stocks.length, refreshQuotes]);
|
||||
|
||||
// 相关概念 - 展开/收起(无需加载)
|
||||
const handleConceptsToggle = useCallback(() => {
|
||||
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
|
||||
}, []);
|
||||
|
||||
// 历史事件对比 - 数据已预加载,只需切换展开状态
|
||||
const handleHistoricalToggle = useCallback(() => {
|
||||
@@ -350,13 +347,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,116 @@
|
||||
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
|
||||
// 相关概念区组件(主组件)
|
||||
// 相关概念区组件 - 便当盒网格布局
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
Button,
|
||||
Collapse,
|
||||
Heading,
|
||||
Center,
|
||||
Spinner,
|
||||
Text,
|
||||
Badge,
|
||||
SimpleGrid,
|
||||
HStack,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } 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 ConceptCard = ({ concept, onNavigate, isLocked, onLockedClick }) => {
|
||||
// 深色主题固定颜色
|
||||
const cardBg = 'rgba(252, 129, 129, 0.15)'; // 浅红色背景
|
||||
const cardHoverBg = 'rgba(252, 129, 129, 0.25)';
|
||||
const borderColor = 'rgba(252, 129, 129, 0.3)';
|
||||
const conceptColor = '#fc8181'; // 红色文字(与股票涨色一致)
|
||||
|
||||
const handleClick = () => {
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
return;
|
||||
}
|
||||
onNavigate(concept);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={concept.reason || concept.concept}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.800"
|
||||
color="white"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
maxW="300px"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
px={3}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
onClick={handleClick}
|
||||
_hover={{
|
||||
bg: cardHoverBg,
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: 'sm',
|
||||
}}
|
||||
transition="all 0.15s ease"
|
||||
textAlign="center"
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
color={conceptColor}
|
||||
noOfLines={1}
|
||||
>
|
||||
{concept.concept}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 相关概念区组件
|
||||
* @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 navigate = useNavigate();
|
||||
|
||||
// 颜色配置
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
// 颜色配置 - 使用深色主题固定颜色
|
||||
const sectionBg = 'transparent';
|
||||
const headingColor = '#e2e8f0';
|
||||
const textColor = '#a0aec0';
|
||||
const countBadgeBg = '#3182ce';
|
||||
const countBadgeColor = '#ffffff';
|
||||
|
||||
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 +119,87 @@ 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);
|
||||
} 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 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 +207,18 @@ const RelatedConceptsSection = ({
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||||
<SimpleGrid columns={{ base: 2, sm: 3, md: 4 }} spacing={2}>
|
||||
{concepts.map((concept, index) => (
|
||||
<SimpleConceptCard
|
||||
key={index}
|
||||
<ConceptCard
|
||||
key={concept.id || index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
getRelevanceColor={getRelevanceColor}
|
||||
onNavigate={handleNavigate}
|
||||
isLocked={isLocked}
|
||||
onLockedClick={onLockedClick}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* 详细模式:卡片网格(可折叠) */}
|
||||
<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;
|
||||
38
src/components/GlassCard/index.d.ts
vendored
Normal file
38
src/components/GlassCard/index.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* GlassCard 组件类型声明
|
||||
*/
|
||||
|
||||
import { BoxProps } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
export interface GlassCardProps extends Omit<BoxProps, 'children'> {
|
||||
/** 变体: 'default' | 'elevated' | 'subtle' | 'transparent' */
|
||||
variant?: 'default' | 'elevated' | 'subtle' | 'transparent';
|
||||
/** 是否启用悬停效果 */
|
||||
hoverable?: boolean;
|
||||
/** 是否启用发光效果 */
|
||||
glowing?: boolean;
|
||||
/** 是否显示角落装饰 */
|
||||
cornerDecor?: boolean;
|
||||
/** 圆角: 'sm' | 'md' | 'lg' | 'xl' | '2xl' */
|
||||
rounded?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
/** 内边距: 'none' | 'sm' | 'md' | 'lg' */
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
/** 子元素 */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface GlassTheme {
|
||||
colors: {
|
||||
gold: { 400: string; 500: string };
|
||||
bg: { deep: string; primary: string; elevated: string; surface: string };
|
||||
line: { subtle: string; default: string; emphasis: string };
|
||||
};
|
||||
blur: { sm: string; md: string; lg: string };
|
||||
glow: { sm: string; md: string };
|
||||
}
|
||||
|
||||
declare const GlassCard: React.ForwardRefExoticComponent<GlassCardProps & React.RefAttributes<HTMLDivElement>>;
|
||||
|
||||
export { GLASS_THEME } from './index';
|
||||
export default GlassCard;
|
||||
179
src/components/GlassCard/index.js
Normal file
179
src/components/GlassCard/index.js
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* GlassCard - 通用毛玻璃卡片组件
|
||||
*
|
||||
* 复用自 Company 页面的 Glassmorphism 风格
|
||||
* 可在全局使用
|
||||
*/
|
||||
|
||||
import React, { memo, forwardRef } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// 主题配置
|
||||
const GLASS_THEME = {
|
||||
colors: {
|
||||
gold: {
|
||||
400: '#D4AF37',
|
||||
500: '#B8960C',
|
||||
},
|
||||
bg: {
|
||||
deep: '#0A0A14',
|
||||
primary: '#0F0F1A',
|
||||
elevated: '#1A1A2E',
|
||||
surface: '#252540',
|
||||
},
|
||||
line: {
|
||||
subtle: 'rgba(212, 175, 55, 0.1)',
|
||||
default: 'rgba(212, 175, 55, 0.2)',
|
||||
emphasis: 'rgba(212, 175, 55, 0.4)',
|
||||
},
|
||||
},
|
||||
blur: {
|
||||
sm: 'blur(8px)',
|
||||
md: 'blur(16px)',
|
||||
lg: 'blur(24px)',
|
||||
},
|
||||
glow: {
|
||||
sm: '0 0 8px rgba(212, 175, 55, 0.3)',
|
||||
md: '0 0 16px rgba(212, 175, 55, 0.4)',
|
||||
},
|
||||
};
|
||||
|
||||
// 变体样式
|
||||
const VARIANTS = {
|
||||
default: {
|
||||
bg: `linear-gradient(135deg, ${GLASS_THEME.colors.bg.elevated} 0%, ${GLASS_THEME.colors.bg.primary} 100%)`,
|
||||
border: `1px solid ${GLASS_THEME.colors.line.default}`,
|
||||
backdropFilter: GLASS_THEME.blur.md,
|
||||
},
|
||||
elevated: {
|
||||
bg: `linear-gradient(145deg, ${GLASS_THEME.colors.bg.surface} 0%, ${GLASS_THEME.colors.bg.elevated} 100%)`,
|
||||
border: `1px solid ${GLASS_THEME.colors.line.emphasis}`,
|
||||
backdropFilter: GLASS_THEME.blur.lg,
|
||||
},
|
||||
subtle: {
|
||||
bg: 'rgba(212, 175, 55, 0.05)',
|
||||
border: `1px solid ${GLASS_THEME.colors.line.subtle}`,
|
||||
backdropFilter: GLASS_THEME.blur.sm,
|
||||
},
|
||||
transparent: {
|
||||
bg: 'rgba(15, 15, 26, 0.8)',
|
||||
border: `1px solid ${GLASS_THEME.colors.line.default}`,
|
||||
backdropFilter: GLASS_THEME.blur.lg,
|
||||
},
|
||||
};
|
||||
|
||||
const ROUNDED_MAP = {
|
||||
sm: '8px',
|
||||
md: '12px',
|
||||
lg: '16px',
|
||||
xl: '20px',
|
||||
'2xl': '24px',
|
||||
};
|
||||
|
||||
const PADDING_MAP = {
|
||||
none: 0,
|
||||
sm: 3,
|
||||
md: 4,
|
||||
lg: 6,
|
||||
};
|
||||
|
||||
// 角落装饰
|
||||
const CornerDecor = memo(({ position }) => {
|
||||
const baseStyle = {
|
||||
position: 'absolute',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderColor: GLASS_THEME.colors.gold[400],
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 0,
|
||||
opacity: 0.6,
|
||||
};
|
||||
|
||||
const positions = {
|
||||
tl: { top: '8px', left: '8px', borderTopWidth: '2px', borderLeftWidth: '2px' },
|
||||
tr: { top: '8px', right: '8px', borderTopWidth: '2px', borderRightWidth: '2px' },
|
||||
bl: { bottom: '8px', left: '8px', borderBottomWidth: '2px', borderLeftWidth: '2px' },
|
||||
br: { bottom: '8px', right: '8px', borderBottomWidth: '2px', borderRightWidth: '2px' },
|
||||
};
|
||||
|
||||
return <Box sx={{ ...baseStyle, ...positions[position] }} />;
|
||||
});
|
||||
|
||||
CornerDecor.displayName = 'CornerDecor';
|
||||
|
||||
/**
|
||||
* GlassCard 组件
|
||||
*
|
||||
* @param {string} variant - 变体: 'default' | 'elevated' | 'subtle' | 'transparent'
|
||||
* @param {boolean} hoverable - 是否启用悬停效果
|
||||
* @param {boolean} glowing - 是否启用发光效果
|
||||
* @param {boolean} cornerDecor - 是否显示角落装饰
|
||||
* @param {string} rounded - 圆角: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
* @param {string} padding - 内边距: 'none' | 'sm' | 'md' | 'lg'
|
||||
*/
|
||||
const GlassCard = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant = 'default',
|
||||
hoverable = true,
|
||||
glowing = false,
|
||||
cornerDecor = false,
|
||||
rounded = 'lg',
|
||||
padding = 'md',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const variantStyle = VARIANTS[variant] || VARIANTS.default;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
position="relative"
|
||||
bg={variantStyle.bg}
|
||||
border={variantStyle.border}
|
||||
borderRadius={ROUNDED_MAP[rounded]}
|
||||
backdropFilter={variantStyle.backdropFilter}
|
||||
p={PADDING_MAP[padding]}
|
||||
transition="all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)"
|
||||
overflow="hidden"
|
||||
_hover={
|
||||
hoverable
|
||||
? {
|
||||
borderColor: GLASS_THEME.colors.line.emphasis,
|
||||
boxShadow: glowing ? GLASS_THEME.glow.md : GLASS_THEME.glow.sm,
|
||||
transform: 'translateY(-2px)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
sx={{
|
||||
...(glowing && {
|
||||
boxShadow: GLASS_THEME.glow.sm,
|
||||
}),
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* 角落装饰 */}
|
||||
{cornerDecor && (
|
||||
<>
|
||||
<CornerDecor position="tl" />
|
||||
<CornerDecor position="tr" />
|
||||
<CornerDecor position="bl" />
|
||||
<CornerDecor position="br" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
<Box position="relative" zIndex={1}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GlassCard.displayName = 'GlassCard';
|
||||
|
||||
export default memo(GlassCard);
|
||||
export { GLASS_THEME };
|
||||
353
src/components/GlobalSidebar/index.js
Normal file
353
src/components/GlobalSidebar/index.js
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* GlobalSidebar - 全局右侧工具栏
|
||||
*
|
||||
* 可收起/展开的侧边栏,包含关注股票和事件动态
|
||||
* 收起时点击图标显示悬浮弹窗
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Badge,
|
||||
Spinner,
|
||||
Center,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
PopoverHeader,
|
||||
PopoverCloseButton,
|
||||
Text,
|
||||
HStack,
|
||||
Portal,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeft, ChevronRight, BarChart2, Star, TrendingUp } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useGlobalSidebar } from '@/contexts/GlobalSidebarContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
import { Z_INDEX, LAYOUT_SIZE } from '@/layouts/config/layoutConfig';
|
||||
import WatchSidebar from '@views/Profile/components/WatchSidebar';
|
||||
import { WatchlistPanel, FollowingEventsPanel } from '@views/Profile/components/WatchSidebar/components';
|
||||
import HotSectorsRanking from '@views/Profile/components/MarketDashboard/components/atoms/HotSectorsRanking';
|
||||
|
||||
/**
|
||||
* 收起状态下的图标菜单(带悬浮弹窗)
|
||||
*/
|
||||
const CollapsedMenu = ({
|
||||
watchlist,
|
||||
realtimeQuotes,
|
||||
followingEvents,
|
||||
eventComments,
|
||||
onToggle,
|
||||
onStockClick,
|
||||
onEventClick,
|
||||
onCommentClick,
|
||||
onAddStock,
|
||||
onAddEvent,
|
||||
onUnwatch,
|
||||
onUnfollow,
|
||||
}) => {
|
||||
return (
|
||||
<VStack spacing={4} py={4} align="center">
|
||||
{/* 展开按钮 */}
|
||||
<HStack spacing={1} w="100%" justify="center" cursor="pointer" onClick={onToggle} _hover={{ bg: 'rgba(255, 255, 255, 0.05)' }} py={1} borderRadius="md">
|
||||
<Icon as={ChevronLeft} boxSize={4} color="rgba(255, 255, 255, 0.6)" />
|
||||
<Text fontSize="10px" color="rgba(255, 255, 255, 0.5)">
|
||||
展开
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 关注股票 - 悬浮弹窗 */}
|
||||
<Popover placement="left-start" trigger="click" isLazy>
|
||||
<PopoverTrigger>
|
||||
<VStack
|
||||
spacing={1}
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative">
|
||||
<Icon as={BarChart2} boxSize={5} color="rgba(59, 130, 246, 0.9)" />
|
||||
{watchlist.length > 0 && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="-4px"
|
||||
right="-8px"
|
||||
colorScheme="red"
|
||||
fontSize="9px"
|
||||
minW="16px"
|
||||
h="16px"
|
||||
borderRadius="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{watchlist.length > 99 ? '99+' : watchlist.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<Text fontSize="10px" color="rgba(255, 255, 255, 0.6)" whiteSpace="nowrap">
|
||||
关注股票
|
||||
</Text>
|
||||
</VStack>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
w="300px"
|
||||
bg="rgba(26, 32, 44, 0.95)"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||
_focus={{ outline: 'none' }}
|
||||
>
|
||||
<PopoverHeader
|
||||
borderBottomColor="rgba(255, 255, 255, 0.1)"
|
||||
py={2}
|
||||
px={3}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={BarChart2} boxSize={4} color="rgba(59, 130, 246, 0.9)" />
|
||||
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||
关注股票 ({watchlist.length})
|
||||
</Text>
|
||||
</HStack>
|
||||
</PopoverHeader>
|
||||
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||
<PopoverBody p={2}>
|
||||
<WatchlistPanel
|
||||
watchlist={watchlist}
|
||||
realtimeQuotes={realtimeQuotes}
|
||||
onStockClick={onStockClick}
|
||||
onAddStock={onAddStock}
|
||||
onUnwatch={onUnwatch}
|
||||
hideTitle={true}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
|
||||
{/* 事件动态 - 悬浮弹窗 */}
|
||||
<Popover placement="left-start" trigger="click" isLazy>
|
||||
<PopoverTrigger>
|
||||
<VStack
|
||||
spacing={1}
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative">
|
||||
<Icon as={Star} boxSize={5} color="rgba(234, 179, 8, 0.9)" />
|
||||
{followingEvents.length > 0 && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="-4px"
|
||||
right="-8px"
|
||||
colorScheme="yellow"
|
||||
fontSize="9px"
|
||||
minW="16px"
|
||||
h="16px"
|
||||
borderRadius="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{followingEvents.length > 99 ? '99+' : followingEvents.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<Text fontSize="10px" color="rgba(255, 255, 255, 0.6)" whiteSpace="nowrap">
|
||||
关注事件
|
||||
</Text>
|
||||
</VStack>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
w="300px"
|
||||
bg="rgba(26, 32, 44, 0.95)"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||
_focus={{ outline: 'none' }}
|
||||
>
|
||||
<PopoverHeader
|
||||
borderBottomColor="rgba(255, 255, 255, 0.1)"
|
||||
py={2}
|
||||
px={3}
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Star} boxSize={4} color="rgba(234, 179, 8, 0.9)" />
|
||||
<Text fontSize="sm" fontWeight="bold" color="rgba(255, 255, 255, 0.9)">
|
||||
事件动态
|
||||
</Text>
|
||||
</HStack>
|
||||
</PopoverHeader>
|
||||
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||
<PopoverBody p={2}>
|
||||
<FollowingEventsPanel
|
||||
events={followingEvents}
|
||||
eventComments={eventComments}
|
||||
onEventClick={onEventClick}
|
||||
onCommentClick={onCommentClick}
|
||||
onAddEvent={onAddEvent}
|
||||
onUnfollow={onUnfollow}
|
||||
hideTitle={true}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
|
||||
{/* 热门板块 - 悬浮弹窗 */}
|
||||
<Popover placement="left-start" trigger="click" isLazy>
|
||||
<PopoverTrigger>
|
||||
<VStack
|
||||
spacing={1}
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
position="relative"
|
||||
>
|
||||
<Icon as={TrendingUp} boxSize={5} color="rgba(34, 197, 94, 0.9)" />
|
||||
<Text fontSize="10px" color="rgba(255, 255, 255, 0.6)" whiteSpace="nowrap">
|
||||
热门板块
|
||||
</Text>
|
||||
</VStack>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
w="280px"
|
||||
bg="rgba(26, 32, 44, 0.95)"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||
_focus={{ outline: 'none' }}
|
||||
>
|
||||
<PopoverCloseButton color="rgba(255, 255, 255, 0.5)" />
|
||||
<PopoverBody p={2}>
|
||||
<HotSectorsRanking title="热门板块" />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* GlobalSidebar 主组件
|
||||
*/
|
||||
const GlobalSidebar = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
toggle,
|
||||
watchlist,
|
||||
realtimeQuotes,
|
||||
followingEvents,
|
||||
eventComments,
|
||||
loading,
|
||||
unwatchStock,
|
||||
unfollowEvent,
|
||||
} = useGlobalSidebar();
|
||||
|
||||
// 未登录时不显示
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
w={isOpen ? '300px' : '72px'}
|
||||
h="100%"
|
||||
pt={LAYOUT_SIZE.navbarHeight}
|
||||
flexShrink={0}
|
||||
transition="width 0.2s ease-in-out"
|
||||
display={{ base: 'none', md: 'block' }}
|
||||
bg="rgba(26, 32, 44, 0.98)"
|
||||
borderLeft="1px solid rgba(255, 255, 255, 0.08)"
|
||||
position="relative"
|
||||
zIndex={Z_INDEX.SIDEBAR}
|
||||
>
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<Center position="absolute" top={4} left={0} right={0} zIndex={1}>
|
||||
<Spinner size="sm" color="rgba(212, 175, 55, 0.6)" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{isOpen ? (
|
||||
/* 展开状态 */
|
||||
<Box h="100%" display="flex" flexDirection="column">
|
||||
{/* 标题栏 - 收起按钮 + 标题 */}
|
||||
<HStack
|
||||
px={3}
|
||||
py={3}
|
||||
bg="rgba(26, 32, 44, 1)"
|
||||
borderBottom="1px solid rgba(255, 255, 255, 0.1)"
|
||||
flexShrink={0}
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon as={ChevronRight} />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="rgba(255, 255, 255, 0.5)"
|
||||
_hover={{ color: 'rgba(212, 175, 55, 0.9)', bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
onClick={toggle}
|
||||
aria-label="收起工具栏"
|
||||
/>
|
||||
<Text fontSize="sm" fontWeight="medium" color="rgba(255, 255, 255, 0.7)">
|
||||
工具栏
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* WatchSidebar 内容 */}
|
||||
<Box flex="1" overflowY="auto" pt={2} px={2}>
|
||||
<WatchSidebar
|
||||
watchlist={watchlist}
|
||||
realtimeQuotes={realtimeQuotes}
|
||||
followingEvents={followingEvents}
|
||||
eventComments={eventComments}
|
||||
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
|
||||
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
|
||||
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
|
||||
onAddStock={() => navigate('/stocks')}
|
||||
onAddEvent={() => navigate('/community')}
|
||||
onUnwatch={unwatchStock}
|
||||
onUnfollow={unfollowEvent}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
/* 收起状态 - 点击图标显示悬浮弹窗 */
|
||||
<CollapsedMenu
|
||||
watchlist={watchlist}
|
||||
realtimeQuotes={realtimeQuotes}
|
||||
followingEvents={followingEvents}
|
||||
eventComments={eventComments}
|
||||
onToggle={toggle}
|
||||
onStockClick={(stock) => navigate(`/company/${stock.stock_code}`)}
|
||||
onEventClick={(event) => navigate(getEventDetailUrl(event.id))}
|
||||
onCommentClick={(comment) => navigate(getEventDetailUrl(comment.event_id))}
|
||||
onAddStock={() => navigate('/stocks')}
|
||||
onAddEvent={() => navigate('/community')}
|
||||
onUnwatch={unwatchStock}
|
||||
onUnfollow={unfollowEvent}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSidebar;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/Navbars/components/FeatureMenus/FollowingEventsMenu.js
|
||||
// 关注事件下拉菜单组件
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
@@ -22,6 +22,7 @@ import { FiCalendar } from 'react-icons/fi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useFollowingEvents } from '../../../../hooks/useFollowingEvents';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
|
||||
/**
|
||||
* 关注事件下拉菜单组件
|
||||
@@ -30,6 +31,7 @@ import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
*/
|
||||
const FollowingEventsMenu = memo(() => {
|
||||
const navigate = useNavigate();
|
||||
const [unfollowingId, setUnfollowingId] = useState(null);
|
||||
const {
|
||||
followingEvents,
|
||||
eventsLoading,
|
||||
@@ -40,6 +42,17 @@ const FollowingEventsMenu = memo(() => {
|
||||
handleUnfollowEvent
|
||||
} = useFollowingEvents();
|
||||
|
||||
// 处理取消关注(带 loading 状态)
|
||||
const handleUnfollow = async (eventId) => {
|
||||
if (unfollowingId) return;
|
||||
setUnfollowingId(eventId);
|
||||
try {
|
||||
await handleUnfollowEvent(eventId);
|
||||
} finally {
|
||||
setUnfollowingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const titleColor = useColorModeValue('gray.600', 'gray.300');
|
||||
const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
@@ -108,27 +121,6 @@ const FollowingEventsMenu = memo(() => {
|
||||
</HStack>
|
||||
</Box>
|
||||
<HStack flexShrink={0} spacing={1}>
|
||||
{/* 热度 */}
|
||||
{typeof ev.hot_score === 'number' && (
|
||||
<Badge
|
||||
colorScheme={
|
||||
ev.hot_score >= 80 ? 'red' :
|
||||
(ev.hot_score >= 60 ? 'orange' : 'gray')
|
||||
}
|
||||
fontSize="xs"
|
||||
>
|
||||
🔥 {ev.hot_score}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 关注数 */}
|
||||
{typeof ev.follower_count === 'number' && ev.follower_count > 0 && (
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
fontSize="xs"
|
||||
>
|
||||
👥 {ev.follower_count}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 日均涨跌幅 */}
|
||||
{typeof ev.related_avg_chg === 'number' && (
|
||||
<Badge
|
||||
@@ -155,23 +147,21 @@ const FollowingEventsMenu = memo(() => {
|
||||
{ev.related_week_chg.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
{/* 取消关注按钮 */}
|
||||
{/* 取消关注按钮 - 使用 FavoriteButton */}
|
||||
<Box
|
||||
as="span"
|
||||
fontSize="xs"
|
||||
color="red.500"
|
||||
cursor="pointer"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'red.50' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUnfollowEvent(ev.id);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
<FavoriteButton
|
||||
isFavorite={true}
|
||||
isLoading={unfollowingId === ev.id}
|
||||
onClick={() => handleUnfollow(ev.id)}
|
||||
size="sm"
|
||||
colorScheme="gold"
|
||||
showTooltip={true}
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/Navbars/components/FeatureMenus/WatchlistMenu.js
|
||||
// 自选股下拉菜单组件
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
@@ -21,6 +21,7 @@ import { ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { FiStar } from 'react-icons/fi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useWatchlist } from '../../../../hooks/useWatchlist';
|
||||
import FavoriteButton from '@/components/FavoriteButton';
|
||||
|
||||
/**
|
||||
* 自选股下拉菜单组件
|
||||
@@ -29,6 +30,7 @@ import { useWatchlist } from '../../../../hooks/useWatchlist';
|
||||
*/
|
||||
const WatchlistMenu = memo(() => {
|
||||
const navigate = useNavigate();
|
||||
const [removingCode, setRemovingCode] = useState(null);
|
||||
const {
|
||||
watchlistQuotes,
|
||||
watchlistLoading,
|
||||
@@ -39,6 +41,17 @@ const WatchlistMenu = memo(() => {
|
||||
handleRemoveFromWatchlist
|
||||
} = useWatchlist();
|
||||
|
||||
// 处理取消关注(带 loading 状态)
|
||||
const handleUnwatch = async (stockCode) => {
|
||||
if (removingCode) return;
|
||||
setRemovingCode(stockCode);
|
||||
try {
|
||||
await handleRemoveFromWatchlist(stockCode);
|
||||
} finally {
|
||||
setRemovingCode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const titleColor = useColorModeValue('gray.600', 'gray.300');
|
||||
const loadingTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
const emptyTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
@@ -114,21 +127,19 @@ const WatchlistMenu = memo(() => {
|
||||
(item.current_price || '-')}
|
||||
</Text>
|
||||
<Box
|
||||
as="span"
|
||||
fontSize="xs"
|
||||
color="red.500"
|
||||
cursor="pointer"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'red.50' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRemoveFromWatchlist(item.stock_code);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
<FavoriteButton
|
||||
isFavorite={true}
|
||||
isLoading={removingCode === item.stock_code}
|
||||
onClick={() => handleUnwatch(item.stock_code)}
|
||||
size="sm"
|
||||
colorScheme="gold"
|
||||
showTooltip={true}
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
@@ -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线`,
|
||||
|
||||
@@ -1,594 +0,0 @@
|
||||
// src/components/StockChart/StockChartAntdModal.js - Antd版本的股票图表组件
|
||||
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 dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const StockChartAntdModal = ({
|
||||
open = false,
|
||||
onCancel,
|
||||
stock,
|
||||
eventTime,
|
||||
fixed = false,
|
||||
width = 800
|
||||
}) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstanceRef = useRef(null);
|
||||
const [activeChartType, setActiveChartType] = useState('timeline');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [preloadedData, setPreloadedData] = useState({});
|
||||
|
||||
// 预加载数据
|
||||
const preloadData = async (type) => {
|
||||
if (!stock?.stock_code || preloadedData[type]) return;
|
||||
|
||||
try {
|
||||
// 统一的事件时间处理逻辑:盘后事件推到次日开盘
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('StockChartAntdModal', '事件时间解析失败', {
|
||||
eventTime,
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
|
||||
setPreloadedData(prev => ({...prev, [type]: response}));
|
||||
logger.debug('StockChartAntdModal', '数据预加载成功', {
|
||||
stockCode: stock.stock_code,
|
||||
type,
|
||||
dataLength: response?.data?.length || 0
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('StockChartAntdModal', 'preloadData', err, {
|
||||
stockCode: stock?.stock_code,
|
||||
type
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 预加载数据的effect
|
||||
useEffect(() => {
|
||||
if (open && stock?.stock_code) {
|
||||
// 预加载两种图表类型的数据
|
||||
preloadData('timeline');
|
||||
preloadData('daily');
|
||||
}
|
||||
}, [open, stock?.stock_code, eventTime]);
|
||||
|
||||
// 加载图表数据
|
||||
useEffect(() => {
|
||||
const loadChartData = async () => {
|
||||
if (!stock?.stock_code) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 先尝试使用预加载的数据
|
||||
let data = preloadedData[activeChartType];
|
||||
|
||||
if (!data) {
|
||||
// 如果预加载数据不存在,则立即请求
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('StockChartAntdModal', '事件时间解析失败', {
|
||||
eventTime,
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
data = await stockService.getKlineData(stock.stock_code, activeChartType, adjustedEventTime);
|
||||
}
|
||||
|
||||
setChartData(data);
|
||||
logger.debug('StockChartAntdModal', '图表数据加载成功', {
|
||||
stockCode: stock.stock_code,
|
||||
chartType: activeChartType,
|
||||
dataLength: data?.data?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('StockChartAntdModal', 'loadChartData', error, {
|
||||
stockCode: stock?.stock_code,
|
||||
chartType: activeChartType
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (stock && stock.stock_code) {
|
||||
loadChartData();
|
||||
}
|
||||
}, [stock?.stock_code, activeChartType, eventTime]);
|
||||
|
||||
// 生成图表配置
|
||||
const getChartOption = () => {
|
||||
if (!chartData || !chartData.data) {
|
||||
return {
|
||||
title: { text: '暂无数据', left: 'center' },
|
||||
xAxis: { type: 'category', data: [] },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ data: [], type: 'line' }]
|
||||
};
|
||||
}
|
||||
|
||||
const data = chartData.data;
|
||||
const tradeDate = chartData.trade_date;
|
||||
|
||||
// 处理数据格式
|
||||
let times = [];
|
||||
let prices = [];
|
||||
let opens = [];
|
||||
let highs = [];
|
||||
let lows = [];
|
||||
let closes = [];
|
||||
let volumes = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
times = data.map(item => item.time || item.date || item.timestamp);
|
||||
prices = data.map(item => item.close || item.price || item.value);
|
||||
opens = data.map(item => item.open);
|
||||
highs = data.map(item => item.high);
|
||||
lows = data.map(item => item.low);
|
||||
closes = data.map(item => item.close);
|
||||
volumes = data.map(item => item.volume);
|
||||
} else if (data.times && data.prices) {
|
||||
times = data.times;
|
||||
prices = data.prices;
|
||||
opens = data.opens || [];
|
||||
highs = data.highs || [];
|
||||
lows = data.lows || [];
|
||||
closes = data.closes || [];
|
||||
volumes = data.volumes || [];
|
||||
}
|
||||
|
||||
// 生成K线数据结构
|
||||
const klineData = times.map((t, i) => [opens[i], closes[i], lows[i], highs[i]]);
|
||||
|
||||
// 计算事件标记线位置
|
||||
let markLineData = [];
|
||||
if (eventTime && times.length > 0) {
|
||||
const eventMoment = dayjs(eventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
if (activeChartType === 'timeline') {
|
||||
// 分时图:在相同交易日内定位具体时间
|
||||
if (eventDate === tradeDate) {
|
||||
const eventTime = eventMoment.format('HH:mm');
|
||||
let nearestIdx = 0;
|
||||
const eventMinutes = eventMoment.hour() * 60 + eventMoment.minute();
|
||||
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
const [h, m] = times[i].split(':').map(Number);
|
||||
const timeMinutes = h * 60 + m;
|
||||
const currentDiff = Math.abs(timeMinutes - eventMinutes);
|
||||
const nearestDiff = Math.abs(
|
||||
(times[nearestIdx].split(':').map(Number)[0] * 60 + times[nearestIdx].split(':').map(Number)[1]) - eventMinutes
|
||||
);
|
||||
if (currentDiff < nearestDiff) {
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
markLineData = [{
|
||||
name: '事件发生',
|
||||
xAxis: nearestIdx,
|
||||
label: {
|
||||
formatter: '事件发生',
|
||||
position: 'middle',
|
||||
color: '#FFD700',
|
||||
fontSize: 12
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#FFD700',
|
||||
type: 'solid',
|
||||
width: 2
|
||||
}
|
||||
}];
|
||||
}
|
||||
} else if (activeChartType === 'daily') {
|
||||
// 日K线:定位到交易日
|
||||
let targetIndex = -1;
|
||||
|
||||
// 1. 先尝试找到完全匹配的日期
|
||||
targetIndex = times.findIndex(time => time === eventDate);
|
||||
|
||||
// 2. 如果没有完全匹配,找到第一个大于等于事件日期的交易日
|
||||
if (targetIndex === -1) {
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
if (times[i] >= eventDate) {
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果事件日期晚于所有交易日,则标记在最后一个交易日
|
||||
if (targetIndex === -1 && eventDate > times[times.length - 1]) {
|
||||
targetIndex = times.length - 1;
|
||||
}
|
||||
|
||||
// 4. 如果事件日期早于所有交易日,则标记在第一个交易日
|
||||
if (targetIndex === -1 && eventDate < times[0]) {
|
||||
targetIndex = 0;
|
||||
}
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
let labelText = '事件发生';
|
||||
let labelPosition = 'middle';
|
||||
|
||||
// 根据事件时间和交易日的关系调整标签
|
||||
if (eventDate === times[targetIndex]) {
|
||||
if (eventMoment.hour() >= 15) {
|
||||
labelText = '事件发生\n(盘后)';
|
||||
} else if (eventMoment.hour() < 9 || (eventMoment.hour() === 9 && eventMoment.minute() < 30)) {
|
||||
labelText = '事件发生\n(盘前)';
|
||||
}
|
||||
} else if (eventDate < times[targetIndex]) {
|
||||
labelText = '事件发生\n(前一日)';
|
||||
labelPosition = 'start';
|
||||
} else {
|
||||
labelText = '事件发生\n(影响日)';
|
||||
labelPosition = 'end';
|
||||
}
|
||||
|
||||
markLineData = [{
|
||||
name: '事件发生',
|
||||
xAxis: targetIndex,
|
||||
label: {
|
||||
formatter: labelText,
|
||||
position: labelPosition,
|
||||
color: '#FFD700',
|
||||
fontSize: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
padding: [4, 8],
|
||||
borderRadius: 4
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#FFD700',
|
||||
type: 'solid',
|
||||
width: 2
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分时图
|
||||
if (activeChartType === 'timeline') {
|
||||
const avgPrices = data.map(item => item.avg_price);
|
||||
|
||||
// 获取昨收盘价作为基准
|
||||
const prevClose = chartData.prev_close || (prices.length > 0 ? prices[0] : 0);
|
||||
|
||||
// 计算涨跌幅数据
|
||||
const changePercentData = prices.map(price => ((price - prevClose) / prevClose * 100));
|
||||
const avgChangePercentData = avgPrices.map(avgPrice => ((avgPrice - prevClose) / prevClose * 100));
|
||||
|
||||
const currentPrice = prices[prices.length - 1];
|
||||
const currentChange = ((currentPrice - prevClose) / prevClose * 100);
|
||||
const isUp = currentChange >= 0;
|
||||
const lineColor = isUp ? '#ef5350' : '#26a69a';
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: `${stock.stock_name || stock.stock_code} - 分时图`,
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 16, fontWeight: 'bold' }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter: function(params) {
|
||||
const d = params[0]?.dataIndex ?? 0;
|
||||
const price = prices[d];
|
||||
const avgPrice = avgPrices[d];
|
||||
const volume = volumes[d];
|
||||
|
||||
// 安全计算涨跌幅,处理 undefined/null/0 的情况
|
||||
const safeCalcPercent = (val, base) => {
|
||||
if (val == null || base == null || base === 0) return 0;
|
||||
return ((val - base) / base * 100);
|
||||
};
|
||||
|
||||
const priceChangePercent = safeCalcPercent(price, prevClose);
|
||||
const avgChangePercent = safeCalcPercent(avgPrice, prevClose);
|
||||
const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a';
|
||||
const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a';
|
||||
|
||||
// 安全格式化数字
|
||||
const safeFixed = (val, digits = 2) => (val != null && !isNaN(val)) ? val.toFixed(digits) : '-';
|
||||
const formatPercent = (val) => {
|
||||
if (val == null || isNaN(val)) return '-';
|
||||
return (val >= 0 ? '+' : '') + val.toFixed(2) + '%';
|
||||
};
|
||||
|
||||
return `时间:${times[d] || '-'}<br/>现价:<span style="color: ${priceColor}">¥${safeFixed(price)} (${formatPercent(priceChangePercent)})</span><br/>均价:<span style="color: ${avgColor}">¥${safeFixed(avgPrice)} (${formatPercent(avgChangePercent)})</span><br/>昨收:¥${safeFixed(prevClose)}<br/>成交量:${volume != null ? Math.round(volume/100) + '手' : '-'}`;
|
||||
}
|
||||
},
|
||||
grid: [
|
||||
{ left: '10%', right: '10%', height: '50%', top: '15%' },
|
||||
{ left: '10%', right: '10%', top: '70%', height: '20%' }
|
||||
],
|
||||
xAxis: [
|
||||
{ type: 'category', data: times, gridIndex: 0, boundaryGap: false },
|
||||
{ type: 'category', data: times, gridIndex: 1, axisLabel: { show: false } }
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
gridIndex: 0,
|
||||
scale: false,
|
||||
position: 'left',
|
||||
axisLabel: {
|
||||
formatter: function(value) {
|
||||
if (value == null || isNaN(value)) return '-';
|
||||
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: '#f0f0f0'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
gridIndex: 0,
|
||||
scale: false,
|
||||
position: 'right',
|
||||
axisLabel: {
|
||||
formatter: function(value) {
|
||||
if (value == null || isNaN(value)) return '-';
|
||||
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => (v != null && !isNaN(v)) ? Math.round(v/100) + '手' : '-' } }
|
||||
],
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
|
||||
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%' }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '分时价',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
data: changePercentData,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: lineColor, width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' },
|
||||
{ offset: 1, color: isUp ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)' }
|
||||
])
|
||||
},
|
||||
markLine: {
|
||||
symbol: 'none',
|
||||
data: [
|
||||
// 昨收盘价基准线 (0%)
|
||||
{
|
||||
yAxis: 0,
|
||||
lineStyle: {
|
||||
color: '#666',
|
||||
type: 'dashed',
|
||||
width: 1.5,
|
||||
opacity: 0.8
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '昨收盘价',
|
||||
position: 'insideEndTop',
|
||||
color: '#666',
|
||||
fontSize: 12
|
||||
}
|
||||
},
|
||||
...markLineData
|
||||
],
|
||||
animation: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '均价线',
|
||||
type: 'line',
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 1,
|
||||
data: avgChangePercentData,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#FFA500', width: 1 }
|
||||
},
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 2,
|
||||
data: volumes,
|
||||
itemStyle: { color: '#b0c4de', opacity: 0.6 }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// 日K线图
|
||||
if (activeChartType === 'daily') {
|
||||
return {
|
||||
title: {
|
||||
text: `${stock.stock_name || stock.stock_code} - 日K线`,
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 16, fontWeight: 'bold' }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
formatter: function(params) {
|
||||
const kline = params[0];
|
||||
const volume = params[1];
|
||||
if (!kline || !kline.data) return '';
|
||||
let tooltipHtml = `日期: ${times[kline.dataIndex]}<br/>开盘: ¥${kline.data[0]}<br/>收盘: ¥${kline.data[1]}<br/>最低: ¥${kline.data[2]}<br/>最高: ¥${kline.data[3]}`;
|
||||
if (volume && volume.data) {
|
||||
tooltipHtml += `<br/>成交量: ${Math.round(volume.data/100)}手`;
|
||||
}
|
||||
return tooltipHtml;
|
||||
}
|
||||
},
|
||||
grid: [
|
||||
{ left: '10%', right: '10%', height: '60%' },
|
||||
{ left: '10%', right: '10%', top: '75%', height: '20%' }
|
||||
],
|
||||
xAxis: [
|
||||
{ type: 'category', data: times, scale: true, boundaryGap: true, gridIndex: 0 },
|
||||
{ type: 'category', gridIndex: 1, data: times, axisLabel: { show: false } }
|
||||
],
|
||||
yAxis: [
|
||||
{ scale: true, splitArea: { show: true }, gridIndex: 0 },
|
||||
{ scale: true, gridIndex: 1, axisLabel: { formatter: (value) => Math.round(value/100) + '手' } }
|
||||
],
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], start: 70, end: 100 },
|
||||
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%', start: 70, end: 100 }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'K线',
|
||||
type: 'candlestick',
|
||||
yAxisIndex: 0,
|
||||
data: klineData,
|
||||
markLine: {
|
||||
symbol: 'none',
|
||||
data: markLineData,
|
||||
animation: false
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ef5350',
|
||||
color0: '#26a69a',
|
||||
borderColor: '#ef5350',
|
||||
borderColor0: '#26a69a'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: volumes.map((volume, index) => ({
|
||||
value: volume,
|
||||
itemStyle: {
|
||||
color: closes[index] >= opens[index] ? '#ef5350' : '#26a69a'
|
||||
}
|
||||
}))
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`${stock?.stock_name || stock?.stock_code} (${stock?.stock_code}) - 股票详情`}
|
||||
footer={null}
|
||||
onCancel={onCancel}
|
||||
width={width}
|
||||
centered
|
||||
zIndex={2500}
|
||||
mask={true}
|
||||
destroyOnHidden={true}
|
||||
bodyStyle={{ maxHeight: 'calc(90vh - 120px)', overflowY: 'auto', padding: '16px' }}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
{/* 图表类型切换按钮 */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type={activeChartType === 'timeline' ? 'primary' : 'default'}
|
||||
onClick={() => setActiveChartType('timeline')}
|
||||
>
|
||||
分时图
|
||||
</Button>
|
||||
<Button
|
||||
type={activeChartType === 'daily' ? 'primary' : 'default'}
|
||||
onClick={() => setActiveChartType('daily')}
|
||||
>
|
||||
日K线
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 图表容器 */}
|
||||
<div style={{ height: '400px', width: '100%' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<ReactECharts
|
||||
option={getChartOption()}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
onChartReady={(chart) => {
|
||||
setTimeout(() => chart.resize(), 50);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关联描述 */}
|
||||
{stock?.relation_desc?.data ? (
|
||||
// 使用引用组件(带研报来源)
|
||||
<CitedContent
|
||||
data={stock.relation_desc}
|
||||
title="关联描述"
|
||||
containerStyle={{ marginTop: 16 }}
|
||||
/>
|
||||
) : stock?.relation_desc ? (
|
||||
// 降级显示(无引用数据)
|
||||
<div style={{ marginTop: 16, padding: 16, backgroundColor: '#f5f5f5', borderRadius: 6 }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>关联描述:</Text>
|
||||
<Text>{stock.relation_desc}(AI合成)</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 风险提示 */}
|
||||
<RiskDisclaimer variant="default" />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockChartAntdModal;
|
||||
@@ -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;
|
||||
216
src/contexts/GlobalSidebarContext.js
Normal file
216
src/contexts/GlobalSidebarContext.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* GlobalSidebarContext - 全局右侧工具栏状态管理
|
||||
*
|
||||
* 管理侧边栏的展开/收起状态和数据加载
|
||||
* 自选股和关注事件数据都从 Redux 获取,与导航栏共用数据源
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
loadWatchlist,
|
||||
loadWatchlistQuotes,
|
||||
toggleWatchlist,
|
||||
loadFollowingEvents,
|
||||
loadEventComments,
|
||||
toggleFollowEvent
|
||||
} from '@/store/slices/stockSlice';
|
||||
|
||||
const GlobalSidebarContext = createContext(null);
|
||||
|
||||
/**
|
||||
* GlobalSidebarProvider - 全局侧边栏 Provider
|
||||
*/
|
||||
export const GlobalSidebarProvider = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const userId = user?.id;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 侧边栏展开/收起状态(默认折叠)
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// 从 Redux 获取自选股数据(与导航栏共用)
|
||||
const watchlist = useSelector(state => state.stock.watchlist || []);
|
||||
const watchlistQuotes = useSelector(state => state.stock.watchlistQuotes || []);
|
||||
const watchlistLoading = useSelector(state => state.stock.loading?.watchlist);
|
||||
const quotesLoading = useSelector(state => state.stock.loading?.watchlistQuotes);
|
||||
|
||||
// 将 watchlistQuotes 数组转换为 { stock_code: quote } 格式(兼容现有组件)
|
||||
const realtimeQuotes = React.useMemo(() => {
|
||||
const quotesMap = {};
|
||||
watchlistQuotes.forEach(item => {
|
||||
quotesMap[item.stock_code] = item;
|
||||
});
|
||||
return quotesMap;
|
||||
}, [watchlistQuotes]);
|
||||
|
||||
// 从 Redux 获取关注事件数据(与导航栏共用)
|
||||
const followingEvents = useSelector(state => state.stock.followingEvents || []);
|
||||
const eventComments = useSelector(state => state.stock.eventComments || []);
|
||||
const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false);
|
||||
|
||||
// 防止重复加载
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 切换侧边栏展开/收起
|
||||
*/
|
||||
const toggle = useCallback(() => {
|
||||
setIsOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 加载实时行情(通过 Redux)
|
||||
*/
|
||||
const loadRealtimeQuotes = useCallback(() => {
|
||||
if (!userId) return;
|
||||
dispatch(loadWatchlistQuotes());
|
||||
}, [userId, dispatch]);
|
||||
|
||||
/**
|
||||
* 加载所有数据(自选股和关注事件都从 Redux 获取)
|
||||
*/
|
||||
const loadData = useCallback(() => {
|
||||
if (!userId) return;
|
||||
|
||||
// 自选股通过 Redux 加载
|
||||
dispatch(loadWatchlist());
|
||||
dispatch(loadWatchlistQuotes());
|
||||
|
||||
// 关注事件和评论通过 Redux 加载
|
||||
dispatch(loadFollowingEvents());
|
||||
dispatch(loadEventComments());
|
||||
}, [userId, dispatch]);
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
await loadData();
|
||||
}, [loadData]);
|
||||
|
||||
/**
|
||||
* 取消关注股票(通过 Redux)
|
||||
*/
|
||||
const unwatchStock = useCallback(async (stockCode) => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
// 找到股票名称
|
||||
const stockItem = watchlist.find(s => s.stock_code === stockCode);
|
||||
const stockName = stockItem?.stock_name || '';
|
||||
|
||||
// 通过 Redux action 移除(乐观更新)
|
||||
await dispatch(toggleWatchlist({
|
||||
stockCode,
|
||||
stockName,
|
||||
isInWatchlist: true // 表示当前在自选股中,需要移除
|
||||
})).unwrap();
|
||||
|
||||
logger.debug('GlobalSidebar', 'unwatchStock 成功', { stockCode });
|
||||
} catch (error) {
|
||||
logger.error('GlobalSidebar', 'unwatchStock', error, { stockCode, userId });
|
||||
}
|
||||
}, [userId, dispatch, watchlist]);
|
||||
|
||||
/**
|
||||
* 取消关注事件(通过 Redux)
|
||||
*/
|
||||
const unfollowEvent = useCallback(async (eventId) => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
// 通过 Redux action 取消关注(乐观更新)
|
||||
await dispatch(toggleFollowEvent({
|
||||
eventId,
|
||||
isFollowing: true // 表示当前已关注,需要取消
|
||||
})).unwrap();
|
||||
|
||||
logger.debug('GlobalSidebar', 'unfollowEvent 成功', { eventId });
|
||||
} catch (error) {
|
||||
logger.error('GlobalSidebar', 'unfollowEvent', error, { eventId, userId });
|
||||
// 失败时重新加载列表
|
||||
dispatch(loadFollowingEvents());
|
||||
}
|
||||
}, [userId, dispatch]);
|
||||
|
||||
// 用户登录后加载数据
|
||||
useEffect(() => {
|
||||
if (user && !hasLoadedRef.current) {
|
||||
console.log('[GlobalSidebar] 用户登录,加载数据');
|
||||
hasLoadedRef.current = true;
|
||||
loadData();
|
||||
}
|
||||
|
||||
// 用户登出时重置(所有状态由 Redux 管理)
|
||||
if (!user) {
|
||||
hasLoadedRef.current = false;
|
||||
}
|
||||
}, [user, loadData]);
|
||||
|
||||
// 页面可见性变化时刷新数据
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && user) {
|
||||
console.log('[GlobalSidebar] 页面可见,刷新数据');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}, [user, loadData]);
|
||||
|
||||
// 定时刷新实时行情(每分钟一次,两个面板共用)
|
||||
useEffect(() => {
|
||||
if (watchlist.length > 0 && userId) {
|
||||
const interval = setInterval(() => {
|
||||
console.log('[GlobalSidebar] 定时刷新行情');
|
||||
dispatch(loadWatchlistQuotes());
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [watchlist.length, userId, dispatch]);
|
||||
|
||||
const value = {
|
||||
// 状态
|
||||
isOpen,
|
||||
toggle,
|
||||
|
||||
// 数据(watchlist 和 realtimeQuotes 从 Redux 获取)
|
||||
watchlist,
|
||||
realtimeQuotes,
|
||||
followingEvents,
|
||||
eventComments,
|
||||
|
||||
// 加载状态
|
||||
loading: watchlistLoading || eventsLoading,
|
||||
quotesLoading,
|
||||
|
||||
// 方法
|
||||
refresh,
|
||||
loadRealtimeQuotes,
|
||||
unwatchStock,
|
||||
unfollowEvent,
|
||||
};
|
||||
|
||||
return (
|
||||
<GlobalSidebarContext.Provider value={value}>
|
||||
{children}
|
||||
</GlobalSidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* useGlobalSidebar - 获取全局侧边栏 Context
|
||||
*/
|
||||
export const useGlobalSidebar = () => {
|
||||
const context = useContext(GlobalSidebarContext);
|
||||
if (!context) {
|
||||
throw new Error('useGlobalSidebar must be used within a GlobalSidebarProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default GlobalSidebarContext;
|
||||
@@ -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;
|
||||
@@ -1,16 +1,21 @@
|
||||
// src/hooks/useFollowingEvents.js
|
||||
// 关注事件管理自定义 Hook
|
||||
// 关注事件管理自定义 Hook(与 Redux 状态同步,支持多组件共用)
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
import {
|
||||
loadFollowingEvents as loadFollowingEventsAction,
|
||||
toggleFollowEvent
|
||||
} from '../store/slices/stockSlice';
|
||||
|
||||
const EVENTS_PAGE_SIZE = 8;
|
||||
|
||||
/**
|
||||
* 关注事件管理 Hook
|
||||
* 提供事件加载、分页、取消关注等功能
|
||||
* 关注事件管理 Hook(导航栏专用)
|
||||
* 提供关注事件加载、分页、取消关注等功能
|
||||
* 监听 Redux 中的 followingEvents 变化,自动同步
|
||||
*
|
||||
* @returns {{
|
||||
* followingEvents: Array,
|
||||
@@ -24,77 +29,66 @@ const EVENTS_PAGE_SIZE = 8;
|
||||
*/
|
||||
export const useFollowingEvents = () => {
|
||||
const toast = useToast();
|
||||
const [followingEvents, setFollowingEvents] = useState([]);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const [eventsPage, setEventsPage] = useState(1);
|
||||
|
||||
// 加载关注的事件
|
||||
const loadFollowingEvents = useCallback(async () => {
|
||||
try {
|
||||
setEventsLoading(true);
|
||||
const base = getApiBase();
|
||||
const resp = await fetch(base + '/api/account/events/following', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
// 合并重复的事件(用最新的数据)
|
||||
const eventMap = new Map();
|
||||
for (const evt of data.data) {
|
||||
if (evt && evt.id) {
|
||||
eventMap.set(evt.id, evt);
|
||||
}
|
||||
}
|
||||
const merged = Array.from(eventMap.values());
|
||||
// 按创建时间降序排列(假设事件有 created_at 或 id)
|
||||
if (merged.length > 0 && merged[0].created_at) {
|
||||
merged.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
|
||||
} else {
|
||||
merged.sort((a, b) => (b.id || 0) - (a.id || 0));
|
||||
}
|
||||
setFollowingEvents(merged);
|
||||
} else {
|
||||
setFollowingEvents([]);
|
||||
}
|
||||
} else {
|
||||
setFollowingEvents([]);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('useFollowingEvents', '加载关注事件失败', {
|
||||
error: e.message
|
||||
});
|
||||
setFollowingEvents([]);
|
||||
} finally {
|
||||
setEventsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
// 从 Redux 获取关注事件数据(与 GlobalSidebar 共用)
|
||||
const followingEvents = useSelector(state => state.stock.followingEvents || []);
|
||||
const eventsLoading = useSelector(state => state.stock.loading?.followingEvents || false);
|
||||
|
||||
// 取消关注事件
|
||||
// 从 Redux 获取关注事件列表长度(用于监听变化)
|
||||
const reduxEventsLength = useSelector(state => state.stock.followingEvents?.length || 0);
|
||||
|
||||
// 用于跟踪上一次的事件长度
|
||||
const prevEventsLengthRef = useRef(-1);
|
||||
|
||||
// 初始化时加载 Redux followingEvents(确保 Redux 状态被初始化)
|
||||
const hasInitializedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
logger.debug('useFollowingEvents', '初始化 Redux followingEvents');
|
||||
dispatch(loadFollowingEventsAction());
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// 加载关注事件(通过 Redux)
|
||||
const loadFollowingEvents = useCallback(() => {
|
||||
logger.debug('useFollowingEvents', '触发 loadFollowingEvents');
|
||||
dispatch(loadFollowingEventsAction());
|
||||
}, [dispatch]);
|
||||
|
||||
// 监听 Redux followingEvents 长度变化,自动更新分页
|
||||
useEffect(() => {
|
||||
const currentLength = reduxEventsLength;
|
||||
const prevLength = prevEventsLengthRef.current;
|
||||
|
||||
// 当事件列表长度变化时,更新分页(确保不超出范围)
|
||||
if (prevLength !== -1 && currentLength !== prevLength) {
|
||||
const newMaxPage = Math.max(1, Math.ceil(currentLength / EVENTS_PAGE_SIZE));
|
||||
setEventsPage(p => Math.min(p, newMaxPage));
|
||||
}
|
||||
|
||||
prevEventsLengthRef.current = currentLength;
|
||||
}, [reduxEventsLength]);
|
||||
|
||||
// 取消关注事件(通过 Redux)
|
||||
const handleUnfollowEvent = useCallback(async (eventId) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const resp = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok && data && data.success !== false) {
|
||||
setFollowingEvents((prev) => {
|
||||
const updated = (prev || []).filter((x) => x.id !== eventId);
|
||||
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / EVENTS_PAGE_SIZE));
|
||||
setEventsPage((p) => Math.min(p, newMaxPage));
|
||||
return updated;
|
||||
});
|
||||
toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
|
||||
} else {
|
||||
toast({ title: '操作失败', status: 'error', duration: 2000 });
|
||||
}
|
||||
// 通过 Redux action 取消关注(乐观更新)
|
||||
await dispatch(toggleFollowEvent({
|
||||
eventId,
|
||||
isFollowing: true // 表示当前已关注,需要取消
|
||||
})).unwrap();
|
||||
|
||||
toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
|
||||
} catch (e) {
|
||||
toast({ title: '网络错误,操作失败', status: 'error', duration: 2000 });
|
||||
logger.error('useFollowingEvents', '取消关注事件失败', e);
|
||||
toast({ title: e.message || '操作失败', status: 'error', duration: 2000 });
|
||||
// 失败时重新加载列表
|
||||
dispatch(loadFollowingEventsAction());
|
||||
}
|
||||
}, [toast]);
|
||||
}, [dispatch, toast]);
|
||||
|
||||
return {
|
||||
followingEvents,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// src/hooks/useWatchlist.js
|
||||
// 自选股管理自定义 Hook(导航栏专用,与 Redux 状态同步)
|
||||
// 自选股管理自定义 Hook(与 Redux 状态同步,支持多组件共用)
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
import { toggleWatchlist as toggleWatchlistAction, loadWatchlist } from '../store/slices/stockSlice';
|
||||
import {
|
||||
toggleWatchlist as toggleWatchlistAction,
|
||||
loadWatchlist,
|
||||
loadWatchlistQuotes
|
||||
} from '../store/slices/stockSlice';
|
||||
|
||||
const WATCHLIST_PAGE_SIZE = 10;
|
||||
|
||||
@@ -31,20 +35,18 @@ const WATCHLIST_PAGE_SIZE = 10;
|
||||
export const useWatchlist = () => {
|
||||
const toast = useToast();
|
||||
const dispatch = useDispatch();
|
||||
const [watchlistQuotes, setWatchlistQuotes] = useState([]);
|
||||
const [watchlistLoading, setWatchlistLoading] = useState(false);
|
||||
const [watchlistPage, setWatchlistPage] = useState(1);
|
||||
const [followingEvents, setFollowingEvents] = useState([]);
|
||||
|
||||
// 从 Redux 获取自选股数据(与 GlobalSidebar 共用)
|
||||
const watchlistQuotes = useSelector(state => state.stock.watchlistQuotes || []);
|
||||
const watchlistLoading = useSelector(state => state.stock.loading?.watchlistQuotes || false);
|
||||
|
||||
// 从 Redux 获取自选股列表长度(用于监听变化)
|
||||
// 使用 length 作为依赖,避免数组引用变化导致不必要的重新渲染
|
||||
const reduxWatchlistLength = useSelector(state => state.stock.watchlist?.length || 0);
|
||||
|
||||
// 检查 Redux watchlist 是否已初始化(加载状态)
|
||||
const reduxWatchlistLoading = useSelector(state => state.stock.loading?.watchlist);
|
||||
|
||||
// 用于跟踪上一次的 watchlist 长度
|
||||
const prevWatchlistLengthRef = useRef(-1); // 初始设为 -1,确保第一次变化也能检测到
|
||||
const prevWatchlistLengthRef = useRef(-1);
|
||||
|
||||
// 初始化时加载 Redux watchlist(确保 Redux 状态被初始化)
|
||||
const hasInitializedRef = useRef(false);
|
||||
@@ -56,35 +58,11 @@ export const useWatchlist = () => {
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// 加载自选股实时行情
|
||||
const loadWatchlistQuotes = useCallback(async () => {
|
||||
try {
|
||||
setWatchlistLoading(true);
|
||||
const base = getApiBase();
|
||||
const resp = await fetch(base + '/api/account/watchlist/realtime', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setWatchlistQuotes(data.data);
|
||||
logger.debug('useWatchlist', '自选股行情加载成功', { count: data.data.length });
|
||||
} else {
|
||||
setWatchlistQuotes([]);
|
||||
}
|
||||
} else {
|
||||
setWatchlistQuotes([]);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('useWatchlist', '加载自选股实时行情失败', {
|
||||
error: e.message
|
||||
});
|
||||
setWatchlistQuotes([]);
|
||||
} finally {
|
||||
setWatchlistLoading(false);
|
||||
}
|
||||
}, []);
|
||||
// 加载自选股实时行情(通过 Redux)
|
||||
const loadWatchlistQuotesFunc = useCallback(() => {
|
||||
logger.debug('useWatchlist', '触发 loadWatchlistQuotes');
|
||||
dispatch(loadWatchlistQuotes());
|
||||
}, [dispatch]);
|
||||
|
||||
// 监听 Redux watchlist 长度变化,自动刷新行情数据
|
||||
useEffect(() => {
|
||||
@@ -102,7 +80,7 @@ export const useWatchlist = () => {
|
||||
// 延迟一小段时间再刷新,确保后端数据已更新
|
||||
const timer = setTimeout(() => {
|
||||
logger.debug('useWatchlist', '执行 loadWatchlistQuotes');
|
||||
loadWatchlistQuotes();
|
||||
dispatch(loadWatchlistQuotes());
|
||||
}, 500);
|
||||
|
||||
prevWatchlistLengthRef.current = currentLength;
|
||||
@@ -111,66 +89,53 @@ export const useWatchlist = () => {
|
||||
|
||||
// 更新 ref
|
||||
prevWatchlistLengthRef.current = currentLength;
|
||||
}, [reduxWatchlistLength, loadWatchlistQuotes]);
|
||||
}, [reduxWatchlistLength, dispatch]);
|
||||
|
||||
// 添加到自选股
|
||||
// 添加到自选股(通过 Redux)
|
||||
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const resp = await fetch(base + '/api/account/watchlist', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok && data.success) {
|
||||
// 刷新自选股列表
|
||||
loadWatchlistQuotes();
|
||||
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
|
||||
return true;
|
||||
} else {
|
||||
toast({ title: '添加失败', status: 'error', duration: 2000 });
|
||||
return false;
|
||||
}
|
||||
// 通过 Redux action 添加(乐观更新)
|
||||
await dispatch(toggleWatchlistAction({
|
||||
stockCode,
|
||||
stockName,
|
||||
isInWatchlist: false // 表示当前不在自选股中,需要添加
|
||||
})).unwrap();
|
||||
|
||||
// 刷新行情
|
||||
dispatch(loadWatchlistQuotes());
|
||||
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 });
|
||||
logger.error('useWatchlist', '添加自选股失败', e);
|
||||
toast({ title: e.message || '添加失败', status: 'error', duration: 2000 });
|
||||
return false;
|
||||
}
|
||||
}, [toast, loadWatchlistQuotes]);
|
||||
}, [dispatch, toast]);
|
||||
|
||||
// 从自选股移除
|
||||
// 从自选股移除(通过 Redux)
|
||||
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
||||
try {
|
||||
// 找到股票名称
|
||||
const stockItem = watchlistQuotes.find(item => {
|
||||
const normalize6 = (code) => {
|
||||
const m = String(code || '').match(/(\d{6})/);
|
||||
return m ? m[1] : String(code || '');
|
||||
};
|
||||
return normalize6(item.stock_code) === normalize6(stockCode);
|
||||
});
|
||||
const normalize6 = (code) => {
|
||||
const m = String(code || '').match(/(\d{6})/);
|
||||
return m ? m[1] : String(code || '');
|
||||
};
|
||||
const stockItem = watchlistQuotes.find(item =>
|
||||
normalize6(item.stock_code) === normalize6(stockCode)
|
||||
);
|
||||
const stockName = stockItem?.stock_name || '';
|
||||
|
||||
// 通过 Redux action 移除(会同步更新 Redux 状态)
|
||||
// 通过 Redux action 移除(乐观更新)
|
||||
await dispatch(toggleWatchlistAction({
|
||||
stockCode,
|
||||
stockName,
|
||||
isInWatchlist: true // 表示当前在自选股中,需要移除
|
||||
})).unwrap();
|
||||
|
||||
// 更新本地状态(立即响应 UI)
|
||||
setWatchlistQuotes((prev) => {
|
||||
const normalize6 = (code) => {
|
||||
const m = String(code || '').match(/(\d{6})/);
|
||||
return m ? m[1] : String(code || '');
|
||||
};
|
||||
const target = normalize6(stockCode);
|
||||
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
|
||||
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
|
||||
setWatchlistPage((p) => Math.min(p, newMaxPage));
|
||||
return updated;
|
||||
});
|
||||
// 更新分页(如果当前页超出范围)
|
||||
const newLength = watchlistQuotes.length - 1;
|
||||
const newMaxPage = Math.max(1, Math.ceil(newLength / WATCHLIST_PAGE_SIZE));
|
||||
setWatchlistPage(p => Math.min(p, newMaxPage));
|
||||
|
||||
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
|
||||
} catch (e) {
|
||||
@@ -195,7 +160,7 @@ export const useWatchlist = () => {
|
||||
watchlistPage,
|
||||
setWatchlistPage,
|
||||
WATCHLIST_PAGE_SIZE,
|
||||
loadWatchlistQuotes,
|
||||
loadWatchlistQuotes: loadWatchlistQuotesFunc,
|
||||
followingEvents,
|
||||
handleAddToWatchlist,
|
||||
handleRemoveFromWatchlist,
|
||||
|
||||
14
src/index.js
14
src/index.js
@@ -5,11 +5,25 @@ 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,该文件无效)
|
||||
// import './styles/brainwave.css';
|
||||
|
||||
// 导入全局滚动条隐藏样式
|
||||
import './styles/scrollbar-hide.css';
|
||||
|
||||
// 导入 Select 下拉框颜色修复样式
|
||||
import './styles/select-fix.css';
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
// 主布局组件 - 为所有带导航栏的页面提供统一布局
|
||||
import React, { memo, Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
import AppFooter from "./AppFooter";
|
||||
import BackToTopButton from "./components/BackToTopButton";
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import PageLoader from "../components/Loading/PageLoader";
|
||||
import { BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
|
||||
import GlobalSidebar from "../components/GlobalSidebar";
|
||||
import { BACK_TO_TOP_CONFIG, LAYOUT_SIZE } from "./config/layoutConfig";
|
||||
|
||||
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
|
||||
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
|
||||
@@ -27,6 +28,7 @@ const MemoizedAppFooter = memo(AppFooter);
|
||||
* - ✅ 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
|
||||
* - ✅ 懒加载支持 - Suspense 统一处理懒加载
|
||||
* - ✅ 布局简化 - 直接内联容器逻辑,减少嵌套层级
|
||||
* - ✅ 全局侧边栏 - 右侧可收起的工具栏(关注股票、事件动态)
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
@@ -34,17 +36,26 @@ export default function MainLayout() {
|
||||
{/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedHomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
||||
<Box flex="1" pt="60px" bg="#1A202C">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
{/* 主体区域 - 页面内容 + 右侧全局侧边栏(绝对定位覆盖) */}
|
||||
<Box flex="1" bg="#1A202C" position="relative" overflow="hidden">
|
||||
{/* 页面内容区域 - 全宽度,与导航栏对齐 */}
|
||||
<Box h="100%" overflowY="auto" display="flex" flexDirection="column">
|
||||
<Box flex="1" pt={LAYOUT_SIZE.navbarHeight}>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
{/* 页脚 - 在滚动区域内,随内容滚动 */}
|
||||
<MemoizedAppFooter />
|
||||
</Box>
|
||||
|
||||
{/* 页脚 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedAppFooter />
|
||||
{/* 全局右侧工具栏 - 绝对定位覆盖在内容上方 */}
|
||||
<Box position="absolute" top={0} right={0} bottom={0}>
|
||||
<GlobalSidebar />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 返回顶部按钮 - 滚动超过阈值时显示 */}
|
||||
{/* <BackToTopButton
|
||||
|
||||
@@ -12,13 +12,62 @@
|
||||
/**
|
||||
* Z-Index 层级管理
|
||||
* 统一管理 z-index,避免层级冲突
|
||||
*
|
||||
* 层级规则(从低到高):
|
||||
* - 0-99: 页面内部元素(背景、卡片内部层级)
|
||||
* - 100-499: 页面级浮动元素(侧边栏、面板)
|
||||
* - 500-999: 全局固定元素(工具栏、返回顶部)
|
||||
* - 1000-1499: 导航相关(导航栏、状态栏)
|
||||
* - 1500-1999: 弹出层(下拉菜单、Popover)
|
||||
* - 2000-2999: 模态框(普通 Modal)
|
||||
* - 3000-8999: 特殊模态框(图表全屏、预览)
|
||||
* - 9000-9999: 全局提示(Toast、通知)
|
||||
* - 10000+: 系统级覆盖(第三方组件、客服系统)
|
||||
*/
|
||||
export const Z_INDEX = {
|
||||
BACK_TO_TOP: 1000, // 返回顶部按钮
|
||||
NAVBAR: 1100, // 导航栏
|
||||
MODAL: 1200, // 模态框
|
||||
TOAST: 1300, // 提示消息
|
||||
TOOLTIP: 1400, // 工具提示
|
||||
// === 页面内部元素 (0-99) ===
|
||||
BACKGROUND: 0, // 背景层
|
||||
CARD_CONTENT: 1, // 卡片内容
|
||||
CARD_OVERLAY: 2, // 卡片覆盖层
|
||||
|
||||
// === 页面级浮动元素 (100-499) ===
|
||||
SIDEBAR: 100, // 全局侧边栏
|
||||
STICKY_HEADER: 200, // 粘性表头
|
||||
|
||||
// === 全局固定元素 (500-999) ===
|
||||
BACK_TO_TOP: 900, // 返回顶部按钮
|
||||
AUTH_MODAL_BG: 999, // 认证模态框背景
|
||||
|
||||
// === 导航相关 (1000-1499) ===
|
||||
NAVBAR: 1000, // 顶部导航栏
|
||||
CONNECTION_STATUS: 1050, // 连接状态栏
|
||||
PROFILE_ALERT: 1100, // 个人资料提示
|
||||
|
||||
// === 弹出层 (1500-1999) ===
|
||||
DROPDOWN: 1500, // 下拉菜单
|
||||
POPOVER: 1600, // Popover 弹出
|
||||
TOOLTIP: 1700, // 工具提示
|
||||
CITATION: 1800, // 引用标记
|
||||
|
||||
// === 模态框 (2000-2999) ===
|
||||
MODAL: 2000, // 普通模态框
|
||||
MODAL_OVERLAY: 2001, // 模态框遮罩
|
||||
STOCK_CHART_MODAL: 2500, // 股票图表模态框
|
||||
|
||||
// === 特殊模态框 (3000-8999) ===
|
||||
FULLSCREEN: 3000, // 全屏模式
|
||||
IMAGE_PREVIEW: 5000, // 图片预览
|
||||
|
||||
// === 全局提示 (9000-9999) ===
|
||||
NOTIFICATION: 9000, // 通知容器
|
||||
TOAST: 9500, // Toast 提示
|
||||
SEARCH_DROPDOWN: 9800, // 搜索下拉框
|
||||
PERFORMANCE_PANEL: 9900, // 性能面板(开发用)
|
||||
|
||||
// === 系统级覆盖 (10000+) ===
|
||||
KLINE_FULLSCREEN: 10000, // K线图全屏
|
||||
THIRD_PARTY: 99999, // 第三方组件
|
||||
BYTEDESK: 999999, // Bytedesk 客服系统
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -116,9 +165,9 @@ export const PAGE_LOADER_CONFIG = {
|
||||
* 布局尺寸配置
|
||||
*/
|
||||
export const LAYOUT_SIZE = {
|
||||
navbarHeight: '80px',
|
||||
navbarHeight: '60px', // 导航栏统一高度
|
||||
footerHeight: 'auto',
|
||||
contentMinHeight: 'calc(100vh - 80px)', // 100vh - navbar高度
|
||||
contentMinHeight: 'calc(100vh - 60px)', // 100vh - navbar高度
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
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;
|
||||
@@ -770,6 +770,144 @@ export const mockInvestmentPlans = [
|
||||
updated_at: '2024-10-08T10:00:00Z',
|
||||
tags: ['季度复盘', '半导体', 'Q3'],
|
||||
stocks: ['688981.SH', '002371.SZ']
|
||||
},
|
||||
|
||||
// ==================== 今日数据(用于日历视图展示) ====================
|
||||
// 测试同日期多事件显示:计划 x3, 复盘 x3, 系统 x3
|
||||
{
|
||||
id: 320,
|
||||
user_id: 1,
|
||||
type: 'plan',
|
||||
title: '今日交易计划 - 年末布局',
|
||||
content: `【今日目标】
|
||||
重点关注年末资金流向,寻找低位优质标的布局机会。
|
||||
|
||||
【操作计划】
|
||||
1. 白酒板块:观察茅台、五粮液走势,若出现回调可适当加仓
|
||||
2. 新能源:宁德时代逢低补仓,目标价位160元附近
|
||||
3. AI算力:关注寒武纪的突破信号
|
||||
|
||||
【资金安排】
|
||||
- 当前仓位:65%
|
||||
- 可动用资金:35%
|
||||
- 计划使用资金:15%(分3笔建仓)
|
||||
|
||||
【风险控制】
|
||||
- 单笔止损:-3%
|
||||
- 日内最大亏损:-5%
|
||||
- 不追涨,只接回调`,
|
||||
target_date: '2025-12-23',
|
||||
status: 'active',
|
||||
created_at: '2025-12-23T08:30:00Z',
|
||||
updated_at: '2025-12-23T08:30:00Z',
|
||||
tags: ['日计划', '年末布局'],
|
||||
stocks: ['600519.SH', '300750.SZ', '688256.SH']
|
||||
},
|
||||
{
|
||||
id: 321,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '今日交易复盘 - 市场震荡',
|
||||
content: `【操作回顾】
|
||||
1. 上午10:30 在茅台1580元位置加仓0.5%
|
||||
2. 下午14:00 宁德时代触及160元支撑位,建仓1%
|
||||
3. AI算力板块异动,寒武纪涨幅超5%,观望未操作
|
||||
|
||||
【盈亏分析】
|
||||
- 茅台加仓部分:浮盈+0.8%
|
||||
- 宁德时代:浮亏-0.3%(正常波动范围内)
|
||||
- 当日账户变动:+0.15%
|
||||
|
||||
【经验总结】
|
||||
- 茅台买点把握较好,符合预期的回调位置
|
||||
- 宁德时代略显急躁,可以再等一等
|
||||
- AI算力虽然错过涨幅,但不追高的纪律执行到位
|
||||
|
||||
【明日计划】
|
||||
- 继续持有今日新增仓位
|
||||
- 如茅台继续上涨至1620,可考虑获利了结一半
|
||||
- 关注周五PMI数据公布对市场影响`,
|
||||
target_date: '2025-12-23',
|
||||
status: 'completed',
|
||||
created_at: '2025-12-23T15:30:00Z',
|
||||
updated_at: '2025-12-23T16:00:00Z',
|
||||
tags: ['日复盘', '年末交易'],
|
||||
stocks: ['600519.SH', '300750.SZ']
|
||||
},
|
||||
// 额外计划2:测试同日期多计划显示
|
||||
{
|
||||
id: 322,
|
||||
user_id: 1,
|
||||
type: 'plan',
|
||||
title: 'AI算力板块布局',
|
||||
content: `【目标】捕捉AI算力板块机会
|
||||
|
||||
【策略】
|
||||
- 寒武纪:关注突破信号
|
||||
- 中科曙光:服务器龙头
|
||||
- 浪潮信息:算力基础设施`,
|
||||
target_date: '2025-12-23',
|
||||
status: 'pending',
|
||||
created_at: '2025-12-23T09:00:00Z',
|
||||
updated_at: '2025-12-23T09:00:00Z',
|
||||
tags: ['AI', '算力'],
|
||||
stocks: ['688256.SH', '603019.SH', '000977.SZ']
|
||||
},
|
||||
// 额外计划3:测试同日期多计划显示
|
||||
{
|
||||
id: 323,
|
||||
user_id: 1,
|
||||
type: 'plan',
|
||||
title: '医药板块观察计划',
|
||||
content: `【目标】关注创新药投资机会
|
||||
|
||||
【策略】
|
||||
- 恒瑞医药:创新药龙头
|
||||
- 药明康德:CRO龙头`,
|
||||
target_date: '2025-12-23',
|
||||
status: 'pending',
|
||||
created_at: '2025-12-23T10:00:00Z',
|
||||
updated_at: '2025-12-23T10:00:00Z',
|
||||
tags: ['医药', '创新药'],
|
||||
stocks: ['600276.SH', '603259.SH']
|
||||
},
|
||||
// 额外复盘2:测试同日期多复盘显示
|
||||
{
|
||||
id: 324,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '半导体操作复盘',
|
||||
content: `【操作回顾】
|
||||
- 中芯国际:持仓未动
|
||||
- 北方华创:观望
|
||||
|
||||
【经验总结】
|
||||
半导体板块整体震荡,等待突破信号`,
|
||||
target_date: '2025-12-23',
|
||||
status: 'completed',
|
||||
created_at: '2025-12-23T16:00:00Z',
|
||||
updated_at: '2025-12-23T16:30:00Z',
|
||||
tags: ['半导体复盘'],
|
||||
stocks: ['688981.SH', '002371.SZ']
|
||||
},
|
||||
// 额外复盘3:测试同日期多复盘显示
|
||||
{
|
||||
id: 325,
|
||||
user_id: 1,
|
||||
type: 'review',
|
||||
title: '本周白酒持仓复盘',
|
||||
content: `【操作回顾】
|
||||
- 茅台:本周加仓0.5%
|
||||
- 五粮液:持仓未动
|
||||
|
||||
【盈亏分析】
|
||||
白酒板块本周表现平稳,继续持有`,
|
||||
target_date: '2025-12-23',
|
||||
status: 'completed',
|
||||
created_at: '2025-12-23T17:00:00Z',
|
||||
updated_at: '2025-12-23T17:30:00Z',
|
||||
tags: ['白酒复盘', '周复盘'],
|
||||
stocks: ['600519.SH', '000858.SZ']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1101,6 +1239,87 @@ export const mockCalendarEvents = [
|
||||
is_recurring: true,
|
||||
recurrence_rule: 'weekly',
|
||||
created_at: '2025-01-01T10:00:00Z'
|
||||
},
|
||||
|
||||
// ==================== 今日事件(2025-12-23) ====================
|
||||
{
|
||||
id: 409,
|
||||
user_id: 1,
|
||||
title: '比亚迪全球发布会',
|
||||
date: '2025-12-23',
|
||||
event_date: '2025-12-23',
|
||||
type: 'earnings',
|
||||
category: 'company_event',
|
||||
description: `比亚迪将于今日14:00召开全球发布会,预计发布新一代刀片电池技术和2026年新车规划。
|
||||
|
||||
重点关注:
|
||||
1. 刀片电池2.0技术参数:能量密度提升预期
|
||||
2. 2026年新车型规划:高端品牌仰望系列
|
||||
3. 海外市场扩张计划:欧洲建厂进度
|
||||
4. 年度交付量预告
|
||||
|
||||
投资建议:
|
||||
- 关注发布会后股价走势
|
||||
- 若技术突破超预期,可考虑加仓
|
||||
- 设置止损位:当前价-5%`,
|
||||
stock_code: '002594.SZ',
|
||||
stock_name: '比亚迪',
|
||||
importance: 5,
|
||||
source: 'future',
|
||||
stocks: ['002594.SZ', '300750.SZ', '601238.SH'],
|
||||
created_at: '2025-12-20T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 410,
|
||||
user_id: 1,
|
||||
title: '12月LPR报价公布',
|
||||
date: '2025-12-23',
|
||||
event_date: '2025-12-23',
|
||||
type: 'policy',
|
||||
category: 'macro_policy',
|
||||
description: `中国人民银行将于今日9:30公布12月贷款市场报价利率(LPR)。
|
||||
|
||||
市场预期:
|
||||
- 1年期LPR:3.10%(维持不变)
|
||||
- 5年期以上LPR:3.60%(维持不变)
|
||||
|
||||
影响板块:
|
||||
1. 银行板块:利差压力关注
|
||||
2. 房地产:按揭成本影响
|
||||
3. 基建:融资成本变化
|
||||
|
||||
投资策略:
|
||||
- 若降息,利好成长股,可加仓科技板块
|
||||
- 若维持,银行股防守价值凸显`,
|
||||
importance: 5,
|
||||
source: 'future',
|
||||
stocks: ['601398.SH', '600036.SH', '000001.SZ'],
|
||||
created_at: '2025-12-20T08:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 411,
|
||||
user_id: 1,
|
||||
title: 'A股年末交易策略会议',
|
||||
date: '2025-12-23',
|
||||
event_date: '2025-12-23',
|
||||
type: 'reminder',
|
||||
category: 'personal',
|
||||
description: `个人备忘:年末交易策略规划
|
||||
|
||||
待办事项:
|
||||
1. 回顾2025年度投资收益
|
||||
2. 分析持仓股票基本面变化
|
||||
3. 制定2026年Q1布局计划
|
||||
4. 检查止盈止损纪律执行情况
|
||||
|
||||
重点关注:
|
||||
- 白酒板块持仓是否需要调整
|
||||
- 新能源板块估值是否合理
|
||||
- 是否需要增加防守性配置`,
|
||||
importance: 3,
|
||||
source: 'user',
|
||||
stocks: [],
|
||||
created_at: '2025-12-22T20:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -609,6 +609,49 @@ function generateEventDescription(industry, importance, seed) {
|
||||
return impacts[importance] + details[seed % details.length];
|
||||
}
|
||||
|
||||
// 概念到层级结构的映射(模拟真实 API 的 concept_hierarchy)
|
||||
const conceptHierarchyMap = {
|
||||
// 人工智能主线
|
||||
'大模型': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: 'AI大模型', lv3_id: 'AI_LLM' },
|
||||
'AI应用': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: 'AI应用场景', lv3_id: 'AI_APP' },
|
||||
'算力': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI算力与基础设施', lv2_id: 'AI_INFRA', lv3: 'AI芯片与硬件', lv3_id: 'AI_CHIP' },
|
||||
'数据': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: '数据要素', lv3_id: 'DATA' },
|
||||
'机器学习': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI大模型与应用', lv2_id: 'AI_MODEL_APP', lv3: 'AI算法', lv3_id: 'AI_ALGO' },
|
||||
'AI芯片': { lv1: '人工智能', lv1_id: 'AI', lv2: 'AI算力与基础设施', lv2_id: 'AI_INFRA', lv3: 'AI芯片与硬件', lv3_id: 'AI_CHIP' },
|
||||
// 半导体主线
|
||||
'芯片': { lv1: '半导体', lv1_id: 'SEMI', lv2: '芯片设计', lv2_id: 'CHIP_DESIGN', lv3: '芯片设计', lv3_id: 'CHIP' },
|
||||
'晶圆': { lv1: '半导体', lv1_id: 'SEMI', lv2: '芯片制造', lv2_id: 'CHIP_MFG', lv3: '晶圆代工', lv3_id: 'WAFER' },
|
||||
'封测': { lv1: '半导体', lv1_id: 'SEMI', lv2: '封装测试', lv2_id: 'PKG_TEST', lv3: '封装测试', lv3_id: 'PKG' },
|
||||
'国产替代': { lv1: '半导体', lv1_id: 'SEMI', lv2: '国产替代', lv2_id: 'DOMESTIC', lv3: '自主可控', lv3_id: 'SELF_CTRL' },
|
||||
// 新能源主线
|
||||
'电池': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '新能源汽车', lv2_id: 'EV', lv3: '动力电池', lv3_id: 'BATTERY' },
|
||||
'光伏': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '光伏产业', lv2_id: 'SOLAR', lv3: '光伏组件', lv3_id: 'PV_MODULE' },
|
||||
'储能': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '储能产业', lv2_id: 'ESS', lv3: '电化学储能', lv3_id: 'ESS_CHEM' },
|
||||
'新能源车': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '新能源汽车', lv2_id: 'EV', lv3: '整车制造', lv3_id: 'EV_OEM' },
|
||||
'锂电': { lv1: '新能源', lv1_id: 'ENERGY', lv2: '新能源汽车', lv2_id: 'EV', lv3: '锂电池材料', lv3_id: 'LI_MATERIAL' },
|
||||
// 医药主线
|
||||
'创新药': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '创新药', lv2_id: 'INNOV_DRUG', lv3: '创新药研发', lv3_id: 'DRUG_RD' },
|
||||
'CRO': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '医药服务', lv2_id: 'PHARMA_SVC', lv3: 'CRO/CDMO', lv3_id: 'CRO' },
|
||||
'医疗器械': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '医疗器械', lv2_id: 'MED_DEVICE', lv3: '高端器械', lv3_id: 'HI_DEVICE' },
|
||||
'生物制药': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '生物制药', lv2_id: 'BIO_PHARMA', lv3: '生物药', lv3_id: 'BIO_DRUG' },
|
||||
'仿制药': { lv1: '医药生物', lv1_id: 'PHARMA', lv2: '仿制药', lv2_id: 'GENERIC', lv3: '仿制药', lv3_id: 'GEN_DRUG' },
|
||||
// 消费主线
|
||||
'白酒': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '食品饮料', lv2_id: 'FOOD_BEV', lv3: '白酒', lv3_id: 'BAIJIU' },
|
||||
'食品': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '食品饮料', lv2_id: 'FOOD_BEV', lv3: '食品加工', lv3_id: 'FOOD' },
|
||||
'家电': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '家电', lv2_id: 'HOME_APPL', lv3: '白色家电', lv3_id: 'WHITE_APPL' },
|
||||
'零售': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '商贸零售', lv2_id: 'RETAIL', lv3: '零售连锁', lv3_id: 'CHAIN' },
|
||||
'免税': { lv1: '消费', lv1_id: 'CONSUMER', lv2: '商贸零售', lv2_id: 'RETAIL', lv3: '免税', lv3_id: 'DUTY_FREE' },
|
||||
// 通用概念(分配到多个主线)
|
||||
'政策': { lv1: '宏观政策', lv1_id: 'MACRO', lv2: '产业政策', lv2_id: 'POLICY', lv3: null, lv3_id: null },
|
||||
'利好': { lv1: '市场情绪', lv1_id: 'SENTIMENT', lv2: '利好因素', lv2_id: 'POSITIVE', lv3: null, lv3_id: null },
|
||||
'业绩': { lv1: '基本面', lv1_id: 'FUNDAMENTAL', lv2: '业绩增长', lv2_id: 'EARNINGS', lv3: null, lv3_id: null },
|
||||
'涨停': { lv1: '市场情绪', lv1_id: 'SENTIMENT', lv2: '涨停板', lv2_id: 'LIMIT_UP', lv3: null, lv3_id: null },
|
||||
'龙头': { lv1: '投资策略', lv1_id: 'STRATEGY', lv2: '龙头股', lv2_id: 'LEADER', lv3: null, lv3_id: null },
|
||||
'突破': { lv1: '技术面', lv1_id: 'TECHNICAL', lv2: '技术突破', lv2_id: 'BREAKOUT', lv3: null, lv3_id: null },
|
||||
'合作': { lv1: '公司动态', lv1_id: 'CORP_ACTION', lv2: '战略合作', lv2_id: 'PARTNERSHIP', lv3: null, lv3_id: null },
|
||||
'投资': { lv1: '公司动态', lv1_id: 'CORP_ACTION', lv2: '投资并购', lv2_id: 'MA', lv3: null, lv3_id: null },
|
||||
};
|
||||
|
||||
// 生成关键词(对象数组格式,包含完整信息)
|
||||
function generateKeywords(industry, seed) {
|
||||
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
|
||||
@@ -701,6 +744,16 @@ function generateKeywords(industry, seed) {
|
||||
const score = (70 + Math.floor((seed * 7 + index * 11) % 30)) / 100; // 0.70-0.99的分数
|
||||
const avgChangePct = (Math.random() * 15 - 5).toFixed(2); // -5% ~ +10% 的涨跌幅
|
||||
|
||||
// 获取概念的层级信息
|
||||
const hierarchy = conceptHierarchyMap[name] || {
|
||||
lv1: industry || '其他',
|
||||
lv1_id: 'OTHER',
|
||||
lv2: '未分类',
|
||||
lv2_id: 'UNCATEGORIZED',
|
||||
lv3: null,
|
||||
lv3_id: null
|
||||
};
|
||||
|
||||
return {
|
||||
concept: name, // 使用 concept 字段而不是 name
|
||||
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
|
||||
@@ -711,7 +764,8 @@ function generateKeywords(industry, seed) {
|
||||
},
|
||||
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
|
||||
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
|
||||
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
|
||||
stocks: generateRelatedStocks(name, seed + index), // 核心相关股票
|
||||
hierarchy: hierarchy // 层级信息(用于按主线分组)
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -874,8 +928,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 +1108,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') {
|
||||
@@ -1076,6 +1142,138 @@ function generateTransmissionChain(industry, index) {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// 主线事件标题模板 - 确保生成的事件能够匹配主线定义的关键词
|
||||
// 每个主线定义至少有 2-3 个事件模板,确保数据充足
|
||||
const mainlineEventTemplates = [
|
||||
// ==================== TMT (科技/媒体/通信) ====================
|
||||
|
||||
// AI基础设施 - 算力/芯片 (L3_AI_CHIP)
|
||||
{ title: '英伟达发布新一代GPU,AI算力大幅提升', keywords: ['算力', 'AI芯片', 'GPU', '英伟达'], industry: '人工智能' },
|
||||
{ title: '华为昇腾芯片出货量创新高,国产AI算力加速', keywords: ['华为昇腾', 'AI芯片', '算力'], industry: '人工智能' },
|
||||
{ title: '寒武纪发布新一代AI训练芯片,性能提升50%', keywords: ['寒武纪', 'AI芯片', '算力'], industry: '人工智能' },
|
||||
{ title: 'AI芯片需求激增,GPU供不应求价格上涨', keywords: ['AI芯片', 'GPU', '算力'], industry: '人工智能' },
|
||||
|
||||
// AI基础设施 - 服务器与数据中心 (L3_AI_SERVER)
|
||||
{ title: '智算中心建设加速,多地启动算力基础设施项目', keywords: ['智算中心', '算力', '数据中心'], industry: '人工智能' },
|
||||
{ title: '液冷技术成数据中心标配,服务器散热升级', keywords: ['液冷', '数据中心', '服务器'], industry: '人工智能' },
|
||||
{ title: 'AI服务器订单暴增,数据中心扩容需求旺盛', keywords: ['服务器', '数据中心', '智算中心'], industry: '人工智能' },
|
||||
|
||||
// AI基础设施 - 光通信 (L3_OPTICAL)
|
||||
{ title: 'CPO技术迎来突破,光模块成本大幅下降', keywords: ['CPO', '光模块', '光通信'], industry: '通信' },
|
||||
{ title: '800G光模块量产加速,AI训练网络升级', keywords: ['光模块', '光通信', '光芯片'], industry: '通信' },
|
||||
{ title: '光芯片技术突破,CPO方案渗透率提升', keywords: ['光芯片', 'CPO', '光通信', '光模块'], industry: '通信' },
|
||||
|
||||
// AI基础设施 - PCB与封装 (L3_PCB)
|
||||
{ title: 'AI PCB需求激增,高多层板产能紧张', keywords: ['PCB', 'AI PCB', '封装'], industry: '电子' },
|
||||
{ title: '先进封装技术突破,PCB产业链升级', keywords: ['封装', 'PCB'], industry: '电子' },
|
||||
|
||||
// AI应用与大模型 (L3_AI_APP)
|
||||
{ title: 'DeepSeek发布最新大模型,推理能力超越GPT-4', keywords: ['DeepSeek', '大模型', 'AI', '人工智能'], industry: '人工智能' },
|
||||
{ title: 'KIMI月活突破1亿,国产大模型竞争白热化', keywords: ['KIMI', '大模型', 'AI', '人工智能'], industry: '人工智能' },
|
||||
{ title: 'ChatGPT新版本发布,AI Agent智能体能力升级', keywords: ['ChatGPT', '大模型', '智能体', 'AI'], industry: '人工智能' },
|
||||
{ title: '人工智能+医疗深度融合,AI辅助诊断准确率超90%', keywords: ['人工智能', 'AI', '医疗'], industry: '人工智能' },
|
||||
{ title: '多模态大模型技术突破,AI应用场景扩展', keywords: ['大模型', 'AI', '人工智能'], industry: '人工智能' },
|
||||
|
||||
// 半导体 - 芯片设计 (L3_CHIP_DESIGN)
|
||||
{ title: '芯片设计企业扩产,IC设计产能大幅提升', keywords: ['芯片设计', 'IC设计', '半导体'], industry: '半导体' },
|
||||
{ title: '国产芯片设计工具取得突破,EDA软件自主可控', keywords: ['芯片设计', 'IC设计', '半导体'], industry: '半导体' },
|
||||
|
||||
// 半导体 - 芯片制造 (L3_CHIP_MFG)
|
||||
{ title: '中芯国际N+1工艺量产,芯片制造技术再突破', keywords: ['中芯国际', '芯片制造', '晶圆'], industry: '半导体' },
|
||||
{ title: '国产光刻机实现技术突破,半导体设备自主可控', keywords: ['光刻', '半导体', '芯片制造'], industry: '半导体' },
|
||||
{ title: '晶圆产能利用率回升,半导体行业景气度上行', keywords: ['晶圆', '半导体', '芯片制造'], industry: '半导体' },
|
||||
|
||||
// 机器人 - 人形机器人 (L3_HUMANOID)
|
||||
{ title: '特斯拉人形机器人Optimus量产提速,成本降至2万美元', keywords: ['人形机器人', '特斯拉机器人', '具身智能'], industry: '人工智能' },
|
||||
{ title: '具身智能迎来突破,人形机器人商业化加速', keywords: ['具身智能', '人形机器人', '机器人'], industry: '人工智能' },
|
||||
{ title: '人形机器人产业链爆发,核心零部件需求激增', keywords: ['人形机器人', '具身智能'], industry: '人工智能' },
|
||||
|
||||
// 机器人 - 工业机器人 (L3_INDUSTRIAL_ROBOT)
|
||||
{ title: '工业机器人出货量同比增长30%,自动化渗透率提升', keywords: ['工业机器人', '自动化', '机器人'], industry: '机械' },
|
||||
{ title: '智能制造升级,机器人自动化需求持续增长', keywords: ['机器人', '自动化', '工业机器人'], industry: '机械' },
|
||||
|
||||
// 消费电子 - 智能手机 (L3_MOBILE)
|
||||
{ title: '华为Mate系列销量火爆,折叠屏手机市场爆发', keywords: ['华为', '手机', '折叠屏'], industry: '消费电子' },
|
||||
{ title: '小米可穿戴设备出货量全球第一,智能手表市场扩张', keywords: ['小米', '可穿戴', '手机'], industry: '消费电子' },
|
||||
{ title: '手机市场复苏,5G手机换机潮来临', keywords: ['手机', '华为', '小米'], industry: '消费电子' },
|
||||
|
||||
// 消费电子 - XR与可穿戴 (L3_XR)
|
||||
{ title: '苹果Vision Pro销量不及预期,XR设备面临挑战', keywords: ['苹果', 'Vision Pro', 'XR', 'MR'], industry: '消费电子' },
|
||||
{ title: 'AR眼镜成新风口,VR/AR设备渗透率提升', keywords: ['AR', 'VR', 'XR'], industry: '消费电子' },
|
||||
{ title: 'Meta发布新一代VR头显,XR市场竞争加剧', keywords: ['VR', 'XR', 'MR', '可穿戴'], industry: '消费电子' },
|
||||
|
||||
// 通信 - 5G/6G通信 (L3_5G)
|
||||
{ title: '5G基站建设加速,运营商资本开支超预期', keywords: ['5G', '基站', '通信'], industry: '通信' },
|
||||
{ title: '6G技术标准制定启动,下一代通信网络布局', keywords: ['6G', '5G', '通信'], industry: '通信' },
|
||||
|
||||
// 通信 - 云计算与软件 (L3_CLOUD)
|
||||
{ title: '云计算市场规模突破万亿,SaaS企业业绩增长', keywords: ['云计算', 'SaaS', '软件', '互联网'], industry: '互联网' },
|
||||
{ title: '数字化转型加速,企业级软件需求旺盛', keywords: ['数字化', '软件', '互联网'], industry: '互联网' },
|
||||
{ title: '国产软件替代加速,信创产业迎来发展机遇', keywords: ['软件', '数字化', '互联网'], industry: '互联网' },
|
||||
|
||||
// ==================== 新能源与智能汽车 ====================
|
||||
|
||||
// 新能源 - 光伏 (L3_PV)
|
||||
{ title: '光伏装机量创新高,太阳能发电成本持续下降', keywords: ['光伏', '太阳能', '硅片', '组件'], industry: '新能源' },
|
||||
{ title: '光伏组件价格企稳,行业出清接近尾声', keywords: ['光伏', '组件', '硅片'], industry: '新能源' },
|
||||
{ title: '分布式光伏爆发,太阳能产业链受益', keywords: ['光伏', '太阳能'], industry: '新能源' },
|
||||
|
||||
// 新能源 - 储能与电池 (L3_STORAGE)
|
||||
{ title: '储能市场爆发式增长,电池需求大幅提升', keywords: ['储能', '电池', '锂电', '新能源'], industry: '新能源' },
|
||||
{ title: '固态电池技术突破,新能源汽车续航大幅提升', keywords: ['固态电池', '电池', '锂电', '新能源'], industry: '新能源' },
|
||||
{ title: '钠电池产业化加速,储能成本有望大幅下降', keywords: ['电池', '储能', '新能源'], industry: '新能源' },
|
||||
|
||||
// 智能汽车 - 新能源整车 (L3_EV_OEM)
|
||||
{ title: '比亚迪月销量突破50万辆,新能源汽车市占率第一', keywords: ['比亚迪', '新能源汽车', '电动车'], industry: '新能源' },
|
||||
{ title: '新能源整车出口创新高,中国汽车品牌走向全球', keywords: ['新能源汽车', '整车', '电动车'], industry: '新能源' },
|
||||
{ title: '电动车价格战持续,新能源汽车渗透率突破50%', keywords: ['电动车', '新能源汽车', '整车'], industry: '新能源' },
|
||||
|
||||
// 智能汽车 - 智能驾驶 (L3_AUTO_DRIVE)
|
||||
{ title: '特斯拉FSD自动驾驶进入中国市场,智能驾驶加速', keywords: ['特斯拉', '自动驾驶', '智能驾驶', '智能网联'], industry: '新能源' },
|
||||
{ title: '车路协同试点扩大,智能网联汽车基建提速', keywords: ['车路协同', '智能网联', '智能驾驶'], industry: '新能源' },
|
||||
{ title: 'L3级自动驾驶获批,智能驾驶产业化加速', keywords: ['自动驾驶', '智能驾驶', '智能网联'], industry: '新能源' },
|
||||
|
||||
// ==================== 先进制造 ====================
|
||||
|
||||
// 低空经济 - 无人机 (L3_DRONE)
|
||||
{ title: '低空经济政策密集出台,无人机产业迎来风口', keywords: ['低空', '无人机', '空域'], industry: '航空' },
|
||||
{ title: '无人机应用场景拓展,低空经济市场规模扩大', keywords: ['无人机', '低空', '空域'], industry: '航空' },
|
||||
|
||||
// 低空经济 - eVTOL (L3_EVTOL)
|
||||
{ title: 'eVTOL飞行汽车完成首飞,空中出租车商业化在即', keywords: ['eVTOL', '飞行汽车', '空中出租车'], industry: '航空' },
|
||||
{ title: '飞行汽车获适航认证,eVTOL商业运营启动', keywords: ['飞行汽车', 'eVTOL', '空中出租车'], industry: '航空' },
|
||||
|
||||
// 军工 - 航空航天 (L3_AEROSPACE)
|
||||
{ title: '商业航天发射成功,卫星互联网建设加速', keywords: ['航天', '卫星', '火箭'], industry: '军工' },
|
||||
{ title: '航空发动机国产化取得突破,航空产业链升级', keywords: ['航空', '军工'], industry: '军工' },
|
||||
{ title: '卫星通信需求爆发,航天发射频次创新高', keywords: ['卫星', '航天', '火箭'], industry: '军工' },
|
||||
|
||||
// 军工 - 国防军工 (L3_DEFENSE)
|
||||
{ title: '军工订单饱满,国防装备现代化提速', keywords: ['军工', '国防', '军工装备'], industry: '军工' },
|
||||
{ title: '国防预算增长,军工装备需求持续提升', keywords: ['国防', '军工', '军工装备', '导弹'], industry: '军工' },
|
||||
|
||||
// ==================== 医药健康 ====================
|
||||
|
||||
// 医药 - 创新药 (L3_DRUG)
|
||||
{ title: '创新药获批上市,医药板块迎来业绩兑现', keywords: ['创新药', '医药', '生物'], industry: '医药' },
|
||||
{ title: 'CXO订单持续增长,医药研发外包景气度高', keywords: ['CXO', '医药', '生物'], industry: '医药' },
|
||||
{ title: '生物医药融资回暖,创新药研发管线丰富', keywords: ['医药', '生物', '创新药'], industry: '医药' },
|
||||
|
||||
// 医药 - 医疗器械 (L3_DEVICE)
|
||||
{ title: '医疗器械集采扩面,国产替代加速', keywords: ['医疗器械', '医疗', '器械'], industry: '医药' },
|
||||
{ title: '高端医疗设备国产化突破,医疗器械出口增长', keywords: ['医疗器械', '医疗', '器械'], industry: '医药' },
|
||||
|
||||
// ==================== 金融 ====================
|
||||
|
||||
// 金融 - 银行 (L3_BANK)
|
||||
{ title: '银行净息差企稳,金融板块估值修复', keywords: ['银行', '金融'], industry: '金融' },
|
||||
{ title: '银行业绩超预期,金融股迎来价值重估', keywords: ['银行', '金融'], industry: '金融' },
|
||||
|
||||
// 金融 - 券商 (L3_BROKER)
|
||||
{ title: '券商业绩大幅增长,证券板块活跃', keywords: ['券商', '证券', '金融'], industry: '金融' },
|
||||
{ title: 'A股成交量放大,证券行业业绩弹性显现', keywords: ['证券', '券商', '金融'], industry: '金融' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 生成动态新闻事件(实时要闻·动态追踪专用)
|
||||
* @param {Object} timeRange - 时间范围 { startTime, endTime }
|
||||
@@ -1100,7 +1298,10 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
const timeSpan = endTime.getTime() - startTime.getTime();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const industry = industries[i % industries.length];
|
||||
// 使用主线事件模板生成事件,确保能匹配主线关键词
|
||||
const templateIndex = i % mainlineEventTemplates.length;
|
||||
const template = mainlineEventTemplates[templateIndex];
|
||||
const industry = template.industry;
|
||||
const imp = importanceLevels[i % importanceLevels.length];
|
||||
const eventType = eventTypes[i % eventTypes.length];
|
||||
|
||||
@@ -1133,7 +1334,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,17 +1346,39 @@ 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]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 使用模板标题,并生成包含模板关键词的 keywords 数组
|
||||
const eventTitle = template.title;
|
||||
const eventDescription = generateEventDescription(industry, imp, i);
|
||||
|
||||
// 生成关键词对象数组,包含模板中的关键词
|
||||
const templateKeywords = template.keywords.map((kw, idx) => ({
|
||||
concept: kw,
|
||||
stock_count: 10 + Math.floor(Math.random() * 20),
|
||||
score: parseFloat((0.7 + Math.random() * 0.25).toFixed(2)),
|
||||
description: `${kw}相关概念,市场关注度较高`,
|
||||
price_info: {
|
||||
avg_change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2))
|
||||
},
|
||||
match_type: ['hybrid_knn', 'keyword', 'semantic'][idx % 3]
|
||||
}));
|
||||
|
||||
// 合并模板关键词和行业关键词
|
||||
const mergedKeywords = [
|
||||
...templateKeywords,
|
||||
...generateKeywords(industry, i).slice(0, 2)
|
||||
];
|
||||
|
||||
events.push({
|
||||
id: `dynamic_${i + 1}`,
|
||||
title: generateEventTitle(industry, i),
|
||||
description: generateEventDescription(industry, imp, i),
|
||||
content: generateEventDescription(industry, imp, i),
|
||||
title: eventTitle,
|
||||
description: eventDescription,
|
||||
content: eventDescription,
|
||||
event_type: eventType,
|
||||
importance: imp,
|
||||
status: 'published',
|
||||
@@ -1168,7 +1391,9 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
related_avg_chg: parseFloat(relatedAvgChg),
|
||||
related_max_chg: parseFloat(relatedMaxChg),
|
||||
related_week_chg: parseFloat(relatedWeekChg),
|
||||
keywords: generateKeywords(industry, i),
|
||||
expectation_surprise_score: Math.floor(Math.random() * 60) + 30, // 30-90 超预期得分
|
||||
keywords: mergedKeywords,
|
||||
related_concepts: mergedKeywords, // 添加 related_concepts 字段,兼容主线匹配逻辑
|
||||
is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成
|
||||
industry: industry,
|
||||
related_stocks: relatedStocks,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})
|
||||
},
|
||||
@@ -41,73 +55,118 @@ export const generateMarketData = (stockCode) => {
|
||||
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
|
||||
},
|
||||
securities: {
|
||||
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额
|
||||
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额(股数)
|
||||
balance_amount: Math.floor(Math.random() * 2000000000) + 1000000000, // 融券余额(金额)
|
||||
sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
|
||||
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
// 大单统计 - 包含 daily_stats 数组
|
||||
// 大宗交易 - 包含 daily_stats 数组,符合 BigDealDayStats 类型
|
||||
bigDealData: {
|
||||
success: true,
|
||||
data: [],
|
||||
daily_stats: Array(10).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
big_buy: Math.floor(Math.random() * 300000000) + 100000000,
|
||||
big_sell: Math.floor(Math.random() * 300000000) + 80000000,
|
||||
medium_buy: Math.floor(Math.random() * 200000000) + 60000000,
|
||||
medium_sell: Math.floor(Math.random() * 200000000) + 50000000,
|
||||
small_buy: Math.floor(Math.random() * 100000000) + 30000000,
|
||||
small_sell: Math.floor(Math.random() * 100000000) + 25000000
|
||||
}))
|
||||
daily_stats: Array(10).fill(null).map((_, i) => {
|
||||
const count = Math.floor(Math.random() * 5) + 1; // 1-5 笔交易
|
||||
const avgPrice = parseFloat((basePrice * (0.95 + Math.random() * 0.1)).toFixed(2)); // 折价/溢价 -5%~+5%
|
||||
const deals = Array(count).fill(null).map(() => {
|
||||
const volume = parseFloat((Math.random() * 500 + 100).toFixed(2)); // 100-600 万股
|
||||
const price = parseFloat((avgPrice * (0.98 + Math.random() * 0.04)).toFixed(2));
|
||||
return {
|
||||
buyer_dept: ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司'][Math.floor(Math.random() * 4)],
|
||||
seller_dept: ['中金公司北京营业部', '海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司'][Math.floor(Math.random() * 4)],
|
||||
price,
|
||||
volume,
|
||||
amount: parseFloat((price * volume).toFixed(2))
|
||||
};
|
||||
});
|
||||
const totalVolume = deals.reduce((sum, d) => sum + d.volume, 0);
|
||||
const totalAmount = deals.reduce((sum, d) => sum + d.amount, 0);
|
||||
return {
|
||||
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
count,
|
||||
total_volume: parseFloat(totalVolume.toFixed(2)),
|
||||
total_amount: parseFloat(totalAmount.toFixed(2)),
|
||||
avg_price: avgPrice,
|
||||
deals
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
// 异动分析 - 包含 grouped_data 数组
|
||||
// 龙虎榜数据 - 包含 grouped_data 数组,符合 UnusualDayData 类型
|
||||
unusualData: {
|
||||
success: true,
|
||||
data: [],
|
||||
grouped_data: Array(5).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
events: [
|
||||
{ time: '14:35:22', type: '快速拉升', change: '+2.3%', description: '5分钟内上涨2.3%' },
|
||||
{ time: '11:28:45', type: '大单买入', amount: '5680万', description: '单笔大单买入' },
|
||||
{ time: '10:15:30', type: '量比异动', ratio: '3.2', description: '量比达到3.2倍' }
|
||||
],
|
||||
count: 3
|
||||
}))
|
||||
grouped_data: Array(5).fill(null).map((_, i) => {
|
||||
const buyerDepts = ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司', '中金公司北京营业部'];
|
||||
const sellerDepts = ['海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司', '东方证券上海营业部', '兴业证券福州营业部'];
|
||||
const infoTypes = ['日涨幅偏离值达7%', '日振幅达15%', '连续三日涨幅偏离20%', '换手率达20%'];
|
||||
|
||||
const buyers = buyerDepts.map(dept => ({
|
||||
dept_name: dept,
|
||||
buy_amount: Math.floor(Math.random() * 50000000) + 10000000 // 1000万-6000万
|
||||
})).sort((a, b) => b.buy_amount - a.buy_amount);
|
||||
|
||||
const sellers = sellerDepts.map(dept => ({
|
||||
dept_name: dept,
|
||||
sell_amount: Math.floor(Math.random() * 40000000) + 8000000 // 800万-4800万
|
||||
})).sort((a, b) => b.sell_amount - a.sell_amount);
|
||||
|
||||
const totalBuy = buyers.reduce((sum, b) => sum + b.buy_amount, 0);
|
||||
const totalSell = sellers.reduce((sum, s) => sum + s.sell_amount, 0);
|
||||
|
||||
return {
|
||||
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
total_buy: totalBuy,
|
||||
total_sell: totalSell,
|
||||
net_amount: totalBuy - totalSell,
|
||||
buyers,
|
||||
sellers,
|
||||
info_types: infoTypes.slice(0, Math.floor(Math.random() * 3) + 1) // 随机选1-3个类型
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
// 股权质押
|
||||
// 股权质押 - 匹配 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 +190,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'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -351,15 +351,21 @@ export const accountHandlers = [
|
||||
const body = await request.json();
|
||||
console.log('[Mock] 创建投资计划:', body);
|
||||
|
||||
// 生成唯一 ID(使用时间戳避免冲突)
|
||||
const newId = Date.now();
|
||||
|
||||
const newPlan = {
|
||||
id: mockInvestmentPlans.length + 301,
|
||||
id: newId,
|
||||
user_id: currentUser.id,
|
||||
...body,
|
||||
// 确保 target_date 字段存在(兼容前端发送的 date 字段)
|
||||
target_date: body.target_date || body.date,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
mockInvestmentPlans.push(newPlan);
|
||||
console.log('[Mock] 新增计划/复盘,当前总数:', mockInvestmentPlans.length);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
@@ -488,13 +494,29 @@ export const accountHandlers = [
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 按日期倒序排序(最新的在前面)
|
||||
filteredEvents.sort((a, b) => {
|
||||
const dateA = new Date(a.date || a.event_date);
|
||||
const dateB = new Date(b.date || b.event_date);
|
||||
return dateB - dateA; // 倒序:新日期在前
|
||||
});
|
||||
|
||||
// 打印今天的事件(方便调试)
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayEvents = filteredEvents.filter(e =>
|
||||
(e.date === today || e.event_date === today)
|
||||
);
|
||||
|
||||
console.log('[Mock] 日历事件详情:', {
|
||||
currentUserId: currentUser.id,
|
||||
calendarEvents: calendarEvents.length,
|
||||
investmentPlansAsEvents: investmentPlansAsEvents.length,
|
||||
total: filteredEvents.length,
|
||||
plansCount: filteredEvents.filter(e => e.type === 'plan').length,
|
||||
reviewsCount: filteredEvents.filter(e => e.type === 'review').length
|
||||
reviewsCount: filteredEvents.filter(e => e.type === 'review').length,
|
||||
today,
|
||||
todayEventsCount: todayEvents.length,
|
||||
todayEventTitles: todayEvents.map(e => `[${e.type}] ${e.title}`)
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -1009,6 +1009,24 @@ export const conceptHandlers = [
|
||||
{ id: 'lv2_15_1', name: '国际贸易', concept_count: 15, concepts: ['跨境电商', '出口', '贸易摩擦', '人民币国际化', '中美贸易', '中欧贸易', '东盟贸易'] },
|
||||
{ id: 'lv2_15_2', name: '宏观主题', concept_count: 10, concepts: ['美联储加息', '美债', '汇率', '通胀', '衰退预期', '地缘政治'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'lv1_16',
|
||||
name: '传统能源与资源',
|
||||
concept_count: 30,
|
||||
children: [
|
||||
{ id: 'lv2_16_1', name: '煤炭石油', concept_count: 15, concepts: ['煤炭', '动力煤', '焦煤', '石油', '天然气', '页岩油', '油服', '油气开采', '煤化工', '石油化工'] },
|
||||
{ id: 'lv2_16_2', name: '钢铁建材', concept_count: 15, concepts: ['钢铁', '特钢', '铁矿石', '水泥', '玻璃', '建材', '基建', '房地产', '装配式建筑'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'lv1_17',
|
||||
name: '公用事业与交运',
|
||||
concept_count: 25,
|
||||
children: [
|
||||
{ id: 'lv2_17_1', name: '公用事业', concept_count: 12, concepts: ['电力', '水务', '燃气', '环保', '垃圾处理', '污水处理', '园林绿化'] },
|
||||
{ id: 'lv2_17_2', name: '交通运输', concept_count: 13, concepts: ['航空', '机场', '港口', '航运', '铁路', '公路', '物流', '快递', '冷链物流'] }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -254,6 +257,159 @@ export const eventHandlers = [
|
||||
}
|
||||
}),
|
||||
|
||||
// ==================== 主线模式相关(必须在 :eventId 之前,否则会被通配符匹配)====================
|
||||
|
||||
// 获取按主线(lv1/lv2/lv3概念)分组的事件列表
|
||||
http.get('/api/events/mainline', async ({ request }) => {
|
||||
await delay(500);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const recentDays = parseInt(url.searchParams.get('recent_days') || '7', 10);
|
||||
const importance = url.searchParams.get('importance') || 'all';
|
||||
const limitPerMainline = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
const groupBy = url.searchParams.get('group_by') || 'lv2';
|
||||
|
||||
try {
|
||||
// 生成 mock 事件数据 - 第一个参数是 timeRange(null 表示默认24小时),第二个参数是 count
|
||||
const allEvents = generateDynamicNewsEvents(null, 100);
|
||||
|
||||
const mainlineDefinitions = [
|
||||
{ lv3_id: 'L3_AI_CHIP', lv3_name: 'AI芯片与算力', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['算力', 'AI芯片', 'GPU', '英伟达', '华为昇腾', '寒武纪'] },
|
||||
{ lv3_id: 'L3_AI_SERVER', lv3_name: '服务器与数据中心', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['服务器', '数据中心', '智算中心', '液冷'] },
|
||||
{ lv3_id: 'L3_OPTICAL', lv3_name: '光通信与CPO', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['CPO', '光通信', '光模块', '光芯片'] },
|
||||
{ lv3_id: 'L3_PCB', lv3_name: 'PCB与封装', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['PCB', '封装', 'AI PCB'] },
|
||||
{ lv3_id: 'L3_AI_APP', lv3_name: 'AI应用与大模型', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['大模型', '智能体', 'AI', '人工智能', 'DeepSeek', 'KIMI', 'ChatGPT'] },
|
||||
{ lv3_id: 'L3_CHIP_DESIGN', lv3_name: '芯片设计', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['芯片设计', '半导体', 'IC设计'] },
|
||||
{ lv3_id: 'L3_CHIP_MFG', lv3_name: '芯片制造', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['晶圆', '光刻', '芯片制造', '中芯国际'] },
|
||||
{ lv3_id: 'L3_HUMANOID', lv3_name: '人形机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['人形机器人', '具身智能', '特斯拉机器人'] },
|
||||
{ lv3_id: 'L3_INDUSTRIAL_ROBOT', lv3_name: '工业机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['工业机器人', '自动化', '机器人'] },
|
||||
{ lv3_id: 'L3_MOBILE', lv3_name: '智能手机', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['手机', '华为', '苹果', '小米', '折叠屏'] },
|
||||
{ lv3_id: 'L3_XR', lv3_name: 'XR与可穿戴', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['XR', 'VR', 'AR', '可穿戴', 'MR', 'Vision Pro'] },
|
||||
{ lv3_id: 'L3_5G', lv3_name: '5G/6G通信', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['5G', '6G', '通信', '基站'] },
|
||||
{ lv3_id: 'L3_CLOUD', lv3_name: '云计算与软件', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['云计算', '软件', 'SaaS', '互联网', '数字化'] },
|
||||
{ lv3_id: 'L3_PV', lv3_name: '光伏', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['光伏', '太阳能', '硅片', '组件'] },
|
||||
{ lv3_id: 'L3_STORAGE', lv3_name: '储能与电池', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['储能', '电池', '锂电', '固态电池', '新能源'] },
|
||||
{ lv3_id: 'L3_EV_OEM', lv3_name: '新能源整车', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['新能源汽车', '电动车', '比亚迪', '特斯拉', '整车'] },
|
||||
{ lv3_id: 'L3_AUTO_DRIVE', lv3_name: '智能驾驶', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['智能驾驶', '自动驾驶', '智能网联', '车路协同'] },
|
||||
{ lv3_id: 'L3_DRONE', lv3_name: '无人机', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['无人机', '低空', '空域'] },
|
||||
{ lv3_id: 'L3_EVTOL', lv3_name: 'eVTOL', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['eVTOL', '飞行汽车', '空中出租车'] },
|
||||
{ lv3_id: 'L3_AEROSPACE', lv3_name: '航空航天', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['航空', '航天', '卫星', '火箭', '军工'] },
|
||||
{ lv3_id: 'L3_DEFENSE', lv3_name: '国防军工', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['国防', '导弹', '军工装备'] },
|
||||
{ lv3_id: 'L3_DRUG', lv3_name: '创新药', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['创新药', '医药', '生物', 'CXO'] },
|
||||
{ lv3_id: 'L3_DEVICE', lv3_name: '医疗器械', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['医疗器械', '医疗', '器械'] },
|
||||
{ lv3_id: 'L3_BANK', lv3_name: '银行', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['银行', '金融'] },
|
||||
{ lv3_id: 'L3_BROKER', lv3_name: '券商', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['券商', '证券'] },
|
||||
];
|
||||
|
||||
const hierarchyOptions = {
|
||||
lv1: [...new Map(mainlineDefinitions.map(m => [m.lv1_id, { id: m.lv1_id, name: m.lv1_name }])).values()],
|
||||
lv2: [...new Map(mainlineDefinitions.map(m => [m.lv2_id, { id: m.lv2_id, name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name }])).values()],
|
||||
lv3: mainlineDefinitions.map(m => ({ id: m.lv3_id, name: m.lv3_name, lv2_id: m.lv2_id, lv2_name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name })),
|
||||
};
|
||||
|
||||
const mainlineGroups = {};
|
||||
const isSpecificId = groupBy.startsWith('L1_') || groupBy.startsWith('L2_') || groupBy.startsWith('L3_');
|
||||
|
||||
allEvents.forEach(event => {
|
||||
const keywords = event.keywords || event.related_concepts || [];
|
||||
const conceptNames = keywords.map(k => {
|
||||
if (typeof k === 'string') return k;
|
||||
if (typeof k === 'object' && k !== null) return k.concept || k.name || '';
|
||||
return '';
|
||||
}).filter(Boolean).join(' ');
|
||||
const titleAndDesc = `${event.title || ''} ${event.description || ''}`;
|
||||
const textToMatch = `${conceptNames} ${titleAndDesc}`.toLowerCase();
|
||||
|
||||
mainlineDefinitions.forEach(mainline => {
|
||||
const matched = mainline.keywords.some(kw => textToMatch.includes(kw.toLowerCase()));
|
||||
if (matched) {
|
||||
let groupKey, groupData;
|
||||
|
||||
if (isSpecificId) {
|
||||
if (groupBy.startsWith('L1_') && mainline.lv1_id === groupBy) {
|
||||
groupKey = mainline.lv2_id;
|
||||
groupData = { group_id: mainline.lv2_id, group_name: mainline.lv2_name, parent_name: mainline.lv1_name, events: [] };
|
||||
} else if (groupBy.startsWith('L2_') && mainline.lv2_id === groupBy) {
|
||||
groupKey = mainline.lv3_id;
|
||||
groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] };
|
||||
} else if (groupBy.startsWith('L3_') && mainline.lv3_id === groupBy) {
|
||||
groupKey = mainline.lv3_id;
|
||||
groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (groupBy === 'lv1') {
|
||||
groupKey = mainline.lv1_id;
|
||||
groupData = { group_id: mainline.lv1_id, group_name: mainline.lv1_name, events: [] };
|
||||
} else if (groupBy === 'lv3') {
|
||||
groupKey = mainline.lv3_id;
|
||||
groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] };
|
||||
} else {
|
||||
groupKey = mainline.lv2_id;
|
||||
groupData = { group_id: mainline.lv2_id, group_name: mainline.lv2_name, parent_name: mainline.lv1_name, events: [] };
|
||||
}
|
||||
|
||||
if (!mainlineGroups[groupKey]) {
|
||||
mainlineGroups[groupKey] = groupData;
|
||||
}
|
||||
if (!mainlineGroups[groupKey].events.find(e => e.id === event.id)) {
|
||||
mainlineGroups[groupKey].events.push(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const generatePriceData = () => parseFloat((Math.random() * 13 - 5).toFixed(2));
|
||||
const priceDataMap = { lv1: {}, lv2: {}, lv3: {} };
|
||||
|
||||
mainlineDefinitions.forEach(def => {
|
||||
if (!priceDataMap.lv1[def.lv1_name]) priceDataMap.lv1[def.lv1_name] = generatePriceData();
|
||||
if (!priceDataMap.lv2[def.lv2_name]) priceDataMap.lv2[def.lv2_name] = generatePriceData();
|
||||
if (!priceDataMap.lv3[def.lv3_name]) priceDataMap.lv3[def.lv3_name] = generatePriceData();
|
||||
});
|
||||
|
||||
const mainlines = Object.values(mainlineGroups)
|
||||
.map(group => {
|
||||
let avgChangePct = null;
|
||||
if (groupBy === 'lv1' || groupBy.startsWith('L1_')) {
|
||||
avgChangePct = groupBy.startsWith('L1_') ? priceDataMap.lv2[group.group_name] : priceDataMap.lv1[group.group_name];
|
||||
} else if (groupBy === 'lv3' || groupBy.startsWith('L2_')) {
|
||||
avgChangePct = priceDataMap.lv3[group.group_name];
|
||||
} else {
|
||||
avgChangePct = priceDataMap.lv2[group.group_name];
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
events: group.events.slice(0, limitPerMainline),
|
||||
event_count: Math.min(group.events.length, limitPerMainline),
|
||||
avg_change_pct: avgChangePct ?? null,
|
||||
price_date: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
})
|
||||
.filter(group => group.event_count > 0)
|
||||
.sort((a, b) => b.event_count - a.event_count);
|
||||
|
||||
const groupedEventIds = new Set();
|
||||
mainlines.forEach(m => m.events.forEach(e => groupedEventIds.add(e.id)));
|
||||
const ungroupedCount = allEvents.filter(e => !groupedEventIds.has(e.id)).length;
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
mainlines,
|
||||
total_events: allEvents.length,
|
||||
mainline_count: mainlines.length,
|
||||
ungrouped_count: ungroupedCount,
|
||||
group_by: groupBy,
|
||||
hierarchy_options: hierarchyOptions,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Event] 主线数据获取失败:', error);
|
||||
return HttpResponse.json({ success: false, error: error.message || '获取主线数据失败' }, { status: 500 });
|
||||
}
|
||||
}),
|
||||
|
||||
// ==================== 事件详情相关 ====================
|
||||
|
||||
// 获取事件详情
|
||||
|
||||
@@ -512,7 +512,55 @@ export const marketHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// 12. 市场统计数据(个股中心页面使用)
|
||||
// 12. 市场概况数据(投资仪表盘使用)- 上证/深证/总市值/成交额
|
||||
http.get('/api/market/summary', async () => {
|
||||
await delay(150);
|
||||
|
||||
// 生成实时数据(基于当前时间产生小波动)
|
||||
const now = new Date();
|
||||
const seed = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
// 上证指数(基准 3400)
|
||||
const shBasePrice = 3400;
|
||||
const shChange = parseFloat(((Math.sin(seed / 30) + Math.random() - 0.5) * 2).toFixed(2));
|
||||
const shPrice = parseFloat((shBasePrice * (1 + shChange / 100)).toFixed(2));
|
||||
const shChangeAmount = parseFloat((shPrice - shBasePrice).toFixed(2));
|
||||
|
||||
// 深证指数(基准 10800)
|
||||
const szBasePrice = 10800;
|
||||
const szChange = parseFloat(((Math.sin(seed / 25) + Math.random() - 0.5) * 2.5).toFixed(2));
|
||||
const szPrice = parseFloat((szBasePrice * (1 + szChange / 100)).toFixed(2));
|
||||
const szChangeAmount = parseFloat((szPrice - szBasePrice).toFixed(2));
|
||||
|
||||
// 总市值(约 100-110 万亿波动)
|
||||
const totalMarketCap = parseFloat((105 + (Math.sin(seed / 60) * 5)).toFixed(1)) * 1000000000000;
|
||||
|
||||
// 成交额(约 0.8-1.5 万亿波动)
|
||||
const turnover = parseFloat((1.0 + (Math.random() * 0.5 - 0.2)).toFixed(2)) * 1000000000000;
|
||||
|
||||
console.log('[Mock Market] 获取市场概况数据');
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
shanghai: {
|
||||
value: shPrice,
|
||||
change: shChange,
|
||||
changeAmount: shChangeAmount,
|
||||
},
|
||||
shenzhen: {
|
||||
value: szPrice,
|
||||
change: szChange,
|
||||
changeAmount: szChangeAmount,
|
||||
},
|
||||
totalMarketCap,
|
||||
turnover,
|
||||
updateTime: now.toISOString(),
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// 13. 市场统计数据(个股中心页面使用)
|
||||
http.get('/api/market/statistics', async ({ request }) => {
|
||||
await delay(200);
|
||||
const url = new URL(request.url);
|
||||
|
||||
@@ -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: '获取成功'
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -14,6 +14,7 @@ import theme from '../theme/theme.js';
|
||||
// Contexts
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
import { NotificationProvider } from '../contexts/NotificationContext';
|
||||
import { GlobalSidebarProvider } from '../contexts/GlobalSidebarContext';
|
||||
|
||||
/**
|
||||
* AppProviders - 应用的 Provider 容器
|
||||
@@ -57,7 +58,9 @@ export function AppProviders({ children }) {
|
||||
>
|
||||
<NotificationProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<GlobalSidebarProvider>
|
||||
{children}
|
||||
</GlobalSidebarProvider>
|
||||
</AuthProvider>
|
||||
</NotificationProvider>
|
||||
</ChakraProvider>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const homeRoutes = [
|
||||
// 个人中心 - /home/center
|
||||
{
|
||||
path: 'center',
|
||||
component: lazyComponents.CenterDashboard,
|
||||
component: lazyComponents.Center,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '个人中心',
|
||||
|
||||
@@ -11,7 +11,7 @@ export const lazyComponents = {
|
||||
// Home 模块
|
||||
// ⚡ 直接引用 HomePage,无需中间层(静态页面不需要骨架屏)
|
||||
HomePage: React.lazy(() => import('@views/Home/HomePage')),
|
||||
CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
|
||||
Center: React.lazy(() => import('@views/Center')),
|
||||
ProfilePage: React.lazy(() => import('@views/Profile/ProfilePage')),
|
||||
// 价值论坛 - 我的积分页面
|
||||
ForumMyPoints: React.lazy(() => import('@views/Profile')),
|
||||
@@ -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')),
|
||||
@@ -56,7 +56,7 @@ export const lazyComponents = {
|
||||
*/
|
||||
export const {
|
||||
HomePage,
|
||||
CenterDashboard,
|
||||
Center,
|
||||
ProfilePage,
|
||||
ForumMyPoints,
|
||||
SettingsPage,
|
||||
|
||||
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();
|
||||
|
||||
@@ -9,6 +9,7 @@ import stockReducer from './slices/stockSlice';
|
||||
import authModalReducer from './slices/authModalSlice';
|
||||
import subscriptionReducer from './slices/subscriptionSlice';
|
||||
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
|
||||
import planningReducer from './slices/planningSlice'; // ✅ 投资规划中心状态管理
|
||||
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
|
||||
|
||||
// ⚡ 基础 reducers(首屏必需)
|
||||
@@ -19,6 +20,7 @@ const staticReducers = {
|
||||
authModal: authModalReducer, // ✅ 认证弹窗状态管理
|
||||
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
|
||||
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
|
||||
planning: planningReducer, // ✅ 投资规划中心状态管理
|
||||
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
|
||||
};
|
||||
|
||||
|
||||
301
src/store/slices/planningSlice.ts
Normal file
301
src/store/slices/planningSlice.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* planningSlice - 投资规划中心 Redux Slice
|
||||
* 管理计划、复盘和日历事件数据
|
||||
*/
|
||||
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
// ==================== State 类型定义 ====================
|
||||
|
||||
interface PlanningState {
|
||||
/** 所有事件(计划 + 复盘 + 系统事件) */
|
||||
allEvents: InvestmentEvent[];
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 最后更新时间 */
|
||||
lastUpdated: number | null;
|
||||
}
|
||||
|
||||
// ==================== 初始状态 ====================
|
||||
|
||||
const initialState: PlanningState = {
|
||||
allEvents: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
};
|
||||
|
||||
// ==================== Async Thunks ====================
|
||||
|
||||
/**
|
||||
* 加载所有事件数据
|
||||
*/
|
||||
export const fetchAllEvents = createAsyncThunk(
|
||||
'planning/fetchAllEvents',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const response = await fetch(`${base}/api/account/calendar/events`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
logger.debug('planningSlice', '数据加载成功', {
|
||||
count: data.data?.length || 0,
|
||||
});
|
||||
return data.data || [];
|
||||
} else {
|
||||
throw new Error(data.error || '加载失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('planningSlice', 'fetchAllEvents', error);
|
||||
return rejectWithValue(error instanceof Error ? error.message : '加载失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 创建计划/复盘
|
||||
*/
|
||||
export const createEvent = createAsyncThunk(
|
||||
'planning/createEvent',
|
||||
async (eventData: Partial<InvestmentEvent>, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const response = await fetch(`${base}/api/account/investment-plans`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
logger.info('planningSlice', '创建成功', { title: eventData.title });
|
||||
// 创建成功后重新加载所有数据
|
||||
dispatch(fetchAllEvents());
|
||||
return data.data;
|
||||
} else {
|
||||
throw new Error(data.error || '创建失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('planningSlice', 'createEvent', error);
|
||||
return rejectWithValue(error instanceof Error ? error.message : '创建失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新计划/复盘
|
||||
*/
|
||||
export const updateEvent = createAsyncThunk(
|
||||
'planning/updateEvent',
|
||||
async ({ id, data }: { id: number; data: Partial<InvestmentEvent> }, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
logger.info('planningSlice', '更新成功', { id });
|
||||
// 更新成功后重新加载所有数据
|
||||
dispatch(fetchAllEvents());
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.error || '更新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('planningSlice', 'updateEvent', error);
|
||||
return rejectWithValue(error instanceof Error ? error.message : '更新失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 删除计划/复盘
|
||||
*/
|
||||
export const deleteEvent = createAsyncThunk(
|
||||
'planning/deleteEvent',
|
||||
async (id: number, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
logger.info('planningSlice', '删除成功', { id });
|
||||
// 删除成功后重新加载所有数据
|
||||
dispatch(fetchAllEvents());
|
||||
return id;
|
||||
} else {
|
||||
throw new Error(data.error || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('planningSlice', 'deleteEvent', error);
|
||||
return rejectWithValue(error instanceof Error ? error.message : '删除失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== Slice ====================
|
||||
|
||||
const planningSlice = createSlice({
|
||||
name: 'planning',
|
||||
initialState,
|
||||
reducers: {
|
||||
/** 清空数据 */
|
||||
clearEvents: (state) => {
|
||||
state.allEvents = [];
|
||||
state.error = null;
|
||||
},
|
||||
/** 直接设置事件(用于乐观更新) */
|
||||
setEvents: (state, action: PayloadAction<InvestmentEvent[]>) => {
|
||||
state.allEvents = action.payload;
|
||||
state.lastUpdated = Date.now();
|
||||
},
|
||||
/** 添加单个事件(乐观更新) */
|
||||
addEvent: (state, action: PayloadAction<InvestmentEvent>) => {
|
||||
state.allEvents.push(action.payload);
|
||||
state.lastUpdated = Date.now();
|
||||
},
|
||||
/** 乐观添加事件(插入到开头,使用临时 ID) */
|
||||
optimisticAddEvent: (state, action: PayloadAction<InvestmentEvent>) => {
|
||||
state.allEvents.unshift(action.payload); // 新事件插入开头
|
||||
state.lastUpdated = Date.now();
|
||||
},
|
||||
/** 替换临时事件为真实事件 */
|
||||
replaceEvent: (state, action: PayloadAction<{ tempId: number; realEvent: InvestmentEvent }>) => {
|
||||
const { tempId, realEvent } = action.payload;
|
||||
const index = state.allEvents.findIndex(e => e.id === tempId);
|
||||
if (index !== -1) {
|
||||
state.allEvents[index] = realEvent;
|
||||
}
|
||||
state.lastUpdated = Date.now();
|
||||
},
|
||||
/** 移除事件(用于回滚) */
|
||||
removeEvent: (state, action: PayloadAction<number>) => {
|
||||
state.allEvents = state.allEvents.filter(e => e.id !== action.payload);
|
||||
state.lastUpdated = Date.now();
|
||||
},
|
||||
/** 乐观更新事件(编辑时使用) */
|
||||
optimisticUpdateEvent: (state, action: PayloadAction<InvestmentEvent>) => {
|
||||
const index = state.allEvents.findIndex(e => e.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.allEvents[index] = action.payload;
|
||||
state.lastUpdated = Date.now();
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// fetchAllEvents
|
||||
.addCase(fetchAllEvents.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchAllEvents.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.allEvents = action.payload;
|
||||
state.lastUpdated = Date.now();
|
||||
})
|
||||
.addCase(fetchAllEvents.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// createEvent
|
||||
.addCase(createEvent.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(createEvent.fulfilled, (state) => {
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(createEvent.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// updateEvent
|
||||
.addCase(updateEvent.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(updateEvent.fulfilled, (state) => {
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(updateEvent.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// deleteEvent
|
||||
.addCase(deleteEvent.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(deleteEvent.fulfilled, (state) => {
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(deleteEvent.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
export const {
|
||||
clearEvents,
|
||||
setEvents,
|
||||
addEvent,
|
||||
optimisticAddEvent,
|
||||
replaceEvent,
|
||||
removeEvent,
|
||||
optimisticUpdateEvent,
|
||||
} = planningSlice.actions;
|
||||
|
||||
export default planningSlice.reducer;
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectAllEvents = (state: { planning: PlanningState }) => state.planning.allEvents;
|
||||
export const selectPlanningLoading = (state: { planning: PlanningState }) => state.planning.loading;
|
||||
export const selectPlanningError = (state: { planning: PlanningState }) => state.planning.error;
|
||||
export const selectPlans = (state: { planning: PlanningState }) =>
|
||||
state.planning.allEvents.filter(e => e.type === 'plan' && e.source !== 'future');
|
||||
export const selectReviews = (state: { planning: PlanningState }) =>
|
||||
state.planning.allEvents.filter(e => e.type === 'review' && e.source !== 'future');
|
||||
@@ -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
|
||||
});
|
||||
@@ -223,6 +292,132 @@ export const loadAllStocks = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载自选股实时行情
|
||||
* 用于统一行情刷新,两个面板共用
|
||||
*/
|
||||
export const loadWatchlistQuotes = createAsyncThunk(
|
||||
'stock/loadWatchlistQuotes',
|
||||
async () => {
|
||||
logger.debug('stockSlice', 'loadWatchlistQuotes');
|
||||
|
||||
try {
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/api/account/watchlist/realtime`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
logger.debug('stockSlice', '自选股行情加载成功', { count: data.data.length });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error('stockSlice', 'loadWatchlistQuotes', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载关注事件列表
|
||||
* 用于统一关注事件数据源,两个面板共用
|
||||
*/
|
||||
export const loadFollowingEvents = createAsyncThunk(
|
||||
'stock/loadFollowingEvents',
|
||||
async () => {
|
||||
logger.debug('stockSlice', 'loadFollowingEvents');
|
||||
|
||||
try {
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/api/account/events/following`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
// 合并重复的事件(用最新的数据)
|
||||
const eventMap = new Map();
|
||||
for (const evt of data.data) {
|
||||
if (evt && evt.id) {
|
||||
eventMap.set(evt.id, evt);
|
||||
}
|
||||
}
|
||||
const merged = Array.from(eventMap.values());
|
||||
// 按创建时间降序排列
|
||||
if (merged.length > 0 && merged[0].created_at) {
|
||||
merged.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
|
||||
} else {
|
||||
merged.sort((a, b) => (b.id || 0) - (a.id || 0));
|
||||
}
|
||||
logger.debug('stockSlice', '关注事件列表加载成功', { count: merged.length });
|
||||
return merged;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error('stockSlice', 'loadFollowingEvents', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载用户评论列表
|
||||
*/
|
||||
export const loadEventComments = createAsyncThunk(
|
||||
'stock/loadEventComments',
|
||||
async () => {
|
||||
logger.debug('stockSlice', 'loadEventComments');
|
||||
|
||||
try {
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/api/account/events/posts`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
logger.debug('stockSlice', '用户评论列表加载成功', { count: data.data.length });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error('stockSlice', 'loadEventComments', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 切换关注事件状态(关注/取消关注)
|
||||
*/
|
||||
export const toggleFollowEvent = createAsyncThunk(
|
||||
'stock/toggleFollowEvent',
|
||||
async ({ eventId, isFollowing }) => {
|
||||
logger.debug('stockSlice', 'toggleFollowEvent', { eventId, isFollowing });
|
||||
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || '操作失败');
|
||||
}
|
||||
|
||||
return { eventId, isFollowing };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 切换自选股状态
|
||||
*/
|
||||
@@ -290,6 +485,15 @@ const stockSlice = createSlice({
|
||||
// 自选股列表 [{ stock_code, stock_name }]
|
||||
watchlist: [],
|
||||
|
||||
// 自选股实时行情 [{ stock_code, stock_name, price, change_percent, ... }]
|
||||
watchlistQuotes: [],
|
||||
|
||||
// 关注事件列表 [{ id, title, event_type, ... }]
|
||||
followingEvents: [],
|
||||
|
||||
// 用户评论列表 [{ id, content, event_id, ... }]
|
||||
eventComments: [],
|
||||
|
||||
// 全部股票列表(用于前端模糊搜索)[{ code, name }]
|
||||
allStocks: [],
|
||||
|
||||
@@ -301,6 +505,9 @@ const stockSlice = createSlice({
|
||||
historicalEvents: false,
|
||||
chainAnalysis: false,
|
||||
watchlist: false,
|
||||
watchlistQuotes: false,
|
||||
followingEvents: false,
|
||||
eventComments: false,
|
||||
allStocks: false
|
||||
},
|
||||
|
||||
@@ -340,6 +547,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) => {
|
||||
@@ -428,6 +655,18 @@ const stockSlice = createSlice({
|
||||
state.loading.watchlist = false;
|
||||
})
|
||||
|
||||
// ===== loadWatchlistQuotes =====
|
||||
.addCase(loadWatchlistQuotes.pending, (state) => {
|
||||
state.loading.watchlistQuotes = true;
|
||||
})
|
||||
.addCase(loadWatchlistQuotes.fulfilled, (state, action) => {
|
||||
state.watchlistQuotes = action.payload;
|
||||
state.loading.watchlistQuotes = false;
|
||||
})
|
||||
.addCase(loadWatchlistQuotes.rejected, (state) => {
|
||||
state.loading.watchlistQuotes = false;
|
||||
})
|
||||
|
||||
// ===== loadAllStocks =====
|
||||
.addCase(loadAllStocks.pending, (state) => {
|
||||
state.loading.allStocks = true;
|
||||
@@ -470,9 +709,51 @@ 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);
|
||||
})
|
||||
|
||||
// ===== loadFollowingEvents =====
|
||||
.addCase(loadFollowingEvents.pending, (state) => {
|
||||
state.loading.followingEvents = true;
|
||||
})
|
||||
.addCase(loadFollowingEvents.fulfilled, (state, action) => {
|
||||
state.followingEvents = action.payload;
|
||||
state.loading.followingEvents = false;
|
||||
})
|
||||
.addCase(loadFollowingEvents.rejected, (state) => {
|
||||
state.loading.followingEvents = false;
|
||||
})
|
||||
|
||||
// ===== loadEventComments =====
|
||||
.addCase(loadEventComments.pending, (state) => {
|
||||
state.loading.eventComments = true;
|
||||
})
|
||||
.addCase(loadEventComments.fulfilled, (state, action) => {
|
||||
state.eventComments = action.payload;
|
||||
state.loading.eventComments = false;
|
||||
})
|
||||
.addCase(loadEventComments.rejected, (state) => {
|
||||
state.loading.eventComments = false;
|
||||
})
|
||||
|
||||
// ===== toggleFollowEvent(乐观更新)=====
|
||||
// pending: 立即更新状态
|
||||
.addCase(toggleFollowEvent.pending, (state, action) => {
|
||||
const { eventId, isFollowing } = action.meta.arg;
|
||||
if (isFollowing) {
|
||||
// 当前已关注,取消关注 → 移除
|
||||
state.followingEvents = state.followingEvents.filter(evt => evt.id !== eventId);
|
||||
}
|
||||
// 添加关注的情况需要事件完整数据,不在这里处理
|
||||
})
|
||||
// rejected: 回滚状态(仅取消关注需要回滚)
|
||||
.addCase(toggleFollowEvent.rejected, (state, action) => {
|
||||
// 取消关注失败时,需要刷新列表恢复数据
|
||||
// 由于没有原始事件数据,这里只能触发重新加载
|
||||
logger.warn('stockSlice', 'toggleFollowEvent rejected, 需要重新加载关注事件列表');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -481,7 +762,9 @@ export const {
|
||||
updateQuote,
|
||||
updateQuotes,
|
||||
clearQuotes,
|
||||
clearEventCache
|
||||
clearEventCache,
|
||||
optimisticAddWatchlist,
|
||||
optimisticRemoveWatchlist
|
||||
} = stockSlice.actions;
|
||||
|
||||
export default stockSlice.reducer;
|
||||
|
||||
9
src/styles/scrollbar-hide.css
Normal file
9
src/styles/scrollbar-hide.css
Normal file
@@ -0,0 +1,9 @@
|
||||
/* 全局隐藏滚动条(保持滚动功能) */
|
||||
* {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
361
src/types/center.ts
Normal file
361
src/types/center.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Center(个人中心)模块类型定义
|
||||
*
|
||||
* 包含自选股、实时行情、关注事件等类型
|
||||
*/
|
||||
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
// ============================================================
|
||||
// Dashboard Events Hook 类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* useDashboardEvents Hook 配置选项
|
||||
*/
|
||||
export interface DashboardEventsOptions {
|
||||
/** 页面类型 */
|
||||
pageType?: 'center' | 'profile' | 'settings';
|
||||
/** 路由导航函数 */
|
||||
navigate?: NavigateFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* useDashboardEvents Hook 返回值
|
||||
*/
|
||||
export interface DashboardEventsResult {
|
||||
/** 追踪功能卡片点击 */
|
||||
trackFunctionCardClicked: (cardName: string, cardData?: { count?: number }) => void;
|
||||
/** 追踪自选股列表查看 */
|
||||
trackWatchlistViewed: (stockCount?: number, hasRealtime?: boolean) => void;
|
||||
/** 追踪自选股点击 */
|
||||
trackWatchlistStockClicked: (stock: { code: string; name?: string }, position?: number) => void;
|
||||
/** 追踪自选股添加 */
|
||||
trackWatchlistStockAdded: (stock: { code: string; name?: string }, source?: string) => void;
|
||||
/** 追踪自选股移除 */
|
||||
trackWatchlistStockRemoved: (stock: { code: string; name?: string }) => void;
|
||||
/** 追踪关注事件列表查看 */
|
||||
trackFollowingEventsViewed: (eventCount?: number) => void;
|
||||
/** 追踪关注事件点击 */
|
||||
trackFollowingEventClicked: (event: { id: number; title?: string }, position?: number) => void;
|
||||
/** 追踪评论列表查看 */
|
||||
trackCommentsViewed: (commentCount?: number) => void;
|
||||
/** 追踪订阅信息查看 */
|
||||
trackSubscriptionViewed: (subscription?: { plan?: string; status?: string }) => void;
|
||||
/** 追踪升级按钮点击 */
|
||||
trackUpgradePlanClicked: (currentPlan?: string, targetPlan?: string, source?: string) => void;
|
||||
/** 追踪个人资料更新 */
|
||||
trackProfileUpdated: (updatedFields?: string[]) => void;
|
||||
/** 追踪设置更改 */
|
||||
trackSettingChanged: (settingName: string, oldValue: unknown, newValue: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自选股项目
|
||||
* 来自 /api/account/watchlist 接口
|
||||
*/
|
||||
export interface WatchlistItem {
|
||||
/** 股票代码(如 '600000.SH') */
|
||||
stock_code: string;
|
||||
|
||||
/** 股票名称 */
|
||||
stock_name: string;
|
||||
|
||||
/** 当前价格 */
|
||||
current_price?: number;
|
||||
|
||||
/** 涨跌幅(百分比) */
|
||||
change_percent?: number;
|
||||
|
||||
/** 添加时间 */
|
||||
created_at?: string;
|
||||
|
||||
/** 备注 */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时行情数据
|
||||
* 来自 /api/account/watchlist/realtime 接口
|
||||
*/
|
||||
export interface RealtimeQuote {
|
||||
/** 股票代码 */
|
||||
stock_code: string;
|
||||
|
||||
/** 当前价格 */
|
||||
current_price: number;
|
||||
|
||||
/** 涨跌幅(百分比) */
|
||||
change_percent: number;
|
||||
|
||||
/** 涨跌额 */
|
||||
change_amount?: number;
|
||||
|
||||
/** 成交量 */
|
||||
volume?: number;
|
||||
|
||||
/** 成交额 */
|
||||
amount?: number;
|
||||
|
||||
/** 最高价 */
|
||||
high?: number;
|
||||
|
||||
/** 最低价 */
|
||||
low?: number;
|
||||
|
||||
/** 开盘价 */
|
||||
open?: number;
|
||||
|
||||
/** 昨收价 */
|
||||
pre_close?: number;
|
||||
|
||||
/** 更新时间戳 */
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时行情映射表
|
||||
* key 为股票代码,value 为行情数据
|
||||
*/
|
||||
export type RealtimeQuotesMap = Record<string, RealtimeQuote>;
|
||||
|
||||
/**
|
||||
* 关注的事件
|
||||
* 来自 /api/account/events/following 接口
|
||||
*/
|
||||
export interface FollowingEvent {
|
||||
/** 事件 ID */
|
||||
id: number;
|
||||
|
||||
/** 事件标题 */
|
||||
title: string;
|
||||
|
||||
/** 关注人数 */
|
||||
follower_count?: number;
|
||||
|
||||
/** 相关股票平均涨跌幅(百分比) */
|
||||
related_avg_chg?: number;
|
||||
|
||||
/** 事件类型 */
|
||||
event_type?: string;
|
||||
|
||||
/** 发生日期 */
|
||||
event_date?: string;
|
||||
|
||||
/** 事件描述 */
|
||||
description?: string;
|
||||
|
||||
/** 相关股票列表 */
|
||||
related_stocks?: Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
change_percent?: number;
|
||||
}>;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户评论记录
|
||||
* 来自 /api/account/events/posts 接口
|
||||
*/
|
||||
export interface EventComment {
|
||||
/** 评论 ID */
|
||||
id: number;
|
||||
|
||||
/** 评论内容 */
|
||||
content: string;
|
||||
|
||||
/** 关联事件 ID */
|
||||
event_id: number;
|
||||
|
||||
/** 关联事件标题 */
|
||||
event_title?: string;
|
||||
|
||||
/** 点赞数 */
|
||||
like_count?: number;
|
||||
|
||||
/** 回复数 */
|
||||
reply_count?: number;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 组件 Props 类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* WatchSidebar 组件 Props
|
||||
*/
|
||||
export interface WatchSidebarProps {
|
||||
/** 自选股列表 */
|
||||
watchlist: WatchlistItem[];
|
||||
|
||||
/** 实时行情数据(按股票代码索引) */
|
||||
realtimeQuotes: RealtimeQuotesMap;
|
||||
|
||||
/** 关注的事件列表 */
|
||||
followingEvents: FollowingEvent[];
|
||||
|
||||
/** 用户评论列表 */
|
||||
eventComments?: EventComment[];
|
||||
|
||||
/** 点击股票回调 */
|
||||
onStockClick?: (stock: WatchlistItem) => void;
|
||||
|
||||
/** 点击事件回调 */
|
||||
onEventClick?: (event: FollowingEvent) => void;
|
||||
|
||||
/** 点击评论回调 */
|
||||
onCommentClick?: (comment: EventComment) => void;
|
||||
|
||||
/** 添加股票回调 */
|
||||
onAddStock?: () => void;
|
||||
|
||||
/** 添加事件回调 */
|
||||
onAddEvent?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WatchlistPanel 组件 Props
|
||||
*/
|
||||
export interface WatchlistPanelProps {
|
||||
/** 自选股列表 */
|
||||
watchlist: WatchlistItem[];
|
||||
|
||||
/** 实时行情数据 */
|
||||
realtimeQuotes: RealtimeQuotesMap;
|
||||
|
||||
/** 点击股票回调 */
|
||||
onStockClick?: (stock: WatchlistItem) => void;
|
||||
|
||||
/** 添加股票回调 */
|
||||
onAddStock?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FollowingEventsPanel 组件 Props
|
||||
*/
|
||||
export interface FollowingEventsPanelProps {
|
||||
/** 事件列表 */
|
||||
events: FollowingEvent[];
|
||||
|
||||
/** 用户评论列表 */
|
||||
eventComments?: EventComment[];
|
||||
|
||||
/** 点击事件回调 */
|
||||
onEventClick?: (event: FollowingEvent) => void;
|
||||
|
||||
/** 点击评论回调 */
|
||||
onCommentClick?: (comment: EventComment) => void;
|
||||
|
||||
/** 添加事件回调 */
|
||||
onAddEvent?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Hooks 返回值类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* useCenterColors Hook 返回值
|
||||
* 封装 Center 模块的所有颜色变量
|
||||
*/
|
||||
export interface CenterColors {
|
||||
/** 主要文本颜色 */
|
||||
textColor: string;
|
||||
|
||||
/** 边框颜色 */
|
||||
borderColor: string;
|
||||
|
||||
/** 背景颜色 */
|
||||
bgColor: string;
|
||||
|
||||
/** 悬停背景色 */
|
||||
hoverBg: string;
|
||||
|
||||
/** 次要文本颜色 */
|
||||
secondaryText: string;
|
||||
|
||||
/** 卡片背景色 */
|
||||
cardBg: string;
|
||||
|
||||
/** 区块背景色 */
|
||||
sectionBg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useCenterData Hook 返回值
|
||||
* 封装 Center 页面的数据加载逻辑
|
||||
*/
|
||||
export interface UseCenterDataResult {
|
||||
/** 自选股列表 */
|
||||
watchlist: WatchlistItem[];
|
||||
|
||||
/** 实时行情数据 */
|
||||
realtimeQuotes: RealtimeQuotesMap;
|
||||
|
||||
/** 关注的事件列表 */
|
||||
followingEvents: FollowingEvent[];
|
||||
|
||||
/** 用户评论列表 */
|
||||
eventComments: EventComment[];
|
||||
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
|
||||
/** 行情加载状态 */
|
||||
quotesLoading: boolean;
|
||||
|
||||
/** 刷新数据 */
|
||||
refresh: () => Promise<void>;
|
||||
|
||||
/** 刷新实时行情 */
|
||||
refreshQuotes: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 响应类型定义
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 自选股列表 API 响应
|
||||
*/
|
||||
export interface WatchlistApiResponse {
|
||||
success: boolean;
|
||||
data: WatchlistItem[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时行情 API 响应
|
||||
*/
|
||||
export interface RealtimeQuotesApiResponse {
|
||||
success: boolean;
|
||||
data: RealtimeQuote[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关注事件 API 响应
|
||||
*/
|
||||
export interface FollowingEventsApiResponse {
|
||||
success: boolean;
|
||||
data: FollowingEvent[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户评论 API 响应
|
||||
*/
|
||||
export interface EventCommentsApiResponse {
|
||||
success: boolean;
|
||||
data: EventComment[];
|
||||
message?: string;
|
||||
}
|
||||
@@ -63,3 +63,23 @@ export type {
|
||||
PlanFormData,
|
||||
PlanningContextValue,
|
||||
} from './investment';
|
||||
|
||||
// Center(个人中心)相关类型
|
||||
export type {
|
||||
DashboardEventsOptions,
|
||||
DashboardEventsResult,
|
||||
WatchlistItem,
|
||||
RealtimeQuote,
|
||||
RealtimeQuotesMap,
|
||||
FollowingEvent,
|
||||
EventComment,
|
||||
WatchSidebarProps,
|
||||
WatchlistPanelProps,
|
||||
FollowingEventsPanelProps,
|
||||
CenterColors,
|
||||
UseCenterDataResult,
|
||||
WatchlistApiResponse,
|
||||
RealtimeQuotesApiResponse,
|
||||
FollowingEventsApiResponse,
|
||||
EventCommentsApiResponse,
|
||||
} from './center';
|
||||
|
||||
@@ -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) + '%';
|
||||
};
|
||||
|
||||
238
src/views/Center/components/CalendarPanel.tsx
Normal file
238
src/views/Center/components/CalendarPanel.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* CalendarPanel - 投资日历面板组件
|
||||
* 使用 Ant Design Calendar 展示投资计划、复盘等事件
|
||||
*
|
||||
* 聚合展示模式:每个日期格子显示各类型事件的汇总信息
|
||||
*/
|
||||
|
||||
import React, { useState, lazy, Suspense, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Spinner,
|
||||
Center,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { selectAllEvents } from '@/store/slices/planningSlice';
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import { EventDetailModal } from './EventDetailModal';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
|
||||
// 使用新的公共日历组件
|
||||
import {
|
||||
BaseCalendar,
|
||||
CalendarEventBlock,
|
||||
type CellRenderInfo,
|
||||
type CalendarEvent,
|
||||
CALENDAR_COLORS,
|
||||
} from '@components/Calendar';
|
||||
|
||||
// 懒加载投资日历组件
|
||||
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 事件聚合信息(用于日历格子显示)
|
||||
*/
|
||||
interface EventSummary {
|
||||
plans: InvestmentEvent[];
|
||||
reviews: InvestmentEvent[];
|
||||
systems: InvestmentEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CalendarPanel 组件
|
||||
* 日历视图面板,显示所有投资事件
|
||||
*/
|
||||
export const CalendarPanel: React.FC = () => {
|
||||
// 从 Redux 获取数据(确保与列表视图同步)
|
||||
const allEvents = useAppSelector(selectAllEvents);
|
||||
|
||||
// UI 相关状态仍从 Context 获取
|
||||
const {
|
||||
borderColor,
|
||||
secondaryText,
|
||||
setViewMode,
|
||||
setListTab,
|
||||
} = usePlanningData();
|
||||
|
||||
// 弹窗状态(统一使用 useState)
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [isInvestmentCalendarOpen, setIsInvestmentCalendarOpen] = useState(false);
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [initialFilter, setInitialFilter] = useState<'all' | 'plan' | 'review' | 'system'>('all');
|
||||
|
||||
// 按日期分组事件(用于聚合展示)
|
||||
const eventsByDate = useMemo(() => {
|
||||
const map: Record<string, EventSummary> = {};
|
||||
allEvents.forEach(event => {
|
||||
const dateStr = event.event_date;
|
||||
if (!map[dateStr]) {
|
||||
map[dateStr] = { plans: [], reviews: [], systems: [] };
|
||||
}
|
||||
if (event.source === 'future') {
|
||||
map[dateStr].systems.push(event);
|
||||
} else if (event.type === 'plan') {
|
||||
map[dateStr].plans.push(event);
|
||||
} else if (event.type === 'review') {
|
||||
map[dateStr].reviews.push(event);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [allEvents]);
|
||||
|
||||
// 将事件摘要转换为 CalendarEvent 格式
|
||||
const getCalendarEvents = useCallback((dateStr: string): CalendarEvent[] => {
|
||||
const summary = eventsByDate[dateStr];
|
||||
if (!summary) return [];
|
||||
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
// 系统事件
|
||||
if (summary.systems.length > 0) {
|
||||
events.push({
|
||||
id: `${dateStr}-system`,
|
||||
type: 'system',
|
||||
title: summary.systems[0]?.title || '',
|
||||
date: dateStr,
|
||||
count: summary.systems.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 计划
|
||||
if (summary.plans.length > 0) {
|
||||
events.push({
|
||||
id: `${dateStr}-plan`,
|
||||
type: 'plan',
|
||||
title: summary.plans[0]?.title || '',
|
||||
date: dateStr,
|
||||
count: summary.plans.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 复盘
|
||||
if (summary.reviews.length > 0) {
|
||||
events.push({
|
||||
id: `${dateStr}-review`,
|
||||
type: 'review',
|
||||
title: summary.reviews[0]?.title || '',
|
||||
date: dateStr,
|
||||
count: summary.reviews.length,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}, [eventsByDate]);
|
||||
|
||||
// 处理日期选择(点击日期空白区域)
|
||||
const handleDateSelect = useCallback((date: Dayjs): void => {
|
||||
setSelectedDate(date);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
dayjs(event.event_date).isSame(date, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
setInitialFilter('all'); // 点击日期时显示全部
|
||||
setIsDetailModalOpen(true);
|
||||
}, [allEvents]);
|
||||
|
||||
// 处理事件点击(点击具体事件类型)
|
||||
const handleEventClick = useCallback((event: CalendarEvent): void => {
|
||||
const date = dayjs(event.date);
|
||||
setSelectedDate(date);
|
||||
|
||||
const dayEvents = allEvents.filter(e =>
|
||||
dayjs(e.event_date).isSame(date, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
setInitialFilter(event.type as 'plan' | 'review' | 'system'); // 定位到对应 Tab
|
||||
setIsDetailModalOpen(true);
|
||||
}, [allEvents]);
|
||||
|
||||
// 自定义日期格子内容渲染
|
||||
const renderCellContent = useCallback((date: Dayjs, _info: CellRenderInfo) => {
|
||||
const dateStr = date.format('YYYY-MM-DD');
|
||||
const events = getCalendarEvents(dateStr);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
return (
|
||||
<VStack spacing={0} align="stretch" w="100%" mt={1}>
|
||||
<CalendarEventBlock
|
||||
events={events}
|
||||
maxDisplay={3}
|
||||
compact
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}, [getCalendarEvents, handleEventClick]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 日历容器 - 使用 BaseCalendar */}
|
||||
<Box height={{ base: '420px', md: '600px' }}>
|
||||
<BaseCalendar
|
||||
onSelect={handleDateSelect}
|
||||
cellRender={renderCellContent}
|
||||
height="100%"
|
||||
showToolbar={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 查看事件详情 Modal */}
|
||||
<EventDetailModal
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => setIsDetailModalOpen(false)}
|
||||
selectedDate={selectedDate}
|
||||
events={selectedDateEvents}
|
||||
borderColor={borderColor}
|
||||
secondaryText={secondaryText}
|
||||
initialFilter={initialFilter}
|
||||
onNavigateToPlan={() => {
|
||||
setViewMode('list');
|
||||
setListTab(0);
|
||||
}}
|
||||
onNavigateToReview={() => {
|
||||
setViewMode('list');
|
||||
setListTab(1);
|
||||
}}
|
||||
onOpenInvestmentCalendar={() => {
|
||||
setIsInvestmentCalendarOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 投资日历 Modal */}
|
||||
{isInvestmentCalendarOpen && (
|
||||
<Modal
|
||||
isOpen={isInvestmentCalendarOpen}
|
||||
onClose={() => setIsInvestmentCalendarOpen(false)}
|
||||
size={{ base: 'full', md: '6xl' }}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW={{ base: '100%', md: '1200px' }} mx={{ base: 0, md: 4 }}>
|
||||
<ModalHeader fontSize={{ base: 'md', md: 'lg' }} py={{ base: 3, md: 4 }}>投资日历</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<Suspense fallback={<Center py={{ base: 6, md: 8 }}><Spinner size={{ base: 'lg', md: 'xl' }} color="blue.500" /></Center>}>
|
||||
<InvestmentCalendar />
|
||||
</Suspense>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
142
src/views/Center/components/EventDetailModal.less
Normal file
142
src/views/Center/components/EventDetailModal.less
Normal file
@@ -0,0 +1,142 @@
|
||||
/* EventDetailModal.less - 事件详情弹窗黑金主题样式 */
|
||||
|
||||
// ==================== 变量定义 ====================
|
||||
// 与 GlassCard transparent 变体保持一致
|
||||
@color-bg-deep: rgba(15, 15, 26, 0.95);
|
||||
@color-bg-primary: #0F0F1A;
|
||||
@color-bg-elevated: #1A1A2E;
|
||||
|
||||
@color-gold-400: #D4AF37;
|
||||
@color-gold-500: #B8960C;
|
||||
@color-gold-gradient: linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%);
|
||||
|
||||
@color-line-subtle: rgba(212, 175, 55, 0.1);
|
||||
@color-line-default: rgba(212, 175, 55, 0.2);
|
||||
|
||||
@color-text-primary: rgba(255, 255, 255, 0.95);
|
||||
@color-text-secondary: rgba(255, 255, 255, 0.6);
|
||||
|
||||
// ==================== 全局 Modal 遮罩层样式 ====================
|
||||
.event-detail-modal-root {
|
||||
.ant-modal-mask {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 主样式 ====================
|
||||
.event-detail-modal {
|
||||
// Modal 整体
|
||||
.ant-modal-content {
|
||||
background: @color-bg-deep !important;
|
||||
border: 1px solid @color-line-default;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 24px rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
// Modal 头部
|
||||
.ant-modal-header {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid @color-line-subtle;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
// Modal 标题
|
||||
.ant-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
background: @color-gold-gradient;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
// 关闭按钮
|
||||
.ant-modal-close {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.ant-modal-close-x {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: @color-gold-400 !important;
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Modal 内容区域
|
||||
.ant-modal-body {
|
||||
padding: 16px 24px 24px;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 响应式适配 ====================
|
||||
@media (max-width: 768px) {
|
||||
.event-detail-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 12px 16px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.event-detail-modal-root {
|
||||
.ant-modal {
|
||||
max-width: 100vw !important;
|
||||
margin: 0 !important;
|
||||
top: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-centered .ant-modal {
|
||||
top: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 滚动条样式(备用,已在组件内定义) ====================
|
||||
.event-detail-modal {
|
||||
// 自定义滚动条
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(212, 175, 55, 0.3);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(212, 175, 55, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
315
src/views/Center/components/EventDetailModal.tsx
Normal file
315
src/views/Center/components/EventDetailModal.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* EventDetailModal - 事件详情弹窗组件
|
||||
* 用于展示某一天的所有投资事件
|
||||
*
|
||||
* 功能:
|
||||
* - Tab 筛选(全部/计划/复盘/系统)
|
||||
* - 两列网格布局
|
||||
* - 响应式宽度
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
HStack,
|
||||
Button,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { Target, Heart, Calendar, LayoutGrid } from 'lucide-react';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { FUIEventCard } from './FUIEventCard';
|
||||
import { EventEmptyState } from './EventEmptyState';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
import './EventDetailModal.less';
|
||||
|
||||
/**
|
||||
* 筛选类型
|
||||
*/
|
||||
type FilterType = 'all' | 'plan' | 'review' | 'system';
|
||||
|
||||
/**
|
||||
* 筛选器配置
|
||||
*/
|
||||
interface FilterOption {
|
||||
key: FilterType;
|
||||
label: string;
|
||||
icon?: React.ElementType;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: FilterOption[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: '全部',
|
||||
icon: LayoutGrid,
|
||||
color: '#9B59B6', // 紫罗兰色,汇总概念用独特色
|
||||
},
|
||||
{
|
||||
key: 'plan',
|
||||
label: '计划',
|
||||
icon: Target,
|
||||
color: '#D4AF37',
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
label: '复盘',
|
||||
icon: Heart,
|
||||
color: '#10B981',
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统',
|
||||
icon: Calendar,
|
||||
color: '#3B82F6',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据 hex 颜色生成 rgba 颜色
|
||||
*/
|
||||
const hexToRgba = (hex: string, alpha: number): string => {
|
||||
// 处理 rgba 格式
|
||||
if (hex.startsWith('rgba')) {
|
||||
return hex;
|
||||
}
|
||||
// 处理 hex 格式
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (result) {
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
return hex;
|
||||
};
|
||||
|
||||
/**
|
||||
* EventDetailModal Props
|
||||
*/
|
||||
export interface EventDetailModalProps {
|
||||
/** 是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 选中的日期 */
|
||||
selectedDate: Dayjs | null;
|
||||
/** 选中日期的事件列表 */
|
||||
events: InvestmentEvent[];
|
||||
/** 边框颜色 */
|
||||
borderColor?: string;
|
||||
/** 次要文字颜色 */
|
||||
secondaryText?: string;
|
||||
/** 导航到计划列表 */
|
||||
onNavigateToPlan?: () => void;
|
||||
/** 导航到复盘列表 */
|
||||
onNavigateToReview?: () => void;
|
||||
/** 打开投资日历 */
|
||||
onOpenInvestmentCalendar?: () => void;
|
||||
/** 初始筛选类型(点击事件时定位到对应 Tab) */
|
||||
initialFilter?: FilterType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取各类型事件数量
|
||||
*/
|
||||
const getEventCounts = (events: InvestmentEvent[]) => {
|
||||
const counts = { all: events.length, plan: 0, review: 0, system: 0 };
|
||||
events.forEach(event => {
|
||||
if (event.source === 'future') {
|
||||
counts.system++;
|
||||
} else if (event.type === 'plan') {
|
||||
counts.plan++;
|
||||
} else if (event.type === 'review') {
|
||||
counts.review++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
};
|
||||
|
||||
/**
|
||||
* EventDetailModal 组件
|
||||
*/
|
||||
export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedDate,
|
||||
events,
|
||||
onNavigateToPlan,
|
||||
onNavigateToReview,
|
||||
onOpenInvestmentCalendar,
|
||||
initialFilter,
|
||||
}) => {
|
||||
// 筛选状态
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||
|
||||
// 响应式弹窗宽度
|
||||
const modalWidth = useBreakpointValue({ base: '100%', md: 600, lg: 800 }) || 600;
|
||||
|
||||
// 响应式网格列数
|
||||
const gridColumns = useBreakpointValue({ base: 1, md: 2 }) || 1;
|
||||
|
||||
// 各类型事件数量
|
||||
const eventCounts = useMemo(() => getEventCounts(events), [events]);
|
||||
|
||||
// 筛选后的事件
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (activeFilter === 'all') return events;
|
||||
if (activeFilter === 'system') return events.filter(e => e.source === 'future');
|
||||
return events.filter(e => e.type === activeFilter && e.source !== 'future');
|
||||
}, [events, activeFilter]);
|
||||
|
||||
// 弹窗打开时设置筛选(使用 initialFilter 或默认 'all')
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setActiveFilter(initialFilter || 'all');
|
||||
}
|
||||
}, [isOpen, initialFilter]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<HStack spacing={2}>
|
||||
<Text color="rgba(255, 255, 255, 0.85)" fontWeight="500">
|
||||
{selectedDate?.format('YYYY年MM月DD日') || ''} 的事件
|
||||
</Text>
|
||||
<Badge
|
||||
bg="rgba(212, 175, 55, 0.15)"
|
||||
color="#D4AF37"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
borderRadius="full"
|
||||
>
|
||||
{events.length}
|
||||
</Badge>
|
||||
</HStack>
|
||||
}
|
||||
footer={null}
|
||||
width={modalWidth}
|
||||
maskClosable={false}
|
||||
keyboard={true}
|
||||
centered
|
||||
className="event-detail-modal"
|
||||
rootClassName="event-detail-modal-root"
|
||||
styles={{
|
||||
content: {
|
||||
background: 'rgba(15, 15, 26, 0.95)',
|
||||
border: '1px solid rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
header: {
|
||||
background: 'transparent',
|
||||
},
|
||||
body: { paddingTop: 8, paddingBottom: 24 },
|
||||
}}
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<EventEmptyState
|
||||
onNavigateToPlan={() => {
|
||||
onClose();
|
||||
onNavigateToPlan?.();
|
||||
}}
|
||||
onNavigateToReview={() => {
|
||||
onClose();
|
||||
onNavigateToReview?.();
|
||||
}}
|
||||
onOpenInvestmentCalendar={() => {
|
||||
onClose();
|
||||
onOpenInvestmentCalendar?.();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
{/* Tab 筛选器 */}
|
||||
<HStack spacing={2} mb={4} flexWrap="wrap">
|
||||
{FILTER_OPTIONS.map(option => {
|
||||
const count = eventCounts[option.key];
|
||||
const isActive = activeFilter === option.key;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={option.key}
|
||||
size="sm"
|
||||
variant={isActive ? 'solid' : 'ghost'}
|
||||
bg={isActive ? hexToRgba(option.color, 0.2) : 'rgba(168, 180, 192, 0.05)'}
|
||||
color={isActive ? option.color : 'rgba(168, 180, 192, 0.6)'}
|
||||
border="1px solid"
|
||||
borderColor={isActive ? hexToRgba(option.color, 0.5) : 'rgba(168, 180, 192, 0.15)'}
|
||||
boxShadow={isActive ? `0 0 12px ${hexToRgba(option.color, 0.3)}` : 'none'}
|
||||
_hover={{
|
||||
bg: hexToRgba(option.color, 0.12),
|
||||
borderColor: hexToRgba(option.color, 0.3),
|
||||
color: hexToRgba(option.color, 0.9),
|
||||
}}
|
||||
onClick={() => setActiveFilter(option.key)}
|
||||
leftIcon={option.icon ? <Icon as={option.icon} boxSize={3.5} /> : undefined}
|
||||
rightIcon={
|
||||
count > 0 ? (
|
||||
<Badge
|
||||
bg={isActive ? hexToRgba(option.color, 0.3) : 'rgba(168, 180, 192, 0.1)'}
|
||||
color={isActive ? option.color : 'rgba(168, 180, 192, 0.6)'}
|
||||
fontSize="10px"
|
||||
px={1.5}
|
||||
borderRadius="full"
|
||||
ml={1}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
|
||||
{/* 事件网格 */}
|
||||
<Box
|
||||
maxH="60vh"
|
||||
overflowY="auto"
|
||||
pr={2}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { width: '6px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'rgba(255, 255, 255, 0.05)' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: 'rgba(212, 175, 55, 0.3)',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': { background: 'rgba(212, 175, 55, 0.5)' },
|
||||
}}
|
||||
>
|
||||
{filteredEvents.length === 0 ? (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="rgba(255, 255, 255, 0.5)" fontSize="sm">
|
||||
该分类下暂无事件
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Grid
|
||||
templateColumns={`repeat(${gridColumns}, 1fr)`}
|
||||
gap={4}
|
||||
>
|
||||
{filteredEvents.map((event, idx) => (
|
||||
<FUIEventCard
|
||||
key={event.id || idx}
|
||||
event={event}
|
||||
variant="modal"
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
576
src/views/Center/components/EventFormModal.less
Normal file
576
src/views/Center/components/EventFormModal.less
Normal file
@@ -0,0 +1,576 @@
|
||||
/* EventFormModal.less - 投资计划/复盘弹窗黑金主题样式 */
|
||||
|
||||
// ==================== 变量定义 ====================
|
||||
@mobile-breakpoint: 768px;
|
||||
@modal-border-radius-mobile: 16px;
|
||||
@modal-border-radius-desktop: 12px;
|
||||
|
||||
// 间距
|
||||
@spacing-xs: 4px;
|
||||
@spacing-sm: 8px;
|
||||
@spacing-md: 12px;
|
||||
@spacing-lg: 16px;
|
||||
@spacing-xl: 20px;
|
||||
@spacing-xxl: 24px;
|
||||
|
||||
// 字体大小
|
||||
@font-size-xs: 12px;
|
||||
@font-size-sm: 14px;
|
||||
@font-size-md: 16px;
|
||||
|
||||
// 黑金主题色
|
||||
@color-bg-deep: #0A0A14;
|
||||
@color-bg-primary: #0F0F1A;
|
||||
@color-bg-elevated: #1A1A2E;
|
||||
@color-bg-surface: #252540;
|
||||
@color-bg-input: rgba(26, 26, 46, 0.8);
|
||||
|
||||
@color-gold-400: #D4AF37;
|
||||
@color-gold-500: #B8960C;
|
||||
@color-gold-gradient: linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%);
|
||||
|
||||
@color-line-subtle: rgba(212, 175, 55, 0.1);
|
||||
@color-line-default: rgba(212, 175, 55, 0.2);
|
||||
@color-line-emphasis: rgba(212, 175, 55, 0.4);
|
||||
|
||||
@color-text-primary: rgba(255, 255, 255, 0.95);
|
||||
@color-text-secondary: rgba(255, 255, 255, 0.6);
|
||||
@color-text-muted: rgba(255, 255, 255, 0.4);
|
||||
|
||||
@color-error: #EF4444;
|
||||
|
||||
// ==================== 全局 Modal 遮罩层样式 ====================
|
||||
// 使用 rootClassName="event-form-modal-root" 来控制 mask 样式
|
||||
.event-form-modal-root {
|
||||
.ant-modal-mask {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.ant-modal-wrap {
|
||||
// 确保 wrap 层也有正确的样式
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 主样式 ====================
|
||||
.event-form-modal {
|
||||
|
||||
// Modal 整体
|
||||
.ant-modal-content {
|
||||
background: linear-gradient(135deg, @color-bg-elevated 0%, @color-bg-primary 100%);
|
||||
border: 1px solid @color-line-default;
|
||||
border-radius: @modal-border-radius-desktop;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(212, 175, 55, 0.1);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
// Modal 头部
|
||||
.ant-modal-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid @color-line-subtle;
|
||||
padding: @spacing-lg @spacing-xxl;
|
||||
}
|
||||
|
||||
// Modal 标题
|
||||
.ant-modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: @color-gold-gradient;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
// 关闭按钮
|
||||
.ant-modal-close {
|
||||
color: @color-text-secondary;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: @color-gold-400;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: @spacing-xxl;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
// Modal 底部
|
||||
.ant-modal-footer {
|
||||
background: transparent;
|
||||
border-top: 1px solid @color-line-subtle;
|
||||
padding: @spacing-lg @spacing-xxl;
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: @spacing-xl;
|
||||
}
|
||||
|
||||
// 表单标签
|
||||
.ant-form-item-label {
|
||||
text-align: left !important;
|
||||
|
||||
> label {
|
||||
font-weight: 600 !important;
|
||||
color: @color-text-primary !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 输入框通用样式(支持 Ant Design v5 的 outlined 类名)
|
||||
.ant-input,
|
||||
.ant-input-outlined,
|
||||
.ant-picker,
|
||||
.ant-picker-outlined,
|
||||
.ant-select-selector,
|
||||
.ant-select-outlined .ant-select-selector {
|
||||
background: @color-bg-input !important;
|
||||
border: 1px solid @color-line-default !important;
|
||||
color: @color-text-primary !important;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-line-emphasis !important;
|
||||
background: @color-bg-input !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-within,
|
||||
&.ant-input-focused,
|
||||
&.ant-picker-focused,
|
||||
&.ant-select-focused {
|
||||
border-color: @color-gold-400 !important;
|
||||
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||
background: @color-bg-input !important;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @color-text-muted !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 输入框 focus 状态增强
|
||||
.ant-input:focus,
|
||||
.ant-input-outlined:focus,
|
||||
.ant-input-affix-wrapper:focus,
|
||||
.ant-input-affix-wrapper-focused {
|
||||
border-color: @color-gold-400 !important;
|
||||
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||
}
|
||||
|
||||
// 输入框 wrapper
|
||||
.ant-input-affix-wrapper {
|
||||
background: @color-bg-input !important;
|
||||
border-color: @color-line-default !important;
|
||||
|
||||
.ant-input {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-input-suffix {
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// 输入框 placeholder
|
||||
.ant-input::placeholder,
|
||||
.ant-picker-input input::placeholder,
|
||||
.ant-select-selection-placeholder {
|
||||
color: @color-text-muted !important;
|
||||
}
|
||||
|
||||
// 文本域
|
||||
.ant-input-textarea,
|
||||
.ant-input-textarea-show-count {
|
||||
textarea,
|
||||
.ant-input {
|
||||
background: @color-bg-input !important;
|
||||
border: 1px solid @color-line-default !important;
|
||||
color: @color-text-primary !important;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-line-emphasis !important;
|
||||
background: @color-bg-input !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: @color-gold-400 !important;
|
||||
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||
background: @color-bg-input !important;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @color-text-muted !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文本域 outlined 变体
|
||||
.ant-input-textarea-outlined,
|
||||
textarea.ant-input-outlined {
|
||||
background: @color-bg-input !important;
|
||||
border: 1px solid @color-line-default !important;
|
||||
color: @color-text-primary !important;
|
||||
|
||||
&:hover {
|
||||
border-color: @color-line-emphasis !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: @color-gold-400 !important;
|
||||
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 字符计数样式
|
||||
.ant-input-textarea-show-count::after {
|
||||
font-size: @font-size-xs;
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
// 日期选择器
|
||||
.ant-picker {
|
||||
width: 100%;
|
||||
|
||||
.ant-picker-input > input {
|
||||
color: @color-text-primary !important;
|
||||
}
|
||||
|
||||
.ant-picker-suffix,
|
||||
.ant-picker-clear {
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// Select 选择器
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
min-height: 38px;
|
||||
background: @color-bg-input !important;
|
||||
border: 1px solid @color-line-default !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:hover .ant-select-selector {
|
||||
border-color: @color-line-emphasis !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: @color-gold-400 !important;
|
||||
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: @color-text-primary !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-search-input {
|
||||
color: @color-text-primary !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: @color-text-secondary;
|
||||
}
|
||||
|
||||
.ant-select-clear {
|
||||
background: @color-bg-elevated;
|
||||
color: @color-text-secondary;
|
||||
|
||||
&:hover {
|
||||
color: @color-gold-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select 多选模式下的标签
|
||||
.ant-select-multiple .ant-select-selection-item {
|
||||
background: rgba(212, 175, 55, 0.15) !important;
|
||||
border: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||
color: @color-gold-400 !important;
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
color: @color-gold-400;
|
||||
|
||||
&:hover {
|
||||
color: @color-text-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 股票标签样式
|
||||
.ant-tag {
|
||||
margin: 2px;
|
||||
border-radius: @spacing-xs;
|
||||
background: rgba(212, 175, 55, 0.15) !important;
|
||||
border: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||
color: @color-gold-400 !important;
|
||||
|
||||
.ant-tag-close-icon {
|
||||
color: @color-gold-400;
|
||||
|
||||
&:hover {
|
||||
color: @color-text-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模板按钮组
|
||||
.template-buttons {
|
||||
.ant-btn {
|
||||
font-size: @font-size-xs;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border: 1px solid @color-line-default;
|
||||
color: @color-text-secondary;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
border-color: @color-line-emphasis;
|
||||
color: @color-gold-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部操作栏布局
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.ant-btn-primary {
|
||||
background: linear-gradient(135deg, @color-gold-400 0%, @color-gold-500 100%);
|
||||
border: none;
|
||||
color: @color-bg-deep;
|
||||
font-weight: 600;
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, lighten(@color-gold-400, 5%) 0%, lighten(@color-gold-500, 5%) 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: rgba(212, 175, 55, 0.3);
|
||||
color: @color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.ant-btn-loading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.ant-form-item-has-error {
|
||||
.ant-input,
|
||||
.ant-picker,
|
||||
.ant-select-selector {
|
||||
border-color: @color-error !important;
|
||||
animation: shake 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-form-item-explain-error {
|
||||
color: @color-error;
|
||||
}
|
||||
}
|
||||
|
||||
// Select 下拉菜单(挂载在 body 上,需要全局样式)
|
||||
.ant-select-dropdown {
|
||||
background: @color-bg-elevated !important;
|
||||
border: 1px solid @color-line-default;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
|
||||
.ant-select-item {
|
||||
color: @color-text-primary;
|
||||
|
||||
&:hover {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
&.ant-select-item-option-selected {
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
color: @color-gold-400;
|
||||
}
|
||||
|
||||
&.ant-select-item-option-active {
|
||||
background: rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-item-empty {
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
// 分割线
|
||||
.ant-divider {
|
||||
border-color: @color-line-subtle;
|
||||
}
|
||||
|
||||
// 提示文字
|
||||
span[style*="color: #999"] {
|
||||
color: @color-text-muted !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期选择器下拉面板
|
||||
.ant-picker-dropdown {
|
||||
.ant-picker-panel-container {
|
||||
background: @color-bg-elevated;
|
||||
border: 1px solid @color-line-default;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.ant-picker-header {
|
||||
color: @color-text-primary;
|
||||
border-bottom: 1px solid @color-line-subtle;
|
||||
|
||||
button {
|
||||
color: @color-text-secondary;
|
||||
|
||||
&:hover {
|
||||
color: @color-gold-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker-content th {
|
||||
color: @color-text-muted;
|
||||
}
|
||||
|
||||
.ant-picker-cell {
|
||||
color: @color-text-secondary;
|
||||
|
||||
&:hover .ant-picker-cell-inner {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
&.ant-picker-cell-in-view {
|
||||
color: @color-text-primary;
|
||||
}
|
||||
|
||||
&.ant-picker-cell-selected .ant-picker-cell-inner {
|
||||
background: @color-gold-400;
|
||||
color: @color-bg-deep;
|
||||
}
|
||||
|
||||
&.ant-picker-cell-today .ant-picker-cell-inner::before {
|
||||
border-color: @color-gold-400;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-picker-footer {
|
||||
border-top: 1px solid @color-line-subtle;
|
||||
}
|
||||
|
||||
.ant-picker-today-btn {
|
||||
color: @color-gold-400;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 移动端适配 ====================
|
||||
@media (max-width: @mobile-breakpoint) {
|
||||
.event-form-modal {
|
||||
// Modal 整体尺寸
|
||||
.ant-modal {
|
||||
width: calc(100vw - 32px) !important;
|
||||
max-width: 100% !important;
|
||||
margin: @spacing-lg auto;
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: @modal-border-radius-mobile;
|
||||
}
|
||||
|
||||
// Modal 头部
|
||||
.ant-modal-header {
|
||||
padding: @spacing-md @spacing-lg;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: @font-size-md;
|
||||
}
|
||||
|
||||
// Modal 内容区域
|
||||
.ant-modal-body {
|
||||
padding: @spacing-lg;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// Modal 底部
|
||||
.ant-modal-footer {
|
||||
padding: @spacing-md @spacing-lg;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 表单项间距
|
||||
.ant-form-item {
|
||||
margin-bottom: @spacing-lg;
|
||||
}
|
||||
|
||||
// 表单标签
|
||||
.ant-form-item-label > label {
|
||||
font-size: @font-size-sm;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// 输入框字体 - iOS 防止缩放需要 16px
|
||||
.ant-input,
|
||||
.ant-picker-input > input,
|
||||
.ant-select-selection-search-input {
|
||||
font-size: @font-size-md !important;
|
||||
}
|
||||
|
||||
// 文本域高度
|
||||
.ant-input-textarea textarea {
|
||||
font-size: @font-size-md !important;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
// 模板按钮组
|
||||
.template-buttons .ant-btn {
|
||||
font-size: @font-size-xs;
|
||||
padding: 2px @spacing-sm;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
// 股票选择器
|
||||
.ant-select-selector {
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
// 底部按钮
|
||||
.modal-footer .ant-btn-primary {
|
||||
font-size: @font-size-md;
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 动画 ====================
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
message,
|
||||
Space,
|
||||
Spin,
|
||||
ConfigProvider,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import type { SelectProps } from 'antd';
|
||||
import {
|
||||
@@ -34,7 +36,13 @@ import 'dayjs/locale/zh-cn';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import {
|
||||
fetchAllEvents,
|
||||
optimisticAddEvent,
|
||||
replaceEvent,
|
||||
removeEvent,
|
||||
optimisticUpdateEvent,
|
||||
} from '@/store/slices/planningSlice';
|
||||
import './EventFormModal.less';
|
||||
import type { InvestmentEvent, EventType } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
@@ -184,7 +192,6 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
label = '事件',
|
||||
apiEndpoint = 'investment-plans',
|
||||
}) => {
|
||||
const { loadAllData } = usePlanningData();
|
||||
const dispatch = useAppDispatch();
|
||||
const [form] = Form.useForm<FormData>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -275,7 +282,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
setStockOptions(options.length > 0 ? options : watchlistOptions);
|
||||
}, [allStocks, watchlistOptions]);
|
||||
|
||||
// 保存数据
|
||||
// 保存数据(新建模式使用乐观更新)
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
@@ -315,28 +322,103 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
? `${base}/api/account/${apiEndpoint}/${editingEvent.id}`
|
||||
: `${base}/api/account/${apiEndpoint}`;
|
||||
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('EventFormModal', `${mode === 'edit' ? '更新' : '创建'}${label}成功`, {
|
||||
itemId: editingEvent?.id,
|
||||
// ===== 新建模式:乐观更新 =====
|
||||
if (mode === 'create') {
|
||||
const tempId = -Date.now(); // 负数临时 ID,避免与服务器 ID 冲突
|
||||
const tempEvent: InvestmentEvent = {
|
||||
id: tempId,
|
||||
title: values.title,
|
||||
});
|
||||
message.success(mode === 'edit' ? '修改成功' : '添加成功');
|
||||
onClose();
|
||||
onSuccess();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
content: values.content || '',
|
||||
description: values.content || '',
|
||||
date: values.date.format('YYYY-MM-DD'),
|
||||
event_date: values.date.format('YYYY-MM-DD'),
|
||||
type: eventType,
|
||||
stocks: stocksWithNames,
|
||||
status: 'active',
|
||||
source: 'user',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// ① 立即更新 UI
|
||||
dispatch(optimisticAddEvent(tempEvent));
|
||||
setSaving(false);
|
||||
onClose(); // 立即关闭弹窗
|
||||
|
||||
// ② 后台发送 API 请求
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// ③ 用真实数据替换临时数据
|
||||
dispatch(replaceEvent({ tempId, realEvent: data.data }));
|
||||
logger.info('EventFormModal', `创建${label}成功`, { title: values.title });
|
||||
message.success('添加成功');
|
||||
onSuccess();
|
||||
} else {
|
||||
throw new Error(data.error || '创建失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// ④ 失败回滚
|
||||
dispatch(removeEvent(tempId));
|
||||
logger.error('EventFormModal', 'handleSave optimistic rollback', error);
|
||||
message.error('创建失败,请重试');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== 编辑模式:乐观更新 =====
|
||||
if (editingEvent) {
|
||||
// 构建更新后的事件对象
|
||||
const updatedEvent: InvestmentEvent = {
|
||||
...editingEvent,
|
||||
title: values.title,
|
||||
content: values.content || '',
|
||||
description: values.content || '',
|
||||
date: values.date.format('YYYY-MM-DD'),
|
||||
event_date: values.date.format('YYYY-MM-DD'),
|
||||
stocks: stocksWithNames,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// ① 立即更新 UI
|
||||
dispatch(optimisticUpdateEvent(updatedEvent));
|
||||
setSaving(false);
|
||||
onClose(); // 立即关闭弹窗
|
||||
|
||||
// ② 后台发送 API 请求
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('EventFormModal', `更新${label}成功`, {
|
||||
itemId: editingEvent.id,
|
||||
title: values.title,
|
||||
});
|
||||
message.success('修改成功');
|
||||
onSuccess();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// ③ 失败回滚 - 重新加载数据
|
||||
dispatch(fetchAllEvents());
|
||||
logger.error('EventFormModal', 'handleSave edit rollback', error);
|
||||
message.error('修改失败,请重试');
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message !== '保存失败') {
|
||||
@@ -350,7 +432,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, loadAllData, allStocks, watchlist]);
|
||||
}, [form, eventType, apiEndpoint, mode, editingEvent, label, onClose, onSuccess, dispatch, allStocks, watchlist]);
|
||||
|
||||
// 监听键盘快捷键 Ctrl + Enter
|
||||
useEffect(() => {
|
||||
@@ -393,7 +475,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
// 判断是否显示自选股列表
|
||||
const isShowingWatchlist = !searchText && stockOptions === watchlistOptions;
|
||||
|
||||
// 股票选择器选项配置
|
||||
// 股票选择器选项配置(黑金主题)
|
||||
const selectProps: SelectProps<string[]> = {
|
||||
mode: 'multiple',
|
||||
placeholder: allStocksLoading ? '加载股票列表中...' : '输入股票代码或名称搜索',
|
||||
@@ -401,12 +483,15 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
onSearch: handleStockSearch,
|
||||
loading: watchlistLoading || allStocksLoading,
|
||||
notFoundContent: allStocksLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '8px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '8px', color: 'rgba(255,255,255,0.6)' }}>
|
||||
<Spin size="small" />
|
||||
<span style={{ marginLeft: 8 }}>加载中...</span>
|
||||
</div>
|
||||
) : '暂无结果',
|
||||
) : <span style={{ color: 'rgba(255,255,255,0.4)' }}>暂无结果</span>,
|
||||
options: stockOptions,
|
||||
style: {
|
||||
width: '100%',
|
||||
},
|
||||
onFocus: () => {
|
||||
if (stockOptions.length === 0) {
|
||||
setStockOptions(watchlistOptions);
|
||||
@@ -416,41 +501,49 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
const { label: tagLabel, closable, onClose: onTagClose } = props;
|
||||
return (
|
||||
<Tag
|
||||
color="blue"
|
||||
closable={closable}
|
||||
onClose={onTagClose}
|
||||
style={{ marginRight: 3 }}
|
||||
style={{
|
||||
marginRight: 3,
|
||||
background: 'rgba(212, 175, 55, 0.15)',
|
||||
border: '1px solid rgba(212, 175, 55, 0.3)',
|
||||
color: '#D4AF37',
|
||||
}}
|
||||
>
|
||||
{tagLabel}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
popupRender: (menu) => (
|
||||
<>
|
||||
<div style={{
|
||||
background: '#1A1A2E',
|
||||
border: '1px solid rgba(212, 175, 55, 0.2)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
{isShowingWatchlist && watchlistOptions.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: '4px 8px 0' }}>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>
|
||||
<StarOutlined style={{ marginRight: 4, color: '#faad14' }} />
|
||||
<div style={{ padding: '8px 12px 4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.6)' }}>
|
||||
<StarOutlined style={{ marginRight: 4, color: '#D4AF37' }} />
|
||||
我的自选股
|
||||
</span>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0 0' }} />
|
||||
<Divider style={{ margin: '4px 0 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
|
||||
</>
|
||||
)}
|
||||
{menu}
|
||||
{!isShowingWatchlist && searchText && (
|
||||
<>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<div style={{ padding: '0 8px 4px' }}>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>
|
||||
<Divider style={{ margin: '8px 0', borderColor: 'rgba(212, 175, 55, 0.1)' }} />
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.4)' }}>
|
||||
<BulbOutlined style={{ marginRight: 4 }} />
|
||||
搜索结果(输入代码或名称)
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -462,9 +555,47 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
return eventType === 'plan' ? '创建计划' : '创建复盘';
|
||||
};
|
||||
|
||||
// 黑金主题样式
|
||||
const modalStyles = {
|
||||
mask: {
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
content: {
|
||||
background: 'linear-gradient(135deg, #1A1A2E 0%, #0F0F1A 100%)',
|
||||
border: '1px solid rgba(212, 175, 55, 0.2)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 24px rgba(212, 175, 55, 0.1)',
|
||||
},
|
||||
header: {
|
||||
background: 'transparent',
|
||||
borderBottom: '1px solid rgba(212, 175, 55, 0.1)',
|
||||
padding: '16px 24px',
|
||||
},
|
||||
body: {
|
||||
padding: '24px',
|
||||
paddingTop: '24px',
|
||||
},
|
||||
footer: {
|
||||
background: 'transparent',
|
||||
borderTop: '1px solid rgba(212, 175, 55, 0.1)',
|
||||
padding: '16px 24px',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
|
||||
title={
|
||||
<span style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(135deg, #D4AF37 0%, #F5E6A3 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
{`${mode === 'edit' ? '编辑' : '新建'}${eventType === 'plan' ? '投资计划' : '复盘'}`}
|
||||
</span>
|
||||
}
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
width={600}
|
||||
@@ -472,25 +603,71 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
maskClosable={true}
|
||||
keyboard
|
||||
className="event-form-modal"
|
||||
styles={modalStyles}
|
||||
closeIcon={
|
||||
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: '16px' }}>✕</span>
|
||||
}
|
||||
footer={
|
||||
<div className="modal-footer">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #D4AF37 0%, #B8960C 100%)',
|
||||
border: 'none',
|
||||
color: '#0A0A14',
|
||||
fontWeight: 600,
|
||||
height: '40px',
|
||||
padding: '0 24px',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div ref={modalContentRef}>
|
||||
{isOpen && <Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 4 }}
|
||||
wrapperCol={{ span: 20 }}
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#D4AF37',
|
||||
colorBgContainer: 'rgba(26, 26, 46, 0.8)',
|
||||
colorBorder: 'rgba(212, 175, 55, 0.2)',
|
||||
colorBorderSecondary: 'rgba(212, 175, 55, 0.1)',
|
||||
colorText: 'rgba(255, 255, 255, 0.95)',
|
||||
colorTextSecondary: 'rgba(255, 255, 255, 0.6)',
|
||||
colorTextPlaceholder: 'rgba(255, 255, 255, 0.4)',
|
||||
colorBgElevated: '#1A1A2E',
|
||||
colorFillSecondary: 'rgba(212, 175, 55, 0.1)',
|
||||
},
|
||||
components: {
|
||||
Input: {
|
||||
activeBorderColor: '#D4AF37',
|
||||
hoverBorderColor: 'rgba(212, 175, 55, 0.4)',
|
||||
activeShadow: '0 0 0 2px rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
Select: {
|
||||
optionActiveBg: 'rgba(212, 175, 55, 0.1)',
|
||||
optionSelectedBg: 'rgba(212, 175, 55, 0.2)',
|
||||
optionSelectedColor: '#D4AF37',
|
||||
},
|
||||
DatePicker: {
|
||||
activeBorderColor: '#D4AF37',
|
||||
hoverBorderColor: 'rgba(212, 175, 55, 0.4)',
|
||||
activeShadow: '0 0 0 2px rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div ref={modalContentRef}>
|
||||
{isOpen && <Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 4 }}
|
||||
wrapperCol={{ span: 20 }}
|
||||
labelAlign="left"
|
||||
requiredMark={false}
|
||||
initialValues={{
|
||||
@@ -501,7 +678,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
{/* 标题 */}
|
||||
<Form.Item
|
||||
name="title"
|
||||
label={<span style={{ fontWeight: 600 }}>标题 <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>标题 <span style={{ color: '#D4AF37' }}>*</span></span>}
|
||||
rules={[
|
||||
{ required: true, message: '请输入标题' },
|
||||
{ max: 50, message: '标题不能超过50个字符' },
|
||||
@@ -511,17 +688,31 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
placeholder={getTitlePlaceholder()}
|
||||
maxLength={50}
|
||||
showCount
|
||||
styles={{
|
||||
input: {
|
||||
background: 'rgba(26, 26, 46, 0.8)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
count: {
|
||||
color: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 日期 */}
|
||||
<Form.Item
|
||||
name="date"
|
||||
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>{eventType === 'plan' ? '计划日期' : '复盘日期'} <span style={{ color: '#D4AF37' }}>*</span></span>}
|
||||
rules={[{ required: true, message: '请选择日期' }]}
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'rgba(26, 26, 46, 0.8)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
}}
|
||||
format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
allowClear={false}
|
||||
@@ -531,7 +722,7 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
{/* 描述/内容 - 上下布局 */}
|
||||
<Form.Item
|
||||
name="content"
|
||||
label={<span style={{ fontWeight: 600 }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#ff4d4f' }}>*</span></span>}
|
||||
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>{eventType === 'plan' ? '计划详情' : '复盘内容'} <span style={{ color: '#D4AF37' }}>*</span></span>}
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
labelCol={{ span: 24 }}
|
||||
wrapperCol={{ span: 24 }}
|
||||
@@ -542,18 +733,28 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
rows={8}
|
||||
showCount
|
||||
maxLength={2000}
|
||||
style={{ resize: 'vertical' }}
|
||||
style={{
|
||||
resize: 'vertical',
|
||||
background: 'rgba(26, 26, 46, 0.8)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 模板快捷按钮 - 独立放置在 Form.Item 外部 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Space wrap size="small" className="template-buttons">
|
||||
<Space wrap size="small">
|
||||
{templates.map((template) => (
|
||||
<Button
|
||||
key={template.label}
|
||||
size="small"
|
||||
onClick={() => handleInsertTemplate(template)}
|
||||
style={{
|
||||
background: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
}}
|
||||
>
|
||||
{template.label}
|
||||
</Button>
|
||||
@@ -564,12 +765,13 @@ export const EventFormModal: React.FC<EventFormModalProps> = ({
|
||||
{/* 关联股票 */}
|
||||
<Form.Item
|
||||
name="stocks"
|
||||
label={<span style={{ fontWeight: 600 }}>关联股票</span>}
|
||||
label={<span style={{ fontWeight: 600, color: 'rgba(255,255,255,0.95)' }}>关联股票</span>}
|
||||
>
|
||||
<Select {...selectProps} />
|
||||
</Form.Item>
|
||||
</Form>}
|
||||
</div>
|
||||
</Form>}
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* EventPanel - 通用事件面板组件
|
||||
* EventPanel - 通用事件面板组件 (Redux 版本)
|
||||
* 用于显示、编辑和管理投资计划或复盘
|
||||
*
|
||||
* 通过 props 配置差异化行为:
|
||||
@@ -17,15 +17,23 @@ import {
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiFileText } from 'react-icons/fi';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import {
|
||||
fetchAllEvents,
|
||||
removeEvent,
|
||||
selectPlans,
|
||||
selectReviews,
|
||||
selectPlanningLoading,
|
||||
} from '@/store/slices/planningSlice';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import { EventFormModal } from './EventFormModal';
|
||||
import { EventCard } from './EventCard';
|
||||
import { FUIEventCard } from './FUIEventCard';
|
||||
import type { InvestmentEvent } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
/**
|
||||
* EventPanel Props
|
||||
@@ -51,15 +59,16 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
label,
|
||||
openModalTrigger,
|
||||
}) => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
} = usePlanningData();
|
||||
const dispatch = useAppDispatch();
|
||||
const toast = useToast();
|
||||
|
||||
// Redux 状态
|
||||
const plans = useAppSelector(selectPlans);
|
||||
const reviews = useAppSelector(selectReviews);
|
||||
const loading = useAppSelector(selectPlanningLoading);
|
||||
|
||||
// 根据类型选择事件列表
|
||||
const events = type === 'plan' ? plans : reviews;
|
||||
|
||||
// 弹窗状态
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
@@ -69,9 +78,6 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
// 使用 ref 记录上一次的 trigger 值,避免组件挂载时误触发
|
||||
const prevTriggerRef = useRef<number>(openModalTrigger || 0);
|
||||
|
||||
// 筛选事件列表(按类型过滤,排除系统事件)
|
||||
const events = allEvents.filter(event => event.type === type && event.source !== 'future');
|
||||
|
||||
// 监听外部触发打开新建模态框(修复 bug:只在值变化时触发)
|
||||
useEffect(() => {
|
||||
if (openModalTrigger !== undefined && openModalTrigger > prevTriggerRef.current) {
|
||||
@@ -99,14 +105,17 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
// 删除数据 - 乐观更新模式
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
// ① 立即从 UI 移除
|
||||
dispatch(removeEvent(id));
|
||||
|
||||
// ② 后台发送 API 请求
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
const response = await fetch(`${base}/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
@@ -118,23 +127,34 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventPanel', 'handleDelete', error, { itemId: id });
|
||||
// ③ 失败回滚 - 重新加载数据
|
||||
dispatch(fetchAllEvents());
|
||||
logger.error('EventPanel', 'handleDelete rollback', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
title: '删除失败,请重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新数据
|
||||
const handleRefresh = useCallback(() => {
|
||||
dispatch(fetchAllEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// 使用 useCallback 优化回调函数
|
||||
const handleEdit = useCallback((item: InvestmentEvent) => {
|
||||
handleOpenModal(item);
|
||||
}, []);
|
||||
|
||||
// 颜色主题
|
||||
const secondaryText = 'rgba(255, 255, 255, 0.6)';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
@@ -150,17 +170,13 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={{ base: 3, md: 4 }}>
|
||||
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={{ base: 3, md: 4 }}>
|
||||
{events.map(event => (
|
||||
<EventCard
|
||||
<FUIEventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
variant="list"
|
||||
colorScheme={colorScheme}
|
||||
label={label}
|
||||
textColor={textColor}
|
||||
secondaryText={secondaryText}
|
||||
cardBg={cardBg}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
@@ -176,7 +192,7 @@ export const EventPanel: React.FC<EventPanelProps> = ({
|
||||
mode={modalMode}
|
||||
eventType={type}
|
||||
editingEvent={editingItem}
|
||||
onSuccess={loadAllData}
|
||||
onSuccess={handleRefresh}
|
||||
label={label}
|
||||
apiEndpoint="investment-plans"
|
||||
/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user