Compare commits
37 Commits
aacbe5c31c
...
feature_20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2380c420c | ||
| 8417ab17be | |||
| dd59cb6385 | |||
|
|
d456c3cd5f | ||
|
|
b221c2669c | ||
|
|
356f865f09 | ||
| 512aca16d8 | |||
|
|
e05ea154a2 | ||
|
|
c33181a689 | ||
| 29f035b1cf | |||
| 513134f285 | |||
|
|
7da50aca40 | ||
|
|
72aae585d0 | ||
| 24c6c9e1c6 | |||
|
|
58254d3e8f | ||
|
|
760ce4d5e1 | ||
|
|
95c1eaf97b | ||
|
|
657c446594 | ||
|
|
10f519a764 | ||
|
|
f072256021 | ||
|
|
0e3bdc9b8c | ||
|
|
5e4c4e7cea | ||
|
|
31a7500388 | ||
|
|
03c113fe1b | ||
|
|
0f3bc06716 | ||
|
|
e568b5e05f | ||
| c5aaaabf17 | |||
| 9ede603c9f | |||
|
|
629c63f4ee | ||
|
|
d6bc2c7245 | ||
|
|
dc38199ae6 | ||
|
|
d93b5de319 | ||
|
|
199a54bc12 | ||
|
|
39feae87a6 | ||
|
|
a9dc1191bf | ||
|
|
227e1c9d15 | ||
|
|
b5cdceb92b |
@@ -10,7 +10,8 @@
|
||||
"Bash(npm cache clean --force)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm run start:mock)",
|
||||
"Bash(npm install fsevents@latest --save-optional --force)"
|
||||
"Bash(npm install fsevents@latest --save-optional --force)",
|
||||
"Bash(python -m py_compile:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
BIN
__pycache__/app.cpython-310.pyc
Normal file
BIN
__pycache__/app.cpython-310.pyc
Normal file
Binary file not shown.
330
app.py
330
app.py
@@ -101,7 +101,7 @@ def get_trading_day_near_date(target_date):
|
||||
load_trading_days()
|
||||
|
||||
engine = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/stock?charset=utf8mb4",
|
||||
"mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4",
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
pool_recycle=3600,
|
||||
@@ -110,7 +110,7 @@ engine = create_engine(
|
||||
max_overflow=20
|
||||
)
|
||||
engine_med = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/med?charset=utf8mb4",
|
||||
"mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/med?charset=utf8mb4",
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
pool_recycle=3600,
|
||||
@@ -119,7 +119,7 @@ engine_med = create_engine(
|
||||
max_overflow=10
|
||||
)
|
||||
engine_2 = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/valuefrontier?charset=utf8mb4",
|
||||
"mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/valuefrontier?charset=utf8mb4",
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
pool_recycle=3600,
|
||||
@@ -204,7 +204,7 @@ app.config['COMPRESS_MIMETYPES'] = [
|
||||
'application/javascript',
|
||||
'application/x-javascript'
|
||||
]
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/stock?charset=utf8mb4'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
|
||||
'pool_size': 10,
|
||||
@@ -1849,6 +1849,15 @@ def send_verification_code():
|
||||
if not credential or not code_type:
|
||||
return jsonify({'success': False, 'error': '缺少必要参数'}), 400
|
||||
|
||||
# 清理格式字符(空格、横线、括号等)
|
||||
if code_type == 'phone':
|
||||
# 移除手机号中的空格、横线、括号、加号等格式字符
|
||||
credential = re.sub(r'[\s\-\(\)\+]', '', credential)
|
||||
print(f"📱 清理后的手机号: {credential}")
|
||||
elif code_type == 'email':
|
||||
# 邮箱只移除空格
|
||||
credential = credential.strip()
|
||||
|
||||
# 生成验证码
|
||||
verification_code = generate_verification_code()
|
||||
|
||||
@@ -1897,7 +1906,7 @@ def send_verification_code():
|
||||
|
||||
@app.route('/api/auth/login-with-code', methods=['POST'])
|
||||
def login_with_verification_code():
|
||||
"""使用验证码登录"""
|
||||
"""使用验证码登录/注册(自动注册)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
credential = data.get('credential') # 手机号或邮箱
|
||||
@@ -1907,6 +1916,17 @@ def login_with_verification_code():
|
||||
if not credential or not verification_code or not login_type:
|
||||
return jsonify({'success': False, 'error': '缺少必要参数'}), 400
|
||||
|
||||
# 清理格式字符(空格、横线、括号等)
|
||||
if login_type == 'phone':
|
||||
# 移除手机号中的空格、横线、括号、加号等格式字符
|
||||
original_credential = credential
|
||||
credential = re.sub(r'[\s\-\(\)\+]', '', credential)
|
||||
if original_credential != credential:
|
||||
print(f"📱 登录时清理手机号: {original_credential} -> {credential}")
|
||||
elif login_type == 'email':
|
||||
# 邮箱只移除前后空格
|
||||
credential = credential.strip()
|
||||
|
||||
# 检查验证码
|
||||
session_key = f'verification_code_{login_type}_{credential}_login'
|
||||
stored_code_info = session.get(session_key)
|
||||
@@ -1932,13 +1952,86 @@ def login_with_verification_code():
|
||||
|
||||
# 验证码正确,查找用户
|
||||
user = None
|
||||
is_new_user = False
|
||||
|
||||
if login_type == 'phone':
|
||||
user = User.query.filter_by(phone=credential).first()
|
||||
if not user:
|
||||
# 自动注册新用户
|
||||
is_new_user = True
|
||||
# 生成唯一用户名
|
||||
base_username = f"user_{credential}"
|
||||
username = base_username
|
||||
counter = 1
|
||||
while User.query.filter_by(username=username).first():
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# 创建新用户
|
||||
user = User(username=username, phone=credential)
|
||||
user.phone_confirmed = True
|
||||
user.email = f"{username}@valuefrontier.temp" # 临时邮箱
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
elif login_type == 'email':
|
||||
user = User.query.filter_by(email=credential).first()
|
||||
if not user:
|
||||
# 自动注册新用户
|
||||
is_new_user = True
|
||||
# 从邮箱生成用户名
|
||||
email_prefix = credential.split('@')[0]
|
||||
base_username = f"user_{email_prefix}"
|
||||
username = base_username
|
||||
counter = 1
|
||||
while User.query.filter_by(username=username).first():
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# 如果用户不存在,自动创建新用户
|
||||
if not user:
|
||||
return jsonify({'success': False, 'error': '用户不存在'}), 404
|
||||
try:
|
||||
# 生成用户名
|
||||
if login_type == 'phone':
|
||||
# 使用手机号生成用户名
|
||||
base_username = f"用户{credential[-4:]}"
|
||||
elif login_type == 'email':
|
||||
# 使用邮箱前缀生成用户名
|
||||
base_username = credential.split('@')[0]
|
||||
else:
|
||||
base_username = "新用户"
|
||||
|
||||
# 确保用户名唯一
|
||||
username = base_username
|
||||
counter = 1
|
||||
while User.is_username_taken(username):
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# 创建新用户
|
||||
user = User(username=username)
|
||||
|
||||
# 设置手机号或邮箱
|
||||
if login_type == 'phone':
|
||||
user.phone = credential
|
||||
elif login_type == 'email':
|
||||
user.email = credential
|
||||
|
||||
# 设置默认密码(使用随机密码,用户后续可以修改)
|
||||
user.set_password(uuid.uuid4().hex)
|
||||
user.status = 'active'
|
||||
user.nickname = username
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
is_new_user = True
|
||||
print(f"✅ 自动创建新用户: {username}, {login_type}: {credential}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 创建用户失败: {e}")
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': '创建用户失败'}), 500
|
||||
|
||||
# 清除验证码
|
||||
session.pop(session_key, None)
|
||||
@@ -1955,9 +2048,13 @@ def login_with_verification_code():
|
||||
# 更新最后登录时间
|
||||
user.update_last_seen()
|
||||
|
||||
# 根据是否为新用户返回不同的消息
|
||||
message = '注册成功,欢迎加入!' if is_new_user else '登录成功'
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '登录成功',
|
||||
'message': message,
|
||||
'is_new_user': is_new_user,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
@@ -1971,6 +2068,7 @@ def login_with_verification_code():
|
||||
|
||||
except Exception as e:
|
||||
print(f"验证码登录错误: {e}")
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': '登录失败'}), 500
|
||||
|
||||
|
||||
@@ -2023,8 +2121,8 @@ def register():
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"注册失败: {e}")
|
||||
return jsonify({'success': False, 'error': '注册失败,请重试'}), 500
|
||||
print(f"验证码登录/注册错误: {e}")
|
||||
return jsonify({'success': False, 'error': '登录失败'}), 500
|
||||
|
||||
|
||||
def send_sms_code(phone, code, template_id):
|
||||
@@ -2628,8 +2726,19 @@ def wechat_callback():
|
||||
state = request.args.get('state')
|
||||
error = request.args.get('error')
|
||||
|
||||
# 错误处理
|
||||
if error or not code or not state:
|
||||
# 错误处理:用户拒绝授权
|
||||
if error:
|
||||
if state in wechat_qr_sessions:
|
||||
wechat_qr_sessions[state]['status'] = 'auth_denied'
|
||||
wechat_qr_sessions[state]['error'] = '用户拒绝授权'
|
||||
print(f"❌ 用户拒绝授权: state={state}")
|
||||
return redirect('/auth/signin?error=wechat_auth_denied')
|
||||
|
||||
# 参数验证
|
||||
if not code or not state:
|
||||
if state in wechat_qr_sessions:
|
||||
wechat_qr_sessions[state]['status'] = 'auth_failed'
|
||||
wechat_qr_sessions[state]['error'] = '授权参数缺失'
|
||||
return redirect('/auth/signin?error=wechat_auth_failed')
|
||||
|
||||
# 验证state
|
||||
@@ -2644,14 +2753,28 @@ def wechat_callback():
|
||||
return redirect('/auth/signin?error=session_expired')
|
||||
|
||||
try:
|
||||
# 获取access_token
|
||||
# 步骤1: 用户已扫码并授权(微信回调过来说明用户已完成扫码+授权)
|
||||
session_data['status'] = 'scanned'
|
||||
print(f"✅ 微信扫码回调: state={state}, code={code[:10]}...")
|
||||
|
||||
# 步骤2: 获取access_token
|
||||
token_data = get_wechat_access_token(code)
|
||||
if not token_data:
|
||||
session_data['status'] = 'auth_failed'
|
||||
session_data['error'] = '获取访问令牌失败'
|
||||
print(f"❌ 获取微信access_token失败: state={state}")
|
||||
return redirect('/auth/signin?error=token_failed')
|
||||
|
||||
# 获取用户信息
|
||||
# 步骤3: Token获取成功,标记为已授权
|
||||
session_data['status'] = 'authorized'
|
||||
print(f"✅ 微信授权成功: openid={token_data['openid']}")
|
||||
|
||||
# 步骤4: 获取用户信息
|
||||
user_info = get_wechat_userinfo(token_data['access_token'], token_data['openid'])
|
||||
if not user_info:
|
||||
session_data['status'] = 'auth_failed'
|
||||
session_data['error'] = '获取用户信息失败'
|
||||
print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}")
|
||||
return redirect('/auth/signin?error=userinfo_failed')
|
||||
|
||||
# 查找或创建用户 / 或处理绑定
|
||||
@@ -2696,6 +2819,8 @@ def wechat_callback():
|
||||
return redirect('/home?bind=failed')
|
||||
|
||||
user = None
|
||||
is_new_user = False
|
||||
|
||||
if unionid:
|
||||
user = User.query.filter_by(wechat_union_id=unionid).first()
|
||||
if not user:
|
||||
@@ -2726,6 +2851,9 @@ def wechat_callback():
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
is_new_user = True
|
||||
print(f"✅ 微信扫码自动创建新用户: {username}, openid: {openid}")
|
||||
|
||||
# 更新最后登录时间
|
||||
user.update_last_seen()
|
||||
|
||||
@@ -2739,18 +2867,30 @@ def wechat_callback():
|
||||
# Flask-Login 登录
|
||||
login_user(user, remember=True)
|
||||
|
||||
# 清理微信session(仅登录/注册流程清理;绑定流程在上方已处理,不在此处清理)
|
||||
# 更新微信session状态,供前端轮询检测
|
||||
if state in wechat_qr_sessions:
|
||||
# 仅当不是绑定流程,或没有模式信息时清理
|
||||
if not wechat_qr_sessions[state].get('mode'):
|
||||
del wechat_qr_sessions[state]
|
||||
session_item = wechat_qr_sessions[state]
|
||||
# 仅处理登录/注册流程,不处理绑定流程
|
||||
if not session_item.get('mode'):
|
||||
# 更新状态和用户信息
|
||||
session_item['status'] = 'register_ready' if is_new_user else 'login_ready'
|
||||
session_item['user_info'] = {'user_id': user.id}
|
||||
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
|
||||
|
||||
# 直接跳转到首页
|
||||
return redirect('/home')
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 微信登录失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
db.session.rollback()
|
||||
|
||||
# 更新session状态为失败
|
||||
if state in wechat_qr_sessions:
|
||||
wechat_qr_sessions[state]['status'] = 'auth_failed'
|
||||
wechat_qr_sessions[state]['error'] = str(e)
|
||||
|
||||
return redirect('/auth/signin?error=login_failed')
|
||||
|
||||
|
||||
@@ -2821,61 +2961,6 @@ def login_with_wechat():
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/auth/register/wechat', methods=['POST'])
|
||||
def register_with_wechat():
|
||||
"""微信注册(保留用于特殊情况)"""
|
||||
data = request.get_json()
|
||||
session_id = data.get('session_id')
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
if not all([session_id, username, password]):
|
||||
return jsonify({'error': '所有字段都是必填的'}), 400
|
||||
|
||||
# 验证session
|
||||
session = wechat_qr_sessions.get(session_id)
|
||||
if not session:
|
||||
return jsonify({'error': '微信验证失败或状态无效'}), 400
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
return jsonify({'error': '用户名已存在'}), 400
|
||||
|
||||
# 检查微信OpenID是否已被其他用户使用
|
||||
wechat_openid = session.get('wechat_openid')
|
||||
wechat_unionid = session.get('wechat_unionid')
|
||||
|
||||
if wechat_unionid and User.query.filter_by(wechat_union_id=wechat_unionid).first():
|
||||
return jsonify({'error': '该微信号已被其他用户绑定'}), 400
|
||||
if User.query.filter_by(wechat_open_id=wechat_openid).first():
|
||||
return jsonify({'error': '该微信号已被其他用户绑定'}), 400
|
||||
|
||||
# 创建用户
|
||||
try:
|
||||
wechat_info = session['user_info']
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
# 使用清理后的昵称
|
||||
user.nickname = user._sanitize_nickname(wechat_info.get('nickname', username))
|
||||
user.avatar_url = wechat_info.get('avatar_url')
|
||||
user.wechat_open_id = wechat_openid
|
||||
user.wechat_union_id = wechat_unionid
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# 清除session
|
||||
del wechat_qr_sessions[session_id]
|
||||
|
||||
return jsonify({
|
||||
'message': '注册成功',
|
||||
'user': user.to_dict()
|
||||
}), 201
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"WeChat register error: {e}")
|
||||
return jsonify({'error': '注册失败,请重试'}), 500
|
||||
|
||||
|
||||
@app.route('/api/account/wechat/unbind', methods=['POST'])
|
||||
def unbind_wechat_account():
|
||||
"""解绑当前登录用户的微信"""
|
||||
@@ -4576,8 +4661,8 @@ def get_stock_quotes():
|
||||
|
||||
def get_clickhouse_client():
|
||||
return Cclient(
|
||||
host='111.198.58.126',
|
||||
port=18778,
|
||||
host='222.128.1.157',
|
||||
port=18000,
|
||||
user='default',
|
||||
password='Zzl33818!',
|
||||
database='stock'
|
||||
@@ -7911,6 +7996,98 @@ def format_date(date_obj):
|
||||
return str(date_obj)
|
||||
|
||||
|
||||
def remove_cycles_from_sankey_flows(flows_data):
|
||||
"""
|
||||
移除Sankey图数据中的循环边,确保数据是DAG(有向无环图)
|
||||
使用拓扑排序算法检测循环,优先保留flow_ratio高的边
|
||||
|
||||
Args:
|
||||
flows_data: list of flow objects with 'source', 'target', 'flow_metrics' keys
|
||||
|
||||
Returns:
|
||||
list of flows without cycles
|
||||
"""
|
||||
if not flows_data:
|
||||
return flows_data
|
||||
|
||||
# 按flow_ratio降序排序,优先保留重要的边
|
||||
sorted_flows = sorted(
|
||||
flows_data,
|
||||
key=lambda x: x.get('flow_metrics', {}).get('flow_ratio', 0) or 0,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# 构建图的邻接表和入度表
|
||||
def build_graph(flows):
|
||||
graph = {} # node -> list of successors
|
||||
in_degree = {} # node -> in-degree count
|
||||
all_nodes = set()
|
||||
|
||||
for flow in flows:
|
||||
source = flow['source']['node_name']
|
||||
target = flow['target']['node_name']
|
||||
all_nodes.add(source)
|
||||
all_nodes.add(target)
|
||||
|
||||
if source not in graph:
|
||||
graph[source] = []
|
||||
graph[source].append(target)
|
||||
|
||||
if target not in in_degree:
|
||||
in_degree[target] = 0
|
||||
in_degree[target] += 1
|
||||
|
||||
if source not in in_degree:
|
||||
in_degree[source] = 0
|
||||
|
||||
return graph, in_degree, all_nodes
|
||||
|
||||
# 使用Kahn算法检测是否有环
|
||||
def has_cycle(graph, in_degree, all_nodes):
|
||||
# 找到所有入度为0的节点
|
||||
queue = [node for node in all_nodes if in_degree.get(node, 0) == 0]
|
||||
visited_count = 0
|
||||
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
visited_count += 1
|
||||
|
||||
# 访问所有邻居
|
||||
for neighbor in graph.get(node, []):
|
||||
in_degree[neighbor] -= 1
|
||||
if in_degree[neighbor] == 0:
|
||||
queue.append(neighbor)
|
||||
|
||||
# 如果访问的节点数等于总节点数,说明没有环
|
||||
return visited_count < len(all_nodes)
|
||||
|
||||
# 逐个添加边,如果添加后产生环则跳过
|
||||
result_flows = []
|
||||
|
||||
for flow in sorted_flows:
|
||||
# 尝试添加这条边
|
||||
temp_flows = result_flows + [flow]
|
||||
|
||||
# 检查是否产生环
|
||||
graph, in_degree, all_nodes = build_graph(temp_flows)
|
||||
|
||||
# 复制in_degree用于检测(因为检测过程会修改它)
|
||||
in_degree_copy = in_degree.copy()
|
||||
|
||||
if not has_cycle(graph, in_degree_copy, all_nodes):
|
||||
# 没有产生环,可以添加
|
||||
result_flows.append(flow)
|
||||
else:
|
||||
# 产生环,跳过这条边
|
||||
print(f"Skipping edge that creates cycle: {flow['source']['node_name']} -> {flow['target']['node_name']}")
|
||||
|
||||
removed_count = len(flows_data) - len(result_flows)
|
||||
if removed_count > 0:
|
||||
print(f"Removed {removed_count} edges to eliminate cycles in Sankey diagram")
|
||||
|
||||
return result_flows
|
||||
|
||||
|
||||
def get_report_type(date_str):
|
||||
"""获取报告期类型"""
|
||||
if not date_str:
|
||||
@@ -10159,7 +10336,7 @@ def get_daily_top_concepts():
|
||||
limit = request.args.get('limit', 6, type=int)
|
||||
|
||||
# 构建概念中心API的URL
|
||||
concept_api_url = 'http://111.198.58.126:16801/search'
|
||||
concept_api_url = 'http://222.128.1.157:16801/search'
|
||||
|
||||
# 准备请求数据
|
||||
request_data = {
|
||||
@@ -10621,6 +10798,9 @@ def get_value_chain_analysis(company_code):
|
||||
}
|
||||
})
|
||||
|
||||
# 移除循环边,确保Sankey图数据是DAG(有向无环图)
|
||||
flows_data = remove_cycles_from_sankey_flows(flows_data)
|
||||
|
||||
# 统计各层级节点数量
|
||||
level_stats = {}
|
||||
for level_key, nodes in nodes_by_level.items():
|
||||
|
||||
@@ -143,7 +143,10 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(credential)) {
|
||||
// 清理手机号格式字符(空格、横线、括号等)
|
||||
const cleanedCredential = credential.replace(/[\s\-\(\)\+]/g, '');
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(cleanedCredential)) {
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -156,7 +159,7 @@ export default function AuthFormContent() {
|
||||
setSendingCode(true);
|
||||
|
||||
const requestData = {
|
||||
credential: credential.trim(), // 添加 trim() 防止空格
|
||||
credential: cleanedCredential, // 使用清理后的手机号
|
||||
type: 'phone',
|
||||
purpose: config.api.purpose
|
||||
};
|
||||
@@ -189,13 +192,13 @@ export default function AuthFormContent() {
|
||||
if (response.ok && data.success) {
|
||||
// ❌ 移除成功 toast,静默处理
|
||||
logger.info('AuthFormContent', '验证码发送成功', {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7),
|
||||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7),
|
||||
dev_code: data.dev_code
|
||||
});
|
||||
|
||||
// ✅ 开发环境下在控制台显示验证码
|
||||
if (data.dev_code) {
|
||||
console.log(`%c✅ [验证码] ${credential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
|
||||
console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
|
||||
}
|
||||
|
||||
setVerificationCodeSent(true);
|
||||
@@ -205,7 +208,7 @@ export default function AuthFormContent() {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.api.error('POST', '/api/auth/send-verification-code', error, {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7)
|
||||
});
|
||||
|
||||
// ✅ 显示错误提示给用户
|
||||
@@ -247,7 +250,10 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
// 清理手机号格式字符(空格、横线、括号等)
|
||||
const cleanedPhone = phone.replace(/[\s\-\(\)\+]/g, '');
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(cleanedPhone)) {
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -258,13 +264,13 @@ export default function AuthFormContent() {
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = {
|
||||
credential: phone.trim(), // 添加 trim() 防止空格
|
||||
credential: cleanedPhone, // 使用清理后的手机号
|
||||
verification_code: verificationCode.trim(), // 添加 trim() 防止空格
|
||||
login_type: 'phone',
|
||||
};
|
||||
|
||||
logger.api.request('POST', '/api/auth/login-with-code', {
|
||||
credential: phone.substring(0, 3) + '****' + phone.substring(7),
|
||||
credential: cleanedPhone.substring(0, 3) + '****' + cleanedPhone.substring(7),
|
||||
verification_code: verificationCode.substring(0, 2) + '****',
|
||||
login_type: 'phone'
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { FaQrcode } from "react-icons/fa";
|
||||
import { FiAlertCircle } from "react-icons/fi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService";
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
// 配置常量
|
||||
@@ -33,6 +35,8 @@ const getStatusColor = (status) => {
|
||||
case WECHAT_STATUS.EXPIRED: return "orange.600"; // ✅ 橙色文字
|
||||
case WECHAT_STATUS.LOGIN_SUCCESS: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.REGISTER_SUCCESS: return "green.600";
|
||||
case WECHAT_STATUS.AUTH_DENIED: return "red.600"; // ✅ 红色文字
|
||||
case WECHAT_STATUS.AUTH_FAILED: return "red.600"; // ✅ 红色文字
|
||||
default: return "gray.600";
|
||||
}
|
||||
};
|
||||
@@ -45,6 +49,10 @@ const getStatusText = (status) => {
|
||||
};
|
||||
|
||||
export default function WechatRegister() {
|
||||
// 获取关闭弹窗方法
|
||||
const { closeModal } = useAuthModal();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
// 状态管理
|
||||
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
|
||||
const [wechatSessionId, setWechatSessionId] = useState("");
|
||||
@@ -58,6 +66,7 @@ export default function WechatRegister() {
|
||||
const timeoutRef = useRef(null);
|
||||
const isMountedRef = useRef(true); // 追踪组件挂载状态
|
||||
const containerRef = useRef(null); // 容器DOM引用
|
||||
const sessionIdRef = useRef(null); // 存储最新的 sessionId,避免闭包陷阱
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
@@ -90,6 +99,7 @@ export default function WechatRegister() {
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
* 注意:不清理 sessionIdRef,因为 startPolling 时也会调用此函数
|
||||
*/
|
||||
const clearTimers = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
@@ -124,14 +134,14 @@ export default function WechatRegister() {
|
||||
}
|
||||
|
||||
showSuccess(
|
||||
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "注册成功",
|
||||
"正在跳转..."
|
||||
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "欢迎回来!"
|
||||
);
|
||||
|
||||
// 延迟跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
navigate("/home");
|
||||
}, 1000);
|
||||
// 刷新 AuthContext 状态
|
||||
await refreshSession();
|
||||
|
||||
// 关闭认证弹窗,留在当前页面
|
||||
closeModal();
|
||||
} else {
|
||||
throw new Error(response?.error || '登录失败');
|
||||
}
|
||||
@@ -139,17 +149,27 @@ export default function WechatRegister() {
|
||||
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
|
||||
showError("登录失败", error.message || "请重试");
|
||||
}
|
||||
}, [navigate, showSuccess, showError]);
|
||||
}, [showSuccess, showError, closeModal, refreshSession]);
|
||||
|
||||
/**
|
||||
* 检查微信扫码状态
|
||||
* 使用 sessionIdRef.current 避免闭包陷阱
|
||||
*/
|
||||
const checkWechatStatus = useCallback(async () => {
|
||||
// 检查组件是否已卸载
|
||||
if (!isMountedRef.current || !wechatSessionId) return;
|
||||
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId
|
||||
if (!isMountedRef.current || !sessionIdRef.current) {
|
||||
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
|
||||
isMounted: isMountedRef.current,
|
||||
hasSessionId: !!sessionIdRef.current
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
|
||||
|
||||
try {
|
||||
const response = await authService.checkWechatStatus(wechatSessionId);
|
||||
const response = await authService.checkWechatStatus(currentSessionId);
|
||||
|
||||
// 安全检查:确保 response 存在且包含 status
|
||||
if (!response || typeof response.status === 'undefined') {
|
||||
@@ -158,6 +178,7 @@ export default function WechatRegister() {
|
||||
}
|
||||
|
||||
const { status } = response;
|
||||
logger.debug('WechatRegister', '微信状态', { status });
|
||||
|
||||
// 组件卸载后不再更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
@@ -167,23 +188,14 @@ export default function WechatRegister() {
|
||||
// 处理成功状态
|
||||
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
|
||||
clearTimers(); // 停止轮询
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
|
||||
// 显示"扫码成功,登录中"提示
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "扫码成功",
|
||||
description: "正在登录,请稍候...",
|
||||
status: "info",
|
||||
duration: 2000,
|
||||
isClosable: false,
|
||||
});
|
||||
}
|
||||
|
||||
await handleLoginSuccess(wechatSessionId, status);
|
||||
await handleLoginSuccess(currentSessionId, status);
|
||||
}
|
||||
// 处理过期状态
|
||||
else if (status === WECHAT_STATUS.EXPIRED) {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "授权已过期",
|
||||
@@ -194,12 +206,40 @@ export default function WechatRegister() {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理用户拒绝授权
|
||||
else if (status === WECHAT_STATUS.AUTH_DENIED) {
|
||||
clearTimers();
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "授权已取消",
|
||||
description: "您已取消微信授权登录",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理授权失败
|
||||
else if (status === WECHAT_STATUS.AUTH_FAILED) {
|
||||
clearTimers();
|
||||
if (isMountedRef.current) {
|
||||
const errorMsg = response.error || "授权过程出现错误";
|
||||
toast({
|
||||
title: "授权失败",
|
||||
description: errorMsg,
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: wechatSessionId });
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
|
||||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||||
// 但如果错误持续发生,停止轮询避免无限重试
|
||||
if (error.message.includes('网络连接失败')) {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "网络连接失败",
|
||||
@@ -211,12 +251,17 @@ export default function WechatRegister() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [wechatSessionId, handleLoginSuccess, clearTimers, toast]);
|
||||
}, [handleLoginSuccess, clearTimers, toast]);
|
||||
|
||||
/**
|
||||
* 启动轮询
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
logger.debug('WechatRegister', '启动轮询', {
|
||||
sessionId: sessionIdRef.current,
|
||||
interval: POLL_INTERVAL
|
||||
});
|
||||
|
||||
// 清理旧的定时器
|
||||
clearTimers();
|
||||
|
||||
@@ -227,7 +272,9 @@ export default function WechatRegister() {
|
||||
|
||||
// 设置超时
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
logger.debug('WechatRegister', '二维码超时');
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
setWechatStatus(WECHAT_STATUS.EXPIRED);
|
||||
}, QR_CODE_TIMEOUT);
|
||||
}, [checkWechatStatus, clearTimers]);
|
||||
@@ -254,10 +301,17 @@ export default function WechatRegister() {
|
||||
throw new Error(response.message || '获取二维码失败');
|
||||
}
|
||||
|
||||
// 同时更新 ref 和 state,确保轮询能立即读取到最新值
|
||||
sessionIdRef.current = response.data.session_id;
|
||||
setWechatAuthUrl(response.data.auth_url);
|
||||
setWechatSessionId(response.data.session_id);
|
||||
setWechatStatus(WECHAT_STATUS.WAITING);
|
||||
|
||||
logger.debug('WechatRegister', '获取二维码成功', {
|
||||
sessionId: response.data.session_id,
|
||||
authUrl: response.data.auth_url
|
||||
});
|
||||
|
||||
// 启动轮询检查扫码状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
@@ -293,43 +347,10 @@ export default function WechatRegister() {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
/**
|
||||
* 备用轮询机制 - 防止丢失状态
|
||||
* 每3秒检查一次,仅在获取到二维码URL且状态为waiting时执行
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 只在有auth_url、session_id且状态为waiting时启动备用轮询
|
||||
if (wechatAuthUrl && wechatSessionId && wechatStatus === WECHAT_STATUS.WAITING) {
|
||||
logger.debug('WechatRegister', '备用轮询:启动备用轮询机制');
|
||||
|
||||
backupPollIntervalRef.current = setInterval(() => {
|
||||
try {
|
||||
if (wechatStatus === WECHAT_STATUS.WAITING && isMountedRef.current) {
|
||||
logger.debug('WechatRegister', '备用轮询:检查微信状态');
|
||||
// 添加 .catch() 静默处理异步错误,防止被 ErrorBoundary 捕获
|
||||
checkWechatStatus().catch(error => {
|
||||
logger.warn('WechatRegister', '备用轮询检查失败(静默处理)', { error: error.message });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 捕获所有同步错误,防止被 ErrorBoundary 捕获
|
||||
logger.warn('WechatRegister', '备用轮询执行出错(静默处理)', { error: error.message });
|
||||
}
|
||||
}, BACKUP_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
// 清理备用轮询
|
||||
return () => {
|
||||
if (backupPollIntervalRef.current) {
|
||||
clearInterval(backupPollIntervalRef.current);
|
||||
backupPollIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [wechatAuthUrl, wechatSessionId, wechatStatus, checkWechatStatus]);
|
||||
|
||||
/**
|
||||
* 测量容器尺寸并计算缩放比例
|
||||
*/
|
||||
@@ -397,7 +418,7 @@ export default function WechatRegister() {
|
||||
textAlign="center"
|
||||
mb={3} // 12px底部间距
|
||||
>
|
||||
微信扫码
|
||||
微信登陆
|
||||
</Heading>
|
||||
|
||||
{/* ========== 二维码区域 ========== */}
|
||||
@@ -414,19 +435,26 @@ export default function WechatRegister() {
|
||||
bg="gray.50"
|
||||
boxShadow="sm" // ✅ 添加轻微阴影
|
||||
>
|
||||
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||
{wechatStatus !== WECHAT_STATUS.NONE ? (
|
||||
/* 已获取二维码:显示iframe */
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-20px)', // ✅ 裁剪顶部logo
|
||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||
transformOrigin: 'top left',
|
||||
marginLeft: '-5px'
|
||||
marginLeft: '-5px',
|
||||
pointerEvents: 'auto', // 允许点击 │ │
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
}}
|
||||
// 使用 onWheel 事件阻止滚动 │ │
|
||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Button,
|
||||
VStack,
|
||||
Container
|
||||
} from '@chakra-ui/react';
|
||||
// import {
|
||||
// Box,
|
||||
// Alert,
|
||||
// AlertIcon,
|
||||
// AlertTitle,
|
||||
// AlertDescription,
|
||||
// Button,
|
||||
// VStack,
|
||||
// Container
|
||||
// } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
@@ -40,66 +40,68 @@ class ErrorBoundary extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// 如果有错误,显示错误边界(所有环境)
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Container maxW="lg" py={20}>
|
||||
<VStack spacing={6}>
|
||||
<Alert status="error" borderRadius="lg" p={6}>
|
||||
<AlertIcon boxSize="24px" />
|
||||
<Box>
|
||||
<AlertTitle fontSize="lg" mb={2}>
|
||||
页面出现错误!
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{process.env.NODE_ENV === 'development'
|
||||
? '组件渲染时发生错误,请查看下方详情和控制台日志。'
|
||||
: '页面加载时发生了未预期的错误,请尝试刷新页面。'}
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
// 静默模式:捕获错误并记录日志(已在 componentDidCatch 中完成)
|
||||
// 但继续渲染子组件,不显示错误页面
|
||||
// 注意:如果组件因错误无法渲染,该区域可能显示为空白
|
||||
// // 如果有错误,显示错误边界(所有环境)
|
||||
// if (this.state.hasError) {
|
||||
// return (
|
||||
// <Container maxW="lg" py={20}>
|
||||
// <VStack spacing={6}>
|
||||
// <Alert status="error" borderRadius="lg" p={6}>
|
||||
// <AlertIcon boxSize="24px" />
|
||||
// <Box>
|
||||
// <AlertTitle fontSize="lg" mb={2}>
|
||||
// 页面出现错误!
|
||||
// </AlertTitle>
|
||||
// <AlertDescription>
|
||||
// {process.env.NODE_ENV === 'development'
|
||||
// ? '组件渲染时发生错误,请查看下方详情和控制台日志。'
|
||||
// : '页面加载时发生了未预期的错误,请尝试刷新页面。'}
|
||||
// </AlertDescription>
|
||||
// </Box>
|
||||
// </Alert>
|
||||
|
||||
{/* 开发环境显示详细错误信息 */}
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<Box
|
||||
w="100%"
|
||||
bg="red.50"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
fontSize="sm"
|
||||
overflow="auto"
|
||||
maxH="400px"
|
||||
border="1px"
|
||||
borderColor="red.200"
|
||||
>
|
||||
<Box fontWeight="bold" mb={2} color="red.700">错误详情:</Box>
|
||||
<Box as="pre" whiteSpace="pre-wrap" color="red.900" fontSize="xs">
|
||||
<Box fontWeight="bold" mb={1}>{this.state.error.name}: {this.state.error.message}</Box>
|
||||
{this.state.error.stack && (
|
||||
<Box mt={2} color="gray.700">{this.state.error.stack}</Box>
|
||||
)}
|
||||
{this.state.errorInfo && this.state.errorInfo.componentStack && (
|
||||
<>
|
||||
<Box fontWeight="bold" mt={3} mb={1} color="red.700">组件堆栈:</Box>
|
||||
<Box color="gray.700">{this.state.errorInfo.componentStack}</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新加载页面
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
// {/* 开发环境显示详细错误信息 */}
|
||||
// {process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
// <Box
|
||||
// w="100%"
|
||||
// bg="red.50"
|
||||
// p={4}
|
||||
// borderRadius="lg"
|
||||
// fontSize="sm"
|
||||
// overflow="auto"
|
||||
// maxH="400px"
|
||||
// border="1px"
|
||||
// borderColor="red.200"
|
||||
// >
|
||||
// <Box fontWeight="bold" mb={2} color="red.700">错误详情:</Box>
|
||||
// <Box as="pre" whiteSpace="pre-wrap" color="red.900" fontSize="xs">
|
||||
// <Box fontWeight="bold" mb={1}>{this.state.error.name}: {this.state.error.message}</Box>
|
||||
// {this.state.error.stack && (
|
||||
// <Box mt={2} color="gray.700">{this.state.error.stack}</Box>
|
||||
// )}
|
||||
// {this.state.errorInfo && this.state.errorInfo.componentStack && (
|
||||
// <>
|
||||
// <Box fontWeight="bold" mt={3} mb={1} color="red.700">组件堆栈:</Box>
|
||||
// <Box color="gray.700">{this.state.errorInfo.componentStack}</Box>
|
||||
// </>
|
||||
// )}
|
||||
// </Box>
|
||||
// </Box>
|
||||
// )}
|
||||
|
||||
// <Button
|
||||
// colorScheme="blue"
|
||||
// size="lg"
|
||||
// onClick={() => window.location.reload()}
|
||||
// >
|
||||
// 重新加载页面
|
||||
// </Button>
|
||||
// </VStack>
|
||||
// </Container>
|
||||
// );
|
||||
// }
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,59 +212,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 注册方法
|
||||
const register = async (username, email, password) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('email', email);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await fetch(`/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '注册失败');
|
||||
}
|
||||
|
||||
// 注册成功后自动登录
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿!",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// ⚡ 注册成功后显示欢迎引导(延迟2秒)
|
||||
setTimeout(() => {
|
||||
showWelcomeGuide();
|
||||
}, 2000);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'register', error);
|
||||
|
||||
// ❌ 移除错误 toast,静默失败
|
||||
return { success: false, error: error.message };
|
||||
} finally{
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 手机号注册
|
||||
const registerWithPhone = async (phone, code, username, password) => {
|
||||
@@ -475,7 +422,6 @@ export const AuthProvider = ({ children }) => {
|
||||
isLoading,
|
||||
updateUser,
|
||||
login,
|
||||
register,
|
||||
registerWithPhone,
|
||||
registerWithEmail,
|
||||
sendSmsCode,
|
||||
|
||||
@@ -572,14 +572,10 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 连接到 Socket 服务
|
||||
useEffect(() => {
|
||||
logger.info('NotificationContext', 'Initializing socket connection...');
|
||||
console.log(`%c[NotificationContext] Initializing socket (type: ${SOCKET_TYPE})`, 'color: #673AB7; font-weight: bold;');
|
||||
|
||||
// 连接 socket
|
||||
socket.connect();
|
||||
|
||||
// 获取并保存最大重连次数
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
// ✅ 第一步: 注册所有事件监听器
|
||||
console.log('%c[NotificationContext] Step 1: Registering event listeners...', 'color: #673AB7;');
|
||||
|
||||
// 监听连接状态
|
||||
socket.on('connect', () => {
|
||||
@@ -587,6 +583,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
setIsConnected(true);
|
||||
setReconnectAttempt(0);
|
||||
logger.info('NotificationContext', 'Socket connected', { wasDisconnected });
|
||||
console.log('%c[NotificationContext] ✅ Received connect event, updating state to connected', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// 如果之前断开过,显示 RECONNECTED 状态2秒后自动消失
|
||||
if (wasDisconnected) {
|
||||
@@ -683,6 +680,18 @@ export const NotificationProvider = ({ children }) => {
|
||||
addNotification(data);
|
||||
});
|
||||
|
||||
console.log('%c[NotificationContext] ✅ All event listeners registered', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// ✅ 第二步: 获取最大重连次数
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
|
||||
// ✅ 第三步: 调用 socket.connect()
|
||||
console.log('%c[NotificationContext] Step 2: Calling socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
socket.connect();
|
||||
console.log('%c[NotificationContext] socket.connect() completed', 'color: #673AB7;');
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
logger.info('NotificationContext', 'Cleaning up socket connection');
|
||||
@@ -700,7 +709,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
socket.off('system_notification');
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [adaptEventToNotification, connectionStatus, toast]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, []); // ✅ 空依赖数组,确保只执行一次,避免 React 严格模式重复执行
|
||||
|
||||
// ==================== 智能自动重试 ====================
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { socketService } from '../services/socketService';
|
||||
import socket from '../services/socket';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const useEventNotifications = (options = {}) => {
|
||||
@@ -80,26 +80,31 @@ export const useEventNotifications = (options = {}) => {
|
||||
};
|
||||
|
||||
// 监听连接事件(必须在connect之前设置,否则可能错过事件)
|
||||
socketService.on('connect', handleConnect);
|
||||
socketService.on('disconnect', handleDisconnect);
|
||||
socketService.on('connect_error', handleConnectError);
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('disconnect', handleDisconnect);
|
||||
socket.on('connect_error', handleConnectError);
|
||||
|
||||
// 连接 WebSocket
|
||||
console.log('[useEventNotifications DEBUG] 准备连接 WebSocket...');
|
||||
logger.info('useEventNotifications', 'Initializing WebSocket connection');
|
||||
|
||||
// 先检查是否已经连接
|
||||
const alreadyConnected = socketService.isConnected();
|
||||
const alreadyConnected = socket.connected || false;
|
||||
console.log('[useEventNotifications DEBUG] 当前连接状态:', alreadyConnected);
|
||||
logger.info('useEventNotifications', 'Pre-connection check', { isConnected: alreadyConnected });
|
||||
|
||||
if (alreadyConnected) {
|
||||
// 如果已经连接,直接更新状态
|
||||
console.log('[useEventNotifications DEBUG] Socket已连接,直接更新状态');
|
||||
logger.info('useEventNotifications', 'Socket already connected, updating state immediately');
|
||||
setIsConnected(true);
|
||||
// 验证状态更新
|
||||
setTimeout(() => {
|
||||
console.log('[useEventNotifications DEBUG] 1秒后验证状态更新 - isConnected应该为true');
|
||||
}, 1000);
|
||||
} else {
|
||||
// 否则建立新连接
|
||||
socketService.connect();
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
// 新事件处理函数 - 使用 ref 中的回调
|
||||
@@ -131,21 +136,28 @@ export const useEventNotifications = (options = {}) => {
|
||||
console.log('[useEventNotifications DEBUG] importance:', importance);
|
||||
console.log('[useEventNotifications DEBUG] enabled:', enabled);
|
||||
|
||||
socketService.subscribeToEvents({
|
||||
eventType,
|
||||
importance,
|
||||
onNewEvent: handleNewEvent,
|
||||
onSubscribed: (data) => {
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 订阅成功回调 ==========');
|
||||
console.log('[useEventNotifications DEBUG] 订阅数据:', data);
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅成功处理完成 ==========\n');
|
||||
},
|
||||
});
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅请求已发送 ==========\n');
|
||||
// 检查 socket 是否有 subscribeToEvents 方法(mockSocketService 和 socketService 都有)
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType,
|
||||
importance,
|
||||
onNewEvent: handleNewEvent,
|
||||
onSubscribed: (data) => {
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 订阅成功回调 ==========');
|
||||
console.log('[useEventNotifications DEBUG] 订阅数据:', data);
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅成功处理完成 ==========\n');
|
||||
},
|
||||
});
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅请求已发送 ==========\n');
|
||||
} else {
|
||||
console.warn('[useEventNotifications] socket.subscribeToEvents 方法不存在');
|
||||
}
|
||||
|
||||
// 保存取消订阅函数
|
||||
unsubscribeRef.current = () => {
|
||||
socketService.unsubscribeFromEvents({ eventType });
|
||||
if (socket.unsubscribeFromEvents) {
|
||||
socket.unsubscribeFromEvents({ eventType });
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
@@ -160,18 +172,25 @@ export const useEventNotifications = (options = {}) => {
|
||||
|
||||
// 移除监听器
|
||||
console.log('[useEventNotifications DEBUG] 移除事件监听器...');
|
||||
socketService.off('connect', handleConnect);
|
||||
socketService.off('disconnect', handleDisconnect);
|
||||
socketService.off('connect_error', handleConnectError);
|
||||
|
||||
// 断开连接
|
||||
console.log('[useEventNotifications DEBUG] 断开 WebSocket 连接...');
|
||||
socketService.disconnect();
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('disconnect', handleDisconnect);
|
||||
socket.off('connect_error', handleConnectError);
|
||||
|
||||
// 注意:不断开连接,因为 socket 是全局共享的
|
||||
// 由 NotificationContext 统一管理连接生命周期
|
||||
console.log('[useEventNotifications DEBUG] ========== 清理完成 ==========\n');
|
||||
};
|
||||
}, [eventType, importance, enabled]); // 移除 onNewEvent 依赖
|
||||
|
||||
// 监控 isConnected 状态变化(调试用)
|
||||
useEffect(() => {
|
||||
console.log('[useEventNotifications DEBUG] ========== isConnected 状态变化 ==========');
|
||||
console.log('[useEventNotifications DEBUG] isConnected:', isConnected);
|
||||
console.log('[useEventNotifications DEBUG] ===========================================');
|
||||
}, [isConnected]);
|
||||
|
||||
console.log('[useEventNotifications DEBUG] Hook返回值 - isConnected:', isConnected);
|
||||
|
||||
return {
|
||||
newEvent, // 最新收到的事件
|
||||
isConnected, // WebSocket 连接状态
|
||||
|
||||
535
src/mocks/data/company.js
Normal file
535
src/mocks/data/company.js
Normal file
@@ -0,0 +1,535 @@
|
||||
// src/mocks/data/company.js
|
||||
// 公司相关的 Mock 数据
|
||||
|
||||
// 平安银行 (000001) 的完整数据
|
||||
export const PINGAN_BANK_DATA = {
|
||||
stockCode: '000001',
|
||||
stockName: '平安银行',
|
||||
|
||||
// 基本信息
|
||||
basicInfo: {
|
||||
code: '000001',
|
||||
name: '平安银行',
|
||||
english_name: 'Ping An Bank Co., Ltd.',
|
||||
registered_capital: 1940642.3, // 万元
|
||||
registered_capital_unit: '万元',
|
||||
legal_representative: '谢永林',
|
||||
general_manager: '谢永林',
|
||||
secretary: '周强',
|
||||
registered_address: '深圳市深南东路5047号',
|
||||
office_address: '深圳市深南东路5047号',
|
||||
zipcode: '518001',
|
||||
phone: '0755-82080387',
|
||||
fax: '0755-82080386',
|
||||
email: 'ir@bank.pingan.com',
|
||||
website: 'http://bank.pingan.com',
|
||||
business_scope: '吸收公众存款;发放短期、中期和长期贷款;办理国内外结算;办理票据承兑与贴现;发行金融债券;代理发行、代理兑付、承销政府债券;买卖政府债券、金融债券;从事同业拆借;买卖、代理买卖外汇;从事银行卡业务;提供信用证服务及担保;代理收付款项及代理保险业务;提供保管箱服务;经有关监管机构批准的其他业务。',
|
||||
employees: 36542,
|
||||
introduction: '平安银行股份有限公司是中国平安保险(集团)股份有限公司控股的一家跨区域经营的股份制商业银行,为中国大陆12家全国性股份制商业银行之一。注册资本为人民币51.2335亿元,总资产近1.37万亿元,总部位于深圳。平安银行拥有全国性银行经营资质,主要经营商业银行业务。',
|
||||
list_date: '1991-04-03',
|
||||
establish_date: '1987-12-22',
|
||||
province: '广东省',
|
||||
city: '深圳市',
|
||||
industry: '银行',
|
||||
main_business: '商业银行业务',
|
||||
},
|
||||
|
||||
// 实际控制人信息
|
||||
actualControl: {
|
||||
controller_name: '中国平安保险(集团)股份有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 52.38,
|
||||
control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司',
|
||||
is_listed: true,
|
||||
change_date: '2023-12-31',
|
||||
remark: '中国平安通过直接和间接方式控股平安银行',
|
||||
},
|
||||
|
||||
// 股权集中度
|
||||
concentration: {
|
||||
top1_ratio: 52.38,
|
||||
top3_ratio: 58.42,
|
||||
top5_ratio: 60.15,
|
||||
top10_ratio: 63.28,
|
||||
update_date: '2024-09-30',
|
||||
concentration_level: '高度集中',
|
||||
herfindahl_index: 0.2845,
|
||||
},
|
||||
|
||||
// 高管信息
|
||||
management: [
|
||||
{
|
||||
name: '谢永林',
|
||||
position: '董事长、执行董事、行长',
|
||||
gender: '男',
|
||||
age: 56,
|
||||
education: '硕士',
|
||||
appointment_date: '2019-01-01',
|
||||
annual_compensation: 723.8,
|
||||
shareholding: 0,
|
||||
background: '中国平安保险(集团)股份有限公司副总经理兼首席保险业务执行官'
|
||||
},
|
||||
{
|
||||
name: '周强',
|
||||
position: '执行董事、副行长、董事会秘书',
|
||||
gender: '男',
|
||||
age: 54,
|
||||
education: '硕士',
|
||||
appointment_date: '2016-06-01',
|
||||
annual_compensation: 542.3,
|
||||
shareholding: 0.002,
|
||||
background: '历任平安银行深圳分行行长'
|
||||
},
|
||||
{
|
||||
name: '郭世邦',
|
||||
position: '执行董事、副行长、首席财务官',
|
||||
gender: '男',
|
||||
age: 52,
|
||||
education: '博士',
|
||||
appointment_date: '2018-03-01',
|
||||
annual_compensation: 498.6,
|
||||
shareholding: 0.001,
|
||||
background: '历任中国平安集团财务负责人'
|
||||
},
|
||||
{
|
||||
name: '蔡新发',
|
||||
position: '副行长、首席风险官',
|
||||
gender: '男',
|
||||
age: 51,
|
||||
education: '硕士',
|
||||
appointment_date: '2017-05-01',
|
||||
annual_compensation: 467.2,
|
||||
shareholding: 0.0008,
|
||||
background: '历任平安银行风险管理部总经理'
|
||||
},
|
||||
{
|
||||
name: '项有志',
|
||||
position: '副行长、首席信息官',
|
||||
gender: '男',
|
||||
age: 49,
|
||||
education: '硕士',
|
||||
appointment_date: '2019-09-01',
|
||||
annual_compensation: 425.1,
|
||||
shareholding: 0,
|
||||
background: '历任中国平安科技公司总经理'
|
||||
}
|
||||
],
|
||||
|
||||
// 十大流通股东
|
||||
topCirculationShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业' },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业' },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司' },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金' },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司' },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人' }
|
||||
],
|
||||
|
||||
// 十大股东(与流通股东相同,因为平安银行全流通)
|
||||
topShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业', is_restricted: false },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业', is_restricted: false },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司', is_restricted: false },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司', is_restricted: false },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人', is_restricted: false }
|
||||
],
|
||||
|
||||
// 分支机构
|
||||
branches: [
|
||||
{ name: '北京分行', address: '北京市朝阳区建国路88号SOHO现代城', phone: '010-85806888', type: '一级分行', establish_date: '2007-03-15' },
|
||||
{ name: '上海分行', address: '上海市浦东新区陆家嘴环路1366号', phone: '021-38637777', type: '一级分行', establish_date: '2007-05-20' },
|
||||
{ name: '广州分行', address: '广州市天河区珠江新城珠江东路32号', phone: '020-38390888', type: '一级分行', establish_date: '2007-06-10' },
|
||||
{ name: '深圳分行', address: '深圳市福田区益田路5033号', phone: '0755-82538888', type: '一级分行', establish_date: '1995-01-01' },
|
||||
{ name: '杭州分行', address: '杭州市江干区钱江路1366号', phone: '0571-87028888', type: '一级分行', establish_date: '2008-09-12' },
|
||||
{ name: '成都分行', address: '成都市武侯区人民南路四段13号', phone: '028-85266888', type: '一级分行', establish_date: '2009-04-25' },
|
||||
{ name: '南京分行', address: '南京市建邺区江东中路359号', phone: '025-86625888', type: '一级分行', establish_date: '2010-06-30' },
|
||||
{ name: '武汉分行', address: '武汉市江汉区建设大道568号', phone: '027-85712888', type: '一级分行', establish_date: '2011-08-15' },
|
||||
{ name: '西安分行', address: '西安市高新区唐延路35号', phone: '029-88313888', type: '一级分行', establish_date: '2012-10-20' },
|
||||
{ name: '天津分行', address: '天津市和平区南京路189号', phone: '022-23399888', type: '一级分行', establish_date: '2013-03-18' }
|
||||
],
|
||||
|
||||
// 公告列表
|
||||
announcements: [
|
||||
{
|
||||
title: '平安银行股份有限公司2024年第三季度报告',
|
||||
publish_date: '2024-10-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年前三季度实现营业收入1245.6亿元,同比增长8.2%;净利润402.3亿元,同比增长12.5%',
|
||||
url: '/announcement/detail/ann_20241028_001'
|
||||
},
|
||||
{
|
||||
title: '关于召开2024年第一次临时股东大会的通知',
|
||||
publish_date: '2024-10-15',
|
||||
type: '临时公告',
|
||||
summary: '定于2024年11月5日召开2024年第一次临时股东大会,审议关于调整董事会成员等议案',
|
||||
url: '/announcement/detail/ann_20241015_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司关于完成注册资本变更登记的公告',
|
||||
publish_date: '2024-09-20',
|
||||
type: '临时公告',
|
||||
summary: '公司已完成注册资本由人民币194.06亿元变更为194.06亿元的工商变更登记手续',
|
||||
url: '/announcement/detail/ann_20240920_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司2024年半年度报告',
|
||||
publish_date: '2024-08-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年上半年实现营业收入828.5亿元,同比增长7.8%;净利润265.4亿元,同比增长11.2%',
|
||||
url: '/announcement/detail/ann_20240828_001'
|
||||
},
|
||||
{
|
||||
title: '关于2024年上半年利润分配预案的公告',
|
||||
publish_date: '2024-08-20',
|
||||
type: '分配方案',
|
||||
summary: '拟以总股本194.06亿股为基数,向全体股东每10股派发现金红利2.8元(含税)',
|
||||
url: '/announcement/detail/ann_20240820_001'
|
||||
}
|
||||
],
|
||||
|
||||
// 披露时间表
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第四季度报告', planned_date: '2025-01-31', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
{ report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' },
|
||||
{ report_type: '2024年第一季度报告', planned_date: '2024-04-30', status: '已披露' }
|
||||
],
|
||||
|
||||
// 综合分析
|
||||
comprehensiveAnalysis: {
|
||||
overview: {
|
||||
company_name: '平安银行股份有限公司',
|
||||
stock_code: '000001',
|
||||
industry: '银行',
|
||||
established_date: '1987-12-22',
|
||||
listing_date: '1991-04-03',
|
||||
total_assets: 50245.6, // 亿元
|
||||
net_assets: 3256.8,
|
||||
registered_capital: 194.06,
|
||||
employee_count: 36542
|
||||
},
|
||||
financial_highlights: {
|
||||
revenue: 1623.5,
|
||||
revenue_growth: 8.5,
|
||||
net_profit: 528.6,
|
||||
profit_growth: 12.3,
|
||||
roe: 16.23,
|
||||
roa: 1.05,
|
||||
asset_quality_ratio: 1.02,
|
||||
capital_adequacy_ratio: 13.45,
|
||||
core_tier1_ratio: 10.82
|
||||
},
|
||||
business_structure: [
|
||||
{ business: '对公业务', revenue: 685.4, ratio: 42.2, growth: 6.8 },
|
||||
{ business: '零售业务', revenue: 812.3, ratio: 50.1, growth: 11.2 },
|
||||
{ business: '金融市场业务', revenue: 125.8, ratio: 7.7, growth: 3.5 }
|
||||
],
|
||||
competitive_advantages: [
|
||||
'背靠中国平安集团,综合金融优势明显',
|
||||
'零售业务转型成效显著,客户基础雄厚',
|
||||
'金融科技创新能力强,数字化银行建设领先',
|
||||
'风险管理体系完善,资产质量稳定',
|
||||
'管理团队经验丰富,执行力强'
|
||||
],
|
||||
risk_factors: [
|
||||
'宏观经济下行压力影响信贷质量',
|
||||
'利率市场化导致息差收窄',
|
||||
'金融监管趋严,合规成本上升',
|
||||
'同业竞争激烈,市场份额面临挑战',
|
||||
'金融科技发展带来的技术和运营风险'
|
||||
],
|
||||
development_strategy: '坚持"科技引领、零售突破、对公做精"战略,加快数字化转型,提升综合金融服务能力',
|
||||
analyst_rating: {
|
||||
buy: 18,
|
||||
hold: 12,
|
||||
sell: 2,
|
||||
target_price: 15.8,
|
||||
current_price: 13.2
|
||||
}
|
||||
},
|
||||
|
||||
// 价值链分析
|
||||
valueChainAnalysis: {
|
||||
upstream: [
|
||||
{ name: '央行及监管机构', relationship: '政策与监管', importance: '高', description: '接受货币政策调控和监管指导' },
|
||||
{ name: '同业资金市场', relationship: '资金来源', importance: '高', description: '开展同业拆借、债券回购等业务' },
|
||||
{ name: '金融科技公司', relationship: '技术支持', importance: '中', description: '提供金融科技解决方案和技术服务' }
|
||||
],
|
||||
core_business: {
|
||||
deposit_business: { scale: 33256.8, market_share: 2.8, growth_rate: 9.2 },
|
||||
loan_business: { scale: 28945.3, market_share: 2.5, growth_rate: 12.5 },
|
||||
intermediary_business: { scale: 425.6, market_share: 3.2, growth_rate: 15.8 },
|
||||
digital_banking: { user_count: 11256, app_mau: 4235, growth_rate: 28.5 }
|
||||
},
|
||||
downstream: [
|
||||
{ name: '个人客户', scale: '1.12亿户', contribution: '50.1%', description: '零售银行业务主体' },
|
||||
{ name: '企业客户', scale: '85.6万户', contribution: '42.2%', description: '对公业务主体' },
|
||||
{ name: '政府机构', scale: '2.3万户', contribution: '7.7%', description: '公共事业及政府业务' }
|
||||
],
|
||||
ecosystem_partners: [
|
||||
{ name: '中国平安集团', type: '关联方', cooperation: '综合金融服务、客户共享' },
|
||||
{ name: '平安科技', type: '科技支持', cooperation: '金融科技研发、系统建设' },
|
||||
{ name: '平安普惠', type: '业务协同', cooperation: '普惠金融、小微贷款' },
|
||||
{ name: '平安证券', type: '业务协同', cooperation: '投资银行、资产管理' }
|
||||
]
|
||||
},
|
||||
|
||||
// 关键因素时间线
|
||||
keyFactorsTimeline: [
|
||||
{
|
||||
date: '2024-10-28',
|
||||
event: '发布2024年三季报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '前三季度净利润同比增长12.5%,超市场预期',
|
||||
change: '+5.2%'
|
||||
},
|
||||
{
|
||||
date: '2024-09-15',
|
||||
event: '推出AI智能客服系统',
|
||||
type: '科技创新',
|
||||
importance: 'medium',
|
||||
impact: '提升客户服务效率,降低运营成本',
|
||||
change: '+2.1%'
|
||||
},
|
||||
{
|
||||
date: '2024-08-28',
|
||||
event: '发布2024年中报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '上半年净利润增长11.2%,资产质量保持稳定',
|
||||
change: '+3.8%'
|
||||
},
|
||||
{
|
||||
date: '2024-07-20',
|
||||
event: '获批设立理财子公司',
|
||||
type: '业务拓展',
|
||||
importance: 'high',
|
||||
impact: '完善财富管理业务布局,拓展收入来源',
|
||||
change: '+4.5%'
|
||||
},
|
||||
{
|
||||
date: '2024-06-10',
|
||||
event: '完成300亿元二级资本债发行',
|
||||
type: '融资事件',
|
||||
importance: 'medium',
|
||||
impact: '补充资本实力,支持业务扩张',
|
||||
change: '+1.8%'
|
||||
},
|
||||
{
|
||||
date: '2024-04-30',
|
||||
event: '发布2024年一季报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '一季度净利润增长10.8%,开门红表现优异',
|
||||
change: '+4.2%'
|
||||
},
|
||||
{
|
||||
date: '2024-03-15',
|
||||
event: '零售客户突破1.1亿户',
|
||||
type: '业务里程碑',
|
||||
importance: 'medium',
|
||||
impact: '零售转型成效显著,客户基础进一步夯实',
|
||||
change: '+2.5%'
|
||||
},
|
||||
{
|
||||
date: '2024-01-20',
|
||||
event: '获评"2023年度最佳零售银行"',
|
||||
type: '荣誉奖项',
|
||||
importance: 'low',
|
||||
impact: '品牌影响力提升',
|
||||
change: '+0.8%'
|
||||
}
|
||||
],
|
||||
|
||||
// 盈利预测报告
|
||||
forecastReport: {
|
||||
// 营收与利润趋势
|
||||
income_profit_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
income: [116524, 134632, 148956, 162350, 175280, 189450, 204120], // 营业总收入(百万元)
|
||||
profit: [34562, 39845, 43218, 52860, 58420, 64680, 71250] // 归母净利润(百万元)
|
||||
},
|
||||
// 增长率分析
|
||||
growth_bars: {
|
||||
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
revenue_growth_pct: [15.5, 10.6, 8.9, 8.0, 8.1, 7.7] // 营收增长率(%)
|
||||
},
|
||||
// EPS趋势
|
||||
eps_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
eps: [1.78, 2.05, 2.23, 2.72, 3.01, 3.33, 3.67] // EPS(稀释,元/股)
|
||||
},
|
||||
// PE与PEG分析
|
||||
pe_peg_axes: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
pe: [7.4, 6.9, 7.2, 4.9, 4.4, 4.0, 3.6], // PE(倍)
|
||||
peg: [0.48, 0.65, 0.81, 0.55, 0.55, 0.49, 0.47] // PEG
|
||||
},
|
||||
// 详细数据表格
|
||||
detail_table: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
rows: [
|
||||
{ '指标': '营业总收入(百万元)', '2020': 116524, '2021': 134632, '2022': 148956, '2023': 162350, '2024E': 175280, '2025E': 189450, '2026E': 204120 },
|
||||
{ '指标': '营收增长率(%)', '2020': '-', '2021': 15.5, '2022': 10.6, '2023': 8.9, '2024E': 8.0, '2025E': 8.1, '2026E': 7.7 },
|
||||
{ '指标': '归母净利润(百万元)', '2020': 34562, '2021': 39845, '2022': 43218, '2023': 52860, '2024E': 58420, '2025E': 64680, '2026E': 71250 },
|
||||
{ '指标': '净利润增长率(%)', '2020': '-', '2021': 15.3, '2022': 8.5, '2023': 22.3, '2024E': 10.5, '2025E': 10.7, '2026E': 10.2 },
|
||||
{ '指标': 'EPS(稀释,元)', '2020': 1.78, '2021': 2.05, '2022': 2.23, '2023': 2.72, '2024E': 3.01, '2025E': 3.33, '2026E': 3.67 },
|
||||
{ '指标': 'ROE(%)', '2020': 14.2, '2021': 15.8, '2022': 15.5, '2023': 16.2, '2024E': 16.5, '2025E': 16.8, '2026E': 17.0 },
|
||||
{ '指标': '总资产(百万元)', '2020': 4512360, '2021': 4856230, '2022': 4923150, '2023': 5024560, '2024E': 5230480, '2025E': 5445200, '2026E': 5668340 },
|
||||
{ '指标': '净资产(百万元)', '2020': 293540, '2021': 312680, '2022': 318920, '2023': 325680, '2024E': 338560, '2025E': 352480, '2026E': 367820 },
|
||||
{ '指标': '资产负债率(%)', '2020': 93.5, '2021': 93.6, '2022': 93.5, '2023': 93.5, '2024E': 93.5, '2025E': 93.5, '2026E': 93.5 },
|
||||
{ '指标': 'PE(倍)', '2020': 7.4, '2021': 6.9, '2022': 7.2, '2023': 4.9, '2024E': 4.4, '2025E': 4.0, '2026E': 3.6 },
|
||||
{ '指标': 'PB(倍)', '2020': 1.05, '2021': 1.09, '2022': 1.12, '2023': 0.79, '2024E': 0.72, '2025E': 0.67, '2026E': 0.61 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 生成通用公司数据的工具函数
|
||||
export const generateCompanyData = (stockCode, stockName) => {
|
||||
// 如果是平安银行,直接返回详细数据
|
||||
if (stockCode === '000001') {
|
||||
return PINGAN_BANK_DATA;
|
||||
}
|
||||
|
||||
// 否则生成通用数据
|
||||
return {
|
||||
stockCode,
|
||||
stockName,
|
||||
basicInfo: {
|
||||
code: stockCode,
|
||||
name: stockName,
|
||||
registered_capital: Math.floor(Math.random() * 500000) + 10000,
|
||||
registered_capital_unit: '万元',
|
||||
legal_representative: '张三',
|
||||
general_manager: '李四',
|
||||
secretary: '王五',
|
||||
registered_address: '中国某省某市某区某路123号',
|
||||
office_address: '中国某省某市某区某路123号',
|
||||
phone: '021-12345678',
|
||||
email: 'ir@company.com',
|
||||
website: 'http://www.company.com',
|
||||
employees: Math.floor(Math.random() * 10000) + 1000,
|
||||
list_date: '2010-01-01',
|
||||
industry: '制造业',
|
||||
},
|
||||
actualControl: {
|
||||
controller_name: '某控股集团有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 35.5,
|
||||
control_chain: '某控股集团有限公司 -> ' + stockName,
|
||||
},
|
||||
concentration: {
|
||||
top1_ratio: 35.5,
|
||||
top3_ratio: 52.3,
|
||||
top5_ratio: 61.8,
|
||||
top10_ratio: 72.5,
|
||||
concentration_level: '适度集中',
|
||||
},
|
||||
management: [
|
||||
{ name: '张三', position: '董事长', gender: '男', age: 55, education: '硕士', annual_compensation: 320.5 },
|
||||
{ name: '李四', position: '总经理', gender: '男', age: 50, education: '硕士', annual_compensation: 280.3 },
|
||||
{ name: '王五', position: '董事会秘书', gender: '女', age: 45, education: '本科', annual_compensation: 180.2 },
|
||||
],
|
||||
topCirculationShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: (10 - i) * 0.8,
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: '企业'
|
||||
})),
|
||||
topShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: (10 - i) * 0.8,
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: '企业',
|
||||
is_restricted: false
|
||||
})),
|
||||
branches: [
|
||||
{ name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司' },
|
||||
{ name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司' },
|
||||
],
|
||||
announcements: [
|
||||
{ title: stockName + '2024年第三季度报告', publish_date: '2024-10-28', type: '定期报告', summary: '业绩稳步增长' },
|
||||
{ title: stockName + '2024年半年度报告', publish_date: '2024-08-28', type: '定期报告', summary: '经营情况良好' },
|
||||
],
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
],
|
||||
comprehensiveAnalysis: {
|
||||
overview: {
|
||||
company_name: stockName,
|
||||
stock_code: stockCode,
|
||||
industry: '制造业',
|
||||
total_assets: Math.floor(Math.random() * 10000) + 100,
|
||||
},
|
||||
financial_highlights: {
|
||||
revenue: Math.floor(Math.random() * 1000) + 50,
|
||||
revenue_growth: (Math.random() * 20 - 5).toFixed(2),
|
||||
net_profit: Math.floor(Math.random() * 100) + 10,
|
||||
profit_growth: (Math.random() * 20 - 5).toFixed(2),
|
||||
},
|
||||
competitive_advantages: ['技术领先', '品牌优势', '管理团队优秀'],
|
||||
risk_factors: ['市场竞争激烈', '原材料价格波动'],
|
||||
},
|
||||
valueChainAnalysis: {
|
||||
upstream: [
|
||||
{ name: '原材料供应商A', relationship: '供应商', importance: '高' },
|
||||
{ name: '原材料供应商B', relationship: '供应商', importance: '中' },
|
||||
],
|
||||
downstream: [
|
||||
{ name: '经销商网络', scale: '1000家', contribution: '60%' },
|
||||
{ name: '直营渠道', scale: '100家', contribution: '40%' },
|
||||
],
|
||||
},
|
||||
keyFactorsTimeline: [
|
||||
{ date: '2024-10-28', event: '发布三季报', type: '业绩公告', importance: 'high', impact: '业绩超预期' },
|
||||
{ date: '2024-08-28', event: '发布中报', type: '业绩公告', importance: 'high', impact: '业绩稳定增长' },
|
||||
],
|
||||
// 通用预测报告数据
|
||||
forecastReport: {
|
||||
income_profit_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
income: [5000, 5800, 6500, 7200, 7900, 8600, 9400],
|
||||
profit: [450, 520, 580, 650, 720, 800, 890]
|
||||
},
|
||||
growth_bars: {
|
||||
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
revenue_growth_pct: [16.0, 12.1, 10.8, 9.7, 8.9, 9.3]
|
||||
},
|
||||
eps_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
eps: [0.45, 0.52, 0.58, 0.65, 0.72, 0.80, 0.89]
|
||||
},
|
||||
pe_peg_axes: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
pe: [22.2, 19.2, 17.2, 15.4, 13.9, 12.5, 11.2],
|
||||
peg: [1.39, 1.59, 1.59, 1.42, 1.43, 1.40, 1.20]
|
||||
},
|
||||
detail_table: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
rows: [
|
||||
{ '指标': '营业总收入(百万元)', '2020': 5000, '2021': 5800, '2022': 6500, '2023': 7200, '2024E': 7900, '2025E': 8600, '2026E': 9400 },
|
||||
{ '指标': '营收增长率(%)', '2020': '-', '2021': 16.0, '2022': 12.1, '2023': 10.8, '2024E': 9.7, '2025E': 8.9, '2026E': 9.3 },
|
||||
{ '指标': '归母净利润(百万元)', '2020': 450, '2021': 520, '2022': 580, '2023': 650, '2024E': 720, '2025E': 800, '2026E': 890 },
|
||||
{ '指标': 'EPS(稀释,元)', '2020': 0.45, '2021': 0.52, '2022': 0.58, '2023': 0.65, '2024E': 0.72, '2025E': 0.80, '2026E': 0.89 },
|
||||
{ '指标': 'ROE(%)', '2020': 12.5, '2021': 13.2, '2022': 13.8, '2023': 14.2, '2024E': 14.5, '2025E': 14.8, '2026E': 15.0 },
|
||||
{ '指标': 'PE(倍)', '2020': 22.2, '2021': 19.2, '2022': 17.2, '2023': 15.4, '2024E': 13.9, '2025E': 12.5, '2026E': 11.2 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
139
src/mocks/data/financial.js
Normal file
139
src/mocks/data/financial.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// src/mocks/data/financial.js
|
||||
// 财务数据相关的 Mock 数据
|
||||
|
||||
// 生成财务数据
|
||||
export const generateFinancialData = (stockCode) => {
|
||||
const periods = ['2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31'];
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
|
||||
// 股票基本信息
|
||||
stockInfo: {
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
industry: stockCode === '000001' ? '银行' : '制造业',
|
||||
list_date: '1991-04-03',
|
||||
market: 'SZ'
|
||||
},
|
||||
|
||||
// 资产负债表
|
||||
balanceSheet: periods.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
|
||||
})),
|
||||
|
||||
// 利润表
|
||||
incomeStatement: periods.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
|
||||
})),
|
||||
|
||||
// 现金流量表
|
||||
cashflow: periods.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
|
||||
})),
|
||||
|
||||
// 财务指标
|
||||
financialMetrics: periods.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 // 银行特殊
|
||||
})),
|
||||
|
||||
// 主营业务
|
||||
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 }
|
||||
],
|
||||
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 }
|
||||
]
|
||||
},
|
||||
|
||||
// 业绩预告
|
||||
forecast: {
|
||||
period: '2024',
|
||||
forecast_net_profit_min: 580000, // 百万元
|
||||
forecast_net_profit_max: 620000,
|
||||
yoy_growth_min: 10.0, // %
|
||||
yoy_growth_max: 17.0,
|
||||
forecast_type: '预增',
|
||||
reason: '受益于零售业务快速增长及资产质量改善,预计全年业绩保持稳定增长',
|
||||
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 }
|
||||
]
|
||||
},
|
||||
|
||||
// 期间对比
|
||||
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]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
};
|
||||
150
src/mocks/data/market.js
Normal file
150
src/mocks/data/market.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// src/mocks/data/market.js
|
||||
// 市场行情相关的 Mock 数据
|
||||
|
||||
// 生成市场数据
|
||||
export const generateMarketData = (stockCode) => {
|
||||
const basePrice = 13.50; // 基准价格(平安银行约13.5元)
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
|
||||
// 成交数据 - 必须包含K线所需的字段
|
||||
tradeData: {
|
||||
success: true,
|
||||
data: Array(30).fill(null).map((_, i) => {
|
||||
const open = basePrice + (Math.random() - 0.5) * 0.5;
|
||||
const close = basePrice + (Math.random() - 0.5) * 0.5;
|
||||
const high = Math.max(open, close) + Math.random() * 0.3;
|
||||
const low = Math.min(open, close) - Math.random() * 0.3;
|
||||
return {
|
||||
date: new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
open: parseFloat(open.toFixed(2)),
|
||||
close: parseFloat(close.toFixed(2)),
|
||||
high: parseFloat(high.toFixed(2)),
|
||||
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%
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
// 资金流向 - 融资融券数据数组
|
||||
fundingData: {
|
||||
success: true,
|
||||
data: Array(30).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
financing: {
|
||||
balance: Math.floor(Math.random() * 5000000000) + 10000000000, // 融资余额
|
||||
buy: Math.floor(Math.random() * 500000000) + 100000000, // 融资买入
|
||||
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
|
||||
},
|
||||
securities: {
|
||||
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额
|
||||
sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
|
||||
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
// 大单统计 - 包含 daily_stats 数组
|
||||
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
|
||||
}))
|
||||
},
|
||||
|
||||
// 异动分析 - 包含 grouped_data 数组
|
||||
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
|
||||
}))
|
||||
},
|
||||
|
||||
// 股权质押
|
||||
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'
|
||||
}
|
||||
},
|
||||
|
||||
// 市场摘要
|
||||
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
|
||||
}
|
||||
},
|
||||
|
||||
// 涨停分析
|
||||
riseAnalysisData: {
|
||||
success: true,
|
||||
data: {
|
||||
is_limit_up: false,
|
||||
limit_up_price: basePrice * 1.10,
|
||||
current_price: basePrice,
|
||||
distance_to_limit: 8.92, // %
|
||||
consecutive_days: 0,
|
||||
reason: '',
|
||||
concept_tags: ['银行', '深圳国资', 'MSCI', '沪深300']
|
||||
}
|
||||
},
|
||||
|
||||
// 最新分时数据
|
||||
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)
|
||||
};
|
||||
}),
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例股票',
|
||||
trade_date: new Date().toISOString().split('T')[0],
|
||||
type: 'minute'
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -136,7 +136,9 @@ export const authHandlers = [
|
||||
});
|
||||
|
||||
// 模拟微信授权 URL(实际是微信的 URL)
|
||||
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=mock&redirect_uri=&response_type=code&scope=snsapi_login&state=${sessionId}#wechat_redirect`;
|
||||
// 使用真实的微信 AppID 和真实的授权回调地址(必须与微信开放平台配置的域名一致)
|
||||
const mockRedirectUri = encodeURIComponent('http://valuefrontier.cn/api/auth/wechat/callback');
|
||||
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=wxa8d74c47041b5f87&redirect_uri=${mockRedirectUri}&response_type=code&scope=snsapi_login&state=${sessionId}#wechat_redirect`;
|
||||
|
||||
console.log('[Mock] 生成微信二维码:', { sessionId, authUrl });
|
||||
|
||||
@@ -147,16 +149,16 @@ export const authHandlers = [
|
||||
session.status = 'scanned';
|
||||
console.log(`[Mock] 模拟用户扫码: ${sessionId}`);
|
||||
|
||||
// 再过2秒自动确认登录
|
||||
// 再过5秒自动确认登录(延长时间让用户看到 scanned 状态)
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(sessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
session2.status = 'confirmed';
|
||||
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与后端保持一致
|
||||
session2.user = {
|
||||
id: 999,
|
||||
nickname: '微信用户',
|
||||
wechat_openid: 'mock_openid_' + sessionId,
|
||||
avatar_url: 'https://i.pravatar.cc/150?img=99',
|
||||
avatar_url: 'https://ui-avatars.com/api/?name=微信用户&size=150&background=4299e1&color=fff',
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
@@ -168,6 +170,7 @@ export const authHandlers = [
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
|
||||
console.log(`[Mock] 模拟用户确认登录: ${sessionId}`, session2.user);
|
||||
}
|
||||
}, 2000);
|
||||
@@ -185,7 +188,7 @@ export const authHandlers = [
|
||||
}),
|
||||
|
||||
// 4. 检查微信扫码状态
|
||||
http.post('/api/auth/wechat/check-status', async ({ request }) => {
|
||||
http.post('/api/auth/wechat/check', async ({ request }) => {
|
||||
await delay(200); // 轮询请求,延迟短一些
|
||||
|
||||
const body = await request.json();
|
||||
@@ -209,18 +212,16 @@ export const authHandlers = [
|
||||
|
||||
console.log('[Mock] 检查微信状态:', { session_id, status: session.status });
|
||||
|
||||
// ✅ 返回与后端真实 API 一致的扁平化数据结构
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
message: '成功',
|
||||
data: {
|
||||
status: session.status,
|
||||
user: session.user
|
||||
}
|
||||
status: session.status,
|
||||
user_info: session.user_info,
|
||||
expires_in: Math.floor((session.createdAt + 5 * 60 * 1000 - Date.now()) / 1000)
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 微信登录确认
|
||||
http.post('/api/auth/wechat/login', async ({ request }) => {
|
||||
http.post('/api/auth/login/wechat', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
@@ -228,7 +229,7 @@ export const authHandlers = [
|
||||
|
||||
const session = mockWechatSessions.get(session_id);
|
||||
|
||||
if (!session || session.status !== 'confirmed') {
|
||||
if (!session || session.status !== 'authorized') { // ✅ 使用 'authorized' 状态,与前端保持一致
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '微信登录未确认或已过期'
|
||||
@@ -386,12 +387,12 @@ if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(targetSessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
session2.status = 'confirmed';
|
||||
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与自动扫码流程保持一致
|
||||
session2.user = {
|
||||
id: 999,
|
||||
nickname: '微信测试用户',
|
||||
wechat_openid: 'mock_openid_' + targetSessionId,
|
||||
avatar_url: 'https://i.pravatar.cc/150?img=99',
|
||||
avatar_url: 'https://ui-avatars.com/api/?name=微信测试用户&size=150&background=4299e1&color=fff',
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
@@ -402,6 +403,7 @@ if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
|
||||
console.log(`[Mock API] ✅ 模拟确认登录: ${targetSessionId}`, session2.user);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
215
src/mocks/handlers/company.js
Normal file
215
src/mocks/handlers/company.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// src/mocks/handlers/company.js
|
||||
// 公司相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { PINGAN_BANK_DATA, generateCompanyData } from '../data/company';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 获取公司数据的辅助函数
|
||||
const getCompanyData = (stockCode) => {
|
||||
return stockCode === '000001' ? PINGAN_BANK_DATA : generateCompanyData(stockCode, '示例公司');
|
||||
};
|
||||
|
||||
export const companyHandlers = [
|
||||
// 1. 综合分析
|
||||
http.get('/api/company/comprehensive-analysis/:stockCode', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.comprehensiveAnalysis
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 价值链分析
|
||||
http.get('/api/company/value-chain-analysis/:stockCode', async ({ params }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.valueChainAnalysis
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 关键因素时间线
|
||||
http.get('/api/company/key-factors-timeline/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
timeline: data.keyFactorsTimeline,
|
||||
total: data.keyFactorsTimeline.length
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 基本信息
|
||||
http.get('/api/stock/:stockCode/basic-info', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.basicInfo
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 实际控制人
|
||||
http.get('/api/stock/:stockCode/actual-control', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.actualControl
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 股权集中度
|
||||
http.get('/api/stock/:stockCode/concentration', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.concentration
|
||||
});
|
||||
}),
|
||||
|
||||
// 7. 高管信息
|
||||
http.get('/api/stock/:stockCode/management', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const activeOnly = url.searchParams.get('active_only') === 'true';
|
||||
|
||||
let management = data.management || [];
|
||||
|
||||
// 如果需要只返回在职高管(mock 数据中默认都是在职)
|
||||
if (activeOnly) {
|
||||
management = management.filter(m => m.status !== 'resigned');
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: management // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 8. 十大流通股东
|
||||
http.get('/api/stock/:stockCode/top-circulation-shareholders', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
||||
|
||||
const shareholders = (data.topCirculationShareholders || []).slice(0, limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: shareholders // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 9. 十大股东
|
||||
http.get('/api/stock/:stockCode/top-shareholders', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
||||
|
||||
const shareholders = (data.topShareholders || []).slice(0, limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: shareholders // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 10. 分支机构
|
||||
http.get('/api/stock/:stockCode/branches', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.branches || [] // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 11. 公告列表
|
||||
http.get('/api/stock/:stockCode/announcements', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const type = url.searchParams.get('type');
|
||||
|
||||
let announcements = data.announcements || [];
|
||||
|
||||
// 类型筛选
|
||||
if (type) {
|
||||
announcements = announcements.filter(a => a.type === type);
|
||||
}
|
||||
|
||||
// 分页
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const paginatedAnnouncements = announcements.slice(start, end);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: paginatedAnnouncements // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 12. 披露时间表
|
||||
http.get('/api/stock/:stockCode/disclosure-schedule', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.disclosureSchedule || [] // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 13. 盈利预测报告
|
||||
http.get('/api/stock/:stockCode/forecast-report', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.forecastReport || null
|
||||
});
|
||||
}),
|
||||
];
|
||||
121
src/mocks/handlers/financial.js
Normal file
121
src/mocks/handlers/financial.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// src/mocks/handlers/financial.js
|
||||
// 财务数据相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { generateFinancialData } from '../data/financial';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const financialHandlers = [
|
||||
// 1. 股票基本信息
|
||||
http.get('/api/financial/stock-info/:stockCode', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.stockInfo
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 资产负债表
|
||||
http.get('/api/financial/balance-sheet/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.balanceSheet.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 利润表
|
||||
http.get('/api/financial/income-statement/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.incomeStatement.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 现金流量表
|
||||
http.get('/api/financial/cashflow/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.cashflow.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 财务指标
|
||||
http.get('/api/financial/financial-metrics/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.financialMetrics.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 主营业务
|
||||
http.get('/api/financial/main-business/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.mainBusiness
|
||||
});
|
||||
}),
|
||||
|
||||
// 7. 业绩预告
|
||||
http.get('/api/financial/forecast/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.forecast
|
||||
});
|
||||
}),
|
||||
|
||||
// 8. 行业排名
|
||||
http.get('/api/financial/industry-rank/:stockCode', async ({ params }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.industryRank
|
||||
});
|
||||
}),
|
||||
|
||||
// 9. 期间对比
|
||||
http.get('/api/financial/comparison/:stockCode', async ({ params }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.periodComparison
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -9,6 +9,9 @@ import { paymentHandlers } from './payment';
|
||||
import { industryHandlers } from './industry';
|
||||
import { conceptHandlers } from './concept';
|
||||
import { stockHandlers } from './stock';
|
||||
import { companyHandlers } from './company';
|
||||
import { marketHandlers } from './market';
|
||||
import { financialHandlers } from './financial';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
@@ -22,5 +25,8 @@ export const handlers = [
|
||||
...industryHandlers,
|
||||
...conceptHandlers,
|
||||
...stockHandlers,
|
||||
...companyHandlers,
|
||||
...marketHandlers,
|
||||
...financialHandlers,
|
||||
// ...userHandlers,
|
||||
];
|
||||
|
||||
74
src/mocks/handlers/market.js
Normal file
74
src/mocks/handlers/market.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// src/mocks/handlers/market.js
|
||||
// 市场行情相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { generateMarketData } from '../data/market';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const marketHandlers = [
|
||||
// 1. 成交数据
|
||||
http.get('/api/market/trade/:stockCode', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.tradeData);
|
||||
}),
|
||||
|
||||
// 2. 资金流向
|
||||
http.get('/api/market/funding/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.fundingData);
|
||||
}),
|
||||
|
||||
// 3. 大单统计
|
||||
http.get('/api/market/bigdeal/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.bigDealData);
|
||||
}),
|
||||
|
||||
// 4. 异动分析
|
||||
http.get('/api/market/unusual/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.unusualData);
|
||||
}),
|
||||
|
||||
// 5. 股权质押
|
||||
http.get('/api/market/pledge/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.pledgeData);
|
||||
}),
|
||||
|
||||
// 6. 市场摘要
|
||||
http.get('/api/market/summary/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.summaryData);
|
||||
}),
|
||||
|
||||
// 7. 涨停分析
|
||||
http.get('/api/market/rise-analysis/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.riseAnalysisData);
|
||||
}),
|
||||
|
||||
// 8. 最新分时数据
|
||||
http.get('/api/stock/:stockCode/latest-minute', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.latestMinuteData);
|
||||
}),
|
||||
];
|
||||
@@ -144,19 +144,23 @@ export const WECHAT_STATUS = {
|
||||
WAITING: 'waiting',
|
||||
SCANNED: 'scanned',
|
||||
AUTHORIZED: 'authorized',
|
||||
LOGIN_SUCCESS: 'login_success',
|
||||
REGISTER_SUCCESS: 'register_success',
|
||||
LOGIN_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
REGISTER_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
EXPIRED: 'expired',
|
||||
AUTH_DENIED: 'auth_denied', // 用户拒绝授权
|
||||
AUTH_FAILED: 'auth_failed', // 授权失败
|
||||
};
|
||||
|
||||
/**
|
||||
* 状态提示信息映射
|
||||
*/
|
||||
export const STATUS_MESSAGES = {
|
||||
[WECHAT_STATUS.WAITING]: '请使用微信扫码',
|
||||
[WECHAT_STATUS.WAITING]: '使用微信扫一扫登陆',
|
||||
[WECHAT_STATUS.SCANNED]: '扫码成功,请在手机上确认',
|
||||
[WECHAT_STATUS.AUTHORIZED]: '授权成功,正在登录...',
|
||||
[WECHAT_STATUS.EXPIRED]: '二维码已过期',
|
||||
[WECHAT_STATUS.AUTH_DENIED]: '用户取消授权',
|
||||
[WECHAT_STATUS.AUTH_FAILED]: '授权失败,请重试',
|
||||
};
|
||||
|
||||
export default authService;
|
||||
|
||||
@@ -303,6 +303,7 @@ const mockFinancialNews = [
|
||||
class MockSocketService {
|
||||
constructor() {
|
||||
this.connected = false;
|
||||
this.connecting = false; // 新增:正在连接标志,防止重复连接
|
||||
this.listeners = new Map();
|
||||
this.intervals = [];
|
||||
this.messageQueue = [];
|
||||
@@ -325,18 +326,30 @@ class MockSocketService {
|
||||
* 连接到 mock socket
|
||||
*/
|
||||
connect() {
|
||||
// ✅ 防止重复连接
|
||||
if (this.connected) {
|
||||
logger.warn('mockSocketService', 'Already connected');
|
||||
console.log('%c[Mock Socket] Already connected, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connecting) {
|
||||
logger.warn('mockSocketService', 'Connection in progress');
|
||||
console.log('%c[Mock Socket] Connection already in progress, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
this.connecting = true; // 标记为连接中
|
||||
logger.info('mockSocketService', 'Connecting to mock socket service...');
|
||||
console.log('%c[Mock Socket] 🔌 Connecting...', 'color: #2196F3; font-weight: bold;');
|
||||
|
||||
// 模拟连接延迟
|
||||
setTimeout(() => {
|
||||
// 检查是否应该模拟连接失败
|
||||
if (this.failConnection) {
|
||||
this.connecting = false; // 清除连接中标志
|
||||
logger.warn('mockSocketService', 'Simulated connection failure');
|
||||
console.log('%c[Mock Socket] ❌ Connection failed (simulated)', 'color: #F44336; font-weight: bold;');
|
||||
|
||||
// 触发连接错误事件
|
||||
this.emit('connect_error', {
|
||||
@@ -351,6 +364,7 @@ class MockSocketService {
|
||||
|
||||
// 正常连接成功
|
||||
this.connected = true;
|
||||
this.connecting = false; // 清除连接中标志
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// 清除自定义重连定时器
|
||||
@@ -360,9 +374,15 @@ class MockSocketService {
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', 'Mock socket connected successfully');
|
||||
console.log('%c[Mock Socket] ✅ Connected successfully!', 'color: #4CAF50; font-weight: bold; font-size: 14px;');
|
||||
console.log(`%c[Mock Socket] Status: connected=${this.connected}, connecting=${this.connecting}`, 'color: #4CAF50;');
|
||||
|
||||
// 触发连接成功事件
|
||||
this.emit('connect', { timestamp: Date.now() });
|
||||
// ✅ 使用 setTimeout(0) 确保监听器已注册后再触发事件
|
||||
setTimeout(() => {
|
||||
console.log('%c[Mock Socket] Emitting connect event...', 'color: #9C27B0;');
|
||||
this.emit('connect', { timestamp: Date.now() });
|
||||
console.log('%c[Mock Socket] Connect event emitted', 'color: #9C27B0;');
|
||||
}, 0);
|
||||
|
||||
// 在连接后3秒发送欢迎消息
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -25,4 +25,73 @@ console.log(
|
||||
`color: ${useMock ? '#FF9800' : '#4CAF50'}; font-weight: bold; font-size: 12px;`
|
||||
);
|
||||
|
||||
// ========== 暴露调试 API 到全局 ==========
|
||||
if (typeof window !== 'undefined') {
|
||||
// 暴露 Socket 类型到全局
|
||||
window.SOCKET_TYPE = SOCKET_TYPE;
|
||||
|
||||
// 暴露调试 API
|
||||
window.__SOCKET_DEBUG__ = {
|
||||
// 获取当前连接状态
|
||||
getStatus: () => {
|
||||
const isConnected = socket.connected || false;
|
||||
return {
|
||||
type: SOCKET_TYPE,
|
||||
connected: isConnected,
|
||||
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
|
||||
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
|
||||
service: useMock ? 'mockSocketService' : 'socketService',
|
||||
};
|
||||
},
|
||||
|
||||
// 手动重连
|
||||
reconnect: () => {
|
||||
console.log('[Socket Debug] Manual reconnect triggered');
|
||||
if (socket.reconnect) {
|
||||
socket.reconnect();
|
||||
} else {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
}
|
||||
},
|
||||
|
||||
// 断开连接
|
||||
disconnect: () => {
|
||||
console.log('[Socket Debug] Manual disconnect triggered');
|
||||
socket.disconnect();
|
||||
},
|
||||
|
||||
// 连接
|
||||
connect: () => {
|
||||
console.log('[Socket Debug] Manual connect triggered');
|
||||
socket.connect();
|
||||
},
|
||||
|
||||
// 获取服务实例 (仅用于调试)
|
||||
getService: () => socket,
|
||||
|
||||
// 导出诊断信息
|
||||
exportDiagnostics: () => {
|
||||
const status = window.__SOCKET_DEBUG__.getStatus();
|
||||
const diagnostics = {
|
||||
...status,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
};
|
||||
console.log('[Socket Diagnostics]', diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
};
|
||||
|
||||
console.log(
|
||||
'%c[Socket Debug] Debug API available at window.__SOCKET_DEBUG__',
|
||||
'color: #2196F3; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
}
|
||||
|
||||
export default socket;
|
||||
|
||||
@@ -145,7 +145,7 @@ export const fetchHotEvents = createAsyncThunk(
|
||||
try {
|
||||
return await fetchWithCache({
|
||||
cacheKey: CACHE_KEYS.HOT_EVENTS,
|
||||
fetchFn: () => eventService.getHotEvents({ days: 5, limit: 4 }),
|
||||
fetchFn: () => eventService.getHotEvents({ days: 5, limit: 20 }),
|
||||
getState,
|
||||
stateKey: 'hotEvents',
|
||||
forceRefresh
|
||||
|
||||
@@ -3,6 +3,43 @@
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
// ========== 日志限流配置 ==========
|
||||
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次
|
||||
const recentLogs = new Map(); // 日志缓存,用于去重
|
||||
const MAX_CACHE_SIZE = 100; // 最大缓存数量
|
||||
|
||||
/**
|
||||
* 生成日志的唯一键
|
||||
*/
|
||||
function getLogKey(component, message) {
|
||||
return `${component}:${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该输出日志(限流检查)
|
||||
*/
|
||||
function shouldLog(component, message) {
|
||||
const key = getLogKey(component, message);
|
||||
const now = Date.now();
|
||||
const lastLog = recentLogs.get(key);
|
||||
|
||||
// 如果1秒内已经输出过相同日志,跳过
|
||||
if (lastLog && now - lastLog < LOG_THROTTLE_TIME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 记录日志时间
|
||||
recentLogs.set(key, now);
|
||||
|
||||
// 限制缓存大小,避免内存泄漏
|
||||
if (recentLogs.size > MAX_CACHE_SIZE) {
|
||||
const oldestKey = recentLogs.keys().next().value;
|
||||
recentLogs.delete(oldestKey);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一日志工具
|
||||
* 开发环境:输出详细日志
|
||||
@@ -20,7 +57,7 @@ export const logger = {
|
||||
* @param {object} data - 请求参数/body
|
||||
*/
|
||||
request: (method, url, data = null) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog('API', `${method} ${url}`)) {
|
||||
console.group(`🌐 API Request: ${method} ${url}`);
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
if (data) console.log('Data:', data);
|
||||
@@ -36,7 +73,7 @@ export const logger = {
|
||||
* @param {any} data - 响应数据
|
||||
*/
|
||||
response: (method, url, status, data) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog('API', `${method} ${url} ${status}`)) {
|
||||
console.group(`✅ API Response: ${method} ${url}`);
|
||||
console.log('Status:', status);
|
||||
console.log('Data:', data);
|
||||
@@ -53,6 +90,7 @@ export const logger = {
|
||||
* @param {object} requestData - 请求参数(可选)
|
||||
*/
|
||||
error: (method, url, error, requestData = null) => {
|
||||
// API 错误始终输出,不做限流
|
||||
console.group(`❌ API Error: ${method} ${url}`);
|
||||
console.error('Error:', error);
|
||||
console.error('Message:', error?.message || error);
|
||||
@@ -75,6 +113,7 @@ export const logger = {
|
||||
* @param {object} context - 上下文信息(可选)
|
||||
*/
|
||||
error: (component, method, error, context = {}) => {
|
||||
// 错误日志始终输出,不做限流
|
||||
console.group(`🔴 Error in ${component}.${method}`);
|
||||
console.error('Error:', error);
|
||||
console.error('Message:', error?.message || error);
|
||||
@@ -93,7 +132,7 @@ export const logger = {
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
warn: (component, message, data = {}) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
console.group(`⚠️ Warning: ${component}`);
|
||||
console.warn('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
@@ -111,7 +150,7 @@ export const logger = {
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
debug: (component, message, data = {}) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
console.group(`🐛 Debug: ${component}`);
|
||||
console.log('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
@@ -129,7 +168,7 @@ export const logger = {
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
info: (component, message, data = {}) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
console.group(`ℹ️ Info: ${component}`);
|
||||
console.log('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import EventListSection from './EventListSection';
|
||||
* @param {Array} popularKeywords - 热门关键词
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onSearch - 搜索回调
|
||||
* @param {Function} onSearchFocus - 搜索框获得焦点回调
|
||||
* @param {Function} onPageChange - 分页变化回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
@@ -35,15 +36,17 @@ const EventTimelineCard = forwardRef(({
|
||||
popularKeywords,
|
||||
lastUpdateTime,
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
onPageChange,
|
||||
onEventClick,
|
||||
onViewDetail
|
||||
onViewDetail,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
return (
|
||||
<Card ref={ref} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader>
|
||||
<EventTimelineHeader lastUpdateTime={lastUpdateTime} />
|
||||
@@ -55,6 +58,7 @@ const EventTimelineCard = forwardRef(({
|
||||
<Box mb={4}>
|
||||
<UnifiedSearchBox
|
||||
onSearch={onSearch}
|
||||
onSearchFocus={onSearchFocus}
|
||||
popularKeywords={popularKeywords}
|
||||
filters={filters}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@ const EventTimelineHeader = ({ lastUpdateTime }) => {
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>实时事件时间轴</Text>
|
||||
<Text>实时事件</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* Hot Events Section */
|
||||
.hot-events-section {
|
||||
padding: 24px 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -17,11 +16,76 @@
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Carousel */
|
||||
.carousel-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-counter {
|
||||
position: absolute;
|
||||
top: 8px; /* 容器内部顶部 */
|
||||
right: 48px; /* 避开右侧箭头 */
|
||||
z-index: 100; /* 确保在卡片和箭头上方 */
|
||||
background: rgba(24, 144, 255, 0.95);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none; /* 不阻挡鼠标事件 */
|
||||
}
|
||||
|
||||
.hot-events-carousel {
|
||||
padding: 0 40px; /* 增加左右padding为箭头留出空间 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hot-events-carousel .carousel-item {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* 自定义箭头样式 */
|
||||
.custom-carousel-arrow {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
.custom-carousel-arrow:hover {
|
||||
background: rgba(255, 255, 255, 1) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.custom-carousel-arrow:hover .anticon {
|
||||
color: #096dd9 !important;
|
||||
}
|
||||
|
||||
/* 箭头位置 */
|
||||
.hot-events-carousel .slick-prev.custom-carousel-arrow {
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
.hot-events-carousel .slick-next.custom-carousel-arrow {
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
/* 禁用状态 */
|
||||
.custom-carousel-arrow.slick-disabled {
|
||||
opacity: 0.3 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.hot-event-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hot-event-card:hover {
|
||||
@@ -29,11 +93,16 @@
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Cover image */
|
||||
/* Card body padding */
|
||||
.hot-event-card .ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Cover image - 高度减半 */
|
||||
.event-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -55,28 +124,53 @@
|
||||
|
||||
/* Card content */
|
||||
.event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.event-header .ant-tag {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
flex: 1;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 标题文字 - inline显示,可以换行 */
|
||||
.event-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 标签紧跟标题后面 */
|
||||
.event-tag {
|
||||
display: inline;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.event-tag .ant-tag {
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
transform: scale(0.9);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 详情描述 - 三行省略 */
|
||||
.event-description {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 4.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-footer {
|
||||
@@ -84,6 +178,7 @@
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.creator {
|
||||
@@ -93,6 +188,19 @@
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
/* 时间样式 - 年月日高亮 */
|
||||
.time {
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.time-date {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time-hour {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
// src/views/Community/components/HotEvents.js
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Badge, Tag, Empty } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, FireOutlined } from '@ant-design/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import './HotEvents.css';
|
||||
import defaultEventImage from '../../../assets/img/default-event.jpg'
|
||||
const HotEvents = ({ events }) => {
|
||||
|
||||
// 自定义箭头组件
|
||||
const CustomArrow = ({ className, style, onClick, direction }) => {
|
||||
const Icon = direction === 'left' ? LeftOutlined : RightOutlined;
|
||||
return (
|
||||
<div
|
||||
className={`${className} custom-carousel-arrow`}
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon style={{ fontSize: '20px', color: '#1890ff' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HotEvents = ({ events, onPageChange }) => {
|
||||
const navigate = useNavigate();
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
const renderPriceChange = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -39,18 +60,60 @@ const HotEvents = ({ events }) => {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = Math.ceil((events?.length || 0) / 4);
|
||||
|
||||
// Carousel 配置
|
||||
const carouselSettings = {
|
||||
dots: false, // 隐藏圆点导航
|
||||
infinite: true, // 始终启用无限循环,确保箭头显示
|
||||
speed: 500,
|
||||
slidesToShow: 4,
|
||||
slidesToScroll: 1,
|
||||
arrows: true, // 保留左右箭头
|
||||
prevArrow: <CustomArrow direction="left" />,
|
||||
nextArrow: <CustomArrow direction="right" />,
|
||||
autoplay: false,
|
||||
beforeChange: (_current, next) => {
|
||||
// 计算实际页码(考虑无限循环)
|
||||
const actualPage = next % totalPages;
|
||||
setCurrentSlide(actualPage);
|
||||
// 通知父组件页码变化
|
||||
if (onPageChange) {
|
||||
onPageChange(actualPage + 1, totalPages);
|
||||
}
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 1200,
|
||||
settings: {
|
||||
slidesToShow: 3,
|
||||
slidesToScroll: 1,
|
||||
}
|
||||
},
|
||||
{
|
||||
breakpoint: 992,
|
||||
settings: {
|
||||
slidesToShow: 2,
|
||||
slidesToScroll: 1,
|
||||
}
|
||||
},
|
||||
{
|
||||
breakpoint: 576,
|
||||
settings: {
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hot-events-section">
|
||||
<h2 className="section-title">
|
||||
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
|
||||
近期热点信息
|
||||
</h2>
|
||||
<p className="section-subtitle">展示最近5天内涨幅最高的事件,助您把握市场热点</p>
|
||||
|
||||
{events && events.length > 0 ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Carousel {...carouselSettings} className="hot-events-carousel">
|
||||
{events.map((event, index) => (
|
||||
<Col lg={6} md={12} sm={24} key={event.id}>
|
||||
<div key={event.id} className="carousel-item">
|
||||
<Card
|
||||
hoverable
|
||||
className="hot-event-card"
|
||||
@@ -75,33 +138,36 @@ const HotEvents = ({ events }) => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div className="event-header">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<p className="event-description">
|
||||
{event.description && event.description.length > 80
|
||||
? `${event.description.substring(0, 80)}...`
|
||||
: event.description}
|
||||
</p>
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="time">{moment(event.created_at).format('MM-DD HH:mm')}</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{/* Custom layout without Card.Meta */}
|
||||
<div className="event-header">
|
||||
<Tooltip title={event.title}>
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="event-tag">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tooltip title={event.description}>
|
||||
<div className="event-description">
|
||||
{event.description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="time">
|
||||
<span className="time-date">{moment(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
{' '}
|
||||
<span className="time-hour">{moment(event.created_at).format('HH:mm')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
</Carousel>
|
||||
) : (
|
||||
<Card>
|
||||
<Empty description="暂无热点信息" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// src/views/Community/components/HotEventsSection.js
|
||||
// 热点事件区域组件
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import HotEvents from './HotEvents';
|
||||
@@ -17,6 +19,14 @@ import HotEvents from './HotEvents';
|
||||
*/
|
||||
const HotEventsSection = ({ events }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page, total) => {
|
||||
setCurrentPage(page);
|
||||
setTotalPages(total);
|
||||
};
|
||||
|
||||
// 如果没有热点事件,不渲染组件
|
||||
if (!events || events.length === 0) {
|
||||
@@ -24,12 +34,28 @@ const HotEventsSection = ({ events }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card mt={8} bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="md">🔥 热点事件</Heading>
|
||||
<Card mt={0} bg={cardBg}>
|
||||
<CardHeader pb={0} display="flex" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box>
|
||||
<Heading size="md">🔥 热点事件</Heading>
|
||||
<p className="section-subtitle" style={{paddingTop: '8px'}}>展示最近5天内涨幅最高的事件,助您把握市场热点</p>
|
||||
</Box>
|
||||
{/* 页码指示器 */}
|
||||
{totalPages > 1 && (
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
ml={4}
|
||||
>
|
||||
{currentPage} / {totalPages}
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<HotEvents events={events} />
|
||||
<CardBody py={0} px={4}>
|
||||
<HotEvents events={events} onPageChange={handlePageChange} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -480,9 +480,10 @@ export default function MidjourneyHeroSection() {
|
||||
minH="100vh"
|
||||
bg="linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, #000000 100%)"
|
||||
overflow="hidden"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{/* 粒子背景 */}
|
||||
<Box position="absolute" inset={0} zIndex={0}>
|
||||
<Box position="absolute" inset={0} zIndex={-1} pointerEvents="none">
|
||||
<Particles
|
||||
id="tsparticles"
|
||||
init={particlesInit}
|
||||
@@ -499,7 +500,7 @@ export default function MidjourneyHeroSection() {
|
||||
<DataStreams />
|
||||
|
||||
{/* 内容容器 */}
|
||||
<Container maxW="7xl" position="relative" zIndex={20} pt={20} pb={20}>
|
||||
<Container maxW="7xl" position="relative" zIndex={1} pt={20} pb={20}>
|
||||
<Grid templateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }} gap={12} alignItems="center" minH="70vh">
|
||||
|
||||
{/* 左侧文本内容 */}
|
||||
@@ -776,7 +777,7 @@ export default function MidjourneyHeroSection() {
|
||||
borderRadius="full"
|
||||
filter="blur(40px)"
|
||||
animation="pulse 4s ease-in-out infinite"
|
||||
animationDelay="2s"
|
||||
sx={{ animationDelay: '2s' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -793,7 +794,7 @@ export default function MidjourneyHeroSection() {
|
||||
right={0}
|
||||
h="128px"
|
||||
bgGradient="linear(to-t, black, transparent)"
|
||||
zIndex={10}
|
||||
zIndex={-1}
|
||||
/>
|
||||
|
||||
{/* 全局样式 */}
|
||||
|
||||
@@ -138,9 +138,9 @@ const PopularKeywords = ({ onKeywordClick, keywords: propKeywords }) => {
|
||||
</span>
|
||||
|
||||
{/* 所有标签 */}
|
||||
{keywords.map((item) => (
|
||||
{keywords.map((item, index) => (
|
||||
<Tag
|
||||
key={item.concept_id}
|
||||
key={item.concept_id || `keyword-${index}`}
|
||||
color={getTagColor(item.change_pct)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
|
||||
@@ -21,6 +21,7 @@ const { Option } = AntSelect;
|
||||
|
||||
const UnifiedSearchBox = ({
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
popularKeywords = [],
|
||||
filters = {}
|
||||
}) => {
|
||||
@@ -385,7 +386,7 @@ const UnifiedSearchBox = ({
|
||||
page: 1,
|
||||
|
||||
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
|
||||
q: overrides.q ?? filters.q ?? '',
|
||||
q: (overrides.q ?? filters.q) ?? '',
|
||||
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
|
||||
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
|
||||
|
||||
@@ -486,10 +487,8 @@ const UnifiedSearchBox = ({
|
||||
} else if (key === 'date_range') {
|
||||
// 清除日期范围
|
||||
setDateRange(null);
|
||||
setTimeout(() => {
|
||||
const params = buildFilterParams();
|
||||
triggerSearch(params);
|
||||
}, 50);
|
||||
const params = buildFilterParams({ date_range: '' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'importance') {
|
||||
// 重置重要性为默认值
|
||||
setImportance('all');
|
||||
@@ -521,9 +520,14 @@ const UnifiedSearchBox = ({
|
||||
onChange={handleInputChange}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleStockSelect}
|
||||
onFocus={onSearchFocus}
|
||||
options={stockOptions}
|
||||
placeholder="请输入股票代码/股票名称/相关话题"
|
||||
onPressEnter={handleMainSearch}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleMainSearch();
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
size="large"
|
||||
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
|
||||
|
||||
@@ -210,8 +210,6 @@
|
||||
|
||||
/* 热点事件部分样式 */
|
||||
.hot-events-section {
|
||||
margin-top: 48px;
|
||||
padding: 32px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Community/index.js
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchPopularKeywords, fetchHotEvents } from '../../store/slices/communityDataSlice';
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
// 导入组件
|
||||
import MidjourneyHeroSection from './components/MidjourneyHeroSection';
|
||||
import EventTimelineCard from './components/EventTimelineCard';
|
||||
import HotEventsSection from './components/HotEventsSection';
|
||||
import EventModals from './components/EventModals';
|
||||
@@ -72,38 +71,30 @@ const Community = () => {
|
||||
}
|
||||
}, [showCommunityGuide]); // 只在组件挂载时执行一次
|
||||
|
||||
// ⚡ 页面渲染完成后1秒,自动滚动到实时事件时间轴
|
||||
useEffect(() => {
|
||||
// 只在第一次数据加载完成后滚动
|
||||
if (!loading && !hasScrolledRef.current && eventTimelineRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
if (eventTimelineRef.current) {
|
||||
eventTimelineRef.current.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动动画
|
||||
block: 'start', // 元素顶部对齐视口顶部,标题正好可见
|
||||
inline: 'nearest' // 水平方向最小滚动
|
||||
});
|
||||
hasScrolledRef.current = true; // 标记已滚动
|
||||
logger.debug('Community', '页面渲染完成,自动滚动到实时事件时间轴(顶部对齐)');
|
||||
}
|
||||
}, 1000); // 渲染完成后延迟1秒
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// ⚡ 滚动到实时事件区域(由搜索框聚焦触发)
|
||||
const scrollToTimeline = useCallback(() => {
|
||||
if (!hasScrolledRef.current && eventTimelineRef.current) {
|
||||
eventTimelineRef.current.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动动画
|
||||
block: 'start', // 元素顶部对齐视口顶部,标题正好可见
|
||||
inline: 'nearest' // 水平方向最小滚动
|
||||
});
|
||||
hasScrolledRef.current = true; // 标记已滚动
|
||||
logger.debug('Community', '用户触发搜索,滚动到实时事件时间轴');
|
||||
}
|
||||
}, [loading]); // 监听 loading 状态变化
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 导航栏已由 MainLayout 提供 */}
|
||||
|
||||
{/* Midjourney风格英雄区域 */}
|
||||
<MidjourneyHeroSection />
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Container maxW="container.xl" py={8}>
|
||||
{/* 实时事件时间轴卡片 */}
|
||||
<Container maxW="container.xl" pt={6} pb={8}>
|
||||
{/* 热点事件区域 */}
|
||||
<HotEventsSection events={hotEvents} />
|
||||
|
||||
{/* 实时事件 */}
|
||||
<EventTimelineCard
|
||||
ref={eventTimelineRef}
|
||||
mt={6}
|
||||
events={events}
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
@@ -111,13 +102,11 @@ const Community = () => {
|
||||
popularKeywords={popularKeywords}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
onSearch={updateFilters}
|
||||
onSearchFocus={scrollToTimeline}
|
||||
onPageChange={handlePageChange}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
|
||||
{/* 热点事件区域 */}
|
||||
<HotEventsSection events={hotEvents} />
|
||||
</Container>
|
||||
|
||||
{/* 事件弹窗 */}
|
||||
|
||||
@@ -473,6 +473,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
}
|
||||
];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(balanceSheet) || balanceSheet.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无资产负债表数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(balanceSheet.length, 6);
|
||||
const displayData = balanceSheet.slice(0, maxColumns);
|
||||
|
||||
@@ -707,6 +717,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
}
|
||||
];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(incomeStatement) || incomeStatement.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无利润表数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(incomeStatement.length, 6);
|
||||
const displayData = incomeStatement.slice(0, maxColumns);
|
||||
|
||||
@@ -866,6 +886,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
{ name: '自由现金流', key: 'free_cash_flow', path: 'key_metrics.free_cash_flow' },
|
||||
];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(cashflow) || cashflow.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无现金流量表数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(cashflow.length, 8);
|
||||
const displayData = cashflow.slice(0, maxColumns);
|
||||
|
||||
@@ -1069,6 +1099,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无财务指标数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(financialMetrics.length, 6);
|
||||
const displayData = financialMetrics.slice(0, maxColumns);
|
||||
const currentCategory = metricsCategories[selectedCategory];
|
||||
@@ -1426,8 +1466,9 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{industryRank.map((periodData, periodIdx) => (
|
||||
<Card key={periodIdx}>
|
||||
{Array.isArray(industryRank) && industryRank.length > 0 ? (
|
||||
industryRank.map((periodData, periodIdx) => (
|
||||
<Card key={periodIdx}>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">{periodData.report_type} 行业排名</Heading>
|
||||
@@ -1486,7 +1527,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Text textAlign="center" color="gray.500" py={8}>
|
||||
暂无行业排名数据
|
||||
</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
@@ -1738,7 +1788,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
|
||||
// 综合对比分析
|
||||
const ComparisonAnalysis = () => {
|
||||
if (!comparison || comparison.length === 0) return null;
|
||||
if (!Array.isArray(comparison) || comparison.length === 0) return null;
|
||||
|
||||
const revenueData = comparison.map(item => ({
|
||||
period: formatUtils.getReportType(item.period),
|
||||
|
||||
@@ -1471,7 +1471,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.textPrimary} fontSize="lg">
|
||||
{minuteData.data[0]?.open.toFixed(2)}
|
||||
{minuteData.data[0]?.open != null ? minuteData.data[0].open.toFixed(2) : '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
@@ -1485,13 +1485,15 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
color={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? theme.success : theme.danger}
|
||||
fontSize="lg"
|
||||
>
|
||||
{minuteData.data[minuteData.data.length - 1]?.close.toFixed(2)}
|
||||
{minuteData.data[minuteData.data.length - 1]?.close != null ? minuteData.data[minuteData.data.length - 1].close.toFixed(2) : '-'}
|
||||
</StatNumber>
|
||||
<StatHelpText fontSize="xs">
|
||||
<StatArrow
|
||||
type={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? 'increase' : 'decrease'}
|
||||
/>
|
||||
{Math.abs(((minuteData.data[minuteData.data.length - 1]?.close - minuteData.data[0]?.open) / minuteData.data[0]?.open * 100)).toFixed(2)}%
|
||||
{(minuteData.data[minuteData.data.length - 1]?.close != null && minuteData.data[0]?.open != null)
|
||||
? Math.abs(((minuteData.data[minuteData.data.length - 1].close - minuteData.data[0].open) / minuteData.data[0].open * 100)).toFixed(2)
|
||||
: '0.00'}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
<Stat>
|
||||
@@ -1502,7 +1504,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.success} fontSize="lg">
|
||||
{Math.max(...minuteData.data.map(item => item.high)).toFixed(2)}
|
||||
{(() => {
|
||||
const highs = minuteData.data.map(item => item.high).filter(h => h != null);
|
||||
return highs.length > 0 ? Math.max(...highs).toFixed(2) : '-';
|
||||
})()}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
@@ -1513,7 +1518,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.danger} fontSize="lg">
|
||||
{Math.min(...minuteData.data.map(item => item.low)).toFixed(2)}
|
||||
{(() => {
|
||||
const lows = minuteData.data.map(item => item.low).filter(l => l != null);
|
||||
return lows.length > 0 ? Math.min(...lows).toFixed(2) : '-';
|
||||
})()}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
@@ -1558,7 +1566,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
平均价格
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.textPrimary}>
|
||||
{(minuteData.data.reduce((sum, item) => sum + item.close, 0) / minuteData.data.length).toFixed(2)}
|
||||
{(() => {
|
||||
const closes = minuteData.data.map(item => item.close).filter(c => c != null);
|
||||
return closes.length > 0 ? (closes.reduce((sum, c) => sum + c, 0) / closes.length).toFixed(2) : '-';
|
||||
})()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -1744,7 +1755,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
成交额: {formatUtils.formatNumber(dayStats.total_amount)}万元
|
||||
</Badge>
|
||||
<Badge colorScheme="purple" fontSize="md">
|
||||
均价: {dayStats.avg_price.toFixed(2)}元
|
||||
均价: {dayStats.avg_price != null ? dayStats.avg_price.toFixed(2) : '-'}元
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
@@ -1766,23 +1777,23 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
{dayStats.deals.map((deal, i) => (
|
||||
<Tr key={i} _hover={{ bg: colorMode === 'light' ? 'rgba(43, 108, 176, 0.05)' : 'rgba(255, 215, 0, 0.1)' }}>
|
||||
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
|
||||
<Tooltip label={deal.buyer_dept} placement="top">
|
||||
<Text>{deal.buyer_dept}</Text>
|
||||
<Tooltip label={deal.buyer_dept || '-'} placement="top">
|
||||
<Text>{deal.buyer_dept || '-'}</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
|
||||
<Tooltip label={deal.seller_dept} placement="top">
|
||||
<Text>{deal.seller_dept}</Text>
|
||||
<Tooltip label={deal.seller_dept || '-'} placement="top">
|
||||
<Text>{deal.seller_dept || '-'}</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
|
||||
{deal.price.toFixed(2)}
|
||||
{deal.price != null ? deal.price.toFixed(2) : '-'}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
{deal.volume.toFixed(2)}
|
||||
{deal.volume != null ? deal.volume.toFixed(2) : '-'}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textSecondary} fontWeight="bold">
|
||||
{deal.amount.toFixed(2)}
|
||||
{deal.amount != null ? deal.amount.toFixed(2) : '-'}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
@@ -1845,22 +1856,26 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
买入前五
|
||||
</Text>
|
||||
<VStack spacing={1} align="stretch">
|
||||
{dayData.buyers.slice(0, 5).map((buyer, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{buyer.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.success} fontWeight="bold">
|
||||
{formatUtils.formatNumber(buyer.buy_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{dayData.buyers && dayData.buyers.length > 0 ? (
|
||||
dayData.buyers.slice(0, 5).map((buyer, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{buyer.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.success} fontWeight="bold">
|
||||
{formatUtils.formatNumber(buyer.buy_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
@@ -1869,22 +1884,26 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
卖出前五
|
||||
</Text>
|
||||
<VStack spacing={1} align="stretch">
|
||||
{dayData.sellers.slice(0, 5).map((seller, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{seller.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
|
||||
{formatUtils.formatNumber(seller.sell_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{dayData.sellers && dayData.sellers.length > 0 ? (
|
||||
dayData.sellers.slice(0, 5).map((seller, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{seller.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
|
||||
{formatUtils.formatNumber(seller.sell_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Grid>
|
||||
@@ -1948,19 +1967,27 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{pledgeData.map((item, idx) => (
|
||||
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
|
||||
<Td color={theme.textPrimary}>{item.end_date}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{formatUtils.formatNumber(item.total_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.total_shares, 0)}</Td>
|
||||
<Td isNumeric color={theme.warning} fontWeight="bold">
|
||||
{formatUtils.formatPercent(item.pledge_ratio)}
|
||||
{Array.isArray(pledgeData) && pledgeData.length > 0 ? (
|
||||
pledgeData.map((item, idx) => (
|
||||
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
|
||||
<Td color={theme.textPrimary}>{item.end_date}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{formatUtils.formatNumber(item.total_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.total_shares, 0)}</Td>
|
||||
<Td isNumeric color={theme.warning} fontWeight="bold">
|
||||
{formatUtils.formatPercent(item.pledge_ratio)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{item.pledge_count}</Td>
|
||||
</Tr>
|
||||
))
|
||||
) : (
|
||||
<Tr>
|
||||
<Td colSpan={7} textAlign="center" py={8}>
|
||||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{item.pledge_count}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import heroBg from '../../assets/img/BackgroundCard1.png';
|
||||
import '../../styles/home-animations.css';
|
||||
import { logger } from '../../utils/logger';
|
||||
import MidjourneyHeroSection from '../Community/components/MidjourneyHeroSection';
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, isAuthenticated } = useAuth(); // ⚡ 移除 isLoading,不再依赖它
|
||||
@@ -33,7 +34,6 @@ export default function HomePage() {
|
||||
const heroTextSize = useBreakpointValue({ base: 'md', md: 'lg', lg: 'xl' });
|
||||
const containerPx = useBreakpointValue({ base: 10, md: 10, lg: 10 });
|
||||
const showDecorations = useBreakpointValue({ base: false, md: true });
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 保留原有的调试信息
|
||||
useEffect(() => {
|
||||
@@ -50,11 +50,11 @@ export default function HomePage() {
|
||||
const coreFeatures = [
|
||||
{
|
||||
id: 'news-catalyst',
|
||||
title: '新闻催化分析',
|
||||
title: '新闻中心',
|
||||
description: '实时新闻事件分析,捕捉市场催化因子',
|
||||
icon: '📊',
|
||||
color: 'yellow',
|
||||
url: 'https://valuefrontier.cn/community',
|
||||
url: '/community',
|
||||
badge: '核心',
|
||||
featured: true
|
||||
},
|
||||
@@ -64,7 +64,7 @@ export default function HomePage() {
|
||||
description: '热门概念与主题投资分析追踪',
|
||||
icon: '🎯',
|
||||
color: 'purple',
|
||||
url: 'https://valuefrontier.cn/concepts',
|
||||
url: '/concepts',
|
||||
badge: '热门'
|
||||
},
|
||||
{
|
||||
@@ -73,7 +73,7 @@ export default function HomePage() {
|
||||
description: '全面的个股基本面信息整合',
|
||||
icon: '📈',
|
||||
color: 'blue',
|
||||
url: 'https://valuefrontier.cn/stocks',
|
||||
url: '/stocks',
|
||||
badge: '全面'
|
||||
},
|
||||
{
|
||||
@@ -82,7 +82,7 @@ export default function HomePage() {
|
||||
description: '涨停板数据深度分析与规律挖掘',
|
||||
icon: '🚀',
|
||||
color: 'green',
|
||||
url: 'https://valuefrontier.cn/limit-analyse',
|
||||
url: '/limit-analyse',
|
||||
badge: '精准'
|
||||
},
|
||||
{
|
||||
@@ -91,7 +91,7 @@ export default function HomePage() {
|
||||
description: '个股全方位分析与投资决策支持',
|
||||
icon: '🧭',
|
||||
color: 'orange',
|
||||
url: 'https://valuefrontier.cn/company?scode=688256',
|
||||
url: '/company?scode=688256',
|
||||
badge: '专业'
|
||||
},
|
||||
{
|
||||
@@ -105,15 +105,6 @@ export default function HomePage() {
|
||||
}
|
||||
];
|
||||
|
||||
// 个人中心配置
|
||||
// const personalCenter = {
|
||||
// title: '个人中心',
|
||||
// description: '账户管理与个人设置',
|
||||
// icon: '👤',
|
||||
// color: 'gray',
|
||||
// url: 'https://valuefrontier.cn/home/center'
|
||||
// };
|
||||
|
||||
// @TODO 如何区分内部链接和外部链接?
|
||||
const handleProductClick = (url) => {
|
||||
if (url.startsWith('http')) {
|
||||
@@ -201,7 +192,7 @@ export default function HomePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Container maxW="7xl" position="relative" zIndex={2} px={containerPx}>
|
||||
<Container maxW="7xl" position="relative" zIndex={30} px={containerPx}>
|
||||
<VStack spacing={{ base: 8, md: 12, lg: 16 }} align="stretch" minH={heroHeight} justify="center">
|
||||
{/* 主标题区域 */}
|
||||
<VStack spacing={{ base: 4, md: 5, lg: 6 }} textAlign="center" pt={{ base: 4, md: 6, lg: 8 }}>
|
||||
@@ -224,7 +215,7 @@ export default function HomePage() {
|
||||
<Box pb={{ base: 8, md: 12 }}>
|
||||
<VStack spacing={{ base: 6, md: 8 }}>
|
||||
|
||||
{/* 新闻催化分析 - 突出显示 */}
|
||||
{/* 新闻中心 - 突出显示 */}
|
||||
<Card
|
||||
bg="transparent"
|
||||
border="2px solid"
|
||||
@@ -246,108 +237,77 @@ export default function HomePage() {
|
||||
}}
|
||||
>
|
||||
<CardBody p={{ base: 6, md: 8 }} position="relative" zIndex={1}>
|
||||
{isMobile ? (
|
||||
/* 移动端:垂直布局 */
|
||||
<VStack spacing={4} align="stretch">
|
||||
<HStack spacing={4}>
|
||||
<Box
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
bg="yellow.400"
|
||||
color="black"
|
||||
>
|
||||
<Text fontSize="2xl">{coreFeatures[0].icon}</Text>
|
||||
</Box>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Heading size="lg" color="white">
|
||||
{/* 响应式布局:移动端纵向,桌面端横向 */}
|
||||
<Flex
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
align={{ base: 'stretch', md: 'center' }}
|
||||
justify={{ base: 'flex-start', md: 'space-between' }}
|
||||
gap={{ base: 4, md: 6 }}
|
||||
>
|
||||
<Flex align="center" gap={{ base: 4, md: 6 }} flex={1}>
|
||||
<Box
|
||||
p={{ base: 3, md: 4 }}
|
||||
borderRadius={{ base: 'lg', md: 'xl' }}
|
||||
bg="yellow.400"
|
||||
color="black"
|
||||
>
|
||||
<Text fontSize={{ base: '2xl', md: '3xl' }}>{coreFeatures[0].icon}</Text>
|
||||
</Box>
|
||||
<VStack align="start" spacing={{ base: 1, md: 2 }} flex={1}>
|
||||
<HStack>
|
||||
<Heading size={{ base: 'lg', md: 'xl' }} color="white">
|
||||
{coreFeatures[0].title}
|
||||
</Heading>
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize="xs">
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize={{ base: 'xs', md: 'sm' }}>
|
||||
{coreFeatures[0].badge}
|
||||
</Badge>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Text color="whiteAlpha.800" fontSize="md" lineHeight="tall">
|
||||
{coreFeatures[0].description}
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
size="md"
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
onClick={() => handleProductClick(coreFeatures[0].url)}
|
||||
minH="44px"
|
||||
>
|
||||
进入功能 →
|
||||
</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
/* 桌面端:横向布局 */
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack spacing={6}>
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="xl"
|
||||
bg="yellow.400"
|
||||
color="black"
|
||||
>
|
||||
<Text fontSize="3xl">{coreFeatures[0].icon}</Text>
|
||||
</Box>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack>
|
||||
<Heading size="xl" color="white">
|
||||
{coreFeatures[0].title}
|
||||
</Heading>
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize="sm">
|
||||
{coreFeatures[0].badge}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text color="whiteAlpha.800" fontSize="lg" maxW="md">
|
||||
{coreFeatures[0].description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
onClick={() => handleProductClick(coreFeatures[0].url)}
|
||||
>
|
||||
进入功能 →
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text color="whiteAlpha.800" fontSize={{ base: 'md', md: 'lg' }} maxW={{ md: 'md' }} lineHeight="tall">
|
||||
{coreFeatures[0].description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
)}
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
size={{ base: 'md', md: 'lg' }}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
w={{ base: '100%', md: 'auto' }}
|
||||
onClick={() => handleProductClick(coreFeatures[0].url)}
|
||||
minH="44px"
|
||||
flexShrink={0}
|
||||
>
|
||||
进入功能 →
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 其他5个功能 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 4, md: 5, lg: 6 }} w="100%">
|
||||
{coreFeatures.slice(1).map((feature) => (
|
||||
<Card
|
||||
key={feature.id}
|
||||
bg="whiteAlpha.100"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
borderRadius={{ base: 'xl', md: '2xl' }}
|
||||
cursor="pointer"
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: '2xl'
|
||||
}}
|
||||
_active={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-2px)'
|
||||
}}
|
||||
onClick={() => handleProductClick(feature.url)}
|
||||
minH={{ base: 'auto', md: '180px' }}
|
||||
>
|
||||
<Card
|
||||
key={feature.id}
|
||||
bg="whiteAlpha.100"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
borderRadius={{ base: 'xl', md: '2xl' }}
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: '2xl'
|
||||
}}
|
||||
_active={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-2px)'
|
||||
}}
|
||||
onClick={() => handleProductClick(feature.url)}
|
||||
minH={{ base: 'auto', md: '180px' }}
|
||||
>
|
||||
<CardBody p={{ base: 5, md: 6 }}>
|
||||
<VStack spacing={{ base: 3, md: 4 }} align="start" h="100%">
|
||||
<HStack>
|
||||
@@ -395,6 +355,10 @@ export default function HomePage() {
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Midjourney风格英雄区域 */}
|
||||
<MidjourneyHeroSection />
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user